mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-02-25 17:04:51 +01:00
Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8312baaf45 | ||
|
|
4d346dc278 | ||
|
|
70ff7fab38 | ||
|
|
6947c6affd | ||
|
|
dcab83f936 | ||
|
|
b228e4ec26 | ||
|
|
4071a1301f | ||
|
|
5c9db10710 | ||
|
|
19c92e0014 | ||
|
|
6459f2eb46 | ||
|
|
7926e081ef | ||
|
|
ceefe7075f | ||
|
|
ad3230fd83 | ||
|
|
c89b07ed93 | ||
|
|
201ccea842 | ||
|
|
32ada488b4 | ||
|
|
794d11a355 | ||
|
|
67f8f5fe89 | ||
|
|
9ac69fd92a | ||
|
|
069f1b450c | ||
|
|
2f388af928 | ||
|
|
beeb0579ce | ||
|
|
a8666da57b | ||
|
|
835316d0f3 | ||
|
|
f5feeb9617 | ||
|
|
09e380a480 | ||
|
|
3080df9b66 | ||
|
|
ebc41a8049 | ||
|
|
635628e30e | ||
|
|
819a58ac06 | ||
|
|
d433375522 | ||
|
|
c0150f71a8 | ||
|
|
6119698d38 | ||
|
|
f5ae231601 | ||
|
|
972d23abbd | ||
|
|
9a514a8a69 | ||
|
|
7325231548 | ||
|
|
570657371a | ||
|
|
67da60b5b0 | ||
|
|
84c047c5ab | ||
|
|
23f5d09bec | ||
|
|
2a19075e23 | ||
|
|
7f231175b2 | ||
|
|
062e84f864 | ||
|
|
5521eb20bf | ||
|
|
627b5d250b | ||
|
|
195a8a68d6 | ||
|
|
daf1f68b82 | ||
|
|
dd24fd56d3 | ||
|
|
7a2acb6497 | ||
|
|
9c339faa72 | ||
|
|
02376ad02b | ||
|
|
b53a4a0286 | ||
|
|
a1f618434b | ||
|
|
7b5be29f0d | ||
|
|
56a73b181a | ||
|
|
865618e054 | ||
|
|
9e912b2736 | ||
|
|
da7680e70f | ||
|
|
ab594eb511 | ||
|
|
cffaaa369a | ||
|
|
5f414e82ee | ||
|
|
f3bcef534e | ||
|
|
d140ff5b70 | ||
|
|
7eceacfe68 | ||
|
|
038438fba7 | ||
|
|
ee98a5ef12 | ||
|
|
28b12faaf0 | ||
|
|
d0f2742637 | ||
|
|
9c55dac866 | ||
|
|
e6d8b548b7 | ||
|
|
4f8c2215c1 | ||
|
|
a3a8791e96 |
@@ -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",
|
||||
|
||||
@@ -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")),
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
0
app/apps/export_app/__init__.py
Normal file
0
app/apps/export_app/__init__.py
Normal file
3
app/apps/export_app/admin.py
Normal file
3
app/apps/export_app/admin.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
6
app/apps/export_app/apps.py
Normal file
6
app/apps/export_app/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ExportConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.export_app"
|
||||
189
app/apps/export_app/forms.py
Normal file
189
app/apps/export_app/forms.py
Normal 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
|
||||
0
app/apps/export_app/migrations/__init__.py
Normal file
0
app/apps/export_app/migrations/__init__.py
Normal file
3
app/apps/export_app/models.py
Normal file
3
app/apps/export_app/models.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.db import models
|
||||
|
||||
# Create your models here.
|
||||
0
app/apps/export_app/resources/__init__.py
Normal file
0
app/apps/export_app/resources/__init__.py
Normal file
26
app/apps/export_app/resources/accounts.py
Normal file
26
app/apps/export_app/resources/accounts.py
Normal 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
|
||||
52
app/apps/export_app/resources/currencies.py
Normal file
52
app/apps/export_app/resources/currencies.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from import_export import fields, resources, widgets
|
||||
|
||||
from apps.accounts.models import Account
|
||||
from apps.currencies.models import Currency, ExchangeRate, ExchangeRateService
|
||||
from apps.export_app.widgets.foreign_key import SkipMissingForeignKeyWidget
|
||||
from apps.export_app.widgets.numbers import UniversalDecimalWidget
|
||||
|
||||
|
||||
class CurrencyResource(resources.ModelResource):
|
||||
exchange_currency = fields.Field(
|
||||
attribute="exchange_currency",
|
||||
column_name="exchange_currency",
|
||||
widget=SkipMissingForeignKeyWidget(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"),
|
||||
)
|
||||
rate = fields.Field(
|
||||
attribute="rate", column_name="rate", widget=UniversalDecimalWidget()
|
||||
)
|
||||
|
||||
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
|
||||
38
app/apps/export_app/resources/dca.py
Normal file
38
app/apps/export_app/resources/dca.py
Normal file
@@ -0,0 +1,38 @@
|
||||
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
|
||||
from apps.export_app.widgets.numbers import UniversalDecimalWidget
|
||||
|
||||
|
||||
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):
|
||||
amount_paid = fields.Field(
|
||||
attribute="amount_paid",
|
||||
column_name="amount_paid",
|
||||
widget=UniversalDecimalWidget(),
|
||||
)
|
||||
amount_received = fields.Field(
|
||||
attribute="amount_received",
|
||||
column_name="amount_received",
|
||||
widget=UniversalDecimalWidget(),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = DCAEntry
|
||||
8
app/apps/export_app/resources/import_app.py
Normal file
8
app/apps/export_app/resources/import_app.py
Normal 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
|
||||
25
app/apps/export_app/resources/rules.py
Normal file
25
app/apps/export_app/resources/rules.py
Normal 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
|
||||
143
app/apps/export_app/resources/transactions.py
Normal file
143
app/apps/export_app/resources/transactions.py
Normal file
@@ -0,0 +1,143 @@
|
||||
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,
|
||||
)
|
||||
from apps.export_app.widgets.numbers import UniversalDecimalWidget
|
||||
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
amount = fields.Field(
|
||||
attribute="amount",
|
||||
column_name="amount",
|
||||
widget=UniversalDecimalWidget(),
|
||||
)
|
||||
|
||||
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"),
|
||||
)
|
||||
|
||||
amount = fields.Field(
|
||||
attribute="amount",
|
||||
column_name="amount",
|
||||
widget=UniversalDecimalWidget(),
|
||||
)
|
||||
|
||||
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"),
|
||||
)
|
||||
|
||||
installment_amount = fields.Field(
|
||||
attribute="installment_amount",
|
||||
column_name="installment_amount",
|
||||
widget=UniversalDecimalWidget(),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = InstallmentPlan
|
||||
3
app/apps/export_app/tests.py
Normal file
3
app/apps/export_app/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
8
app/apps/export_app/urls.py
Normal file
8
app/apps/export_app/urls.py
Normal 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"),
|
||||
]
|
||||
284
app/apps/export_app/views.py
Normal file
284
app/apps/export_app/views.py
Normal file
@@ -0,0 +1,284 @@
|
||||
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")
|
||||
|
||||
# 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():
|
||||
files = {}
|
||||
|
||||
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")
|
||||
|
||||
files[name] = content
|
||||
|
||||
for field_name, resource_class in import_order:
|
||||
if field_name in files.keys():
|
||||
content = files[field_name]
|
||||
import_dataset(content, resource_class, field_name)
|
||||
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)
|
||||
0
app/apps/export_app/widgets/__init__.py
Normal file
0
app/apps/export_app/widgets/__init__.py
Normal file
22
app/apps/export_app/widgets/foreign_key.py
Normal file
22
app/apps/export_app/widgets/foreign_key.py
Normal file
@@ -0,0 +1,22 @@
|
||||
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
|
||||
|
||||
|
||||
class SkipMissingForeignKeyWidget(ForeignKeyWidget):
|
||||
def clean(self, value, row=None, *args, **kwargs):
|
||||
if not value:
|
||||
return None
|
||||
|
||||
try:
|
||||
return super().clean(value, row, *args, **kwargs)
|
||||
except self.model.DoesNotExist:
|
||||
return None
|
||||
21
app/apps/export_app/widgets/many_to_many.py
Normal file
21
app/apps/export_app/widgets/many_to_many.py
Normal 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
|
||||
18
app/apps/export_app/widgets/numbers.py
Normal file
18
app/apps/export_app/widgets/numbers.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from import_export.widgets import NumberWidget
|
||||
|
||||
|
||||
class UniversalDecimalWidget(NumberWidget):
|
||||
def clean(self, value, row=None, *args, **kwargs):
|
||||
if self.is_empty(value):
|
||||
return None
|
||||
# Replace comma with dot if present
|
||||
if isinstance(value, str):
|
||||
value = value.replace(",", ".")
|
||||
return Decimal(str(value))
|
||||
|
||||
def render(self, value, obj=None, **kwargs):
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value).replace(",", ".")
|
||||
7
app/apps/export_app/widgets/string.py
Normal file
7
app/apps/export_app/widgets/string.py
Normal 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
|
||||
0
app/apps/insights/__init__.py
Normal file
0
app/apps/insights/__init__.py
Normal file
3
app/apps/insights/admin.py
Normal file
3
app/apps/insights/admin.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
6
app/apps/insights/apps.py
Normal file
6
app/apps/insights/apps.py
Normal 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
131
app/apps/insights/forms.py
Normal 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")
|
||||
0
app/apps/insights/migrations/__init__.py
Normal file
0
app/apps/insights/migrations/__init__.py
Normal file
3
app/apps/insights/models.py
Normal file
3
app/apps/insights/models.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.db import models
|
||||
|
||||
# Create your models here.
|
||||
3
app/apps/insights/tests.py
Normal file
3
app/apps/insights/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
42
app/apps/insights/urls.py
Normal file
42
app/apps/insights/urls.py
Normal file
@@ -0,0 +1,42 @@
|
||||
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",
|
||||
),
|
||||
path(
|
||||
"insights/late-transactions/",
|
||||
views.late_transactions,
|
||||
name="insights_late_transactions",
|
||||
),
|
||||
path(
|
||||
"insights/latest-transactions/",
|
||||
views.latest_transactions,
|
||||
name="insights_latest_transactions",
|
||||
),
|
||||
]
|
||||
0
app/apps/insights/utils/__init__.py
Normal file
0
app/apps/insights/utils/__init__.py
Normal file
161
app/apps/insights/utils/category_explorer.py
Normal file
161
app/apps/insights/utils/category_explorer.py
Normal file
@@ -0,0 +1,161 @@
|
||||
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", 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", is_paid=True, then=-F("amount")),
|
||||
default=Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
)
|
||||
),
|
||||
Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
),
|
||||
projected_income=Coalesce(
|
||||
Sum(
|
||||
Case(
|
||||
When(type="IN", 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", is_paid=False, then=-F("amount")),
|
||||
default=Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
)
|
||||
),
|
||||
Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
),
|
||||
)
|
||||
.order_by("account__name")
|
||||
)
|
||||
|
||||
return {
|
||||
"labels": [item["account__name"] for item in sums],
|
||||
"datasets": [
|
||||
{
|
||||
"label": _("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", 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", is_paid=True, then=-F("amount")),
|
||||
default=Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
)
|
||||
),
|
||||
Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
),
|
||||
projected_income=Coalesce(
|
||||
Sum(
|
||||
Case(
|
||||
When(type="IN", 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", is_paid=False, then=-F("amount")),
|
||||
default=Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
)
|
||||
),
|
||||
Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
),
|
||||
)
|
||||
.order_by("account__currency__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],
|
||||
},
|
||||
],
|
||||
}
|
||||
280
app/apps/insights/utils/sankey.py
Normal file
280
app/apps/insights/utils/sankey.py
Normal 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()
|
||||
},
|
||||
}
|
||||
96
app/apps/insights/utils/transactions.py
Normal file
96
app/apps/insights/utils/transactions.py
Normal 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
|
||||
187
app/apps/insights/views.py
Normal file
187
app/apps/insights/views.py
Normal file
@@ -0,0 +1,187 @@
|
||||
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, Transaction
|
||||
|
||||
|
||||
@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},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def latest_transactions(request):
|
||||
limit = timezone.now() - relativedelta(days=3)
|
||||
transactions = Transaction.objects.filter(created_at__gte=limit)[:30]
|
||||
|
||||
return render(
|
||||
request,
|
||||
"insights/fragments/latest_transactions.html",
|
||||
{"transactions": transactions},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def late_transactions(request):
|
||||
now = timezone.localdate(timezone.now())
|
||||
transactions = Transaction.objects.filter(is_paid=False, date__lt=now)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"insights/fragments/late_transactions.html",
|
||||
{"transactions": transactions},
|
||||
)
|
||||
0
app/apps/mini_tools/utils/__init__.py
Normal file
0
app/apps/mini_tools/utils/__init__.py
Normal file
85
app/apps/mini_tools/utils/exchange_rate_map.py
Normal file
85
app/apps/mini_tools/utils/exchange_rate_map.py
Normal file
@@ -0,0 +1,85 @@
|
||||
from typing import Dict
|
||||
|
||||
from django.db.models import Func, F, Value
|
||||
from django.db.models.functions import Extract
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.currencies.models import ExchangeRate
|
||||
|
||||
|
||||
def get_currency_exchange_map(date=None) -> Dict[str, dict]:
|
||||
"""
|
||||
Creates a nested dictionary of exchange rates and currency information.
|
||||
|
||||
Returns:
|
||||
{
|
||||
'BTC': {
|
||||
'decimal_places': 8,
|
||||
'prefix': '₿',
|
||||
'suffix': '',
|
||||
'rates': {'USD': Decimal('34000.00'), 'EUR': Decimal('31000.00')}
|
||||
},
|
||||
'USD': {
|
||||
'decimal_places': 2,
|
||||
'prefix': '$',
|
||||
'suffix': '',
|
||||
'rates': {'BTC': Decimal('0.0000294'), 'EUR': Decimal('0.91')}
|
||||
},
|
||||
...
|
||||
}
|
||||
"""
|
||||
if date is None:
|
||||
date = timezone.localtime(timezone.now())
|
||||
|
||||
# Get all exchange rates for the closest date
|
||||
exchange_rates = (
|
||||
ExchangeRate.objects.select_related(
|
||||
"from_currency", "to_currency"
|
||||
) # Optimize currency queries
|
||||
.annotate(
|
||||
date_diff=Func(Extract(F("date") - Value(date), "epoch"), function="ABS"),
|
||||
effective_rate=F("rate"),
|
||||
)
|
||||
.order_by("from_currency", "to_currency", "date_diff")
|
||||
.distinct("from_currency", "to_currency")
|
||||
)
|
||||
|
||||
# Initialize the result dictionary
|
||||
rate_map = {}
|
||||
|
||||
# Build the exchange rate mapping with currency info
|
||||
for rate in exchange_rates:
|
||||
# Add from_currency info if not exists
|
||||
if rate.from_currency.name not in rate_map:
|
||||
rate_map[rate.from_currency.name] = {
|
||||
"decimal_places": rate.from_currency.decimal_places,
|
||||
"prefix": rate.from_currency.prefix,
|
||||
"suffix": rate.from_currency.suffix,
|
||||
"rates": {},
|
||||
}
|
||||
|
||||
# Add to_currency info if not exists
|
||||
if rate.to_currency.name not in rate_map:
|
||||
rate_map[rate.to_currency.name] = {
|
||||
"decimal_places": rate.to_currency.decimal_places,
|
||||
"prefix": rate.to_currency.prefix,
|
||||
"suffix": rate.to_currency.suffix,
|
||||
"rates": {},
|
||||
}
|
||||
|
||||
# Add direct rate
|
||||
rate_map[rate.from_currency.name]["rates"][rate.to_currency.name] = {
|
||||
"rate": rate.rate,
|
||||
"decimal_places": rate.to_currency.decimal_places,
|
||||
"prefix": rate.to_currency.prefix,
|
||||
"suffix": rate.to_currency.suffix,
|
||||
}
|
||||
# Add inverse rate
|
||||
rate_map[rate.to_currency.name]["rates"][rate.from_currency.name] = {
|
||||
"rate": 1 / rate.rate,
|
||||
"decimal_places": rate.from_currency.decimal_places,
|
||||
"prefix": rate.from_currency.prefix,
|
||||
"suffix": rate.from_currency.suffix,
|
||||
}
|
||||
|
||||
return rate_map
|
||||
@@ -5,6 +5,7 @@ from apps.common.widgets.decimal import convert_to_decimal
|
||||
from apps.currencies.models import Currency
|
||||
from apps.currencies.utils.convert import convert
|
||||
from apps.mini_tools.forms import CurrencyConverterForm
|
||||
from apps.mini_tools.utils.exchange_rate_map import get_currency_exchange_map
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -14,11 +15,13 @@ def unit_price_calculator(request):
|
||||
|
||||
@login_required
|
||||
def currency_converter(request):
|
||||
rate_map = get_currency_exchange_map()
|
||||
|
||||
form = CurrencyConverterForm()
|
||||
return render(
|
||||
request,
|
||||
"mini_tools/currency_converter/currency_converter.html",
|
||||
context={"form": form},
|
||||
context={"form": form, "rate_map": rate_map},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -92,6 +92,8 @@ def transactions_list(request, month: int, year: int):
|
||||
"account__currency",
|
||||
"installment_plan",
|
||||
"entities",
|
||||
"dca_expense_entries",
|
||||
"dca_income_entries",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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=(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -18,9 +18,6 @@ def bulk_pay_transactions(request):
|
||||
count = transactions.count()
|
||||
transactions.update(is_paid=True)
|
||||
|
||||
for transaction in transactions:
|
||||
transaction_updated.send(sender=transaction)
|
||||
|
||||
messages.success(
|
||||
request,
|
||||
ngettext_lazy(
|
||||
@@ -45,9 +42,6 @@ def bulk_unpay_transactions(request):
|
||||
count = transactions.count()
|
||||
transactions.update(is_paid=False)
|
||||
|
||||
for transaction in transactions:
|
||||
transaction_updated.send(sender=transaction)
|
||||
|
||||
messages.success(
|
||||
request,
|
||||
ngettext_lazy(
|
||||
@@ -94,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,
|
||||
|
||||
@@ -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 (
|
||||
@@ -363,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)
|
||||
@@ -395,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)
|
||||
@@ -426,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)
|
||||
@@ -453,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)
|
||||
@@ -484,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",
|
||||
@@ -493,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(
|
||||
@@ -500,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
@@ -1,7 +1,7 @@
|
||||
<div class="row {% if not remove_padding %}p-5{% endif %}">
|
||||
<div class="col {% if not remove_padding %}p-5{% endif %}">
|
||||
<div class="text-center">
|
||||
<i class="fa-solid fa-circle-xmark tw-text-6xl"></i>
|
||||
<i class="{% if icon %}{{ icon }}{% else %}fa-solid fa-circle-xmark{% endif %} tw-text-6xl"></i>
|
||||
<p class="lead mt-4 mb-0">{{ title }}</p>
|
||||
<p class="tw-text-gray-500">{{ subtitle }}</p>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% load markdown %}
|
||||
{% load i18n %}
|
||||
<div class="transaction">
|
||||
<div class="d-flex my-1 {% if transaction.type == "EX" %}expense{% else %}income{% endif %}">
|
||||
<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 }}"
|
||||
@@ -44,13 +44,16 @@
|
||||
{# Description#}
|
||||
<div class="mb-2 mb-lg-1 text-white tw-text-base">
|
||||
{% spaceless %}
|
||||
<span>{{ transaction.description }}</span>
|
||||
<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 ms-2">{{ transaction.installment_id }}/{{ transaction.installment_plan.installment_total_number }}</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 ms-2"><i class="fa-solid fa-arrows-rotate fa-fw"></i></span>
|
||||
<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>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<div class="card tw-relative h-100 shadow">
|
||||
<div class="tw-absolute tw-h-8 tw-w-8 tw-right-2 tw-top-2 tw-bg-{{ color }}-300 tw-text-{{ color }}-800 text-center align-items-center d-flex justify-content-center rounded-2">
|
||||
<i class="{{ icon }}"></i>
|
||||
{% if icon %}<i class="{{ icon }}"></i>{% else %}<span class="fw-bold">{{ title.0 }}</span>{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h5 class="tw-text-{{ color }}-400 fw-bold tw-mr-[50px]" {{ attrs }}>{{ title }}{% if help_text %}{% include 'includes/help_icon.html' with content=help_text %}{% endif %}</h5>
|
||||
{{ slot }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
13
app/templates/export_app/fragments/export.html
Normal file
13
app/templates/export_app/fragments/export.html
Normal 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 %}
|
||||
17
app/templates/export_app/fragments/restore.html
Normal file
17
app/templates/export_app/fragments/restore.html
Normal 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 %}
|
||||
29
app/templates/export_app/pages/index.html
Normal file
29
app/templates/export_app/pages/index.html
Normal 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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
{% 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 'Projected Expenses' %}",
|
||||
data: accountData.datasets[3].data,
|
||||
backgroundColor: '#f8717180', // Added transparency
|
||||
stack: 'stack0'
|
||||
},
|
||||
{
|
||||
label: "{% trans 'Current Expenses' %}",
|
||||
data: accountData.datasets[1].data,
|
||||
backgroundColor: '#f87171',
|
||||
stack: 'stack0'
|
||||
},
|
||||
{
|
||||
label: "{% trans 'Current Income' %}",
|
||||
data: accountData.datasets[0].data,
|
||||
backgroundColor: '#4dde80',
|
||||
stack: 'stack0'
|
||||
},
|
||||
{
|
||||
label: "{% trans 'Projected Income' %}",
|
||||
data: accountData.datasets[2].data,
|
||||
backgroundColor: '#4dde8080', // 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 %}
|
||||
@@ -0,0 +1,93 @@
|
||||
{% 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 'Projected Expenses' %}",
|
||||
data: currencyData.datasets[3].data,
|
||||
backgroundColor: '#f8717180', // Added transparency
|
||||
stack: 'stack0'
|
||||
},
|
||||
{
|
||||
label: "{% trans 'Current Expenses' %}",
|
||||
data: currencyData.datasets[1].data,
|
||||
backgroundColor: '#f87171',
|
||||
stack: 'stack0'
|
||||
},
|
||||
{
|
||||
label: "{% trans 'Current Income' %}",
|
||||
data: currencyData.datasets[0].data,
|
||||
backgroundColor: '#4dde80',
|
||||
stack: 'stack0'
|
||||
},
|
||||
{
|
||||
label: "{% trans 'Projected Income' %}",
|
||||
data: currencyData.datasets[2].data,
|
||||
backgroundColor: '#4dde8080', // Added transparency
|
||||
stack: 'stack0'
|
||||
},
|
||||
|
||||
]
|
||||
},
|
||||
options: chartOptions
|
||||
}
|
||||
);
|
||||
}
|
||||
</script>
|
||||
{% else %}
|
||||
<c-msg.empty title="{% translate "No information to display" %}"></c-msg.empty>
|
||||
{% endif %}
|
||||
@@ -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>
|
||||
18
app/templates/insights/fragments/late_transactions.html
Normal file
18
app/templates/insights/fragments/late_transactions.html
Normal file
@@ -0,0 +1,18 @@
|
||||
{% load i18n %}
|
||||
|
||||
|
||||
<div hx-get="{% url 'insights_late_transactions' %}" hx-trigger="updated from:window" class="show-loading"
|
||||
id="transactions-list" hx-swap="outerHTML">
|
||||
{% if transactions %}
|
||||
{% for transaction in transactions %}
|
||||
<c-transaction.item :transaction="transaction"></c-transaction.item>
|
||||
{% endfor %}
|
||||
{# Floating bar #}
|
||||
<c-ui.transactions-action-bar></c-ui.transactions-action-bar>
|
||||
{% else %}
|
||||
<c-msg.empty
|
||||
icon="fa-regular fa-hourglass"
|
||||
title="{% translate 'All good!' %}"
|
||||
subtitle="{% translate "No late transactions" %}"></c-msg.empty>
|
||||
{% endif %}
|
||||
</div>
|
||||
16
app/templates/insights/fragments/latest_transactions.html
Normal file
16
app/templates/insights/fragments/latest_transactions.html
Normal file
@@ -0,0 +1,16 @@
|
||||
{% load i18n %}
|
||||
|
||||
|
||||
<div hx-get="{% url 'insights_late_transactions' %}" hx-trigger="updated from:window" class="show-loading"
|
||||
id="transactions-list" hx-swap="outerHTML">
|
||||
{% if transactions %}
|
||||
{% for transaction in transactions %}
|
||||
<c-transaction.item :transaction="transaction"></c-transaction.item>
|
||||
{% endfor %}
|
||||
{# Floating bar #}
|
||||
<c-ui.transactions-action-bar></c-ui.transactions-action-bar>
|
||||
{% else %}
|
||||
<c-msg.empty
|
||||
title="{% translate 'No recent transactions' %}"></c-msg.empty>
|
||||
{% endif %}
|
||||
</div>
|
||||
117
app/templates/insights/fragments/sankey.html
Normal file
117
app/templates/insights/fragments/sankey.html
Normal 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>
|
||||
122
app/templates/insights/pages/index.html
Normal file
122
app/templates/insights/pages/index.html
Normal file
@@ -0,0 +1,122 @@
|
||||
{% 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>
|
||||
<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>
|
||||
<hr>
|
||||
<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_late_transactions' %}"
|
||||
hx-indicator="#tab-content"
|
||||
hx-target="#tab-content">{% trans 'Late Transactions' %}
|
||||
</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_latest_transactions' %}"
|
||||
hx-indicator="#tab-content"
|
||||
hx-target="#tab-content">{% trans 'Latest Transactions' %}
|
||||
</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 %}
|
||||
@@ -45,7 +45,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="tw-cursor-pointer text-primary text-end"
|
||||
<div class="tw-cursor-pointer text-primary text-end"
|
||||
_="on click
|
||||
set from_value to #id_from_currency's value
|
||||
set to_value to #id_to_currency's value
|
||||
@@ -58,5 +58,39 @@
|
||||
<i class="fa-solid fa-rotate me-2"></i><span>{% trans 'Invert' %}</span>
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4">
|
||||
{% for currency, data in rate_map.items %}
|
||||
<div class="col">
|
||||
<c-ui.info-card color="yellow" title="{{ currency }}">
|
||||
{% for rate in data.rates.values %}
|
||||
<div class="d-flex justify-content-between align-items-baseline mt-2">
|
||||
<div class="text-end font-monospace">
|
||||
<div class="tw-text-gray-400">
|
||||
{# <c-amount.display#}
|
||||
{# :amount="1"#}
|
||||
{# :prefix="data.prefix"#}
|
||||
{# :suffix="data.suffix"#}
|
||||
{# :decimal_places="data.decimal_places"></c-amount.display>#}
|
||||
</div>
|
||||
</div>
|
||||
<div class="dotted-line flex-grow-1"></div>
|
||||
{% if currency.income_projected != 0 %}
|
||||
<div class="text-end font-monospace tw-text-green-400">
|
||||
<c-amount.display
|
||||
:amount="rate.rate"
|
||||
:prefix="rate.prefix"
|
||||
:suffix="rate.suffix"
|
||||
:decimal_places="rate.decimal_places"></c-amount.display>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-end font-monospace">-</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</c-ui.info-card>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -10,6 +10,4 @@ until [ -f /tmp/migrations_complete ]; do
|
||||
sleep 2
|
||||
done
|
||||
|
||||
rm -f /tmp/migrations_complete
|
||||
|
||||
exec python manage.py procrastinate worker
|
||||
|
||||
10
frontend/package-lock.json
generated
10
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
import Chart from 'chart.js/auto';
|
||||
import {SankeyController, Flow} from 'chartjs-chart-sankey';
|
||||
|
||||
Chart.register(SankeyController, Flow);
|
||||
window.Chart = Chart;
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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>…</div>`;
|
||||
},
|
||||
},
|
||||
option_create: function(data, escape) {
|
||||
return `<div class="create">${element.dataset.txtCreate || 'Add'} <strong>${escape(data.input)}</strong>…</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);
|
||||
};
|
||||
|
||||
@@ -78,6 +78,7 @@
|
||||
.show-loading.htmx-request {
|
||||
position: relative;
|
||||
top: 0;
|
||||
min-height: 100px;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user