Compare commits

...

76 Commits

Author SHA1 Message Date
Herculino Trotta
19c92e0014 Merge pull request #180
fix(export): 403 when exporting
2025-02-19 14:02:52 -03:00
Herculino Trotta
6459f2eb46 fix(export): 403 when exporting 2025-02-19 14:02:31 -03:00
Herculino Trotta
7926e081ef locale: update locales 2025-02-19 13:50:45 -03:00
Herculino Trotta
ceefe7075f locale: update locales 2025-02-19 13:48:54 -03:00
Herculino Trotta
ad3230fd83 Merge pull request #179 from eitchtee/export
feat: export and restore
2025-02-19 13:41:53 -03:00
Herculino Trotta
c89b07ed93 Merge branch 'main' into export 2025-02-19 13:41:04 -03:00
Herculino Trotta
201ccea842 feat: export (WIP) 2025-02-19 13:38:00 -03:00
Herculino Trotta
32ada488b4 Merge pull request #178
feat(transactions:actions): select all only selects displayed transactions
2025-02-19 09:08:06 -03:00
Herculino Trotta
794d11a355 feat(transactions:actions): select all only selects displayed transactions 2025-02-19 09:07:49 -03:00
Herculino Trotta
67f8f5fe89 Merge pull request #177
fix(transactions:actions): sum considers everything an expense
2025-02-19 09:00:02 -03:00
Herculino Trotta
9ac69fd92a fix(transactions:actions): sum considers everything an expense 2025-02-19 08:59:30 -03:00
Herculino Trotta
069f1b450c feat: export (WIP) 2025-02-19 08:51:33 -03:00
Herculino Trotta
2f388af928 Merge pull request #176
feat(insights): make sidebar sticky
2025-02-18 21:04:36 -03:00
Herculino Trotta
beeb0579ce feat(insights): make sidebar sticky 2025-02-18 21:04:09 -03:00
Herculino Trotta
a8666da57b Merge pull request #175
feat(insights:category-explorer): separate current and projected totals
2025-02-18 20:46:28 -03:00
Herculino Trotta
835316d0f3 feat(insights:category-explorer): separate current and projected totals 2025-02-18 20:46:06 -03:00
Herculino Trotta
f5feeb9617 Merge pull request #174
feat(insights:category-explorer): allow for uncategorized totals
2025-02-18 20:45:24 -03:00
Herculino Trotta
09e380a480 feat(insights:category-explorer): allow for uncategorized totals 2025-02-18 20:45:07 -03:00
Herculino Trotta
3080df9b66 feat: export (WIP) 2025-02-18 19:55:12 -03:00
Herculino Trotta
ebc41a8049 Merge pull request #173 from eitchtee/insights
fix(insights): error if filter is empty
2025-02-17 21:49:00 -03:00
Herculino Trotta
635628e30e fix(insights): error if filter is empty 2025-02-17 21:48:33 -03:00
Herculino Trotta
819a58ac06 Merge pull request #172
feat(datepicker): disable input and fix toggling dates
2025-02-17 21:37:16 -03:00
Herculino Trotta
d433375522 feat(datepicker): disable input and fix toggling dates 2025-02-17 21:36:11 -03:00
Herculino Trotta
c0150f71a8 Merge pull request #171 from eitchtee/insights
fix(insights:category-explorer): silent categories can't be displayed
2025-02-17 10:43:12 -03:00
Herculino Trotta
6119698d38 fix(insights:category-explorer): silent categories can't be displayed 2025-02-17 10:42:38 -03:00
Herculino Trotta
f5ae231601 Merge pull request #170
feat(insights:category-explorer): add empty message when there's no data or no category selected
2025-02-17 10:28:55 -03:00
Herculino Trotta
972d23abbd feat(insights:category-explorer): add empty message when there's no data or no category selected 2025-02-17 10:28:37 -03:00
Herculino Trotta
9a514a8a69 Merge pull request #169
refactor(insights:flows): improve readability when there's a lot of nodes
2025-02-17 10:21:36 -03:00
Herculino Trotta
7325231548 refactor(insights:flows): improve readability when there's a lot of nodes 2025-02-17 10:21:18 -03:00
Herculino Trotta
570657371a Merge pull request #168
fix(insights:category-explorer): use currency name instead of code
2025-02-16 19:34:15 -03:00
Herculino Trotta
67da60b5b0 fix(insights:category-explorer): use currency name instead of code 2025-02-16 19:33:58 -03:00
Herculino Trotta
84c047c5ab Merge pull request #167 from eitchtee/insights
insights
2025-02-16 13:06:03 -03:00
Herculino Trotta
23f5d09bec locale: update locales 2025-02-16 13:05:35 -03:00
Herculino Trotta
2a19075e23 Merge pull request #166
feat(insights): category explorer
2025-02-16 13:03:20 -03:00
Herculino Trotta
7f231175b2 feat(insights): category explorer 2025-02-16 13:03:02 -03:00
Herculino Trotta
062e84f864 Merge pull request #165
fix(insights): sankey diagrams nodes too far from destination
2025-02-16 02:25:45 -03:00
Herculino Trotta
5521eb20bf fix(insights): sankey diagrams nodes too far from destination 2025-02-16 02:25:29 -03:00
Herculino Trotta
627b5d250b Merge pull request #164
feat: insights page
2025-02-16 00:14:56 -03:00
Herculino Trotta
195a8a68d6 feat: insight page 2025-02-16 00:14:23 -03:00
Herculino Trotta
daf1f68b82 Merge remote-tracking branch 'origin/insights' into insights 2025-02-15 00:49:25 -03:00
Herculino Trotta
dd24fd56d3 insights (wip) 2025-02-15 00:49:00 -03:00
Herculino Trotta
7a2acb6497 fix(insights): sankey diagram inconsistent sizing 2025-02-15 00:48:59 -03:00
Herculino Trotta
9c339faa72 chore(frontend): install chartjs-chart-sankey 2025-02-15 00:48:59 -03:00
Herculino Trotta
02376ad02b feat(insights): sankey diagram (WIP) 2025-02-15 00:48:59 -03:00
Herculino Trotta
b53a4a0286 feat(insights): create app 2025-02-15 00:48:59 -03:00
Herculino Trotta
a1f618434b Merge pull request #163 from eitchtee/dca_improvements
feat(dca): link transactions to DCA
2025-02-15 00:43:07 -03:00
Herculino Trotta
7b5be29f0d locale: update locales 2025-02-15 00:42:38 -03:00
Herculino Trotta
56a73b181a Merge remote-tracking branch 'origin/main' into dca_improvements
# Conflicts:
#	app/locale/nl/LC_MESSAGES/django.po
2025-02-15 00:41:49 -03:00
Herculino Trotta
865618e054 feat(dca): link transactions to DCA 2025-02-15 00:41:06 -03:00
Herculino Trotta
9e912b2736 locale: update locales 2025-02-15 00:40:44 -03:00
Herculino Trotta
da7680e70f Merge pull request #159 from DragonHeart69/main
update NL to version 0.9.4
2025-02-14 10:20:40 -03:00
Herculino Trotta
ab594eb511 Merge pull request #162
fix(style): selecting transaction no longer highlights it
2025-02-14 00:50:30 -03:00
Herculino Trotta
cffaaa369a fix(style): selecting transaction no longer highlights it 2025-02-14 00:50:01 -03:00
Herculino Trotta
5f414e82ee Merge pull request #161
feat(internal): trigger rules on bulk actions
2025-02-14 00:35:10 -03:00
Herculino Trotta
f3bcef534e feat(internal): trigger rules on bulk actions 2025-02-14 00:34:51 -03:00
Herculino Trotta
d140ff5b70 Merge pull request #160
fix(frontend): loading indicator on empty div too close to the top
2025-02-14 00:04:03 -03:00
Herculino Trotta
7eceacfe68 fix(frontend): loading indicator on empty div too close to the top 2025-02-14 00:03:43 -03:00
Herculino Trotta
038438fba7 insights (wip) 2025-02-12 09:48:31 -03:00
Dimitri Decrock
ee98a5ef12 update NL to version 0.9.4 2025-02-12 06:59:28 +01:00
Herculino Trotta
28b12faaf0 fix(insights): sankey diagram inconsistent sizing 2025-02-11 00:40:37 -03:00
Herculino Trotta
d0f2742637 chore(frontend): install chartjs-chart-sankey 2025-02-11 00:37:48 -03:00
Herculino Trotta
9c55dac866 feat(insights): sankey diagram (WIP) 2025-02-11 00:37:30 -03:00
Herculino Trotta
e6d8b548b7 Merge pull request #157
fix(docker): procrastinate can't recover if it crashes in a running instance
2025-02-10 23:13:33 -03:00
Herculino Trotta
4f8c2215c1 fix(docker): procrastinate can't recover if it crashes in a running instance 2025-02-10 23:13:16 -03:00
Herculino Trotta
851b34f07a Merge pull request #156 from eitchtee/dev
fix(transactions): paying transaction doesn't trigger update rules
2025-02-09 23:38:58 -03:00
Herculino Trotta
546ed5c6af fix(transactions): bulk (un)paying transactions doesn't trigger update rules 2025-02-09 23:38:22 -03:00
Herculino Trotta
04ae7337f5 fix(transactions): paying transaction doesn't trigger update rules 2025-02-09 23:33:57 -03:00
Herculino Trotta
a3a8791e96 feat(insights): create app 2025-02-09 23:00:33 -03:00
Herculino Trotta
63069f0ec9 Merge pull request #155 from eitchtee/dev
refactor: don't display currency code
2025-02-09 19:50:09 -03:00
Herculino Trotta
32b522dad2 refactor: don't display currency code 2025-02-09 19:49:47 -03:00
Herculino Trotta
0c20a079e3 Merge pull request #154 from eitchtee/dev
locale: update locales
2025-02-09 17:31:03 -03:00
Herculino Trotta
7c9697f683 locale: update locales 2025-02-09 17:30:39 -03:00
Herculino Trotta
15d04230ae Merge pull request #153
feat(monthly): add quick-search field
2025-02-09 17:14:44 -03:00
Herculino Trotta
ecc09ca6a6 feat(monthly): add quick-search field 2025-02-09 17:14:25 -03:00
Herculino Trotta
cd753c5dd5 Merge pull request #152 from luzpaz/readme-typos
fix: typos in README
2025-02-09 10:55:54 -03:00
luzpaz
a3b9952f80 fix: typos in README
Found via `codespell -q 3 -S "*.po" -L bu,nome,vew`
2025-02-09 09:47:03 +00:00
79 changed files with 5537 additions and 1366 deletions

View File

@@ -80,7 +80,7 @@ $ docker compose exec -it web python manage.py createsuperuser
```
> [!NOTE]
> If you're using Unraid, you don't need to follow these steps, use the app on the store. Make sure to read the [Unraid section](#unraid) and [Enviroment Variables](#enviroment-variables) for an explanation of all available variables
> If you're using Unraid, you don't need to follow these steps, use the app on the store. Make sure to read the [Unraid section](#unraid) and [Environment Variables](#environment-variables) for an explanation of all available variables
## Running locally
@@ -110,7 +110,7 @@ WYGIWYH is available on the Unraid Store. You'll need to provision your own post
To create the first user, open the container's console using Unraid's UI, by clicking on WYGIWYH icon on the Docker page and selecting `Console`, then type `python manage.py createsuperuser`, you'll them be prompted to input your e-mail and password.
## Enviroment Variables
## Environment Variables
| variable | type | default | explanation |
|-------------------------------|-------------|-----------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
@@ -122,7 +122,7 @@ To create the first user, open the container's console using Unraid's UI, by cli
| SQL_DATABASE | string | None *required | The name of your postgres database |
| SQL_USER | string | user | The username used to connect to your postgres database |
| SQL_PASSWORD | string | password | The password used to connect to your postgres database |
| SQL_HOST | string | localhost | The adress used to connect to your postgres database |
| SQL_HOST | string | localhost | The address used to connect to your postgres database |
| SQL_PORT | string | 5432 | The port used to connect to your postgres database |
| SESSION_EXPIRY_TIME | int | 2678400 (31 days) | The age of session cookies, in seconds. E.g. how long you will stay logged in |
| ENABLE_SOFT_DELETE | true\|false | false | Whether to enable transactions soft delete, if enabled, deleted transactions will remain in the database. Useful for imports and avoiding duplicate entries. |

View File

@@ -55,6 +55,7 @@ INSTALLED_APPS = [
"hijack",
"hijack.contrib.admin",
"django_filters",
"import_export",
"apps.users.apps.UsersConfig",
"procrastinate.contrib.django",
"apps.transactions.apps.TransactionsConfig",
@@ -63,6 +64,7 @@ INSTALLED_APPS = [
"apps.common.apps.CommonConfig",
"apps.net_worth.apps.NetWorthConfig",
"apps.import_app.apps.ImportConfig",
"apps.export_app.apps.ExportConfig",
"apps.api.apps.ApiConfig",
"cachalot",
"rest_framework",

View File

@@ -49,4 +49,6 @@ urlpatterns = [
path("", include("apps.dca.urls")),
path("", include("apps.mini_tools.urls")),
path("", include("apps.import_app.urls")),
path("", include("apps.export_app.urls")),
path("", include("apps.insights.urls")),
]

View File

@@ -12,15 +12,14 @@ class DynamicModelChoiceField(forms.ModelChoiceField):
self.to_field_name = kwargs.pop("to_field_name", "pk")
self.create_field = kwargs.pop("create_field", None)
if not self.create_field:
raise ValueError("The 'create_field' parameter is required.")
self.queryset = kwargs.pop("queryset", model.objects.all())
super().__init__(queryset=self.queryset, *args, **kwargs)
self._created_instance = None
self.widget = TomSelect(clear_button=True, create=True)
super().__init__(queryset=self.queryset, *args, **kwargs)
self._created_instance = None
def to_python(self, value):
if value in self.empty_values:
return None
@@ -53,14 +52,19 @@ class DynamicModelChoiceField(forms.ModelChoiceField):
else:
raise self.model.DoesNotExist
except self.model.DoesNotExist:
try:
with transaction.atomic():
instance, _ = self.model.objects.update_or_create(
**{self.create_field: value}
if self.create_field:
try:
with transaction.atomic():
instance, _ = self.model.objects.update_or_create(
**{self.create_field: value}
)
self._created_instance = instance
return instance
except Exception as e:
raise ValidationError(
self.error_messages["invalid_choice"], code="invalid_choice"
)
self._created_instance = instance
return instance
except Exception as e:
else:
raise ValidationError(
self.error_messages["invalid_choice"], code="invalid_choice"
)

View File

@@ -19,6 +19,8 @@ class AirDatePickerInput(widgets.DateInput):
format=None,
clear_button=True,
auto_close=True,
read_only=True,
toggle_selected=None,
*args,
**kwargs,
):
@@ -26,6 +28,10 @@ class AirDatePickerInput(widgets.DateInput):
super().__init__(attrs=attrs, format=format, *args, **kwargs)
self.clear_button = clear_button
self.auto_close = auto_close
self.read_only = read_only
self.toggle_selected = (
toggle_selected if isinstance(toggle_selected, bool) else self.clear_button
)
@staticmethod
def _get_current_language():
@@ -47,9 +53,13 @@ class AirDatePickerInput(widgets.DateInput):
attrs["data-now-button-txt"] = _("Today")
attrs["data-auto-close"] = str(self.auto_close).lower()
attrs["data-clear-button"] = str(self.clear_button).lower()
attrs["data-toggle-selected"] = str(self.toggle_selected).lower()
attrs["data-language"] = self._get_current_language()
attrs["data-date-format"] = django_to_airdatepicker_datetime(self._get_format())
if self.read_only:
attrs["readonly"] = True
return attrs
def format_value(self, value):
@@ -89,6 +99,8 @@ class AirDateTimePickerInput(widgets.DateTimeInput):
timepicker=True,
clear_button=True,
auto_close=True,
read_only=True,
toggle_selected=None,
*args,
**kwargs,
):
@@ -97,6 +109,10 @@ class AirDateTimePickerInput(widgets.DateTimeInput):
self.timepicker = timepicker
self.clear_button = clear_button
self.auto_close = auto_close
self.read_only = read_only
self.toggle_selected = (
toggle_selected if isinstance(toggle_selected, bool) else self.clear_button
)
@staticmethod
def _get_current_language():
@@ -123,11 +139,15 @@ class AirDateTimePickerInput(widgets.DateTimeInput):
attrs["data-now-button-txt"] = _("Now")
attrs["data-timepicker"] = str(self.timepicker).lower()
attrs["data-auto-close"] = str(self.auto_close).lower()
attrs["data-toggle-selected"] = str(self.toggle_selected).lower()
attrs["data-clear-button"] = str(self.clear_button).lower()
attrs["data-language"] = self._get_current_language()
attrs["data-date-format"] = date_format
attrs["data-time-format"] = time_format
if self.read_only:
attrs["readonly"] = True
return attrs
def format_value(self, value):
@@ -227,3 +247,56 @@ class AirMonthYearPickerInput(AirDatePickerInput):
except (ValueError, KeyError):
return None
return None
class AirYearPickerInput(AirDatePickerInput):
def __init__(self, attrs=None, format=None, *args, **kwargs):
super().__init__(attrs=attrs, format=format, *args, **kwargs)
# Store the display format for AirDatepicker
self.display_format = "yyyy"
# Store the Python format for internal use
self.python_format = "%Y"
def build_attrs(self, base_attrs, extra_attrs=None):
attrs = super().build_attrs(base_attrs, extra_attrs)
# Add data attributes for AirDatepicker configuration
attrs["data-now-button-txt"] = _("Today")
attrs["data-date-format"] = "yyyy"
return attrs
def format_value(self, value):
"""Format the value for display in the widget."""
if value:
self.attrs["data-value"] = (
value # We use this to dynamically select the initial date on AirDatePicker
)
if value is None:
return ""
if isinstance(value, str):
try:
value = datetime.datetime.strptime(value, "%Y-%m-%d").date()
except ValueError:
return value
if isinstance(value, (datetime.datetime, datetime.date)):
# Use Django's date translation
return f"{value.year}"
return value
def value_from_datadict(self, data, files, name):
"""Convert the value from the widget format back to a format Django can handle."""
value = super().value_from_datadict(data, files, name)
if value:
try:
# Split the value into month name and year
year_str = value
year = int(year_str)
if year:
# Return the first day of the month in Django's expected format
return datetime.date(year, 1, 1).strftime("%Y-%m-%d")
except (ValueError, KeyError):
return None
return None

View File

@@ -1,4 +1,5 @@
from django.forms import widgets, SelectMultiple
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
@@ -129,3 +130,28 @@ class TomSelect(widgets.Select):
class TomSelectMultiple(SelectMultiple, TomSelect):
pass
class TransactionSelect(TomSelect):
def __init__(self, income: bool = True, expense: bool = True, *args, **kwargs):
super().__init__(*args, **kwargs)
self.load_income = income
self.load_expense = expense
self.create = False
def build_attrs(self, base_attrs, extra_attrs=None):
attrs = super().build_attrs(base_attrs, extra_attrs)
if self.load_income and self.load_expense:
attrs["data-load"] = reverse("transactions_search")
elif self.load_income and not self.load_expense:
attrs["data-load"] = reverse(
"transactions_search", kwargs={"filter_type": "income"}
)
elif self.load_expense and not self.load_income:
attrs["data-load"] = reverse(
"transactions_search", kwargs={"filter_type": "expenses"}
)
return attrs

View File

@@ -1,14 +1,22 @@
from crispy_forms.bootstrap import FormActions
from crispy_bootstrap5.bootstrap5 import Switch, BS5Accordion
from crispy_forms.bootstrap import FormActions, AccordionGroup
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Row, Column
from crispy_forms.layout import Layout, Row, Column, HTML
from django import forms
from django.utils.translation import gettext_lazy as _
from apps.accounts.models import Account
from apps.common.widgets.crispy.submit import NoClassSubmit
from apps.common.widgets.datepicker import AirDatePickerInput
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
from apps.common.widgets.tom_select import TomSelect
from apps.dca.models import DCAStrategy, DCAEntry
from apps.common.widgets.tom_select import TransactionSelect
from apps.transactions.models import Transaction, TransactionTag, TransactionCategory
from apps.common.fields.forms.dynamic_select import (
DynamicModelChoiceField,
DynamicModelMultipleChoiceField,
)
class DCAStrategyForm(forms.ModelForm):
@@ -53,6 +61,75 @@ class DCAStrategyForm(forms.ModelForm):
class DCAEntryForm(forms.ModelForm):
create_transaction = forms.BooleanField(
label=_("Create transaction"), initial=False, required=False
)
from_account = forms.ModelChoiceField(
queryset=Account.objects.filter(is_archived=False),
label=_("From Account"),
widget=TomSelect(clear_button=False, group_by="group"),
required=False,
)
to_account = forms.ModelChoiceField(
queryset=Account.objects.filter(is_archived=False),
label=_("To Account"),
widget=TomSelect(clear_button=False, group_by="group"),
required=False,
)
from_category = DynamicModelChoiceField(
create_field="name",
model=TransactionCategory,
required=False,
label=_("Category"),
queryset=TransactionCategory.objects.filter(active=True),
)
to_category = DynamicModelChoiceField(
create_field="name",
model=TransactionCategory,
required=False,
label=_("Category"),
queryset=TransactionCategory.objects.filter(active=True),
)
from_tags = DynamicModelMultipleChoiceField(
model=TransactionTag,
to_field_name="name",
create_field="name",
required=False,
label=_("Tags"),
queryset=TransactionTag.objects.filter(active=True),
)
to_tags = DynamicModelMultipleChoiceField(
model=TransactionTag,
to_field_name="name",
create_field="name",
required=False,
label=_("Tags"),
queryset=TransactionTag.objects.filter(active=True),
)
expense_transaction = DynamicModelChoiceField(
model=Transaction,
to_field_name="id",
label=_("Expense Transaction"),
required=False,
queryset=Transaction.objects.none(),
widget=TransactionSelect(clear_button=True, income=False, expense=True),
help_text=_("Type to search for a transaction to link to this entry"),
)
income_transaction = DynamicModelChoiceField(
model=Transaction,
to_field_name="id",
label=_("Income Transaction"),
required=False,
queryset=Transaction.objects.none(),
widget=TransactionSelect(clear_button=True, income=True, expense=False),
help_text=_("Type to search for a transaction to link to this entry"),
)
class Meta:
model = DCAEntry
fields = [
@@ -60,13 +137,19 @@ class DCAEntryForm(forms.ModelForm):
"amount_paid",
"amount_received",
"notes",
"expense_transaction",
"income_transaction",
]
widgets = {
"notes": forms.Textarea(attrs={"rows": 3}),
}
def __init__(self, *args, **kwargs):
strategy = kwargs.pop("strategy", None)
super().__init__(*args, **kwargs)
self.strategy = strategy if strategy else self.instance.strategy
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.layout = Layout(
@@ -75,18 +158,66 @@ class DCAEntryForm(forms.ModelForm):
Column("amount_paid", css_class="form-group col-md-6"),
Column("amount_received", css_class="form-group col-md-6"),
),
Row(
Column("expense_transaction", css_class="form-group col-md-6"),
Column("income_transaction", css_class="form-group col-md-6"),
),
"notes",
BS5Accordion(
AccordionGroup(
_("Create transaction"),
Switch("create_transaction"),
Row(
Column(
Row(
Column(
"from_account",
css_class="form-group col-md-6 mb-0",
),
css_class="form-row",
),
Row(
Column(
"from_category",
css_class="form-group col-md-6 mb-0",
),
Column(
"from_tags", css_class="form-group col-md-6 mb-0"
),
css_class="form-row",
),
),
css_class="p-1 mx-1 my-3 border rounded-3",
),
Row(
Column(
Row(
Column(
"to_account",
css_class="form-group col-md-6 mb-0",
),
css_class="form-row",
),
Row(
Column(
"to_category", css_class="form-group col-md-6 mb-0"
),
Column("to_tags", css_class="form-group col-md-6 mb-0"),
css_class="form-row",
),
),
css_class="p-1 mx-1 my-3 border rounded-3",
),
active=False,
),
AccordionGroup(
_("Link transaction"),
"income_transaction",
"expense_transaction",
),
flush=False,
always_open=False,
css_class="mb-3",
),
)
if self.instance and self.instance.pk:
# decimal_places = self.instance.account.currency.decimal_places
# self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput(
# decimal_places=decimal_places
# )
self.helper.layout.append(
FormActions(
NoClassSubmit(
@@ -95,7 +226,6 @@ class DCAEntryForm(forms.ModelForm):
),
)
else:
# self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput()
self.helper.layout.append(
FormActions(
NoClassSubmit(
@@ -107,3 +237,118 @@ class DCAEntryForm(forms.ModelForm):
self.fields["amount_paid"].widget = ArbitraryDecimalDisplayNumberInput()
self.fields["amount_received"].widget = ArbitraryDecimalDisplayNumberInput()
self.fields["date"].widget = AirDatePickerInput(clear_button=False)
expense_transaction = None
income_transaction = None
if self.instance and self.instance.pk:
# Edit mode - get from instance
expense_transaction = self.instance.expense_transaction
income_transaction = self.instance.income_transaction
elif self.data.get("expense_transaction"):
# Form validation - get from submitted data
try:
expense_transaction = Transaction.objects.get(
id=self.data["expense_transaction"]
)
income_transaction = Transaction.objects.get(
id=self.data["income_transaction"]
)
except Transaction.DoesNotExist:
pass
# If we have a current transaction, ensure it's in the queryset
if income_transaction:
self.fields["income_transaction"].queryset = Transaction.objects.filter(
id=income_transaction.id
)
if expense_transaction:
self.fields["expense_transaction"].queryset = Transaction.objects.filter(
id=expense_transaction.id
)
def clean(self):
cleaned_data = super().clean()
if cleaned_data.get("create_transaction"):
from_account = cleaned_data.get("from_account")
to_account = cleaned_data.get("to_account")
if not from_account and not to_account:
raise forms.ValidationError(
{
"from_account": _("You must provide an account."),
"to_account": _("You must provide an account."),
}
)
elif not from_account and to_account:
raise forms.ValidationError(
{"from_account": _("You must provide an account.")}
)
elif not to_account and from_account:
raise forms.ValidationError(
{"to_account": _("You must provide an account.")}
)
if from_account == to_account:
raise forms.ValidationError(
_("From and To accounts must be different.")
)
return cleaned_data
def save(self, **kwargs):
instance = super().save(commit=False)
if self.cleaned_data.get("create_transaction"):
from_account = self.cleaned_data["from_account"]
to_account = self.cleaned_data["to_account"]
from_amount = instance.amount_paid
to_amount = instance.amount_received
date = instance.date
description = _("DCA for %(strategy_name)s") % {
"strategy_name": self.strategy.name
}
from_category = self.cleaned_data.get("from_category")
to_category = self.cleaned_data.get("to_category")
notes = self.cleaned_data.get("notes")
# Create "From" transaction
from_transaction = Transaction.objects.create(
account=from_account,
type=Transaction.Type.EXPENSE,
is_paid=True,
date=date,
amount=from_amount,
description=description,
category=from_category,
notes=notes,
)
from_transaction.tags.set(self.cleaned_data.get("from_tags", []))
# Create "To" transaction
to_transaction = Transaction.objects.create(
account=to_account,
type=Transaction.Type.INCOME,
is_paid=True,
date=date,
amount=to_amount,
description=description,
category=to_category,
notes=notes,
)
to_transaction.tags.set(self.cleaned_data.get("to_tags", []))
instance.expense_transaction = from_transaction
instance.income_transaction = to_transaction
else:
if instance.expense_transaction:
instance.expense_transaction.amount = instance.amount_paid
instance.expense_transaction.save()
if instance.income_transaction:
instance.income_transaction.amount = instance.amount_received
instance.income_transaction.save()
instance.strategy = self.strategy
instance.save()
return instance

View File

@@ -155,11 +155,9 @@ def strategy_detail(request, strategy_id):
def strategy_entry_add(request, strategy_id):
strategy = get_object_or_404(DCAStrategy, id=strategy_id)
if request.method == "POST":
form = DCAEntryForm(request.POST)
form = DCAEntryForm(request.POST, strategy=strategy)
if form.is_valid():
entry = form.save(commit=False)
entry.strategy = strategy
entry.save()
entry = form.save()
messages.success(request, _("Entry added successfully"))
return HttpResponse(
@@ -169,7 +167,7 @@ def strategy_entry_add(request, strategy_id):
},
)
else:
form = DCAEntryForm()
form = DCAEntryForm(strategy=strategy)
return render(
request,

View File

View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ExportConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.export_app"

View File

@@ -0,0 +1,189 @@
from crispy_forms.bootstrap import FormActions
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, HTML
from django import forms
from django.utils.translation import gettext_lazy as _
from apps.common.widgets.crispy.submit import NoClassSubmit
class ExportForm(forms.Form):
accounts = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("Accounts"),
initial=True,
)
currencies = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("Currencies"),
initial=True,
)
transactions = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("Transactions"),
initial=True,
)
categories = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("Categories"),
initial=True,
)
tags = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("Tags"),
initial=False,
)
entities = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("Entities"),
initial=False,
)
recurring_transactions = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("Recurring Transactions"),
initial=True,
)
installment_plans = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("Installment Plans"),
initial=True,
)
exchange_rates = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("Exchange Rates"),
initial=False,
)
exchange_rates_services = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("Automatic Exchange Rates"),
initial=False,
)
rules = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("Rules"),
initial=True,
)
dca = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("DCA"),
initial=False,
)
import_profiles = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("Import Profiles"),
initial=True,
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.form_method = "post"
self.helper.layout = Layout(
"accounts",
"currencies",
"transactions",
"categories",
"entities",
"tags",
"installment_plans",
"recurring_transactions",
"exchange_rates_services",
"exchange_rates",
"rules",
"dca",
"import_profiles",
FormActions(
NoClassSubmit(
"submit", _("Export"), css_class="btn btn-outline-primary w-100"
),
),
)
class RestoreForm(forms.Form):
zip_file = forms.FileField(
required=False,
help_text=_("Import a ZIP file exported from WYGIWYH"),
label=_("ZIP File"),
)
accounts = forms.FileField(required=False, label=_("Accounts"))
currencies = forms.FileField(required=False, label=_("Currencies"))
transactions_categories = forms.FileField(required=False, label=_("Categories"))
transactions_tags = forms.FileField(required=False, label=_("Tags"))
transactions_entities = forms.FileField(required=False, label=_("Entities"))
transactions = forms.FileField(required=False, label=_("Transactions"))
installment_plans = forms.FileField(required=False, label=_("Installment Plans"))
recurring_transactions = forms.FileField(
required=False, label=_("Recurring Transactions")
)
automatic_exchange_rates = forms.FileField(
required=False, label=_("Automatic Exchange Rates")
)
exchange_rates = forms.FileField(required=False, label=_("Exchange Rates"))
transaction_rules = forms.FileField(required=False, label=_("Transaction rules"))
transaction_rules_actions = forms.FileField(
required=False, label=_("Edit transaction action")
)
transaction_rules_update_or_create = forms.FileField(
required=False, label=_("Update or create transaction actions")
)
dca_strategies = forms.FileField(required=False, label=_("DCA Strategies"))
dca_entries = forms.FileField(required=False, label=_("DCA Entries"))
import_profiles = forms.FileField(required=False, label=_("Import Profiles"))
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.form_method = "post"
self.helper.layout = Layout(
"zip_file",
HTML("<hr />"),
"accounts",
"currencies",
"transactions",
"transactions_categories",
"transactions_entities",
"transactions_tags",
"installment_plans",
"recurring_transactions",
"automatic_exchange_rates",
"exchange_rates",
"transaction_rules",
"transaction_rules_actions",
"transaction_rules_update_or_create",
"dca_strategies",
"dca_entries",
"import_profiles",
FormActions(
NoClassSubmit(
"submit", _("Restore"), css_class="btn btn-outline-primary w-100"
),
),
)
def clean(self):
cleaned_data = super().clean()
if not cleaned_data.get("zip_file") and not any(
cleaned_data.get(field) for field in self.fields if field != "zip_file"
):
raise forms.ValidationError(
_("Please upload either a ZIP file or at least one CSV file")
)
return cleaned_data

View File

@@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

View File

@@ -0,0 +1,26 @@
from import_export import fields, resources, widgets
from apps.accounts.models import Account, AccountGroup
from apps.export_app.widgets.foreign_key import AutoCreateForeignKeyWidget
from apps.currencies.models import Currency
class AccountResource(resources.ModelResource):
group = fields.Field(
attribute="group",
column_name="group",
widget=AutoCreateForeignKeyWidget(AccountGroup, "name"),
)
currency = fields.Field(
attribute="currency",
column_name="currency",
widget=widgets.ForeignKeyWidget(Currency, "name"),
)
exchange_currency = fields.Field(
attribute="exchange_currency",
column_name="exchange_currency",
widget=widgets.ForeignKeyWidget(Currency, "name"),
)
class Meta:
model = Account

View File

@@ -0,0 +1,47 @@
from import_export import fields, resources, widgets
from apps.accounts.models import Account
from apps.currencies.models import Currency, ExchangeRate, ExchangeRateService
class CurrencyResource(resources.ModelResource):
exchange_currency = fields.Field(
attribute="exchange_currency",
column_name="exchange_currency",
widget=widgets.ForeignKeyWidget(Currency, "name"),
)
class Meta:
model = Currency
class ExchangeRateResource(resources.ModelResource):
from_currency = fields.Field(
attribute="from_currency",
column_name="from_currency",
widget=widgets.ForeignKeyWidget(Currency, "name"),
)
to_currency = fields.Field(
attribute="to_currency",
column_name="to_currency",
widget=widgets.ForeignKeyWidget(Currency, "name"),
)
class Meta:
model = ExchangeRate
class ExchangeRateServiceResource(resources.ModelResource):
target_currencies = fields.Field(
attribute="target_currencies",
column_name="target_currencies",
widget=widgets.ManyToManyWidget(Currency, field="name"),
)
target_accounts = fields.Field(
attribute="target_accounts",
column_name="target_accounts",
widget=widgets.ManyToManyWidget(Account, field="name"),
)
class Meta:
model = ExchangeRateService

View File

@@ -0,0 +1,26 @@
from import_export import fields, resources
from import_export.widgets import ForeignKeyWidget
from apps.dca.models import DCAStrategy, DCAEntry
from apps.currencies.models import Currency
class DCAStrategyResource(resources.ModelResource):
target_currency = fields.Field(
attribute="target_currency",
column_name="target_currency",
widget=ForeignKeyWidget(Currency, "name"),
)
payment_currency = fields.Field(
attribute="payment_currency",
column_name="payment_currency",
widget=ForeignKeyWidget(Currency, "name"),
)
class Meta:
model = DCAStrategy
class DCAEntryResource(resources.ModelResource):
class Meta:
model = DCAEntry

View File

@@ -0,0 +1,8 @@
from import_export import resources
from apps.import_app.models import ImportProfile
class ImportProfileResource(resources.ModelResource):
class Meta:
model = ImportProfile

View File

@@ -0,0 +1,25 @@
from import_export import fields, resources
from import_export.widgets import ForeignKeyWidget
from apps.export_app.widgets.foreign_key import AutoCreateForeignKeyWidget
from apps.export_app.widgets.many_to_many import AutoCreateManyToManyWidget
from apps.rules.models import (
TransactionRule,
TransactionRuleAction,
UpdateOrCreateTransactionRuleAction,
)
class TransactionRuleResource(resources.ModelResource):
class Meta:
model = TransactionRule
class TransactionRuleActionResource(resources.ModelResource):
class Meta:
model = TransactionRuleAction
class UpdateOrCreateTransactionRuleResource(resources.ModelResource):
class Meta:
model = UpdateOrCreateTransactionRuleAction

View File

@@ -0,0 +1,124 @@
from import_export import fields, resources
from import_export.widgets import ForeignKeyWidget
from apps.accounts.models import Account
from apps.export_app.widgets.foreign_key import AutoCreateForeignKeyWidget
from apps.export_app.widgets.many_to_many import AutoCreateManyToManyWidget
from apps.export_app.widgets.string import EmptyStringToNoneField
from apps.transactions.models import (
Transaction,
TransactionCategory,
TransactionTag,
TransactionEntity,
RecurringTransaction,
InstallmentPlan,
)
class TransactionResource(resources.ModelResource):
account = fields.Field(
attribute="account",
column_name="account",
widget=ForeignKeyWidget(Account, "name"),
)
category = fields.Field(
attribute="category",
column_name="category",
widget=AutoCreateForeignKeyWidget(TransactionCategory, "name"),
)
tags = fields.Field(
attribute="tags",
column_name="tags",
widget=AutoCreateManyToManyWidget(TransactionTag, field="name"),
)
entities = fields.Field(
attribute="entities",
column_name="entities",
widget=AutoCreateManyToManyWidget(TransactionEntity, field="name"),
)
internal_id = EmptyStringToNoneField(
column_name="internal_id", attribute="internal_id"
)
class Meta:
model = Transaction
def get_queryset(self):
return Transaction.all_objects.all()
class TransactionTagResource(resources.ModelResource):
class Meta:
model = TransactionTag
class TransactionEntityResource(resources.ModelResource):
class Meta:
model = TransactionEntity
class TransactionCategoyResource(resources.ModelResource):
class Meta:
model = TransactionCategory
class RecurringTransactionResource(resources.ModelResource):
account = fields.Field(
attribute="account",
column_name="account",
widget=ForeignKeyWidget(Account, "name"),
)
category = fields.Field(
attribute="category",
column_name="category",
widget=AutoCreateForeignKeyWidget(TransactionCategory, "name"),
)
tags = fields.Field(
attribute="tags",
column_name="tags",
widget=AutoCreateManyToManyWidget(TransactionTag, field="name"),
)
entities = fields.Field(
attribute="entities",
column_name="entities",
widget=AutoCreateManyToManyWidget(TransactionEntity, field="name"),
)
class Meta:
model = RecurringTransaction
class InstallmentPlanResource(resources.ModelResource):
account = fields.Field(
attribute="account",
column_name="account",
widget=ForeignKeyWidget(Account, "name"),
)
category = fields.Field(
attribute="category",
column_name="category",
widget=AutoCreateForeignKeyWidget(TransactionCategory, "name"),
)
tags = fields.Field(
attribute="tags",
column_name="tags",
widget=AutoCreateManyToManyWidget(TransactionTag, field="name"),
)
entities = fields.Field(
attribute="entities",
column_name="entities",
widget=AutoCreateManyToManyWidget(TransactionEntity, field="name"),
)
class Meta:
model = InstallmentPlan

View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@@ -0,0 +1,8 @@
from django.urls import path
import apps.export_app.views as views
urlpatterns = [
path("export/", views.export_index, name="export_index"),
path("export/form/", views.export_form, name="export_form"),
path("export/restore/", views.import_form, name="restore_form"),
]

View File

@@ -0,0 +1,285 @@
import logging
import zipfile
from io import BytesIO, TextIOWrapper
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.db import transaction
from django.http import HttpResponse
from django.shortcuts import render
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_http_methods
from tablib import Dataset
from apps.export_app.forms import ExportForm, RestoreForm
from apps.export_app.resources.accounts import AccountResource
from apps.export_app.resources.transactions import (
TransactionResource,
TransactionTagResource,
TransactionEntityResource,
TransactionCategoyResource,
InstallmentPlanResource,
RecurringTransactionResource,
)
from apps.export_app.resources.currencies import (
CurrencyResource,
ExchangeRateResource,
ExchangeRateServiceResource,
)
from apps.export_app.resources.rules import (
TransactionRuleResource,
TransactionRuleActionResource,
UpdateOrCreateTransactionRuleResource,
)
from apps.export_app.resources.dca import (
DCAStrategyResource,
DCAEntryResource,
)
from apps.export_app.resources.import_app import (
ImportProfileResource,
)
from apps.common.decorators.htmx import only_htmx
logger = logging.getLogger()
@login_required
@require_http_methods(["GET"])
def export_index(request):
return render(request, "export_app/pages/index.html")
@login_required
@require_http_methods(["GET", "POST"])
def export_form(request):
timestamp = timezone.localtime(timezone.now()).strftime("%Y-%m-%dT%H-%M-%S")
if request.method == "POST":
form = ExportForm(request.POST)
if form.is_valid():
zip_buffer = BytesIO()
export_accounts = form.cleaned_data.get("accounts", False)
export_currencies = form.cleaned_data.get("currencies", False)
export_transactions = form.cleaned_data.get("transactions", False)
export_categories = form.cleaned_data.get("categories", False)
export_tags = form.cleaned_data.get("tags", False)
export_entities = form.cleaned_data.get("entities", False)
export_installment_plans = form.cleaned_data.get("installment_plans", False)
export_recurring_transactions = form.cleaned_data.get(
"recurring_transactions", False
)
export_exchange_rates_services = form.cleaned_data.get(
"exchange_rates_services", False
)
export_exchange_rates = form.cleaned_data.get("exchange_rates", False)
export_rules = form.cleaned_data.get("rules", False)
export_dca = form.cleaned_data.get("dca", False)
export_import_profiles = form.cleaned_data.get("import_profiles", False)
exports = []
if export_accounts:
exports.append((AccountResource().export(), "accounts"))
if export_currencies:
exports.append((CurrencyResource().export(), "currencies"))
if export_transactions:
exports.append((TransactionResource().export(), "transactions"))
if export_categories:
exports.append(
(TransactionCategoyResource().export(), "transactions_categories")
)
if export_tags:
exports.append((TransactionTagResource().export(), "transactions_tags"))
if export_entities:
exports.append(
(TransactionEntityResource().export(), "transactions_entities")
)
if export_installment_plans:
exports.append(
(InstallmentPlanResource().export(), "installment_plans")
)
if export_recurring_transactions:
exports.append(
(RecurringTransactionResource().export(), "recurring_transactions")
)
if export_exchange_rates_services:
exports.append(
(ExchangeRateServiceResource().export(), "automatic_exchange_rates")
)
if export_exchange_rates:
exports.append((ExchangeRateResource().export(), "exchange_rates"))
if export_rules:
exports.append(
(TransactionRuleResource().export(), "transaction_rules")
)
exports.append(
(
TransactionRuleActionResource().export(),
"transaction_rules_actions",
)
)
exports.append(
(
UpdateOrCreateTransactionRuleResource().export(),
"transaction_rules_update_or_create",
)
)
if export_dca:
exports.append((DCAStrategyResource().export(), "dca_strategies"))
exports.append(
(
DCAEntryResource().export(),
"dca_entries",
)
)
if export_import_profiles:
exports.append((ImportProfileResource().export(), "import_profiles"))
if len(exports) >= 2:
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
for dataset, name in exports:
zip_file.writestr(f"{name}.csv", dataset.csv)
response = HttpResponse(
zip_buffer.getvalue(),
content_type="application/zip",
headers={
"HX-Trigger": "hide_offcanvas, updated",
"Content-Disposition": f'attachment; filename="{timestamp}_WYGIWYH_export.zip"',
},
)
return response
elif len(exports) == 1:
dataset, name = exports[0]
response = HttpResponse(
dataset.csv,
content_type="text/csv",
headers={
"HX-Trigger": "hide_offcanvas, updated",
"Content-Disposition": f'attachment; filename="{timestamp}_WYGIWYH_export_{name}.csv"',
},
)
return response
else:
return HttpResponse(
_("You have to select at least one export"),
)
else:
form = ExportForm()
return render(request, "export_app/fragments/export.html", context={"form": form})
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def import_form(request):
if request.method == "POST":
form = RestoreForm(request.POST, request.FILES)
if form.is_valid():
try:
process_imports(request, form.cleaned_data)
messages.success(request, _("Data restored successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "hide_offcanvas, updated",
},
)
except Exception as e:
logger.error("Error importing", exc_info=e)
messages.error(
request,
_(
"There was an error restoring your data. Check the logs for more details."
),
)
else:
form = RestoreForm()
response = render(request, "export_app/fragments/restore.html", {"form": form})
response["HX-Trigger"] = "updated"
return response
def process_imports(request, cleaned_data):
# Define import order to handle dependencies
import_order = [
("currencies", CurrencyResource),
(
"currencies",
CurrencyResource,
), # We do a double pass because exchange_currency may not exist when currency is initially created
("accounts", AccountResource),
("transactions_categories", TransactionCategoyResource),
("transactions_tags", TransactionTagResource),
("transactions_entities", TransactionEntityResource),
("automatic_exchange_rates", ExchangeRateServiceResource),
("exchange_rates", ExchangeRateResource),
("installment_plans", InstallmentPlanResource),
("recurring_transactions", RecurringTransactionResource),
("transactions", TransactionResource),
("dca_strategies", DCAStrategyResource),
("dca_entries", DCAEntryResource),
("import_profiles", ImportProfileResource),
("transaction_rules", TransactionRuleResource),
("transaction_rules_actions", TransactionRuleActionResource),
("transaction_rules_update_or_create", UpdateOrCreateTransactionRuleResource),
]
def import_dataset(content, resource_class, field_name):
try:
# Create a new resource instance
resource = resource_class()
# Create dataset from CSV content
dataset = Dataset()
dataset.load(content, format="csv")
# Debug logging
logger.debug(f"Importing {field_name}")
logger.debug(f"Headers: {dataset.headers}")
logger.debug(f"First row: {dataset[0] if len(dataset) > 0 else 'No data'}")
# Perform the import
result = resource.import_data(
dataset,
dry_run=False,
raise_errors=True,
collect_failed_rows=True,
use_transactions=False,
skip_unchanged=True,
)
if result.has_errors():
raise ImportError(f"Failed rows: {result.failed_dataset}")
return result
except Exception as e:
logger.error(f"Error importing {field_name}: {str(e)}")
raise ImportError(f"Error importing {field_name}: {str(e)}")
with transaction.atomic():
if zip_file := cleaned_data.get("zip_file"):
# Process ZIP file
with zipfile.ZipFile(zip_file) as z:
for filename in z.namelist():
name = filename.replace(".csv", "")
with z.open(filename) as f:
content = f.read().decode("utf-8")
for field_name, resource_class in import_order:
if name == field_name:
import_dataset(content, resource_class, field_name)
break
else:
# Process individual files
for field_name, resource_class in import_order:
if csv_file := cleaned_data.get(field_name):
content = csv_file.read().decode("utf-8")
import_dataset(content, resource_class, field_name)

View File

View File

@@ -0,0 +1,11 @@
from import_export.widgets import ForeignKeyWidget
class AutoCreateForeignKeyWidget(ForeignKeyWidget):
def clean(self, value, row=None, *args, **kwargs):
if value:
try:
return super().clean(value, row, **kwargs)
except self.model.DoesNotExist:
return self.model.objects.create(name=value)
return None

View File

@@ -0,0 +1,21 @@
from import_export.widgets import ManyToManyWidget
class AutoCreateManyToManyWidget(ManyToManyWidget):
def clean(self, value, row=None, *args, **kwargs):
if not value:
return []
values = value.split(self.separator)
cleaned_values = []
for val in values:
val = val.strip()
if val:
try:
obj = self.model.objects.get(**{self.field: val})
except self.model.DoesNotExist:
obj = self.model.objects.create(name=val)
cleaned_values.append(obj)
return cleaned_values

View File

@@ -0,0 +1,7 @@
from import_export import fields
class EmptyStringToNoneField(fields.Field):
def clean(self, data, **kwargs):
value = super().clean(data)
return None if value == "" else value

View File

View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class InsightsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.insights"

131
app/apps/insights/forms.py Normal file
View File

@@ -0,0 +1,131 @@
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Field, Row, Column
from django import forms
from django.utils.translation import gettext_lazy as _
from apps.common.widgets.datepicker import (
AirMonthYearPickerInput,
AirYearPickerInput,
AirDatePickerInput,
)
from apps.transactions.models import TransactionCategory
from apps.common.widgets.tom_select import TomSelect
class SingleMonthForm(forms.Form):
month = forms.DateField(
widget=AirMonthYearPickerInput(clear_button=False), label="", required=True
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.disable_csrf = True
self.helper.layout = Layout(Field("month"))
class SingleYearForm(forms.Form):
year = forms.DateField(
widget=AirYearPickerInput(clear_button=False), label="", required=True
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.disable_csrf = True
self.helper.layout = Layout(Field("year"))
class MonthRangeForm(forms.Form):
month_from = forms.DateField(
widget=AirMonthYearPickerInput(clear_button=False), label="", required=True
)
month_to = forms.DateField(
widget=AirMonthYearPickerInput(clear_button=False), label="", required=True
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.disable_csrf = True
self.helper.layout = Layout(
Row(
Column("month_from", css_class="form-group col-md-6"),
Column("month_to", css_class="form-group col-md-6"),
),
)
class YearRangeForm(forms.Form):
year_from = forms.DateField(
widget=AirYearPickerInput(clear_button=False), label="", required=True
)
year_to = forms.DateField(
widget=AirYearPickerInput(clear_button=False), label="", required=True
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.disable_csrf = True
self.helper.layout = Layout(
Row(
Column("year_from", css_class="form-group col-md-6"),
Column("year_to", css_class="form-group col-md-6"),
),
)
class DateRangeForm(forms.Form):
date_from = forms.DateField(
widget=AirDatePickerInput(clear_button=False), label="", required=True
)
date_to = forms.DateField(
widget=AirDatePickerInput(clear_button=False), label="", required=True
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.disable_csrf = True
self.helper.layout = Layout(
Row(
Column("date_from", css_class="form-group col-md-6"),
Column("date_to", css_class="form-group col-md-6"),
css_class="mb-0",
),
)
class CategoryForm(forms.Form):
category = forms.ModelChoiceField(
required=False,
label=_("Category"),
empty_label=_("Uncategorized"),
queryset=TransactionCategory.objects.filter(active=True),
widget=TomSelect(clear_button=True),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.disable_csrf = True
self.helper.layout = Layout("category")

View File

View File

@@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

32
app/apps/insights/urls.py Normal file
View File

@@ -0,0 +1,32 @@
from django.urls import path
from . import views
urlpatterns = [
path("insights/", views.index, name="insights_index"),
path(
"insights/sankey/account/",
views.sankey_by_account,
name="insights_sankey_by_account",
),
path(
"insights/sankey/currency/",
views.sankey_by_currency,
name="insights_sankey_by_currency",
),
path(
"insights/category-explorer/",
views.category_explorer_index,
name="category_explorer_index",
),
path(
"insights/category-explorer/account/",
views.category_sum_by_account,
name="category_sum_by_account",
),
path(
"insights/category-explorer/currency/",
views.category_sum_by_currency,
name="category_sum_by_currency",
),
]

View File

View File

@@ -0,0 +1,169 @@
from django.db.models import Sum, Case, When, F, DecimalField, Value
from django.db.models.functions import Coalesce
from django.utils.translation import gettext_lazy as _
def get_category_sums_by_account(queryset, category=None):
"""
Returns income/expense sums per account for a specific category.
"""
sums = (
queryset.filter(category=category)
.values("account__name")
.annotate(
current_income=Coalesce(
Sum(
Case(
When(type="IN", then="amount"),
When(is_paid=True, then="amount"),
default=Value(0),
output_field=DecimalField(max_digits=42, decimal_places=30),
)
),
Value(0),
output_field=DecimalField(max_digits=42, decimal_places=30),
),
current_expense=Coalesce(
Sum(
Case(
When(type="EX", then=-F("amount")),
When(is_paid=True, then="amount"),
default=Value(0),
output_field=DecimalField(max_digits=42, decimal_places=30),
)
),
Value(0),
output_field=DecimalField(max_digits=42, decimal_places=30),
),
projected_income=Coalesce(
Sum(
Case(
When(type="IN", then="amount"),
When(is_paid=False, then="amount"),
default=Value(0),
output_field=DecimalField(max_digits=42, decimal_places=30),
)
),
Value(0),
output_field=DecimalField(max_digits=42, decimal_places=30),
),
projected_expense=Coalesce(
Sum(
Case(
When(type="EX", then=-F("amount")),
When(is_paid=False, then="amount"),
default=Value(0),
output_field=DecimalField(max_digits=42, decimal_places=30),
)
),
Value(0),
output_field=DecimalField(max_digits=42, decimal_places=30),
),
)
.order_by("account__name")
)
return {
"labels": [item["account__name"] for item in sums],
"datasets": [
{
"label": _("Current Income"),
"data": [float(item["current_income"]) for item in sums],
},
{
"label": _("Current Expenses"),
"data": [float(item["current_expense"]) for item in sums],
},
{
"label": _("Projected Income"),
"data": [float(item["projected_income"]) for item in sums],
},
{
"label": _("Projected Expenses"),
"data": [float(item["projected_expense"]) for item in sums],
},
],
}
def get_category_sums_by_currency(queryset, category=None):
"""
Returns income/expense sums per currency for a specific category.
"""
sums = (
queryset.filter(category=category)
.values("account__currency__name")
.annotate(
current_income=Coalesce(
Sum(
Case(
When(type="IN", then="amount"),
When(is_paid=True, then="amount"),
default=Value(0),
output_field=DecimalField(max_digits=42, decimal_places=30),
)
),
Value(0),
output_field=DecimalField(max_digits=42, decimal_places=30),
),
current_expense=Coalesce(
Sum(
Case(
When(type="EX", then=-F("amount")),
When(is_paid=True, then="amount"),
default=Value(0),
output_field=DecimalField(max_digits=42, decimal_places=30),
)
),
Value(0),
output_field=DecimalField(max_digits=42, decimal_places=30),
),
projected_income=Coalesce(
Sum(
Case(
When(type="IN", then="amount"),
When(is_paid=False, then="amount"),
default=Value(0),
output_field=DecimalField(max_digits=42, decimal_places=30),
)
),
Value(0),
output_field=DecimalField(max_digits=42, decimal_places=30),
),
projected_expense=Coalesce(
Sum(
Case(
When(type="EX", then=-F("amount")),
When(is_paid=False, then="amount"),
default=Value(0),
output_field=DecimalField(max_digits=42, decimal_places=30),
)
),
Value(0),
output_field=DecimalField(max_digits=42, decimal_places=30),
),
)
.order_by("account__currency__name")
)
return {
"labels": [item["account__currency__name"] for item in sums],
"datasets": [
{
"label": _("Current Income"),
"data": [float(item["current_income"]) for item in sums],
},
{
"label": _("Current Expenses"),
"data": [float(item["current_expense"]) for item in sums],
},
{
"label": _("Projected Income"),
"data": [float(item["projected_income"]) for item in sums],
},
{
"label": _("Projected Expenses"),
"data": [float(item["projected_expense"]) for item in sums],
},
],
}

View File

@@ -0,0 +1,280 @@
from django.utils.translation import gettext_lazy as _
from decimal import Decimal
from typing import Dict, List, TypedDict
class SankeyNode(TypedDict):
name: str
class SankeyFlow(TypedDict):
from_node: str
to_node: str
flow: float
currency: Dict
original_amount: float
percentage: float
def generate_sankey_data_by_account(transactions_queryset):
"""
Generates Sankey diagram data from transaction queryset using account as intermediary.
"""
nodes: Dict[str, Dict] = {}
flows: List[SankeyFlow] = []
# Aggregate transactions
income_data = {} # {(category, currency, account) -> amount}
expense_data = {} # {(category, currency, account) -> amount}
total_income_by_currency = {} # {currency -> amount}
total_expense_by_currency = {} # {currency -> amount}
total_volume_by_currency = {} # {currency -> amount}
for transaction in transactions_queryset:
currency = transaction.account.currency
account = transaction.account
category = transaction.category or _("Uncategorized")
key = (category, currency, account)
amount = transaction.amount
if transaction.type == "IN":
income_data[key] = income_data.get(key, Decimal("0")) + amount
total_income_by_currency[currency] = (
total_income_by_currency.get(currency, Decimal("0")) + amount
)
else:
expense_data[key] = expense_data.get(key, Decimal("0")) + amount
total_expense_by_currency[currency] = (
total_expense_by_currency.get(currency, Decimal("0")) + amount
)
total_volume_by_currency[currency] = (
total_volume_by_currency.get(currency, Decimal("0")) + amount
)
unique_accounts = {
account_id: idx
for idx, account_id in enumerate(
transactions_queryset.values_list("account", flat=True).distinct()
)
}
def get_node_priority(node_id: str) -> int:
"""Get priority based on the account ID embedded in the node ID."""
account_id = int(node_id.split("_")[-1])
return unique_accounts[account_id]
def get_node_id(node_type: str, name: str, account_id: int) -> str:
"""Generate unique node ID."""
return f"{node_type}_{name}_{account_id}".lower().replace(" ", "_")
def add_node(node_id: str, display_name: str) -> None:
"""Add node with ID, display name and priority."""
nodes[node_id] = {
"id": node_id,
"name": display_name,
"priority": get_node_priority(node_id),
}
def add_flow(
from_node_id: str, to_node_id: str, amount: Decimal, currency, is_income: bool
) -> None:
"""
Add flow with percentage based on total transaction volume for the specific currency.
"""
total_volume = total_volume_by_currency.get(currency, Decimal("0"))
percentage = (amount / total_volume) * 100 if total_volume else 0
scaled_flow = percentage / 100
flows.append(
{
"from_node": from_node_id,
"to_node": to_node_id,
"flow": float(scaled_flow),
"currency": {
"code": currency.code,
"prefix": currency.prefix,
"suffix": currency.suffix,
"decimal_places": currency.decimal_places,
},
"original_amount": float(amount),
"percentage": float(percentage),
}
)
# Process income
for (category, currency, account), amount in income_data.items():
category_node_id = get_node_id("income", category, account.id)
account_node_id = get_node_id("account", account.name, account.id)
add_node(category_node_id, str(category))
add_node(account_node_id, account.name)
add_flow(category_node_id, account_node_id, amount, currency, is_income=True)
# Process expenses
for (category, currency, account), amount in expense_data.items():
category_node_id = get_node_id("expense", category, account.id)
account_node_id = get_node_id("account", account.name, account.id)
add_node(category_node_id, str(category))
add_node(account_node_id, account.name)
add_flow(account_node_id, category_node_id, amount, currency, is_income=False)
# Calculate and add savings flows
savings_data = {} # {(account, currency) -> amount}
for (category, currency, account), amount in income_data.items():
key = (account, currency)
savings_data[key] = savings_data.get(key, Decimal("0")) + amount
for (category, currency, account), amount in expense_data.items():
key = (account, currency)
savings_data[key] = savings_data.get(key, Decimal("0")) - amount
for (account, currency), amount in savings_data.items():
if amount > 0:
account_node_id = get_node_id("account", account.name, account.id)
savings_node_id = get_node_id("savings", _("Saved"), account.id)
add_node(savings_node_id, str(_("Saved")))
add_flow(account_node_id, savings_node_id, amount, currency, is_income=True)
# Calculate total across all currencies (for reference only)
total_amount = sum(float(amount) for amount in total_income_by_currency.values())
return {
"nodes": list(nodes.values()),
"flows": flows,
"total_amount": total_amount,
"total_by_currency": {
curr.code: float(amount)
for curr, amount in total_income_by_currency.items()
},
}
def generate_sankey_data_by_currency(transactions_queryset):
"""
Generates Sankey diagram data from transaction queryset, using currency as intermediary.
"""
nodes: Dict[str, Dict] = {}
flows: List[SankeyFlow] = []
# Aggregate transactions
income_data = {} # {(category, currency) -> amount}
expense_data = {} # {(category, currency) -> amount}
total_income_by_currency = {} # {currency -> amount}
total_expense_by_currency = {} # {currency -> amount}
total_volume_by_currency = {} # {currency -> amount}
for transaction in transactions_queryset:
currency = transaction.account.currency
category = transaction.category or _("Uncategorized")
key = (category, currency)
amount = transaction.amount
if transaction.type == "IN":
income_data[key] = income_data.get(key, Decimal("0")) + amount
total_income_by_currency[currency] = (
total_income_by_currency.get(currency, Decimal("0")) + amount
)
else:
expense_data[key] = expense_data.get(key, Decimal("0")) + amount
total_expense_by_currency[currency] = (
total_expense_by_currency.get(currency, Decimal("0")) + amount
)
total_volume_by_currency[currency] = (
total_volume_by_currency.get(currency, Decimal("0")) + amount
)
unique_currencies = {
currency_id: idx
for idx, currency_id in enumerate(
transactions_queryset.values_list("account__currency", flat=True).distinct()
)
}
def get_node_priority(node_id: str) -> int:
"""Get priority based on the currency ID embedded in the node ID."""
currency_id = int(node_id.split("_")[-1])
return unique_currencies[currency_id]
def get_node_id(node_type: str, name: str, currency_id: int) -> str:
"""Generate unique node ID including currency information."""
return f"{node_type}_{name}_{currency_id}".lower().replace(" ", "_")
def add_node(node_id: str, display_name: str) -> None:
"""Add node with ID, display name and priority."""
nodes[node_id] = {
"id": node_id,
"name": display_name,
"priority": get_node_priority(node_id),
}
def add_flow(
from_node_id: str, to_node_id: str, amount: Decimal, currency, is_income: bool
) -> None:
"""
Add flow with percentage based on total transaction volume for the specific currency.
"""
total_volume = total_volume_by_currency.get(currency, Decimal("0"))
percentage = (amount / total_volume) * 100 if total_volume else 0
scaled_flow = percentage / 100
flows.append(
{
"from_node": from_node_id,
"to_node": to_node_id,
"flow": float(scaled_flow),
"currency": {
"code": currency.code,
"name": currency.name,
"prefix": currency.prefix,
"suffix": currency.suffix,
"decimal_places": currency.decimal_places,
},
"original_amount": float(amount),
"percentage": float(percentage),
}
)
# Process income
for (category, currency), amount in income_data.items():
category_node_id = get_node_id("income", category, currency.id)
currency_node_id = get_node_id("currency", currency.name, currency.id)
add_node(category_node_id, str(category))
add_node(currency_node_id, currency.name)
add_flow(category_node_id, currency_node_id, amount, currency, is_income=True)
# Process expenses
for (category, currency), amount in expense_data.items():
category_node_id = get_node_id("expense", category, currency.id)
currency_node_id = get_node_id("currency", currency.name, currency.id)
add_node(category_node_id, str(category))
add_node(currency_node_id, currency.name)
add_flow(currency_node_id, category_node_id, amount, currency, is_income=False)
# Calculate and add savings flows
savings_data = {} # {currency -> amount}
for (category, currency), amount in income_data.items():
savings_data[currency] = savings_data.get(currency, Decimal("0")) + amount
for (category, currency), amount in expense_data.items():
savings_data[currency] = savings_data.get(currency, Decimal("0")) - amount
for currency, amount in savings_data.items():
if amount > 0:
currency_node_id = get_node_id("currency", currency.name, currency.id)
savings_node_id = get_node_id("savings", _("Saved"), currency.id)
add_node(savings_node_id, str(_("Saved")))
add_flow(
currency_node_id, savings_node_id, amount, currency, is_income=True
)
# Calculate total across all currencies (for reference only)
total_amount = sum(float(amount) for amount in total_income_by_currency.values())
return {
"nodes": list(nodes.values()),
"flows": flows,
"total_amount": total_amount,
"total_by_currency": {
curr.name: float(amount)
for curr, amount in total_income_by_currency.items()
},
}

View File

@@ -0,0 +1,96 @@
from django.db.models import Q
from django.utils import timezone
from dateutil.relativedelta import relativedelta
from apps.transactions.models import Transaction
from apps.insights.forms import (
SingleMonthForm,
SingleYearForm,
MonthRangeForm,
YearRangeForm,
DateRangeForm,
)
def get_transactions(request, include_unpaid=True, include_silent=False):
transactions = Transaction.objects.all()
filter_type = request.GET.get("type", None)
if filter_type is not None:
if filter_type == "month":
form = SingleMonthForm(request.GET)
if form.is_valid():
month = form.cleaned_data["month"].replace(day=1)
else:
month = timezone.localdate(timezone.now()).replace(day=1)
transactions = transactions.filter(
reference_date__month=month.month, reference_date__year=month.year
)
elif filter_type == "year":
form = SingleYearForm(request.GET)
if form.is_valid():
year = form.cleaned_data["year"].replace(day=1, month=1)
else:
year = timezone.localdate(timezone.now()).replace(day=1, month=1)
transactions = transactions.filter(reference_date__year=year.year)
elif filter_type == "month-range":
form = MonthRangeForm(request.GET)
if form.is_valid():
month_from = form.cleaned_data["month_from"].replace(day=1)
month_to = form.cleaned_data["month_to"].replace(day=1)
else:
month_from = timezone.localdate(timezone.now()).replace(day=1)
month_to = (
timezone.localdate(timezone.now()) + relativedelta(months=1)
).replace(day=1)
transactions = transactions.filter(
reference_date__gte=month_from,
reference_date__lte=month_to,
)
elif filter_type == "year-range":
form = YearRangeForm(request.GET)
if form.is_valid():
year_from = form.cleaned_data["year_from"].replace(day=1, month=1)
year_to = form.cleaned_data["year_to"].replace(day=31, month=12)
else:
year_from = timezone.localdate(timezone.now()).replace(day=1, month=1)
year_to = (
timezone.localdate(timezone.now()) + relativedelta(years=1)
).replace(day=31, month=12)
transactions = transactions.filter(
reference_date__gte=year_from,
reference_date__lte=year_to,
)
elif filter_type == "date-range":
form = DateRangeForm(request.GET)
if form.is_valid():
date_from = form.cleaned_data["date_from"]
date_to = form.cleaned_data["date_to"]
else:
date_from = timezone.localdate(timezone.now())
date_to = timezone.localdate(timezone.now()) + relativedelta(months=1)
transactions = transactions.filter(
date__gte=date_from,
date__lte=date_to,
)
else: # Default to current month
month = timezone.localdate(timezone.now())
transactions = transactions.filter(
reference_date__month=month.month, reference_date__year=month.year
)
if not include_unpaid:
transactions = transactions.filter(is_paid=True)
if not include_silent:
transactions = transactions.exclude(Q(category__mute=True) & ~Q(category=None))
return transactions

159
app/apps/insights/views.py Normal file
View File

@@ -0,0 +1,159 @@
from dateutil.relativedelta import relativedelta
from django.contrib.auth.decorators import login_required
from django.shortcuts import render
from django.utils import timezone
from django.views.decorators.http import require_http_methods
from apps.common.decorators.htmx import only_htmx
from apps.insights.forms import (
SingleMonthForm,
SingleYearForm,
MonthRangeForm,
YearRangeForm,
DateRangeForm,
CategoryForm,
)
from apps.insights.utils.category_explorer import (
get_category_sums_by_account,
get_category_sums_by_currency,
)
from apps.insights.utils.sankey import (
generate_sankey_data_by_account,
generate_sankey_data_by_currency,
)
from apps.insights.utils.transactions import get_transactions
from apps.transactions.models import TransactionCategory
@login_required
@require_http_methods(["GET"])
def index(request):
date = timezone.localdate(timezone.now())
month_form = SingleMonthForm(initial={"month": date.replace(day=1)})
year_form = SingleYearForm(initial={"year": date.replace(day=1)})
month_range_form = MonthRangeForm(
initial={
"month_from": date.replace(day=1),
"month_to": date.replace(day=1) + relativedelta(months=1),
}
)
year_range_form = YearRangeForm(
initial={
"year_from": date.replace(day=1, month=1),
"year_to": date.replace(day=1, month=1) + relativedelta(years=1),
}
)
date_range_form = DateRangeForm(
initial={
"date_from": date,
"date_to": date + relativedelta(months=1),
}
)
return render(
request,
"insights/pages/index.html",
context={
"month_form": month_form,
"year_form": year_form,
"month_range_form": month_range_form,
"year_range_form": year_range_form,
"date_range_form": date_range_form,
},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def sankey_by_account(request):
# Get filtered transactions
transactions = get_transactions(request)
# Generate Sankey data
sankey_data = generate_sankey_data_by_account(transactions)
return render(
request,
"insights/fragments/sankey.html",
{"sankey_data": sankey_data, "type": "account"},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def sankey_by_currency(request):
# Get filtered transactions
transactions = get_transactions(request)
# Generate Sankey data
sankey_data = generate_sankey_data_by_currency(transactions)
return render(
request,
"insights/fragments/sankey.html",
{"sankey_data": sankey_data, "type": "currency"},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def category_explorer_index(request):
category_form = CategoryForm()
return render(
request,
"insights/fragments/category_explorer/index.html",
{"category_form": category_form},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def category_sum_by_account(request):
# Get filtered transactions
transactions = get_transactions(request, include_silent=True)
category = request.GET.get("category")
if category:
category = TransactionCategory.objects.get(id=category)
# Generate data
account_data = get_category_sums_by_account(transactions, category)
else:
account_data = get_category_sums_by_account(transactions, category=None)
return render(
request,
"insights/fragments/category_explorer/charts/account.html",
{"account_data": account_data},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def category_sum_by_currency(request):
# Get filtered transactions
transactions = get_transactions(request, include_silent=True)
category = request.GET.get("category")
if category:
category = TransactionCategory.objects.get(id=category)
# Generate data
currency_data = get_category_sums_by_currency(transactions, category)
else:
currency_data = get_category_sums_by_currency(transactions, category=None)
return render(
request,
"insights/fragments/category_explorer/charts/currency.html",
{"currency_data": currency_data},
)

View File

@@ -92,6 +92,8 @@ def transactions_list(request, month: int, year: int):
"account__currency",
"installment_plan",
"entities",
"dca_expense_entries",
"dca_income_entries",
)
)

View File

@@ -0,0 +1,138 @@
# Generated by Django 5.1.6 on 2025-02-09 20:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('rules', '0009_alter_transactionrule_options_and_more'),
]
operations = [
migrations.AlterField(
model_name='updateorcreatetransactionruleaction',
name='search_account',
field=models.TextField(blank=True, verbose_name='Search Account'),
),
migrations.AlterField(
model_name='updateorcreatetransactionruleaction',
name='search_amount',
field=models.TextField(blank=True, verbose_name='Search Amount'),
),
migrations.AlterField(
model_name='updateorcreatetransactionruleaction',
name='search_category',
field=models.TextField(blank=True, verbose_name='Search Category'),
),
migrations.AlterField(
model_name='updateorcreatetransactionruleaction',
name='search_description',
field=models.TextField(blank=True, verbose_name='Search Description'),
),
migrations.AlterField(
model_name='updateorcreatetransactionruleaction',
name='search_entities',
field=models.TextField(blank=True, verbose_name='Search Entities'),
),
migrations.AlterField(
model_name='updateorcreatetransactionruleaction',
name='search_internal_id',
field=models.TextField(blank=True, verbose_name='Search Internal ID'),
),
migrations.AlterField(
model_name='updateorcreatetransactionruleaction',
name='search_internal_note',
field=models.TextField(blank=True, verbose_name='Search Internal Note'),
),
migrations.AlterField(
model_name='updateorcreatetransactionruleaction',
name='search_is_paid',
field=models.TextField(blank=True, verbose_name='Search Is Paid'),
),
migrations.AlterField(
model_name='updateorcreatetransactionruleaction',
name='search_notes',
field=models.TextField(blank=True, verbose_name='Search Notes'),
),
migrations.AlterField(
model_name='updateorcreatetransactionruleaction',
name='search_reference_date',
field=models.TextField(blank=True, verbose_name='Search Reference Date'),
),
migrations.AlterField(
model_name='updateorcreatetransactionruleaction',
name='search_tags',
field=models.TextField(blank=True, verbose_name='Search Tags'),
),
migrations.AlterField(
model_name='updateorcreatetransactionruleaction',
name='search_type',
field=models.TextField(blank=True, verbose_name='Search Type'),
),
migrations.AlterField(
model_name='updateorcreatetransactionruleaction',
name='set_account',
field=models.TextField(blank=True, verbose_name='Account'),
),
migrations.AlterField(
model_name='updateorcreatetransactionruleaction',
name='set_amount',
field=models.TextField(blank=True, verbose_name='Amount'),
),
migrations.AlterField(
model_name='updateorcreatetransactionruleaction',
name='set_category',
field=models.TextField(blank=True, verbose_name='Category'),
),
migrations.AlterField(
model_name='updateorcreatetransactionruleaction',
name='set_date',
field=models.TextField(blank=True, verbose_name='Date'),
),
migrations.AlterField(
model_name='updateorcreatetransactionruleaction',
name='set_description',
field=models.TextField(blank=True, verbose_name='Description'),
),
migrations.AlterField(
model_name='updateorcreatetransactionruleaction',
name='set_entities',
field=models.TextField(blank=True, verbose_name='Entities'),
),
migrations.AlterField(
model_name='updateorcreatetransactionruleaction',
name='set_internal_id',
field=models.TextField(blank=True, verbose_name='Internal ID'),
),
migrations.AlterField(
model_name='updateorcreatetransactionruleaction',
name='set_internal_note',
field=models.TextField(blank=True, verbose_name='Internal Note'),
),
migrations.AlterField(
model_name='updateorcreatetransactionruleaction',
name='set_is_paid',
field=models.TextField(blank=True, verbose_name='Is Paid'),
),
migrations.AlterField(
model_name='updateorcreatetransactionruleaction',
name='set_notes',
field=models.TextField(blank=True, verbose_name='Notes'),
),
migrations.AlterField(
model_name='updateorcreatetransactionruleaction',
name='set_reference_date',
field=models.TextField(blank=True, verbose_name='Reference Date'),
),
migrations.AlterField(
model_name='updateorcreatetransactionruleaction',
name='set_tags',
field=models.TextField(blank=True, verbose_name='Tags'),
),
migrations.AlterField(
model_name='updateorcreatetransactionruleaction',
name='set_type',
field=models.TextField(blank=True, verbose_name='Type'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.6 on 2025-02-09 20:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('rules', '0010_alter_updateorcreatetransactionruleaction_search_account_and_more'),
]
operations = [
migrations.AlterField(
model_name='updateorcreatetransactionruleaction',
name='set_is_paid',
field=models.TextField(blank=True, verbose_name='Paid'),
),
]

View File

@@ -88,226 +88,201 @@ class UpdateOrCreateTransactionRuleAction(models.Model):
# Search fields with operators
search_account = models.TextField(
verbose_name=_("Search Account"),
verbose_name="Search Account",
blank=True,
help_text=_("Expression to match transaction account (ID or name)"),
)
search_account_operator = models.CharField(
max_length=10,
choices=SearchOperator.choices,
default=SearchOperator.EXACT,
verbose_name=_("Account Operator"),
verbose_name="Account Operator",
)
search_type = models.TextField(
verbose_name=_("Search Type"),
verbose_name="Search Type",
blank=True,
help_text=_("Expression to match transaction type ('IN' or 'EX')"),
)
search_type_operator = models.CharField(
max_length=10,
choices=SearchOperator.choices,
default=SearchOperator.EXACT,
verbose_name=_("Type Operator"),
verbose_name="Type Operator",
)
search_is_paid = models.TextField(
verbose_name=_("Search Is Paid"),
verbose_name="Search Is Paid",
blank=True,
help_text=_("Expression to match transaction paid status"),
)
search_is_paid_operator = models.CharField(
max_length=10,
choices=SearchOperator.choices,
default=SearchOperator.EXACT,
verbose_name=_("Is Paid Operator"),
verbose_name="Is Paid Operator",
)
search_date = models.TextField(
verbose_name=_("Search Date"),
verbose_name="Search Date",
blank=True,
help_text=_("Expression to match transaction date"),
help_text="Expression to match transaction date",
)
search_date_operator = models.CharField(
max_length=10,
choices=SearchOperator.choices,
default=SearchOperator.EXACT,
verbose_name=_("Date Operator"),
verbose_name="Date Operator",
)
search_reference_date = models.TextField(
verbose_name=_("Search Reference Date"),
verbose_name="Search Reference Date",
blank=True,
help_text=_("Expression to match transaction reference date"),
)
search_reference_date_operator = models.CharField(
max_length=10,
choices=SearchOperator.choices,
default=SearchOperator.EXACT,
verbose_name=_("Reference Date Operator"),
verbose_name="Reference Date Operator",
)
search_amount = models.TextField(
verbose_name=_("Search Amount"),
verbose_name="Search Amount",
blank=True,
help_text=_("Expression to match transaction amount"),
)
search_amount_operator = models.CharField(
max_length=10,
choices=SearchOperator.choices,
default=SearchOperator.EXACT,
verbose_name=_("Amount Operator"),
verbose_name="Amount Operator",
)
search_description = models.TextField(
verbose_name=_("Search Description"),
verbose_name="Search Description",
blank=True,
help_text=_("Expression to match transaction description"),
)
search_description_operator = models.CharField(
max_length=10,
choices=SearchOperator.choices,
default=SearchOperator.CONTAINS,
verbose_name=_("Description Operator"),
verbose_name="Description Operator",
)
search_notes = models.TextField(
verbose_name=_("Search Notes"),
verbose_name="Search Notes",
blank=True,
help_text=_("Expression to match transaction notes"),
)
search_notes_operator = models.CharField(
max_length=10,
choices=SearchOperator.choices,
default=SearchOperator.CONTAINS,
verbose_name=_("Notes Operator"),
verbose_name="Notes Operator",
)
search_category = models.TextField(
verbose_name=_("Search Category"),
verbose_name="Search Category",
blank=True,
help_text=_("Expression to match transaction category (ID or name)"),
)
search_category_operator = models.CharField(
max_length=10,
choices=SearchOperator.choices,
default=SearchOperator.EXACT,
verbose_name=_("Category Operator"),
verbose_name="Category Operator",
)
search_tags = models.TextField(
verbose_name=_("Search Tags"),
verbose_name="Search Tags",
blank=True,
help_text=_("Expression to match transaction tags (list of IDs or names)"),
)
search_tags_operator = models.CharField(
max_length=10,
choices=SearchOperator.choices,
default=SearchOperator.CONTAINS,
verbose_name=_("Tags Operator"),
verbose_name="Tags Operator",
)
search_entities = models.TextField(
verbose_name=_("Search Entities"),
verbose_name="Search Entities",
blank=True,
help_text=_("Expression to match transaction entities (list of IDs or names)"),
)
search_entities_operator = models.CharField(
max_length=10,
choices=SearchOperator.choices,
default=SearchOperator.CONTAINS,
verbose_name=_("Entities Operator"),
verbose_name="Entities Operator",
)
search_internal_note = models.TextField(
verbose_name=_("Search Internal Note"),
verbose_name="Search Internal Note",
blank=True,
help_text=_("Expression to match transaction internal note"),
)
search_internal_note_operator = models.CharField(
max_length=10,
choices=SearchOperator.choices,
default=SearchOperator.EXACT,
verbose_name=_("Internal Note Operator"),
verbose_name="Internal Note Operator",
)
search_internal_id = models.TextField(
verbose_name=_("Search Internal ID"),
verbose_name="Search Internal ID",
blank=True,
help_text=_("Expression to match transaction internal ID"),
)
search_internal_id_operator = models.CharField(
max_length=10,
choices=SearchOperator.choices,
default=SearchOperator.EXACT,
verbose_name=_("Internal ID Operator"),
verbose_name="Internal ID Operator",
)
# Set fields
set_account = models.TextField(
verbose_name=_("Set Account"),
verbose_name=_("Account"),
blank=True,
help_text=_("Expression for account to set (ID or name)"),
)
set_type = models.TextField(
verbose_name=_("Set Type"),
verbose_name=_("Type"),
blank=True,
help_text=_("Expression for type to set ('IN' or 'EX')"),
)
set_is_paid = models.TextField(
verbose_name=_("Set Is Paid"),
verbose_name=_("Paid"),
blank=True,
help_text=_("Expression for paid status to set"),
)
set_date = models.TextField(
verbose_name=_("Set Date"),
verbose_name=_("Date"),
blank=True,
help_text=_("Expression for date to set"),
)
set_reference_date = models.TextField(
verbose_name=_("Set Reference Date"),
verbose_name=_("Reference Date"),
blank=True,
help_text=_("Expression for reference date to set"),
)
set_amount = models.TextField(
verbose_name=_("Set Amount"),
verbose_name=_("Amount"),
blank=True,
help_text=_("Expression for amount to set"),
)
set_description = models.TextField(
verbose_name=_("Set Description"),
verbose_name=_("Description"),
blank=True,
help_text=_("Expression for description to set"),
)
set_notes = models.TextField(
verbose_name=_("Set Notes"),
verbose_name=_("Notes"),
blank=True,
help_text=_("Expression for notes to set"),
)
set_internal_note = models.TextField(
verbose_name=_("Set Internal Note"),
verbose_name=_("Internal Note"),
blank=True,
help_text=_("Expression for internal note to set"),
)
set_internal_id = models.TextField(
verbose_name=_("Set Internal ID"),
verbose_name=_("Internal ID"),
blank=True,
help_text=_("Expression for internal ID to set"),
)
set_entities = models.TextField(
verbose_name=_("Set Entities"),
verbose_name=_("Entities"),
blank=True,
help_text=_("Expression for entities to set (list of IDs or names)"),
)
set_category = models.TextField(
verbose_name=_("Set Category"),
verbose_name=_("Category"),
blank=True,
help_text=_("Expression for category to set (ID or name)"),
)
set_tags = models.TextField(
verbose_name=_("Set Tags"),
verbose_name=_("Tags"),
blank=True,
help_text=_("Expression for tags to set (list of IDs or names)"),
)
class Meta:

View File

@@ -1,15 +1,23 @@
from django.dispatch import Signal, receiver
from django.dispatch import receiver
from apps.transactions.models import Transaction
from apps.transactions.models import (
Transaction,
transaction_created,
transaction_updated,
)
from apps.rules.tasks import check_for_transaction_rules
transaction_created = Signal()
transaction_updated = Signal()
@receiver(transaction_created)
@receiver(transaction_updated)
def transaction_changed_receiver(sender: Transaction, signal, **kwargs):
for dca_entry in sender.dca_expense_entries.all():
dca_entry.amount_paid = sender.amount
dca_entry.save()
for dca_entry in sender.dca_income_entries.all():
dca_entry.amount_received = sender.amount
dca_entry.save()
check_for_transaction_rules.defer(
instance_id=sender.id,
signal=(

View File

@@ -63,7 +63,9 @@ class TransactionForm(forms.ModelForm):
date = forms.DateField(label=_("Date"))
reference_date = forms.DateField(
widget=AirMonthYearPickerInput(), label=_("Reference Date"), required=False
widget=AirMonthYearPickerInput(),
label=_("Reference Date"),
required=False,
)
class Meta:
@@ -176,7 +178,6 @@ class TransactionForm(forms.ModelForm):
),
)
self.fields["reference_date"].required = False
self.fields["date"].widget = AirDatePickerInput(clear_button=False)
if self.instance and self.instance.pk:

View File

@@ -1,22 +1,66 @@
import logging
from dateutil.relativedelta import relativedelta
from django.conf import settings
from django.core.validators import MinValueValidator
from django.db import models, transaction
from django.db.models import Q
from django.dispatch import Signal
from django.template.defaultfilters import date
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.conf import settings
from apps.common.fields.month_year import MonthYearModelField
from apps.common.functions.decimals import truncate_decimal
from apps.common.templatetags.decimal import localize_number, drop_trailing_zeros
from apps.currencies.utils.convert import convert
from apps.transactions.validators import validate_decimal_places, validate_non_negative
logger = logging.getLogger()
transaction_created = Signal()
transaction_updated = Signal()
class SoftDeleteQuerySet(models.QuerySet):
@staticmethod
def _emit_signals(instances, created=False):
"""Helper to emit signals for multiple instances"""
for instance in instances:
if created:
transaction_created.send(sender=instance)
else:
transaction_updated.send(sender=instance)
def bulk_create(self, objs, emit_signal=True, **kwargs):
instances = super().bulk_create(objs, **kwargs)
if emit_signal:
self._emit_signals(instances, created=True)
return instances
def bulk_update(self, objs, fields, emit_signal=True, **kwargs):
result = super().bulk_update(objs, fields, **kwargs)
if emit_signal:
self._emit_signals(objs, created=False)
return result
def update(self, emit_signal=True, **kwargs):
# Get instances before update
instances = list(self)
result = super().update(**kwargs)
if emit_signal:
# Refresh instances to get new values
refreshed = self.model.objects.filter(pk__in=[obj.pk for obj in instances])
self._emit_signals(refreshed, created=False)
return result
def delete(self):
if not settings.ENABLE_SOFT_DELETE:
# If soft deletion is disabled, perform a normal delete
@@ -274,7 +318,13 @@ class Transaction(models.Model):
def __str__(self):
type_display = self.get_type_display()
return f"{self.description} - {type_display} - {self.account} - {self.date}"
frmt_date = date(self.date, "SHORT_DATE_FORMAT")
account = self.account
tags = ", ".join([x.name for x in self.tags.all()]) or _("No tags")
category = self.category or _("No category")
amount = localize_number(drop_trailing_zeros(self.amount))
description = self.description or _("No description")
return f"[{frmt_date}][{type_display}][{account}] {description}{category}{tags}{amount}"
class InstallmentPlan(models.Model):

View File

@@ -86,6 +86,16 @@ urlpatterns = [
views.transactions_bulk_edit,
name="transactions_bulk_edit",
),
path(
"transactions/json/search/",
views.get_recent_transactions,
name="transactions_search",
),
path(
"transactions/json/search/<str:filter_type>/",
views.get_recent_transactions,
name="transactions_search",
),
path(
"transaction/<int:transaction_id>/clone/",
views.transaction_clone,

View File

@@ -7,6 +7,7 @@ from django.utils.translation import gettext_lazy as _, ngettext_lazy
from apps.common.decorators.htmx import only_htmx
from apps.transactions.models import Transaction
from apps.rules.signals import transaction_updated
@only_htmx
@@ -87,7 +88,7 @@ def bulk_undelete_transactions(request):
selected_transactions = request.GET.getlist("transactions", [])
transactions = Transaction.deleted_objects.filter(id__in=selected_transactions)
count = transactions.count()
transactions.update(deleted=False, deleted_at=None)
transactions.update(deleted=False, deleted_at=None, emit_signal=False)
messages.success(
request,

View File

@@ -4,14 +4,14 @@ from copy import deepcopy
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.http import HttpResponse
from django.db.models import Q
from django.http import HttpResponse, JsonResponse
from django.shortcuts import render, get_object_or_404
from django.utils import timezone
from django.utils.translation import gettext_lazy as _, ngettext_lazy
from django.views.decorators.http import require_http_methods
from apps.common.decorators.htmx import only_htmx
from apps.common.utils.dicts import remove_falsey_entries
from apps.rules.signals import transaction_created, transaction_updated
from apps.transactions.filters import TransactionsFilter
from apps.transactions.forms import (
@@ -316,6 +316,7 @@ def transaction_pay(request, transaction_id):
new_is_paid = False if transaction.is_paid else True
transaction.is_paid = new_is_paid
transaction.save()
transaction_updated.send(sender=transaction)
response = render(
request,
@@ -362,6 +363,8 @@ def transaction_all_list(request):
"account__currency",
"installment_plan",
"entities",
"dca_expense_entries",
"dca_income_entries",
).all()
transactions = default_order(transactions, order=order)
@@ -394,6 +397,9 @@ def transaction_all_summary(request):
"account__exchange_currency",
"account__currency",
"installment_plan",
"entities",
"dca_expense_entries",
"dca_income_entries",
).all()
f = TransactionsFilter(request.GET, queryset=transactions)
@@ -425,6 +431,9 @@ def transaction_all_account_summary(request):
"account__exchange_currency",
"account__currency",
"installment_plan",
"entities",
"dca_expense_entries",
"dca_income_entries",
).all()
f = TransactionsFilter(request.GET, queryset=transactions)
@@ -452,6 +461,9 @@ def transaction_all_currency_summary(request):
"account__exchange_currency",
"account__currency",
"installment_plan",
"entities",
"dca_expense_entries",
"dca_income_entries",
).all()
f = TransactionsFilter(request.GET, queryset=transactions)
@@ -483,6 +495,9 @@ def transactions_trash_can_index(request):
return render(request, "transactions/pages/trash.html")
@only_htmx
@login_required
@require_http_methods(["GET"])
def transactions_trash_can_list(request):
transactions = Transaction.deleted_objects.prefetch_related(
"account",
@@ -492,6 +507,10 @@ def transactions_trash_can_list(request):
"account__exchange_currency",
"account__currency",
"installment_plan",
"entities",
"entities",
"dca_expense_entries",
"dca_income_entries",
).all()
return render(
@@ -499,3 +518,41 @@ def transactions_trash_can_list(request):
"transactions/fragments/trash_list.html",
{"transactions": transactions},
)
@login_required
@require_http_methods(["GET"])
def get_recent_transactions(request, filter_type=None):
"""Return the 100 most recent non-deleted transactions with optional search."""
# Get search term from query params
search_term = request.GET.get("q", "").strip()
# Base queryset with selected fields
queryset = (
Transaction.objects.filter(deleted=False)
.select_related("account", "category")
.order_by("-created_at")
)
if filter_type:
if filter_type == "expenses":
queryset = queryset.filter(type=Transaction.Type.EXPENSE)
elif filter_type == "income":
queryset = queryset.filter(type=Transaction.Type.INCOME)
# Apply search if provided
if search_term:
queryset = queryset.filter(
Q(description__icontains=search_term)
| Q(notes__icontains=search_term)
| Q(internal_note__icontains=search_term)
| Q(tags__name__icontains=search_term)
| Q(category__name__icontains=search_term)
)
# Prepare data for JSON response
data = []
for t in queryset:
data.append({"text": str(t), "value": str(t.id)})
return JsonResponse(data, safe=False)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -57,9 +57,8 @@
</td>
<td class="col">{{ account.name }}</td>
<td class="col">{{ account.group.name }}</td>
<td class="col">{{ account.currency }} ({{ account.currency.code }})</td>
<td class="col">{% if account.exchange_currency %}{{ account.exchange_currency }} (
{{ account.exchange_currency.code }}){% else %}-{% endif %}</td>
<td class="col">{{ account.currency }}</td>
<td class="col">{% if account.exchange_currency %}{{ account.exchange_currency }}{% else %}-{% endif %}</td>
<td class="col">{% if account.is_asset %}<i class="fa-solid fa-solid fa-check text-success"></i>{% endif %}</td>
<td class="col">{% if account.is_archived %}<i class="fa-solid fa-solid fa-check text-success"></i>{% endif %}</td>
</tr>

View File

@@ -1,172 +1,177 @@
{% load markdown %}
{% load i18n %}
<div class="transaction d-flex my-1 {% if transaction.type == "EX" %}expense{% else %}income{% endif %}">
{% if not disable_selection %}
<label class="px-3 d-flex align-items-center justify-content-center">
<input class="form-check-input" type="checkbox" name="transactions" value="{{ transaction.id }}"
id="check-{{ transaction.id }}" aria-label="{% translate 'Select' %}" hx-preserve>
</label>
{% endif %}
<div class="tw-border-s-6 tw-border-e-0 tw-border-t-0 tw-border-b-0 border-bottom
<div class="transaction {% if transaction.type == "EX" %}expense{% else %}income{% endif %}">
<div class="d-flex my-1">
{% if not disable_selection %}
<label class="px-3 d-flex align-items-center justify-content-center">
<input class="form-check-input" type="checkbox" name="transactions" value="{{ transaction.id }}"
id="check-{{ transaction.id }}" aria-label="{% translate 'Select' %}" hx-preserve>
</label>
{% endif %}
<div class="tw-border-s-6 tw-border-e-0 tw-border-t-0 tw-border-b-0 border-bottom
hover:tw-bg-zinc-900 p-2 {% if transaction.account.is_asset %}tw-border-dashed{% else %}tw-border-solid{% endif %}
{% if transaction.type == "EX" %}tw-border-red-500{% else %}tw-border-green-500{% endif %} tw-relative
w-100 transaction-item"
_="on mouseover remove .tw-invisible from the first .transaction-actions in me end
_="on mouseover remove .tw-invisible from the first .transaction-actions in me end
on mouseout add .tw-invisible to the first .transaction-actions in me end">
<div class="row font-monospace tw-text-sm align-items-center">
<div class="col-lg-1 col-12 d-flex align-items-center tw-text-2xl lg:tw-text-xl text-lg-center text-center">
{% if not transaction.deleted %}
<a class="text-decoration-none my-lg-3 mx-lg-3 mx-2 my-2 tw-text-gray-500"
title="{% if transaction.is_paid %}{% trans 'Paid' %}{% else %}{% trans 'Projected' %}{% endif %}"
role="button"
hx-get="{% url 'transaction_pay' transaction_id=transaction.id %}"
hx-target="closest .transaction"
hx-swap="outerHTML">
{% if transaction.is_paid %}<i class="fa-regular fa-circle-check"></i>{% else %}<i
class="fa-regular fa-circle"></i>{% endif %}
</a>
{% else %}
<div class="text-decoration-none my-lg-3 mx-lg-3 mx-2 my-2 tw-text-gray-500"
title="{% if transaction.is_paid %}{% trans 'Paid' %}{% else %}{% trans 'Projected' %}{% endif %}">
{% if transaction.is_paid %}<i class="fa-regular fa-circle-check"></i>{% else %}<i
class="fa-regular fa-circle"></i>{% endif %}
<div class="row font-monospace tw-text-sm align-items-center">
<div class="col-lg-1 col-12 d-flex align-items-center tw-text-2xl lg:tw-text-xl text-lg-center text-center">
{% if not transaction.deleted %}
<a class="text-decoration-none my-lg-3 mx-lg-3 mx-2 my-2 tw-text-gray-500"
title="{% if transaction.is_paid %}{% trans 'Paid' %}{% else %}{% trans 'Projected' %}{% endif %}"
role="button"
hx-get="{% url 'transaction_pay' transaction_id=transaction.id %}"
hx-target="closest .transaction"
hx-swap="outerHTML">
{% if transaction.is_paid %}<i class="fa-regular fa-circle-check"></i>{% else %}<i
class="fa-regular fa-circle"></i>{% endif %}
</a>
{% else %}
<div class="text-decoration-none my-lg-3 mx-lg-3 mx-2 my-2 tw-text-gray-500"
title="{% if transaction.is_paid %}{% trans 'Paid' %}{% else %}{% trans 'Projected' %}{% endif %}">
{% if transaction.is_paid %}<i class="fa-regular fa-circle-check"></i>{% else %}<i
class="fa-regular fa-circle"></i>{% endif %}
</div>
{% endif %}
</div>
<div class="col-lg-8 col-12">
{# Date#}
<div class="row mb-2 mb-lg-1 tw-text-gray-400">
<div class="col-auto pe-1"><i class="fa-solid fa-calendar fa-fw me-1 fa-xs"></i></div>
<div
class="col ps-0">{{ transaction.date|date:"SHORT_DATE_FORMAT" }} • {{ transaction.reference_date|date:"b/Y" }}</div>
</div>
{% endif %}
</div>
<div class="col-lg-8 col-12">
{# Date#}
<div class="row mb-2 mb-lg-1 tw-text-gray-400">
<div class="col-auto pe-1"><i class="fa-solid fa-calendar fa-fw me-1 fa-xs"></i></div>
<div
class="col ps-0">{{ transaction.date|date:"SHORT_DATE_FORMAT" }} • {{ transaction.reference_date|date:"b/Y" }}</div>
</div>
{# Description#}
<div class="mb-2 mb-lg-1 text-white tw-text-base">
{% spaceless %}
<span>{{ transaction.description }}</span>
{% if transaction.installment_plan and transaction.installment_id %}
<span
class="badge text-bg-secondary ms-2">{{ transaction.installment_id }}/{{ transaction.installment_plan.installment_total_number }}</span>
{% endif %}
{% if transaction.recurring_transaction %}
<span class="text-primary tw-text-xs ms-2"><i class="fa-solid fa-arrows-rotate fa-fw"></i></span>
{% endif %}
{% endspaceless %}
</div>
<div class="tw-text-gray-400 tw-text-sm">
{# Entities #}
{% with transaction.entities.all as entities %}
{% if entities %}
{# Description#}
<div class="mb-2 mb-lg-1 text-white tw-text-base">
{% spaceless %}
<span class="{% if transaction.description %}me-2{% endif %}">{{ transaction.description }}</span>
{% if transaction.installment_plan and transaction.installment_id %}
<span
class="badge text-bg-secondary">{{ transaction.installment_id }}/{{ transaction.installment_plan.installment_total_number }}</span>
{% endif %}
{% if transaction.recurring_transaction %}
<span class="text-primary tw-text-xs"><i class="fa-solid fa-arrows-rotate fa-fw"></i></span>
{% endif %}
{% if transaction.dca_expense_entries.all or transaction.dca_income_entries.all %}
<span class="badge text-bg-secondary">{% trans 'DCA' %}</span>
{% endif %}
{% endspaceless %}
</div>
<div class="tw-text-gray-400 tw-text-sm">
{# Entities #}
{% with transaction.entities.all as entities %}
{% if entities %}
<div class="row mb-2 mb-lg-1 tw-text-gray-400">
<div class="col-auto pe-1"><i class="fa-solid fa-user-group fa-fw me-1 fa-xs"></i></div>
<div class="col ps-0">{{ entities|join:", " }}</div>
</div>
{% endif %}
{% endwith %}
{# Notes#}
{% if transaction.notes %}
<div class="row mb-2 mb-lg-1 tw-text-gray-400">
<div class="col-auto pe-1"><i class="fa-solid fa-user-group fa-fw me-1 fa-xs"></i></div>
<div class="col ps-0">{{ entities|join:", " }}</div>
</div>
{% endif %}
{% endwith %}
{# Notes#}
{% if transaction.notes %}
<div class="row mb-2 mb-lg-1 tw-text-gray-400">
<div class="col-auto pe-1"><i class="fa-solid fa-align-left fa-fw me-1 fa-xs"></i></div>
<div class="col ps-0">{{ transaction.notes | limited_markdown | linebreaksbr }}</div>
</div>
{% endif %}
{# Category#}
{% if transaction.category %}
<div class="row mb-2 mb-lg-1 tw-text-gray-400">
<div class="col-auto pe-1"><i class="fa-solid fa-icons fa-fw me-1 fa-xs"></i></div>
<div class="col ps-0">{{ transaction.category.name }}</div>
</div>
{% endif %}
{# Tags#}
{% with transaction.tags.all as tags %}
{% if tags %}
<div class="row mb-2 mb-lg-1 tw-text-gray-400">
<div class="col-auto pe-1"><i class="fa-solid fa-hashtag fa-fw me-1 fa-xs"></i></div>
<div class="col ps-0">{{ tags|join:", " }}</div>
<div class="col-auto pe-1"><i class="fa-solid fa-align-left fa-fw me-1 fa-xs"></i></div>
<div class="col ps-0">{{ transaction.notes | limited_markdown | linebreaksbr }}</div>
</div>
{% endif %}
{# Category#}
{% if transaction.category %}
<div class="row mb-2 mb-lg-1 tw-text-gray-400">
<div class="col-auto pe-1"><i class="fa-solid fa-icons fa-fw me-1 fa-xs"></i></div>
<div class="col ps-0">{{ transaction.category.name }}</div>
</div>
{% endif %}
{# Tags#}
{% with transaction.tags.all as tags %}
{% if tags %}
<div class="row mb-2 mb-lg-1 tw-text-gray-400">
<div class="col-auto pe-1"><i class="fa-solid fa-hashtag fa-fw me-1 fa-xs"></i></div>
<div class="col ps-0">{{ tags|join:", " }}</div>
</div>
{% endif %}
{% endwith %}
</div>
</div>
<div class="col-lg-3 col-12 text-lg-end align-self-end">
<div class="main-amount mb-2 mb-lg-0">
<c-amount.display
:amount="transaction.amount"
:prefix="transaction.account.currency.prefix"
:suffix="transaction.account.currency.suffix"
:decimal_places="transaction.account.currency.decimal_places"
color="{% if transaction.type == "EX" %}red{% else %}green{% endif %}"></c-amount.display>
</div>
{# Exchange Rate#}
{% with exchanged=transaction.exchanged_amount %}
{% if exchanged %}
<div class="exchanged-amount mb-2 mb-lg-0">
<c-amount.display
:amount="exchanged.amount"
:prefix="exchanged.prefix"
:suffix="exchanged.suffix"
:decimal_places="exchanged.decimal_places"
color="grey"></c-amount.display>
</div>
{% endif %}
{% endwith %}
<div>
{% if transaction.account.group %}{{ transaction.account.group.name }} • {% endif %}{{ transaction.account.name }}
</div>
</div>
</div>
<div class="col-lg-3 col-12 text-lg-end align-self-end">
<div class="main-amount mb-2 mb-lg-0">
<c-amount.display
:amount="transaction.amount"
:prefix="transaction.account.currency.prefix"
:suffix="transaction.account.currency.suffix"
:decimal_places="transaction.account.currency.decimal_places"
color="{% if transaction.type == "EX" %}red{% else %}green{% endif %}"></c-amount.display>
</div>
{# Exchange Rate#}
{% with exchanged=transaction.exchanged_amount %}
{% if exchanged %}
<div class="exchanged-amount mb-2 mb-lg-0">
<c-amount.display
:amount="exchanged.amount"
:prefix="exchanged.prefix"
:suffix="exchanged.suffix"
:decimal_places="exchanged.decimal_places"
color="grey"></c-amount.display>
</div>
{% endif %}
{% endwith %}
<div>
{% if transaction.account.group %}{{ transaction.account.group.name }} • {% endif %}{{ transaction.account.name }}
</div>
</div>
<div>
{# Item actions#}
<div
class="transaction-actions !tw-absolute tw-left-1/2 tw-top-0 tw--translate-x-1/2 tw--translate-y-1/2 tw-invisible d-flex flex-row card">
<div class="card-body p-1 shadow-lg">
{% if not transaction.deleted %}
<a class="btn btn-secondary btn-sm transaction-action"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Edit" %}"
hx-get="{% url 'transaction_edit' transaction_id=transaction.id %}"
hx-target="#generic-offcanvas" hx-swap="innerHTML">
<i class="fa-solid fa-pencil fa-fw"></i></a>
<a class="btn btn-secondary btn-sm transaction-action"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Duplicate" %}"
hx-get="{% url 'transaction_clone' transaction_id=transaction.id %}"
_="on click if event.ctrlKey set @hx-get to `{% url 'transaction_clone' transaction_id=transaction.id %}?edit=true` then call htmx.process(me) end then trigger ready"
hx-trigger="ready">
<i class="fa-solid fa-clone fa-fw"></i></a>
<a class="btn btn-secondary btn-sm transaction-action"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Delete" %}"
hx-delete="{% url 'transaction_delete' transaction_id=transaction.id %}"
hx-trigger='confirmed'
data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}"
data-text="{% translate "You won't be able to revert this!" %}"
data-confirm-text="{% translate "Yes, delete it!" %}"
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw text-danger"></i>
</a>
{% else %}
<a class="btn btn-secondary btn-sm transaction-action"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Restore" %}"
hx-get="{% url 'transaction_undelete' transaction_id=transaction.id %}"><i
class="fa-solid fa-trash-arrow-up"></i></a>
<a class="btn btn-secondary btn-sm transaction-action"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Delete" %}"
hx-delete="{% url 'transaction_delete' transaction_id=transaction.id %}"
hx-trigger='confirmed'
data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}"
data-text="{% translate "You won't be able to revert this!" %}"
data-confirm-text="{% translate "Yes, delete it!" %}"
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw text-danger"></i>
</a>
{% endif %}
{# Item actions#}
<div
class="transaction-actions !tw-absolute tw-left-1/2 tw-top-0 tw--translate-x-1/2 tw--translate-y-1/2 tw-invisible d-flex flex-row card">
<div class="card-body p-1 shadow-lg">
{% if not transaction.deleted %}
<a class="btn btn-secondary btn-sm transaction-action"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Edit" %}"
hx-get="{% url 'transaction_edit' transaction_id=transaction.id %}"
hx-target="#generic-offcanvas" hx-swap="innerHTML">
<i class="fa-solid fa-pencil fa-fw"></i></a>
<a class="btn btn-secondary btn-sm transaction-action"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Duplicate" %}"
hx-get="{% url 'transaction_clone' transaction_id=transaction.id %}"
_="on click if event.ctrlKey set @hx-get to `{% url 'transaction_clone' transaction_id=transaction.id %}?edit=true` then call htmx.process(me) end then trigger ready"
hx-trigger="ready">
<i class="fa-solid fa-clone fa-fw"></i></a>
<a class="btn btn-secondary btn-sm transaction-action"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Delete" %}"
hx-delete="{% url 'transaction_delete' transaction_id=transaction.id %}"
hx-trigger='confirmed'
data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}"
data-text="{% translate "You won't be able to revert this!" %}"
data-confirm-text="{% translate "Yes, delete it!" %}"
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw text-danger"></i>
</a>
{% else %}
<a class="btn btn-secondary btn-sm transaction-action"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Restore" %}"
hx-get="{% url 'transaction_undelete' transaction_id=transaction.id %}"><i
class="fa-solid fa-trash-arrow-up"></i></a>
<a class="btn btn-secondary btn-sm transaction-action"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Delete" %}"
hx-delete="{% url 'transaction_delete' transaction_id=transaction.id %}"
hx-trigger='confirmed'
data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}"
data-text="{% translate "You won't be able to revert this!" %}"
data-confirm-text="{% translate "Yes, delete it!" %}"
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw text-danger"></i>
</a>
{% endif %}
</div>
</div>
</div>
</div>

View File

@@ -27,7 +27,7 @@
<ul class="dropdown-menu">
<li>
<div class="dropdown-item px-3 tw-cursor-pointer"
_="on click set <#transactions-list input[type='checkbox']/>'s checked to true then call me.blur() then trigger change">
_="on click set <#transactions-list .transaction:not([style*='display: none']) input[type='checkbox']/>'s checked to true then call me.blur() then trigger change">
<i class="fa-regular fa-square-check tw-text-green-400 me-3"></i>{% translate 'Select All' %}
</div>
</li>

View File

@@ -0,0 +1,13 @@
{% extends "extends/offcanvas.html" %}
{% load crispy_forms_tags %}
{% load i18n %}
{% block title %}{% translate 'Export' %}{% endblock %}
{% block body %}
<div class="container p-3">
<form method="post" action="{% url 'export_form' %}" id="export-form" class="show-loading px-1" _="on submit trigger hide_offcanvas" target="_blank">
{% crispy form %}
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,17 @@
{% extends "extends/offcanvas.html" %}
{% load crispy_forms_tags %}
{% load i18n %}
{% block title %}{% translate 'Restore' %}{% endblock %}
{% block body %}
<div class="container p-3">
<form hx-post="{% url 'restore_form' %}"
hx-target="#generic-offcanvas"
id="restore-form"
enctype="multipart/form-data"
class="show-loading px-1">
{% crispy form %}
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,29 @@
{% extends "layouts/base.html" %}
{% load i18n %}
{% block title %}{% translate 'Export and Restore' %}{% endblock %}
{% block content %}
<div class="container">
<div class="row d-flex flex-row align-items-center justify-content-center my-5">
<div class="text-center w-auto mb-3">
<button class="btn btn-outline-success d-flex flex-column align-items-center justify-content-center p-3"
style="width: 100px; height: 100px;"
hx-get="{% url 'export_form' %}"
hx-target="#generic-offcanvas">
<i class="fa-solid fa-download mb-1"></i>
<span>{% trans 'Export' %}</span>
</button>
</div>
<div class="text-center w-auto mb-3">
<button class="btn btn-outline-primary d-flex flex-column align-items-center justify-content-center p-3"
style="width: 100px; height: 100px;"
hx-get="{% url 'restore_form' %}"
hx-target="#generic-offcanvas">
<i class="fa-solid fa-upload mb-1"></i>
<span>{% trans 'Restore' %}</span>
</button>
</div>
</div>
</div>
{% endblock %}

View File

@@ -46,8 +46,11 @@
href="{% url 'net_worth_projected' %}">{% translate 'Projected' %}</a></li>
</ul>
</li>
<li class="nav-item">
<a class="nav-link {% active_link views='insights_index' %}" href="{% url 'insights_index' %}">{% trans 'Insights' %}</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {% active_link views='installment_plans_index||recurring_trasanctions_index||transactions_all_index' %}"
<a class="nav-link dropdown-toggle {% active_link views='installment_plans_index||recurring_trasanctions_index||transactions_all_index||transactions_trash_index' %}"
href="#" role="button"
data-bs-toggle="dropdown"
aria-expanded="false">
@@ -91,7 +94,7 @@
</ul>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {% active_link views='tags_index||entities_index||categories_index||accounts_index||account_groups_index||currencies_index||exchange_rates_index||rules_index' %}"
<a class="nav-link dropdown-toggle {% active_link views='tags_index||entities_index||categories_index||accounts_index||account_groups_index||currencies_index||exchange_rates_index||rules_index||import_profiles_index||automatic_exchange_rates_index||export_index' %}"
href="#" role="button"
data-bs-toggle="dropdown"
aria-expanded="false">
@@ -129,6 +132,8 @@
href="{% url 'rules_index' %}">{% translate 'Rules' %}</a></li>
<li><a class="dropdown-item {% active_link views='import_profiles_index' %}"
href="{% url 'import_profiles_index' %}">{% translate 'Import' %} <span class="badge text-bg-primary">beta</span></a></li>
<li><a class="dropdown-item {% active_link views='export_index' %}"
href="{% url 'export_index' %}">{% translate 'Export and Restore' %}</a></li>
<li><a class="dropdown-item {% active_link views='automatic_exchange_rates_index' %}"
href="{% url 'automatic_exchange_rates_index' %}">{% translate 'Automatic Exchange Rates' %}</a></li>
<li>

View File

@@ -17,6 +17,10 @@
for x in datepickers
MonthYearPicker(it)
end
set datepickers to <.airyearpickerinput/> in me
for x in datepickers
YearPicker(it)
end
end
end
</script>

View File

@@ -0,0 +1,100 @@
{% load i18n %}
{% if account_data.labels %}
<div class="chart-container" style="position: relative; height:400px; width:100%"
_="init call setupAccountChart() end">
<canvas id="accountChart"></canvas>
</div>
<script>
// Get the data from your Django view (passed as JSON)
var accountData = {{ account_data|safe }};
function setupAccountChart() {
var chartOptions = {
indexAxis: 'y', // This makes the chart horizontal
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
stacked: true, // Enable stacking on the x-axis
title: {
display: false,
},
},
y: {
stacked: true, // Enable stacking on the y-axis
title: {
display: false,
},
}
},
plugins: {
legend: {
display: false,
},
tooltip: {
callbacks: {
label: function (context) {
if (context.parsed.x !== null) {
return `${context.dataset.label}: ${new Intl.NumberFormat(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 30,
roundingMode: 'trunc'
}).format(Math.abs(context.parsed.x))}`;
}
return "";
},
}
}
}
};
new Chart(
document.getElementById('accountChart'),
{
type: 'bar',
data: {
labels: accountData.labels,
datasets: [
{
label: "{% trans 'Current Income' %}",
data: accountData.datasets[0].data,
backgroundColor: '#4dde80',
stack: 'stack0'
},
{
label: "{% trans 'Current Expenses' %}",
data: accountData.datasets[1].data,
backgroundColor: '#f87171',
stack: 'stack0'
},
{
label: "{% trans 'Projected Income' %}",
data: accountData.datasets[2].data,
backgroundColor: '#4dde8080', // Added transparency
stack: 'stack0'
},
{
label: "{% trans 'Projected Expenses' %}",
data: accountData.datasets[3].data,
backgroundColor: '#f8717180', // Added transparency
stack: 'stack0'
}
]
},
options: {
...chartOptions,
plugins: {
...chartOptions.plugins,
title: {
display: false,
}
}
}
}
);
}
</script>
{% else %}
<c-msg.empty title="{% translate "No information to display" %}"></c-msg.empty>
{% endif %}

View File

@@ -0,0 +1,92 @@
{% load i18n %}
{% if currency_data.labels %}
<div class="chart-container" style="position: relative; height:400px; width:100%"
_="init call setupCurrencyChart() end">
<canvas id="currencyChart"></canvas>
</div>
<script>
// Get the data from your Django view (passed as JSON)
var currencyData = {{ currency_data|safe }};
function setupCurrencyChart() {
var chartOptions = {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
stacked: true,
title: {
display: false,
},
},
y: {
stacked: true,
title: {
display: false,
},
}
},
plugins: {
legend: {
display: false,
},
tooltip: {
callbacks: {
label: function (context) {
if (context.parsed.x !== null) {
return `${context.dataset.label}: ${new Intl.NumberFormat(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 30,
roundingMode: 'trunc'
}).format(Math.abs(context.parsed.x))}`;
}
return "";
},
}
}
}
};
new Chart(
document.getElementById('currencyChart'),
{
type: 'bar',
data: {
labels: currencyData.labels,
datasets: [
{
label: "{% trans 'Current Income' %}",
data: currencyData.datasets[0].data,
backgroundColor: '#4dde80',
stack: 'stack0'
},
{
label: "{% trans 'Current Expenses' %}",
data: currencyData.datasets[1].data,
backgroundColor: '#f87171',
stack: 'stack0'
},
{
label: "{% trans 'Projected Income' %}",
data: currencyData.datasets[2].data,
backgroundColor: '#4dde8080', // Added transparency
stack: 'stack0'
},
{
label: "{% trans 'Projected Expenses' %}",
data: currencyData.datasets[3].data,
backgroundColor: '#f8717180', // Added transparency
stack: 'stack0'
}
]
},
options: chartOptions
}
);
}
</script>
{% else %}
<c-msg.empty title="{% translate "No information to display" %}"></c-msg.empty>
{% endif %}

View File

@@ -0,0 +1,37 @@
{% load i18n %}
{% load crispy_forms_tags %}
<form _="install init_tom_select
on change trigger updated
init trigger updated" id="category-form">
{% crispy category_form %}
</form>
<div class="row row-cols-1 row-cols-lg-2 gx-3 gy-3">
<div class="col">
<div class="card h-100">
<div class="card-header">
{% trans "Income/Expense by Account" %}
</div>
<div class="card-body">
<div id="account-card" class="show-loading" hx-get="{% url 'category_sum_by_account' %}"
hx-trigger="updated from:window" hx-include="#category-form, #picker-form, #picker-type">
</div>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-header">
{% trans "Income/Expense by Currency" %}
</div>
<div class="card-body">
<div id="currency-card" class="show-loading" hx-get="{% url 'category_sum_by_currency' %}"
hx-trigger="updated from:window" hx-include="#category-form, #picker-form, #picker-type">
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,117 @@
{% load i18n %}
{% if type == 'account' %}
<div class="show-loading" hx-get="{% url 'insights_sankey_by_account' %}" hx-trigger="updated from:window"
hx-swap="outerHTML" hx-include="#picker-form, #picker-type">
{% else %}
<div class="show-loading" hx-get="{% url 'insights_sankey_by_currency' %}" hx-trigger="updated from:window"
hx-swap="outerHTML" hx-include="#picker-form, #picker-type">
{% endif %}
<div class="chart-container position-relative tw-min-h-[85vh] tw-max-h-[85vh] tw-h-full tw-w-full"
id="sankeyContainer"
_="init call setupSankeyChart() end">
<canvas id="sankeyChart"></canvas>
</div>
</div>
<script>
var data = {{ sankey_data|safe }};
function setupSankeyChart(chartId = 'sankeyChart') {
function formatCurrency(value, currency) {
return new Intl.NumberFormat(undefined, {
minimumFractionDigits: currency.decimal_places,
maximumFractionDigits: currency.decimal_places
}).format(value);
}
// Create labels object mapping node IDs to display names
const labels = data.nodes.reduce((acc, node) => {
acc[node.id] = node.name;
return acc;
}, {});
// Define colors for each node based on its type
const colors = {};
data.nodes.forEach(node => {
if (node.id.startsWith('income_')) {
colors[node.id] = '#4dde80'; // Green for income
} else if (node.id.startsWith('expense_')) {
colors[node.id] = '#f87171'; // Red for expenses
} else {
colors[node.id] = '#fbb700'; // Primary for others
}
});
// Color getter functions
const getColor = (nodeId) => colors[nodeId];
const getHover = (nodeId) => colors[nodeId];
// Format data for Chart.js
const chartData = {
datasets: [{
data: data.flows.map(flow => ({
from: flow.from_node,
to: flow.to_node,
flow: flow.flow
})),
labels: labels,
colorFrom: (c) => getColor(c.dataset.data[c.dataIndex].from),
colorTo: (c) => getColor(c.dataset.data[c.dataIndex].to),
hoverColorFrom: (c) => getHover(c.dataset.data[c.dataIndex].from),
hoverColorTo: (c) => getHover(c.dataset.data[c.dataIndex].to),
colorMode: 'gradient',
alpha: 0.5,
size: 'max',
color: "white",
nodePadding: 30,
priority: data.nodes.reduce((acc, node) => {
acc[node.id] = node.priority;
return acc;
}, {}),
}]
};
const config = {
type: 'sankey',
data: chartData,
options: {
responsive: true,
maintainAspectRatio: false,
layout: {
padding: 20
},
plugins: {
tooltip: {
callbacks: {
label: (context) => {
const flow = data.flows[context.dataIndex];
const fromNode = data.nodes.find(n => n.id === flow.from_node);
const toNode = data.nodes.find(n => n.id === flow.to_node);
const formattedValue = formatCurrency(flow.original_amount, flow.currency);
return [
`{% trans 'From' %}: ${fromNode.name}`,
`{% trans 'To' %}: ${toNode.name}`,
`{% trans 'Amount' %}: ${flow.currency.prefix}${formattedValue}${flow.currency.suffix}`,
`{% trans 'Percentage' %}: ${flow.percentage.toFixed(2)}%`
];
}
}
},
legend: {
display: false
},
title: {
display: false,
}
}
}
};
// Create new chart
new Chart(
document.getElementById(chartId),
config
);
}
</script>

View File

@@ -0,0 +1,110 @@
{% extends "layouts/base.html" %}
{% load crispy_forms_tags %}
{% load i18n %}
{% block content %}
<div class="container-fluid">
<div class="row my-3 h-100">
<div class="col-lg-2 col-md-3 mb-3 mb-md-0">
<div class="position-sticky tw-top-3">
<div class="">
<div class="mb-2 w-100 d-lg-inline-flex d-grid gap-2 flex-wrap justify-content-lg-center" role="group"
_="on change
set type to event.target.value
add .tw-hidden to <#picker-form > div:not(.tw-hidden)/>
if type == 'month'
remove .tw-hidden from #month-form
end
if type == 'year'
remove .tw-hidden from #year-form
end
if type == 'month-range'
remove .tw-hidden from #month-range-form
end
if type == 'year-range'
remove .tw-hidden from #year-range-form
end
if type == 'date-range'
remove .tw-hidden from #date-range-form
end
then trigger updated"
id="picker-type">
<input type="radio" class="btn-check" name="type" value="month" id="monthradio" autocomplete="off"
checked>
<label class="btn btn-sm btn-outline-primary flex-grow-1" for="monthradio">{% translate 'Month' %}</label>
<input type="radio" class="btn-check" name="type" value="year" id="yearradio" autocomplete="off">
<label class="btn btn-sm btn-outline-primary flex-grow-1" for="yearradio">{% translate 'Year' %}</label>
<input type="radio" class="btn-check" name="type" value="month-range" id="monthrangeradio"
autocomplete="off">
<label class="btn btn-sm btn-outline-primary flex-grow-1"
for="monthrangeradio">{% translate 'Month Range' %}</label>
<input type="radio" class="btn-check" name="type" value="year-range" id="yearrangeradio"
autocomplete="off">
<label class="btn btn-sm btn-outline-primary flex-grow-1"
for="yearrangeradio">{% translate 'Year Range' %}</label>
<input type="radio" class="btn-check" name="type" value="date-range" id="daterangeradio"
autocomplete="off">
<label class="btn btn-sm btn-outline-primary flex-grow-1"
for="daterangeradio">{% translate 'Date Range' %}</label>
</div>
<form id="picker-form"
_="install init_datepicker
on change trigger updated">
<div id="month-form" class="">
{% crispy month_form %}
</div>
<div id="year-form" class="tw-hidden">
{% crispy year_form %}
</div>
<div id="month-range-form" class="tw-hidden">
{% crispy month_range_form %}
</div>
<div id="year-range-form" class="tw-hidden">
{% crispy year_range_form %}
</div>
<div id="date-range-form" class="tw-hidden">
{% crispy date_range_form %}
</div>
</form>
</div>
<hr class="mt-0">
<div class="nav flex-column nav-pills" id="v-pills-tab" role="tablist"
aria-orientation="vertical">
<button class="nav-link" id="v-pills-tab" data-bs-toggle="pill" data-bs-target="#v-pills-content"
type="button" role="tab" aria-controls="v-pills-content" aria-selected="false"
hx-get="{% url 'insights_sankey_by_account' %}" hx-include="#picker-form, #picker-type"
hx-indicator="#tab-content"
hx-target="#tab-content">{% trans 'Account Flow' %}
</button>
<button class="nav-link" id="v-pills-tab" data-bs-toggle="pill" data-bs-target="#v-pills-content"
type="button" role="tab" aria-controls="v-pills-content" aria-selected="false"
hx-get="{% url 'insights_sankey_by_currency' %}"
hx-include="#picker-form, #picker-type"
hx-indicator="#tab-content"
hx-target="#tab-content">{% trans 'Currency Flow' %}
</button>
<button class="nav-link" id="v-pills-tab" data-bs-toggle="pill" data-bs-target="#v-pills-content"
type="button" role="tab" aria-controls="v-pills-content" aria-selected="false"
hx-get="{% url 'category_explorer_index' %}"
hx-include="#picker-form, #picker-type"
hx-indicator="#tab-content"
hx-target="#tab-content">{% trans 'Category Explorer' %}
</button>
</div>
</div>
</div>
<div class="col-md-9 col-lg-10">
<div class="tab-content w-100" id="v-pills-tabContent">
<div class="tab-pane fade" id="v-pills-content" role="tabpanel" tabindex="0">
<div id="tab-content" class="show-loading"></div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -4,9 +4,9 @@
<div id="transactions-list">
{% for x in transactions_by_date %}
<div id="{{ x.grouper|slugify }}"
_="on htmx:afterSettle from #transactions if sessionStorage.getItem(my id) is null then sessionStorage.setItem(my id, 'true')">
<div class="mt-3 mb-1 w-100 tw-text-base border-bottom bg-body">
<div id="{{ x.grouper|slugify }}" class="transactions-divider"
_="on htmx:afterSwap from #transactions if sessionStorage.getItem(my id) is null then sessionStorage.setItem(my id, 'true')">
<div class="mt-3 mb-1 w-100 tw-text-base border-bottom bg-body transactions-divider-title">
<a class="text-decoration-none d-inline-block w-100"
role="button"
data-bs-toggle="collapse"
@@ -17,15 +17,21 @@
{{ x.grouper }}
</a>
</div>
<div class="collapse" id="c-{{ x.grouper|slugify }}-collapse"
<div class="collapse transactions-divider-collapse" id="c-{{ x.grouper|slugify }}-collapse"
_="on shown.bs.collapse sessionStorage.setItem(the closest parent @id, 'true')
on hidden.bs.collapse sessionStorage.setItem(the closest parent @id, 'false')
on htmx:afterSettle from #transactions
on htmx:afterSettle from #transactions or toggle
set state to sessionStorage.getItem(the closest parent @id)
if state is 'true' or state is null
add .show to me
set @aria-expanded of #c-{{ x.grouper|slugify }}-collapsible to true
end">
else
remove .show from me
set @aria-expanded of #c-{{ x.grouper|slugify }}-collapsible to false
end
on show
add .show to me
set @aria-expanded of #c-{{ x.grouper|slugify }}-collapsible to true">
<div class="d-flex flex-column">
{% for transaction in x.list %}
<c-transaction.item

View File

@@ -256,7 +256,7 @@
<div class="col">
<c-ui.info-card color="yellow" icon="fa-solid fa-percent" title="{% trans 'Distribution' %}">
{% for p in percentages.values %}
<p class="tw-text-gray-400 mb-2 {% if not forloop.first %}mt-3{% endif %}">{{ p.currency.name }} ({{ p.currency.code }})</p>
<p class="tw-text-gray-400 mb-2 {% if not forloop.first %}mt-3{% endif %}">{{ p.currency.name }}</p>
<c-ui.percentage-distribution :percentage="p"></c-ui.percentage-distribution>
{% endfor %}
</c-ui.info-card>

View File

@@ -172,6 +172,20 @@
_="on click call #filter.reset() then trigger change on #filter">{% translate 'Clear' %}</button>
</div>
</div>
<div id="search" class="my-3">
<label class="w-100">
<input type="search" class="form-control" placeholder="Buscar" hx-preserve id="quick-search"
_="on input or search or htmx:afterSwap from window
if my value is empty
trigger toggle on <.transactions-divider-collapse/>
else
trigger show on <.transactions-divider-collapse/>
end
show <.transactions-divider-title/> when my value is empty
show <.transaction/> in <#transactions-list/>
when its textContent.toLowerCase() contains my value.toLowerCase()">
</label>
</div>
{# Transactions list#}
<div id="transactions"
class="show-loading"

View File

@@ -10,6 +10,4 @@ until [ -f /tmp/migrations_complete ]; do
sleep 2
done
rm -f /tmp/migrations_complete
exec python manage.py procrastinate worker

View File

@@ -24,6 +24,7 @@
"babel-loader": "^8.2.3",
"bootstrap": "^5.3.3",
"chart.js": "^4.4.6",
"chartjs-chart-sankey": "^0.14.0",
"clean-webpack-plugin": "^4.0.0",
"copy-webpack-plugin": "^11.0.0",
"core-js": "^3.20.3",
@@ -3235,6 +3236,15 @@
"pnpm": ">=8"
}
},
"node_modules/chartjs-chart-sankey": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/chartjs-chart-sankey/-/chartjs-chart-sankey-0.14.0.tgz",
"integrity": "sha512-MrU3lE73TE9kALy4MjWFlfcwf4R1EN/DBvhHxmv9n4AHap//JLKjlJTLIZwHsUjDsYo0B8PuMkrJODwfirEZUA==",
"license": "MIT",
"peerDependencies": {
"chart.js": ">=3.3.0"
}
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",

View File

@@ -37,6 +37,7 @@
"babel-loader": "^8.2.3",
"bootstrap": "^5.3.3",
"chart.js": "^4.4.6",
"chartjs-chart-sankey": "^0.14.0",
"clean-webpack-plugin": "^4.0.0",
"copy-webpack-plugin": "^11.0.0",
"core-js": "^3.20.3",

View File

@@ -1,2 +1,5 @@
import Chart from 'chart.js/auto';
import {SankeyController, Flow} from 'chartjs-chart-sankey';
Chart.register(SankeyController, Flow);
window.Chart = Chart;

View File

@@ -40,6 +40,7 @@ window.DatePicker = function createDynamicDatePicker(element) {
dateFormat: element.dataset.dateFormat,
timeFormat: element.dataset.timeFormat,
timepicker: element.dataset.timepicker === 'true',
toggleSelected: element.dataset.toggleSelected === 'true',
autoClose: element.dataset.autoClose === 'true',
buttons: element.dataset.clearButton === 'true' ? ['clear', todayButton] : [todayButton],
locale: locales[element.dataset.language],
@@ -96,7 +97,6 @@ window.DatePicker = function createDynamicDatePicker(element) {
return new AirDatepicker(element, opts);
};
window.MonthYearPicker = function createDynamicDatePicker(element) {
let todayButton = {
content: element.dataset.nowButtonTxt,
@@ -114,6 +114,7 @@ window.MonthYearPicker = function createDynamicDatePicker(element) {
view: 'months',
minView: 'months',
dateFormat: 'MMMM yyyy',
toggleSelected: element.dataset.toggleSelected === 'true',
autoClose: element.dataset.autoClose === 'true',
buttons: element.dataset.clearButton === 'true' ? ['clear', todayButton] : [todayButton],
locale: locales[element.dataset.language],
@@ -163,8 +164,81 @@ window.MonthYearPicker = function createDynamicDatePicker(element) {
let opts = {...baseOpts, ...positionConfig};
if (element.dataset.value) {
opts["selectedDates"] = [new Date(element.dataset.value + "T00:00:00")];
opts["startDate"] = [new Date(element.dataset.value + "T00:00:00")];
opts["selectedDates"] = [new Date(element.dataset.value + "T00:00:00")];
opts["startDate"] = [new Date(element.dataset.value + "T00:00:00")];
}
return new AirDatepicker(element, opts);
};
window.YearPicker = function createDynamicDatePicker(element) {
let todayButton = {
content: element.dataset.nowButtonTxt,
onClick: (dp) => {
let date = new Date();
dp.selectDate(date, {updateTime: true});
dp.setViewDate(date);
}
}
let isOnMobile = isMobile();
let baseOpts = {
isMobile: isOnMobile,
view: 'years',
minView: 'years',
dateFormat: 'yyyy',
toggleSelected: element.dataset.toggleSelected === 'true',
autoClose: element.dataset.autoClose === 'true',
buttons: element.dataset.clearButton === 'true' ? ['clear', todayButton] : [todayButton],
locale: locales[element.dataset.language],
onSelect: ({date, formattedDate, datepicker}) => {
const _event = new CustomEvent("change", {
bubbles: true,
});
datepicker.$el.dispatchEvent(_event);
}
};
const positionConfig = !isOnMobile ? {
position({$datepicker, $target, $pointer, done}) {
let popper = createPopper($target, $datepicker, {
placement: 'bottom',
modifiers: [
{
name: 'flip',
options: {
padding: {
top: 64
}
}
},
{
name: 'offset',
options: {
offset: [0, 20]
}
},
{
name: 'arrow',
options: {
element: $pointer
}
}
]
});
return function completeHide() {
popper.destroy();
done();
};
}
} : {};
let opts = {...baseOpts, ...positionConfig};
if (element.dataset.value) {
opts["selectedDates"] = [new Date(element.dataset.value + "T00:00:00")];
opts["startDate"] = [new Date(element.dataset.value + "T00:00:00")];
}
return new AirDatepicker(element, opts);
};

View File

@@ -3,71 +3,84 @@ import * as Popper from "@popperjs/core";
window.TomSelect = function createDynamicTomSelect(element) {
// Basic configuration
const config = {
plugins: {},
// Basic configuration
const config = {
plugins: {},
// Extract 'create' option from data attribute
create: element.dataset.create === 'true',
copyClassesToDropdown: true,
allowEmptyOption: element.dataset.allowEmptyOption === 'true',
render: {
no_results: function () {
return `<div class="no-results">${element.dataset.txtNoResults || 'No results...'}</div>`;
// Extract 'create' option from data attribute
create: element.dataset.create === 'true',
copyClassesToDropdown: true,
allowEmptyOption: element.dataset.allowEmptyOption === 'true',
render: {
no_results: function () {
return `<div class="no-results">${element.dataset.txtNoResults || 'No results...'}</div>`;
},
option_create: function (data, escape) {
return `<div class="create">${element.dataset.txtCreate || 'Add'} <strong>${escape(data.input)}</strong>&hellip;</div>`;
},
},
option_create: function(data, escape) {
return `<div class="create">${element.dataset.txtCreate || 'Add'} <strong>${escape(data.input)}</strong>&hellip;</div>`;
},
},
onInitialize: function () {
this.popper = Popper.createPopper(this.control, this.dropdown, {
placement: "bottom-start",
modifiers: [
{
name: "sameWidth",
enabled: true,
fn: ({state}) => {
state.styles.popper.width = `${state.rects.reference.width}px`;
onInitialize: function () {
this.popper = Popper.createPopper(this.control, this.dropdown, {
placement: "bottom-start",
modifiers: [
{
name: "sameWidth",
enabled: true,
fn: ({state}) => {
state.styles.popper.width = `${state.rects.reference.width}px`;
},
phase: "beforeWrite",
requires: ["computeStyles"],
},
phase: "beforeWrite",
requires: ["computeStyles"],
},
{
name: 'flip',
options: {
fallbackPlacements: ['top-start'],
{
name: 'flip',
options: {
fallbackPlacements: ['top-start'],
},
},
},
]
]
});
});
},
onDropdownOpen: function () {
this.popper.update();
}
};
},
onDropdownOpen: function () {
this.popper.update();
}
};
if (element.dataset.checkboxes === 'true') {
config.plugins.checkbox_options = {
if (element.dataset.checkboxes === 'true') {
config.plugins.checkbox_options = {
'checkedClassNames': ['ts-checked'],
'uncheckedClassNames': ['ts-unchecked'],
};
}
}
if (element.dataset.clearButton === 'true') {
config.plugins.clear_button = {
if (element.dataset.clearButton === 'true') {
config.plugins.clear_button = {
'title': element.dataset.txtClear || 'Clear',
};
}
}
if (element.dataset.removeButton === 'true') {
config.plugins.remove_button = {
if (element.dataset.removeButton === 'true') {
config.plugins.remove_button = {
'title': element.dataset.txtRemove || 'Remove',
};
}
}
// Create and return the TomSelect instance
return new TomSelect(element, config);
if (element.dataset.load) {
config.load = function (query, callback) {
let url = element.dataset.load + '?q=' + encodeURIComponent(query);
fetch(url)
.then(response => response.json())
.then(json => {
callback(json);
}).catch(() => {
callback();
});
};
}
// Create and return the TomSelect instance
return new TomSelect(element, config);
};

View File

@@ -78,6 +78,7 @@
.show-loading.htmx-request {
position: relative;
top: 0;
min-height: 100px;
&::before {
content: "";

View File

@@ -50,7 +50,7 @@ select[multiple] {
padding: 0 8px !important;
}
.transaction:has(input[type="checkbox"]:checked) > .transaction-item {
.transaction:has(input[type="checkbox"]:checked) > div > .transaction-item {
background-color: $primary-bg-subtle-dark;
}

View File

@@ -12,6 +12,7 @@ django-cotton~=1.2.1
django-pwa~=2.0.1
djangorestframework~=3.15.2
drf-spectacular~=0.27.2
django-import-export~=4.3.5
gunicorn==22.0.0
whitenoise[brotli]==6.6.0