mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-04-23 00:58:40 +02:00
changes
This commit is contained in:
@@ -1,6 +1,11 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from apps.transactions.models import Transaction, TransactionCategory, TransactionTag
|
||||
from apps.transactions.models import (
|
||||
Transaction,
|
||||
TransactionCategory,
|
||||
TransactionTag,
|
||||
InstallmentPlan,
|
||||
)
|
||||
|
||||
|
||||
@admin.register(Transaction)
|
||||
@@ -16,5 +21,17 @@ class TransactionModelAdmin(admin.ModelAdmin):
|
||||
]
|
||||
|
||||
|
||||
class TransactionInline(admin.TabularInline):
|
||||
model = Transaction
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(InstallmentPlan)
|
||||
class InstallmentPlanAdmin(admin.ModelAdmin):
|
||||
inlines = [
|
||||
TransactionInline,
|
||||
]
|
||||
|
||||
|
||||
admin.site.register(TransactionCategory)
|
||||
admin.site.register(TransactionTag)
|
||||
|
||||
108
app/apps/transactions/filters.py
Normal file
108
app/apps/transactions/filters.py
Normal file
@@ -0,0 +1,108 @@
|
||||
import django_filters
|
||||
from crispy_bootstrap5.bootstrap5 import Switch
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Layout, Field
|
||||
from django import forms
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.accounts.models import Account
|
||||
from apps.transactions.models import Transaction
|
||||
from apps.transactions.models import TransactionCategory
|
||||
from apps.transactions.models import TransactionTag
|
||||
from apps.common.widgets.tom_select import TomSelect, TomSelectMultiple
|
||||
|
||||
SITUACAO_CHOICES = (
|
||||
("1", _("Paid")),
|
||||
("0", _("Projected")),
|
||||
)
|
||||
|
||||
|
||||
def content_filter(queryset, name, value):
|
||||
queryset = queryset.filter(
|
||||
Q(description__icontains=value) | Q(notes__icontains=value)
|
||||
)
|
||||
return queryset
|
||||
|
||||
|
||||
class TransactionsFilter(django_filters.FilterSet):
|
||||
description = django_filters.CharFilter(
|
||||
label=_("Content"),
|
||||
method=content_filter,
|
||||
widget=forms.TextInput(attrs={"type": "search"}),
|
||||
)
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=Transaction.Type.choices,
|
||||
label=_("Transaction Type"),
|
||||
)
|
||||
account = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name="account__name",
|
||||
queryset=Account.objects.all(),
|
||||
to_field_name="name",
|
||||
label="Accounts",
|
||||
widget=TomSelectMultiple(checkboxes=True, remove_button=True),
|
||||
)
|
||||
category = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name="category__name",
|
||||
queryset=TransactionCategory.objects.all(),
|
||||
to_field_name="name",
|
||||
label="Categories",
|
||||
widget=TomSelectMultiple(checkboxes=True, remove_button=True),
|
||||
)
|
||||
tags = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name="tags__name",
|
||||
queryset=TransactionTag.objects.all(),
|
||||
to_field_name="name",
|
||||
label="Tags",
|
||||
widget=TomSelectMultiple(checkboxes=True, remove_button=True),
|
||||
)
|
||||
is_paid = django_filters.MultipleChoiceFilter(
|
||||
choices=SITUACAO_CHOICES,
|
||||
field_name="is_paid",
|
||||
label="Situação",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Transaction
|
||||
fields = [
|
||||
"description",
|
||||
"type",
|
||||
"account",
|
||||
"is_paid",
|
||||
"category",
|
||||
"tags",
|
||||
]
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
# if filterset is bound, use initial values as defaults
|
||||
if data is not None:
|
||||
# get a mutable copy of the QueryDict
|
||||
data = data.copy()
|
||||
|
||||
# # set type to all if it isn't set
|
||||
if data.get("type") is None:
|
||||
data.setlist("type", ["IN", "EX"])
|
||||
|
||||
if data.get("is_paid") is None:
|
||||
data.setlist("is_paid", ["1", "0"])
|
||||
|
||||
super().__init__(data, *args, **kwargs)
|
||||
|
||||
self.form.helper = FormHelper()
|
||||
self.form.helper.form_tag = False
|
||||
self.form.helper.form_method = "GET"
|
||||
self.form.helper.disable_csrf = True
|
||||
self.form.helper.layout = Layout(
|
||||
Field(
|
||||
"type",
|
||||
template="transactions/widgets/transaction_type_filter_buttons.html",
|
||||
),
|
||||
Field(
|
||||
"is_paid",
|
||||
template="transactions/widgets/transaction_type_filter_buttons.html",
|
||||
),
|
||||
Field("description"),
|
||||
Field("account", size=1),
|
||||
Field("category", size=1),
|
||||
Field("tags", size=1),
|
||||
)
|
||||
@@ -1,12 +1,26 @@
|
||||
from crispy_bootstrap5.bootstrap5 import Switch
|
||||
from crispy_forms.bootstrap import FormActions
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Layout, Row, Column, Field, Fieldset
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django import forms
|
||||
from django.db import transaction
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Layout, Submit, Row, Column, Div, Field, Hidden
|
||||
|
||||
from apps.accounts.models import Account
|
||||
from .models import Transaction, TransactionCategory, TransactionTag
|
||||
from apps.common.fields.forms.dynamic_select import (
|
||||
DynamicModelChoiceField,
|
||||
DynamicModelMultipleChoiceField,
|
||||
)
|
||||
from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||
from apps.common.widgets.tom_select import TomSelect, TomSelectMultiple
|
||||
from apps.transactions.models import (
|
||||
Transaction,
|
||||
TransactionCategory,
|
||||
TransactionTag,
|
||||
InstallmentPlan,
|
||||
)
|
||||
from apps.transactions.widgets import (
|
||||
ArbitraryDecimalDisplayNumberInput,
|
||||
MonthYearWidget,
|
||||
@@ -14,6 +28,19 @@ from apps.transactions.widgets import (
|
||||
|
||||
|
||||
class TransactionForm(forms.ModelForm):
|
||||
category = DynamicModelChoiceField(
|
||||
model=TransactionCategory,
|
||||
required=False,
|
||||
label=_("Category"),
|
||||
)
|
||||
tags = DynamicModelMultipleChoiceField(
|
||||
model=TransactionTag,
|
||||
to_field_name="name",
|
||||
create_field="name",
|
||||
required=False,
|
||||
label=_("Tags"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Transaction
|
||||
fields = [
|
||||
@@ -31,20 +58,21 @@ class TransactionForm(forms.ModelForm):
|
||||
widgets = {
|
||||
"date": forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"),
|
||||
"notes": forms.Textarea(attrs={"rows": 3}),
|
||||
"account": TomSelect(),
|
||||
}
|
||||
labels = {
|
||||
"tags": mark_safe('<i class="fa-solid fa-hashtag me-1"></i>' + _("Tags")),
|
||||
"category": mark_safe(
|
||||
'<i class="fa-solid fa-icons me-1"></i>' + _("Category")
|
||||
),
|
||||
"notes": mark_safe(
|
||||
'<i class="fa-solid fa-align-justify me-1"></i>' + _("Notes")
|
||||
),
|
||||
"amount": mark_safe('<i class="fa-solid fa-coins me-1"></i>' + _("Amount")),
|
||||
"description": mark_safe(
|
||||
'<i class="fa-solid fa-quote-left me-1"></i>' + _("Name")
|
||||
),
|
||||
}
|
||||
# labels = {
|
||||
# "tags": mark_safe('<i class="fa-solid fa-hashtag me-1"></i>' + _("Tags")),
|
||||
# "category": mark_safe(
|
||||
# '<i class="fa-solid fa-icons me-1"></i>' + _("Category")
|
||||
# ),
|
||||
# "notes": mark_safe(
|
||||
# '<i class="fa-solid fa-align-justify me-1"></i>' + _("Notes")
|
||||
# ),
|
||||
# "amount": mark_safe('<i class="fa-solid fa-coins me-1"></i>' + _("Amount")),
|
||||
# "description": mark_safe(
|
||||
# '<i class="fa-solid fa-quote-left me-1"></i>' + _("Name")
|
||||
# ),
|
||||
# }
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -66,28 +94,59 @@ class TransactionForm(forms.ModelForm):
|
||||
),
|
||||
"description",
|
||||
Field("amount", inputmode="decimal"),
|
||||
Field("category", css_class="select"),
|
||||
Field("tags", css_class="multiselect", size=1),
|
||||
Row(
|
||||
Column("category", css_class="form-group col-md-6 mb-0"),
|
||||
Column("tags", css_class="form-group col-md-6 mb-0"),
|
||||
css_class="form-row",
|
||||
),
|
||||
"notes",
|
||||
Submit("submit", "Save", css_class="btn btn-warning"),
|
||||
)
|
||||
|
||||
self.fields["reference_date"].required = False
|
||||
|
||||
if self.instance and self.instance.pk:
|
||||
decimal_places = self.instance.account.currency.decimal_places
|
||||
self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput(
|
||||
decimal_places=decimal_places
|
||||
)
|
||||
else:
|
||||
self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput(
|
||||
decimal_places=2
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
),
|
||||
)
|
||||
else:
|
||||
self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
date = cleaned_data.get("date")
|
||||
reference_date = cleaned_data.get("reference_date")
|
||||
|
||||
if date and not reference_date:
|
||||
cleaned_data["reference_date"] = date.replace(day=1)
|
||||
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class TransferForm(forms.Form):
|
||||
from_account = forms.ModelChoiceField(
|
||||
queryset=Account.objects.all(), label="From Account"
|
||||
queryset=Account.objects.all(),
|
||||
label="From Account",
|
||||
widget=TomSelect(),
|
||||
)
|
||||
to_account = forms.ModelChoiceField(
|
||||
queryset=Account.objects.all(), label="To Account"
|
||||
queryset=Account.objects.all(),
|
||||
label="To Account",
|
||||
widget=TomSelect(),
|
||||
)
|
||||
|
||||
from_amount = forms.DecimalField(
|
||||
@@ -102,20 +161,30 @@ class TransferForm(forms.Form):
|
||||
required=False,
|
||||
)
|
||||
|
||||
from_category = forms.ModelChoiceField(
|
||||
queryset=TransactionCategory.objects.all(),
|
||||
from_category = DynamicModelChoiceField(
|
||||
model=TransactionCategory,
|
||||
required=False,
|
||||
label="From Category",
|
||||
label=_("Category"),
|
||||
)
|
||||
to_category = forms.ModelChoiceField(
|
||||
queryset=TransactionCategory.objects.all(), required=False, label="To Category"
|
||||
to_category = DynamicModelChoiceField(
|
||||
model=TransactionCategory,
|
||||
required=False,
|
||||
label=_("Category"),
|
||||
)
|
||||
|
||||
from_tags = forms.ModelMultipleChoiceField(
|
||||
queryset=TransactionTag.objects.all(), required=False, label="From Tags"
|
||||
from_tags = DynamicModelMultipleChoiceField(
|
||||
model=TransactionTag,
|
||||
to_field_name="name",
|
||||
create_field="name",
|
||||
required=False,
|
||||
label=_("Tags"),
|
||||
)
|
||||
to_tags = forms.ModelMultipleChoiceField(
|
||||
queryset=TransactionTag.objects.all(), required=False, label="To Tags"
|
||||
to_tags = DynamicModelMultipleChoiceField(
|
||||
model=TransactionTag,
|
||||
to_field_name="name",
|
||||
create_field="name",
|
||||
required=False,
|
||||
label=_("Tags"),
|
||||
)
|
||||
|
||||
date = forms.DateField(
|
||||
@@ -133,16 +202,25 @@ class TransferForm(forms.Form):
|
||||
|
||||
self.helper.layout = Layout(
|
||||
Row(
|
||||
Column("date", css_class="form-group col-md-6 mb-0"),
|
||||
Column("reference_date", css_class="form-group col-md-6 mb-0"),
|
||||
Column(Field("date"), css_class="form-group col-md-6 mb-0"),
|
||||
Column(
|
||||
Field("reference_date"),
|
||||
css_class="form-group col-md-6 mb-0",
|
||||
),
|
||||
css_class="form-row",
|
||||
),
|
||||
"description",
|
||||
Field("description"),
|
||||
Row(
|
||||
Column(
|
||||
Row(
|
||||
Column("from_account", css_class="form-group col-md-6 mb-0"),
|
||||
Column("from_amount", css_class="form-group col-md-6 mb-0"),
|
||||
Column(
|
||||
"from_account",
|
||||
css_class="form-group col-md-6 mb-0",
|
||||
),
|
||||
Column(
|
||||
Field("from_amount"),
|
||||
css_class="form-group col-md-6 mb-0",
|
||||
),
|
||||
css_class="form-row",
|
||||
),
|
||||
Row(
|
||||
@@ -156,8 +234,14 @@ class TransferForm(forms.Form):
|
||||
Row(
|
||||
Column(
|
||||
Row(
|
||||
Column("to_account", css_class="form-group col-md-6 mb-0"),
|
||||
Column("to_amount", css_class="form-group col-md-6 mb-0"),
|
||||
Column(
|
||||
"to_account",
|
||||
css_class="form-group col-md-6 mb-0",
|
||||
),
|
||||
Column(
|
||||
Field("to_amount"),
|
||||
css_class="form-group col-md-6 mb-0",
|
||||
),
|
||||
css_class="form-row",
|
||||
),
|
||||
Row(
|
||||
@@ -168,16 +252,16 @@ class TransferForm(forms.Form):
|
||||
),
|
||||
css_class="p-1 mx-1 my-3 border rounded-3",
|
||||
),
|
||||
Submit("submit", "Save", css_class="btn btn-primary"),
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Tranfer"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
self.fields["from_amount"].widget = ArbitraryDecimalDisplayNumberInput(
|
||||
decimal_places=2
|
||||
)
|
||||
self.fields["from_amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||
|
||||
self.fields["to_amount"].widget = ArbitraryDecimalDisplayNumberInput(
|
||||
decimal_places=2
|
||||
)
|
||||
self.fields["to_amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
@@ -227,3 +311,195 @@ class TransferForm(forms.Form):
|
||||
to_transaction.tags.set(self.cleaned_data.get("to_tags", []))
|
||||
|
||||
return from_transaction, to_transaction
|
||||
|
||||
|
||||
class InstallmentPlanForm(forms.Form):
|
||||
type = forms.ChoiceField(choices=Transaction.Type.choices)
|
||||
account = forms.ModelChoiceField(
|
||||
queryset=Account.objects.all(),
|
||||
label=_("Account"),
|
||||
widget=TomSelect(),
|
||||
)
|
||||
start_date = forms.DateField(
|
||||
label=_("Start Date"),
|
||||
widget=forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"),
|
||||
)
|
||||
description = forms.CharField(max_length=500, label=_("Description"))
|
||||
number_of_installments = forms.IntegerField(
|
||||
min_value=1, label=_("Number of Installments")
|
||||
)
|
||||
recurrence = forms.ChoiceField(
|
||||
choices=(
|
||||
("yearly", _("Yearly")),
|
||||
("monthly", _("Monthly")),
|
||||
("weekly", _("Weekly")),
|
||||
("daily", _("Daily")),
|
||||
),
|
||||
initial="monthly",
|
||||
widget=TomSelect(clear_button=False),
|
||||
)
|
||||
installment_amount = forms.DecimalField(
|
||||
max_digits=42,
|
||||
decimal_places=30,
|
||||
required=True,
|
||||
label=_("Installment Amount"),
|
||||
)
|
||||
category = DynamicModelChoiceField(
|
||||
model=TransactionCategory,
|
||||
required=False,
|
||||
label=_("Category"),
|
||||
)
|
||||
tags = DynamicModelMultipleChoiceField(
|
||||
model=TransactionTag,
|
||||
to_field_name="name",
|
||||
create_field="name",
|
||||
required=False,
|
||||
label=_("Tags"),
|
||||
)
|
||||
|
||||
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(
|
||||
Field(
|
||||
"type",
|
||||
template="transactions/widgets/income_expense_toggle_buttons.html",
|
||||
),
|
||||
"account",
|
||||
"description",
|
||||
Row(
|
||||
Column("start_date", css_class="form-group col-md-4 mb-0"),
|
||||
Column("number_of_installments", css_class="form-group col-md-4 mb-0"),
|
||||
Column("recurrence", css_class="form-group col-md-4 mb-0"),
|
||||
css_class="form-row",
|
||||
),
|
||||
"installment_amount",
|
||||
Row(
|
||||
Column("category", css_class="form-group col-md-6 mb-0"),
|
||||
Column("tags", css_class="form-group col-md-6 mb-0"),
|
||||
css_class="form-row",
|
||||
),
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
self.fields["installment_amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||
|
||||
def save(self):
|
||||
number_of_installments = self.cleaned_data["number_of_installments"]
|
||||
transaction_type = self.cleaned_data["type"]
|
||||
start_date = self.cleaned_data["start_date"]
|
||||
recurrence = self.cleaned_data["recurrence"]
|
||||
account = self.cleaned_data["account"]
|
||||
description = self.cleaned_data["description"]
|
||||
installment_amount = self.cleaned_data["installment_amount"]
|
||||
category = self.cleaned_data["category"]
|
||||
|
||||
with transaction.atomic():
|
||||
installment_plan = InstallmentPlan.objects.create(
|
||||
account=account,
|
||||
description=description,
|
||||
number_of_installments=number_of_installments,
|
||||
)
|
||||
|
||||
with transaction.atomic():
|
||||
for i in range(number_of_installments):
|
||||
if recurrence == "yearly":
|
||||
delta = relativedelta(years=i)
|
||||
elif recurrence == "monthly":
|
||||
delta = relativedelta(months=i)
|
||||
elif recurrence == "weekly":
|
||||
delta = relativedelta(weeks=i)
|
||||
elif recurrence == "daily":
|
||||
delta = relativedelta(days=i)
|
||||
|
||||
transaction_date = start_date + delta
|
||||
new_transaction = Transaction.objects.create(
|
||||
account=account,
|
||||
type=transaction_type,
|
||||
date=transaction_date,
|
||||
reference_date=transaction_date.replace(day=1),
|
||||
amount=installment_amount,
|
||||
description=description,
|
||||
notes=f"{i + 1}/{number_of_installments}",
|
||||
category=category,
|
||||
installment_plan=installment_plan,
|
||||
)
|
||||
|
||||
new_transaction.tags.set(self.cleaned_data.get("tags", []))
|
||||
|
||||
return installment_plan
|
||||
|
||||
|
||||
class TransactionTagForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = TransactionTag
|
||||
fields = ["name"]
|
||||
labels = {"name": _("Tag name")}
|
||||
|
||||
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(Field("name", css_class="mb-3"))
|
||||
|
||||
if self.instance and self.instance.pk:
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
),
|
||||
)
|
||||
else:
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class TransactionCategoryForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = TransactionCategory
|
||||
fields = ["name", "mute"]
|
||||
labels = {"name": _("Category name")}
|
||||
help_texts = {
|
||||
"mute": _("Muted categories won't count towards your monthly total")
|
||||
}
|
||||
|
||||
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(Field("name", css_class="mb-3"), Switch("mute"))
|
||||
|
||||
if self.instance and self.instance.pk:
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
),
|
||||
)
|
||||
else:
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.1 on 2024-09-27 19:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('transactions', '0008_rename_transactiontags_transactiontag'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='transaction',
|
||||
name='tags',
|
||||
field=models.ManyToManyField(blank=True, to='transactions.transactiontag', verbose_name='Tags'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,79 @@
|
||||
# Generated by Django 5.1.1 on 2024-10-05 02:15
|
||||
|
||||
import apps.transactions.validators
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("accounts", "0002_account_exchange_currency"),
|
||||
("transactions", "0009_alter_transaction_tags"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="InstallmentPlan",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("start_date", models.DateField(verbose_name="Start Date")),
|
||||
(
|
||||
"description",
|
||||
models.CharField(max_length=500, verbose_name="Description"),
|
||||
),
|
||||
(
|
||||
"number_of_installments",
|
||||
models.PositiveIntegerField(
|
||||
validators=[django.core.validators.MinValueValidator(1)],
|
||||
verbose_name="Number of Installments",
|
||||
),
|
||||
),
|
||||
(
|
||||
"total_amount",
|
||||
models.DecimalField(
|
||||
decimal_places=30,
|
||||
max_digits=42,
|
||||
validators=[
|
||||
apps.transactions.validators.validate_non_negative,
|
||||
apps.transactions.validators.validate_decimal_places,
|
||||
],
|
||||
verbose_name="Total Amount",
|
||||
),
|
||||
),
|
||||
(
|
||||
"account",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="accounts.account",
|
||||
verbose_name="Account",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Installment Plan",
|
||||
"verbose_name_plural": "Installment Plans",
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="transaction",
|
||||
name="installment_plan",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="transactions",
|
||||
to="transactions.installmentplan",
|
||||
verbose_name="Installment Plan",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
# Generated by Django 5.1.1 on 2024-10-05 16:17
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("transactions", "0010_installmentplan_transaction_installment_plan"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="installmentplan",
|
||||
name="start_date",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="installmentplan",
|
||||
name="total_amount",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 5.1.1 on 2024-10-06 22:25
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("transactions", "0011_remove_installmentplan_start_date_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="transaction",
|
||||
name="category",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to="transactions.transactioncategory",
|
||||
verbose_name="Category",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.1.1 on 2024-10-07 14:47
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("transactions", "0012_alter_transaction_category"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="transactioncategory",
|
||||
name="name",
|
||||
field=models.CharField(max_length=255, unique=True, verbose_name="Name"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="transactiontag",
|
||||
name="name",
|
||||
field=models.CharField(max_length=255, unique=True, verbose_name="Name"),
|
||||
),
|
||||
]
|
||||
@@ -1,14 +1,18 @@
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.db import models
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.common.functions.decimals import truncate_decimal
|
||||
from apps.transactions.fields import MonthYearField
|
||||
from apps.transactions.validators import validate_decimal_places, validate_non_negative
|
||||
from apps.currencies.utils.convert import convert
|
||||
|
||||
|
||||
class TransactionCategory(models.Model):
|
||||
name = models.CharField(max_length=255, verbose_name=_("Name"))
|
||||
name = models.CharField(max_length=255, verbose_name=_("Name"), unique=True)
|
||||
mute = models.BooleanField(default=False, verbose_name=_("Mute"))
|
||||
|
||||
class Meta:
|
||||
@@ -21,7 +25,7 @@ class TransactionCategory(models.Model):
|
||||
|
||||
|
||||
class TransactionTag(models.Model):
|
||||
name = models.CharField(max_length=255, verbose_name=_("Name"))
|
||||
name = models.CharField(max_length=255, verbose_name=_("Name"), unique=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Transaction Tags")
|
||||
@@ -32,6 +36,30 @@ class TransactionTag(models.Model):
|
||||
return self.name
|
||||
|
||||
|
||||
class InstallmentPlan(models.Model):
|
||||
account = models.ForeignKey(
|
||||
"accounts.Account", on_delete=models.CASCADE, verbose_name=_("Account")
|
||||
)
|
||||
description = models.CharField(max_length=500, verbose_name=_("Description"))
|
||||
number_of_installments = models.PositiveIntegerField(
|
||||
validators=[MinValueValidator(1)], verbose_name=_("Number of Installments")
|
||||
)
|
||||
# start_date = models.DateField(verbose_name=_("Start Date"))
|
||||
# end_date = models.DateField(verbose_name=_("End Date"))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Installment Plan")
|
||||
verbose_name_plural = _("Installment Plans")
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.description} - {self.number_of_installments} installments"
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
# Delete related transactions
|
||||
self.transactions.all().delete()
|
||||
super().delete(*args, **kwargs)
|
||||
|
||||
|
||||
class Transaction(models.Model):
|
||||
class Type(models.TextChoices):
|
||||
INCOME = "IN", _("Income")
|
||||
@@ -61,13 +89,22 @@ class Transaction(models.Model):
|
||||
notes = models.TextField(blank=True, verbose_name=_("Notes"))
|
||||
category = models.ForeignKey(
|
||||
TransactionCategory,
|
||||
on_delete=models.CASCADE,
|
||||
on_delete=models.SET_NULL,
|
||||
verbose_name=_("Category"),
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
tags = models.ManyToManyField(TransactionTag, verbose_name=_("Tags"), blank=True)
|
||||
|
||||
installment_plan = models.ForeignKey(
|
||||
InstallmentPlan,
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="transactions",
|
||||
verbose_name=_("Installment Plan"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Transaction")
|
||||
verbose_name_plural = _("Transactions")
|
||||
@@ -79,3 +116,21 @@ class Transaction(models.Model):
|
||||
)
|
||||
self.full_clean()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def exchanged_amount(self):
|
||||
if self.account.exchange_currency:
|
||||
converted_amount = convert(
|
||||
self.amount,
|
||||
self.account.exchange_currency,
|
||||
self.account.currency,
|
||||
date=self.date,
|
||||
)
|
||||
if converted_amount:
|
||||
return {
|
||||
"amount": converted_amount,
|
||||
"suffix": self.account.exchange_currency.suffix,
|
||||
"prefix": self.account.exchange_currency.prefix,
|
||||
"decimal_places": self.account.exchange_currency.decimal_places,
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from django import template
|
||||
from django.template.defaultfilters import floatformat
|
||||
from django.utils.formats import number_format
|
||||
|
||||
from apps.transactions.models import Transaction
|
||||
|
||||
@@ -7,7 +8,9 @@ register = template.Library()
|
||||
|
||||
|
||||
def _format_string(prefix, amount, decimal_places, suffix):
|
||||
formatted_amount = floatformat(abs(amount), f"{decimal_places}g")
|
||||
formatted_amount = number_format(
|
||||
value=abs(amount), decimal_pos=decimal_places, force_grouping=True
|
||||
)
|
||||
if amount < 0:
|
||||
return f"-{prefix}{formatted_amount}{suffix}"
|
||||
else:
|
||||
@@ -32,3 +35,8 @@ def entry_currency(entry):
|
||||
suffix = entry["suffix"]
|
||||
|
||||
return _format_string(prefix, amount, decimal_places, suffix)
|
||||
|
||||
|
||||
@register.simple_tag(name="currency_display")
|
||||
def currency_display(amount, prefix, suffix, decimal_places):
|
||||
return _format_string(prefix, amount, decimal_places, suffix)
|
||||
|
||||
@@ -3,22 +3,6 @@ from django.urls import path
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.index, name="index"),
|
||||
path(
|
||||
"transactions/<int:month>/<int:year>/",
|
||||
views.transactions_overview,
|
||||
name="transactions_overview",
|
||||
),
|
||||
path(
|
||||
"transactions/<int:month>/<int:year>/list/",
|
||||
views.transactions_list,
|
||||
name="transactions_list",
|
||||
),
|
||||
path(
|
||||
"transactions/<int:month>/<int:year>/summary/",
|
||||
views.monthly_summary,
|
||||
name="monthly_summary",
|
||||
),
|
||||
path(
|
||||
"transaction/<int:transaction_id>/pay",
|
||||
views.transaction_pay,
|
||||
@@ -39,14 +23,38 @@ urlpatterns = [
|
||||
views.transaction_add,
|
||||
name="transaction_add",
|
||||
),
|
||||
path(
|
||||
"available_dates/",
|
||||
views.month_year_picker,
|
||||
name="available_dates",
|
||||
),
|
||||
path(
|
||||
"transactions/transfer",
|
||||
views.transactions_transfer,
|
||||
name="transactions_transfer",
|
||||
),
|
||||
path(
|
||||
"transactions/installments/add/",
|
||||
views.AddInstallmentPlanView.as_view(),
|
||||
name="installments_add",
|
||||
),
|
||||
path("tags/", views.tag_list, name="tags_list"),
|
||||
path("tags/add/", views.tag_add, name="tag_add"),
|
||||
path(
|
||||
"tags/<int:tag_id>/edit/",
|
||||
views.tag_edit,
|
||||
name="tag_edit",
|
||||
),
|
||||
path(
|
||||
"tags/<int:tag_id>/delete/",
|
||||
views.tag_delete,
|
||||
name="tag_delete",
|
||||
),
|
||||
path("categories/", views.categories_list, name="categories_list"),
|
||||
path("categories/add/", views.category_add, name="category_add"),
|
||||
path(
|
||||
"categories/<int:category_id>/edit/",
|
||||
views.category_edit,
|
||||
name="category_edit",
|
||||
),
|
||||
path(
|
||||
"categories/<int:category_id>/delete/",
|
||||
views.category_delete,
|
||||
name="category_delete",
|
||||
),
|
||||
]
|
||||
|
||||
0
app/apps/transactions/utils/__init__.py
Normal file
0
app/apps/transactions/utils/__init__.py
Normal file
108
app/apps/transactions/utils/monthly_summary.py
Normal file
108
app/apps/transactions/utils/monthly_summary.py
Normal file
@@ -0,0 +1,108 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db.models import Sum
|
||||
|
||||
|
||||
def calculate_sum(queryset, transaction_type, is_paid):
|
||||
return (
|
||||
queryset.filter(type=transaction_type, is_paid=is_paid)
|
||||
.values(
|
||||
"account__currency__name",
|
||||
"account__currency__suffix",
|
||||
"account__currency__prefix",
|
||||
"account__currency__decimal_places",
|
||||
)
|
||||
.annotate(total=Sum("amount"))
|
||||
.order_by("account__currency__name")
|
||||
)
|
||||
|
||||
|
||||
# Helper function to format currency sums
|
||||
def format_currency_sum(queryset):
|
||||
return [
|
||||
{
|
||||
"currency": item["account__currency__name"],
|
||||
"suffix": item["account__currency__suffix"],
|
||||
"prefix": item["account__currency__prefix"],
|
||||
"decimal_places": item["account__currency__decimal_places"],
|
||||
"amount": item["total"],
|
||||
}
|
||||
for item in queryset
|
||||
]
|
||||
|
||||
|
||||
# Calculate totals
|
||||
def calculate_total(income, expenses):
|
||||
totals = {}
|
||||
|
||||
# Process income
|
||||
for item in income:
|
||||
currency = item["account__currency__name"]
|
||||
totals[currency] = totals.get(currency, Decimal("0")) + item["total"]
|
||||
|
||||
# Subtract expenses
|
||||
for item in expenses:
|
||||
currency = item["account__currency__name"]
|
||||
totals[currency] = totals.get(currency, Decimal("0")) - item["total"]
|
||||
|
||||
return [
|
||||
{
|
||||
"currency": currency,
|
||||
"suffix": next(
|
||||
(
|
||||
item["account__currency__suffix"]
|
||||
for item in list(income) + list(expenses)
|
||||
if item["account__currency__name"] == currency
|
||||
),
|
||||
"",
|
||||
),
|
||||
"prefix": next(
|
||||
(
|
||||
item["account__currency__prefix"]
|
||||
for item in list(income) + list(expenses)
|
||||
if item["account__currency__name"] == currency
|
||||
),
|
||||
"",
|
||||
),
|
||||
"decimal_places": next(
|
||||
(
|
||||
item["account__currency__decimal_places"]
|
||||
for item in list(income) + list(expenses)
|
||||
if item["account__currency__name"] == currency
|
||||
),
|
||||
2,
|
||||
),
|
||||
"amount": amount,
|
||||
}
|
||||
for currency, amount in totals.items()
|
||||
]
|
||||
|
||||
|
||||
# Calculate total final
|
||||
def sum_totals(total1, total2):
|
||||
totals = {}
|
||||
for item in total1 + total2:
|
||||
currency = item["currency"]
|
||||
totals[currency] = totals.get(currency, Decimal("0")) + item["amount"]
|
||||
return [
|
||||
{
|
||||
"currency": currency,
|
||||
"suffix": next(
|
||||
item["suffix"]
|
||||
for item in total1 + total2
|
||||
if item["currency"] == currency
|
||||
),
|
||||
"prefix": next(
|
||||
item["prefix"]
|
||||
for item in total1 + total2
|
||||
if item["currency"] == currency
|
||||
),
|
||||
"decimal_places": next(
|
||||
item["decimal_places"]
|
||||
for item in total1 + total2
|
||||
if item["currency"] == currency
|
||||
),
|
||||
"amount": amount,
|
||||
}
|
||||
for currency, amount in totals.items()
|
||||
]
|
||||
@@ -1,419 +0,0 @@
|
||||
import datetime
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import render, redirect, get_object_or_404
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.db.models import Sum, Q, F, Case, When, DecimalField
|
||||
from django.db.models.functions import Coalesce
|
||||
from decimal import Decimal
|
||||
|
||||
from apps.transactions.forms import TransactionForm, TransferForm
|
||||
from apps.transactions.models import Transaction
|
||||
from apps.common.functions.dates import remaining_days_in_month
|
||||
|
||||
|
||||
@login_required
|
||||
def index(request):
|
||||
now = timezone.localdate(timezone.now())
|
||||
|
||||
return redirect(to="transactions_overview", month=now.month, year=now.year)
|
||||
|
||||
|
||||
@login_required
|
||||
def transactions_overview(request, month: int, year: int):
|
||||
from django.utils.formats import get_format
|
||||
from django.utils.translation import get_language
|
||||
|
||||
current_language = get_language()
|
||||
|
||||
thousand_separator = get_format("THOUSAND_SEPARATOR")
|
||||
print(thousand_separator, current_language)
|
||||
if month < 1 or month > 12:
|
||||
from django.http import Http404
|
||||
|
||||
raise Http404("Month is out of range")
|
||||
|
||||
next_month = 1 if month == 12 else month + 1
|
||||
next_year = year + 1 if next_month == 1 and month == 12 else year
|
||||
|
||||
previous_month = 12 if month == 1 else month - 1
|
||||
previous_year = year - 1 if previous_month == 12 and month == 1 else year
|
||||
|
||||
return render(
|
||||
request,
|
||||
"transactions/overview.html",
|
||||
context={
|
||||
"month": month,
|
||||
"year": year,
|
||||
"next_month": next_month,
|
||||
"next_year": next_year,
|
||||
"previous_month": previous_month,
|
||||
"previous_year": previous_year,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def transactions_list(request, month: int, year: int):
|
||||
from django.db.models.functions import ExtractMonth, ExtractYear
|
||||
|
||||
queryset = (
|
||||
Transaction.objects.annotate(
|
||||
month=ExtractMonth("reference_date"), year=ExtractYear("reference_date")
|
||||
)
|
||||
.values("month", "year")
|
||||
.distinct()
|
||||
.order_by("year", "month")
|
||||
)
|
||||
# print(queryset)
|
||||
|
||||
transactions = (
|
||||
Transaction.objects.all()
|
||||
.filter(
|
||||
reference_date__year=year,
|
||||
reference_date__month=month,
|
||||
)
|
||||
.order_by("date", "id")
|
||||
.select_related()
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"transactions/fragments/list.html",
|
||||
context={"transactions": transactions},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def transaction_add(request, **kwargs):
|
||||
month = int(request.GET.get("month", timezone.localdate(timezone.now()).month))
|
||||
year = int(request.GET.get("year", timezone.localdate(timezone.now()).year))
|
||||
transaction_type = Transaction.Type(request.GET.get("type", "IN"))
|
||||
|
||||
now = timezone.localdate(timezone.now())
|
||||
expected_date = datetime.datetime(
|
||||
day=now.day if month == now.month and year == now.year else 1,
|
||||
month=month,
|
||||
year=year,
|
||||
).date()
|
||||
|
||||
if request.method == "POST":
|
||||
form = TransactionForm(request.POST)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Transaction added successfully!"))
|
||||
|
||||
# redirect to a new URL:
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={"HX-Trigger": "transaction_updated, hide_offcanvas, toast"},
|
||||
)
|
||||
else:
|
||||
form = TransactionForm(
|
||||
initial={
|
||||
"reference_date": expected_date,
|
||||
"date": expected_date,
|
||||
"type": transaction_type,
|
||||
}
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"transactions/fragments/add.html",
|
||||
{"form": form},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def transaction_edit(request, transaction_id, **kwargs):
|
||||
transaction = get_object_or_404(Transaction, id=transaction_id)
|
||||
|
||||
if request.method == "POST":
|
||||
form = TransactionForm(request.POST, instance=transaction)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Transaction updated successfully!"))
|
||||
|
||||
# redirect to a new URL:
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={"HX-Trigger": "transaction_updated, hide_offcanvas, toast"},
|
||||
)
|
||||
else:
|
||||
form = TransactionForm(instance=transaction)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"transactions/fragments/edit.html",
|
||||
{"form": form, "transaction": transaction},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def transaction_delete(request, transaction_id, **kwargs):
|
||||
transaction = get_object_or_404(Transaction, id=transaction_id)
|
||||
|
||||
transaction.delete()
|
||||
|
||||
messages.success(request, _("Transaction deleted successfully!"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={"HX-Trigger": "transaction_updated, toast"},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def transactions_transfer(request):
|
||||
month = int(request.GET.get("month", timezone.localdate(timezone.now()).month))
|
||||
year = int(request.GET.get("year", timezone.localdate(timezone.now()).year))
|
||||
|
||||
now = timezone.localdate(timezone.now())
|
||||
expected_date = datetime.datetime(
|
||||
day=now.day if month == now.month and year == now.year else 1,
|
||||
month=month,
|
||||
year=year,
|
||||
).date()
|
||||
|
||||
if request.method == "POST":
|
||||
form = TransferForm(request.POST)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Transfer added successfully."))
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={"HX-Trigger": "transaction_updated, toast, hide_offcanvas"},
|
||||
)
|
||||
else:
|
||||
form = TransferForm(
|
||||
initial={
|
||||
"reference_date": expected_date,
|
||||
"date": expected_date,
|
||||
}
|
||||
)
|
||||
|
||||
return render(request, "transactions/fragments/transfer.html", {"form": form})
|
||||
|
||||
|
||||
@login_required
|
||||
def transaction_pay(request, transaction_id):
|
||||
transaction = get_object_or_404(Transaction, pk=transaction_id)
|
||||
new_is_paid = False if transaction.is_paid else True
|
||||
transaction.is_paid = new_is_paid
|
||||
transaction.save()
|
||||
|
||||
response = render(
|
||||
request,
|
||||
"transactions/fragments/item.html",
|
||||
context={"transaction": transaction},
|
||||
)
|
||||
response.headers["HX-Trigger"] = (
|
||||
f'{"paid" if new_is_paid else "unpaid"}, transaction_updated'
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@login_required
|
||||
def month_year_picker(request):
|
||||
current_month = int(
|
||||
request.GET.get("month", timezone.localdate(timezone.now()).month)
|
||||
)
|
||||
current_year = int(request.GET.get("year", timezone.localdate(timezone.now()).year))
|
||||
|
||||
available_years = Transaction.objects.dates(
|
||||
"reference_date", "year", order="ASC"
|
||||
) or [datetime.datetime(current_year, current_month, 1)]
|
||||
|
||||
return render(
|
||||
request,
|
||||
"transactions/fragments/month_year_picker.html",
|
||||
{
|
||||
"available_years": available_years,
|
||||
"months": range(1, 13),
|
||||
"current_month": current_month,
|
||||
"current_year": current_year,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def monthly_summary(request, month: int, year: int):
|
||||
# Helper function to calculate sums for different transaction types
|
||||
def calculate_sum(transaction_type, is_paid):
|
||||
return (
|
||||
base_queryset.filter(type=transaction_type, is_paid=is_paid)
|
||||
.values(
|
||||
"account__currency__name",
|
||||
"account__currency__suffix",
|
||||
"account__currency__prefix",
|
||||
"account__currency__decimal_places",
|
||||
)
|
||||
.annotate(total=Sum("amount"))
|
||||
.order_by("account__currency__name")
|
||||
)
|
||||
|
||||
# Helper function to format currency sums
|
||||
|
||||
def format_currency_sum(queryset):
|
||||
return [
|
||||
{
|
||||
"currency": item["account__currency__name"],
|
||||
"suffix": item["account__currency__suffix"],
|
||||
"prefix": item["account__currency__prefix"],
|
||||
"decimal_places": item["account__currency__decimal_places"],
|
||||
"amount": round(
|
||||
item["total"], item["account__currency__decimal_places"]
|
||||
),
|
||||
}
|
||||
for item in queryset
|
||||
]
|
||||
|
||||
# Calculate totals
|
||||
def calculate_total(income, expenses):
|
||||
totals = {}
|
||||
|
||||
# Process income
|
||||
for item in income:
|
||||
currency = item["account__currency__name"]
|
||||
totals[currency] = totals.get(currency, Decimal("0")) + item["total"]
|
||||
|
||||
# Subtract expenses
|
||||
for item in expenses:
|
||||
currency = item["account__currency__name"]
|
||||
totals[currency] = totals.get(currency, Decimal("0")) - item["total"]
|
||||
|
||||
return [
|
||||
{
|
||||
"currency": currency,
|
||||
"suffix": next(
|
||||
(
|
||||
item["account__currency__suffix"]
|
||||
for item in list(income) + list(expenses)
|
||||
if item["account__currency__name"] == currency
|
||||
),
|
||||
"",
|
||||
),
|
||||
"prefix": next(
|
||||
(
|
||||
item["account__currency__prefix"]
|
||||
for item in list(income) + list(expenses)
|
||||
if item["account__currency__name"] == currency
|
||||
),
|
||||
"",
|
||||
),
|
||||
"decimal_places": next(
|
||||
(
|
||||
item["account__currency__decimal_places"]
|
||||
for item in list(income) + list(expenses)
|
||||
if item["account__currency__name"] == currency
|
||||
),
|
||||
2,
|
||||
),
|
||||
"amount": round(
|
||||
amount,
|
||||
next(
|
||||
(
|
||||
item["account__currency__decimal_places"]
|
||||
for item in list(income) + list(expenses)
|
||||
if item["account__currency__name"] == currency
|
||||
),
|
||||
2,
|
||||
),
|
||||
),
|
||||
}
|
||||
for currency, amount in totals.items()
|
||||
]
|
||||
|
||||
# Calculate total final
|
||||
def sum_totals(total1, total2):
|
||||
totals = {}
|
||||
for item in total1 + total2:
|
||||
currency = item["currency"]
|
||||
totals[currency] = totals.get(currency, Decimal("0")) + item["amount"]
|
||||
return [
|
||||
{
|
||||
"currency": currency,
|
||||
"suffix": next(
|
||||
item["suffix"]
|
||||
for item in total1 + total2
|
||||
if item["currency"] == currency
|
||||
),
|
||||
"prefix": next(
|
||||
item["prefix"]
|
||||
for item in total1 + total2
|
||||
if item["currency"] == currency
|
||||
),
|
||||
"decimal_places": next(
|
||||
item["decimal_places"]
|
||||
for item in total1 + total2
|
||||
if item["currency"] == currency
|
||||
),
|
||||
"amount": round(
|
||||
amount,
|
||||
next(
|
||||
item["decimal_places"]
|
||||
for item in total1 + total2
|
||||
if item["currency"] == currency
|
||||
),
|
||||
),
|
||||
}
|
||||
for currency, amount in totals.items()
|
||||
]
|
||||
|
||||
# Base queryset with all required filters
|
||||
base_queryset = Transaction.objects.filter(
|
||||
reference_date__year=year, reference_date__month=month, account__is_asset=False
|
||||
).exclude(Q(category__mute=True) & ~Q(category=None))
|
||||
|
||||
# Calculate sums for different transaction types
|
||||
paid_income = calculate_sum(Transaction.Type.INCOME, True)
|
||||
projected_income = calculate_sum(Transaction.Type.INCOME, False)
|
||||
paid_expenses = calculate_sum(Transaction.Type.EXPENSE, True)
|
||||
projected_expenses = calculate_sum(Transaction.Type.EXPENSE, False)
|
||||
|
||||
total_current = calculate_total(paid_income, paid_expenses)
|
||||
total_projected = calculate_total(projected_income, projected_expenses)
|
||||
|
||||
total_final = sum_totals(total_current, total_projected)
|
||||
|
||||
# Calculate daily spending allowance
|
||||
remaining_days = remaining_days_in_month(
|
||||
month=month, year=year, current_date=timezone.localdate(timezone.now())
|
||||
)
|
||||
daily_spending_allowance = [
|
||||
{
|
||||
"currency": item["currency"],
|
||||
"suffix": item["suffix"],
|
||||
"prefix": item["prefix"],
|
||||
"decimal_places": item["decimal_places"],
|
||||
"amount": (
|
||||
amount
|
||||
if (amount := item["amount"] / remaining_days) > 0
|
||||
else Decimal("0")
|
||||
),
|
||||
}
|
||||
for item in total_final
|
||||
]
|
||||
|
||||
# Construct the response dictionary
|
||||
response_data = {
|
||||
"paid_income": format_currency_sum(paid_income),
|
||||
"projected_income": format_currency_sum(projected_income),
|
||||
"paid_expenses": format_currency_sum(paid_expenses),
|
||||
"projected_expenses": format_currency_sum(projected_expenses),
|
||||
"total_current": total_current,
|
||||
"total_projected": total_projected,
|
||||
"total_final": total_final,
|
||||
"daily_spending_allowance": daily_spending_allowance,
|
||||
}
|
||||
|
||||
return render(
|
||||
request,
|
||||
"transactions/fragments/monthly_summary.html",
|
||||
context={
|
||||
"totals": response_data,
|
||||
},
|
||||
)
|
||||
3
app/apps/transactions/views/__init__.py
Normal file
3
app/apps/transactions/views/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .transactions import *
|
||||
from .tags import *
|
||||
from .categories import *
|
||||
92
app/apps/transactions/views/categories.py
Normal file
92
app/apps/transactions/views/categories.py
Normal file
@@ -0,0 +1,92 @@
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import render, get_object_or_404
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from apps.common.decorators.htmx import only_htmx
|
||||
from apps.transactions.forms import TransactionCategoryForm
|
||||
from apps.transactions.models import TransactionCategory
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def categories_list(request):
|
||||
categories = TransactionCategory.objects.all().order_by("id")
|
||||
return render(request, "categories/pages/list.html", {"categories": categories})
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def category_add(request, **kwargs):
|
||||
if request.method == "POST":
|
||||
form = TransactionCategoryForm(request.POST)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Category added successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Location": reverse("categories_list"),
|
||||
"HX-Trigger": "hide_offcanvas, toasts",
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = TransactionCategoryForm()
|
||||
|
||||
return render(
|
||||
request,
|
||||
"categories/fragments/add.html",
|
||||
{"form": form},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def category_edit(request, category_id):
|
||||
category = get_object_or_404(TransactionCategory, id=category_id)
|
||||
|
||||
if request.method == "POST":
|
||||
form = TransactionCategoryForm(request.POST, instance=category)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Category updated successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Location": reverse("categories_list"),
|
||||
"HX-Trigger": "hide_offcanvas, toasts",
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = TransactionCategoryForm(instance=category)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"categories/fragments/edit.html",
|
||||
{"form": form, "category": category},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@csrf_exempt
|
||||
@require_http_methods(["DELETE"])
|
||||
def category_delete(request, category_id):
|
||||
category = get_object_or_404(TransactionCategory, id=category_id)
|
||||
|
||||
category.delete()
|
||||
|
||||
messages.success(request, _("Category deleted successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={"HX-Location": reverse("categories_list")},
|
||||
)
|
||||
92
app/apps/transactions/views/tags.py
Normal file
92
app/apps/transactions/views/tags.py
Normal file
@@ -0,0 +1,92 @@
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import render, get_object_or_404
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from apps.common.decorators.htmx import only_htmx
|
||||
from apps.transactions.forms import TransactionTagForm
|
||||
from apps.transactions.models import TransactionTag
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def tag_list(request):
|
||||
tags = TransactionTag.objects.all().order_by("id")
|
||||
return render(request, "tags/pages/list.html", {"tags": tags})
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def tag_add(request, **kwargs):
|
||||
if request.method == "POST":
|
||||
form = TransactionTagForm(request.POST)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Tag added successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Location": reverse("tags_list"),
|
||||
"HX-Trigger": "hide_offcanvas, toasts",
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = TransactionTagForm()
|
||||
|
||||
return render(
|
||||
request,
|
||||
"tags/fragments/add.html",
|
||||
{"form": form},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def tag_edit(request, tag_id):
|
||||
tag = get_object_or_404(TransactionTag, id=tag_id)
|
||||
|
||||
if request.method == "POST":
|
||||
form = TransactionTagForm(request.POST, instance=tag)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Tag updated successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Location": reverse("tags_list"),
|
||||
"HX-Trigger": "hide_offcanvas, toasts",
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = TransactionTagForm(instance=tag)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"tags/fragments/edit.html",
|
||||
{"form": form, "tag": tag},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@csrf_exempt
|
||||
@require_http_methods(["DELETE"])
|
||||
def tag_delete(request, tag_id):
|
||||
tag = get_object_or_404(TransactionTag, id=tag_id)
|
||||
|
||||
tag.delete()
|
||||
|
||||
messages.success(request, _("Tag deleted successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={"HX-Location": reverse("tags_list")},
|
||||
)
|
||||
181
app/apps/transactions/views/transactions.py
Normal file
181
app/apps/transactions/views/transactions.py
Normal file
@@ -0,0 +1,181 @@
|
||||
import datetime
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import render, get_object_or_404
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views import View
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from apps.common.decorators.htmx import only_htmx
|
||||
from apps.transactions.forms import TransactionForm, TransferForm, InstallmentPlanForm
|
||||
from apps.transactions.models import Transaction
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def transaction_add(request, **kwargs):
|
||||
month = int(request.GET.get("month", timezone.localdate(timezone.now()).month))
|
||||
year = int(request.GET.get("year", timezone.localdate(timezone.now()).year))
|
||||
transaction_type = Transaction.Type(request.GET.get("type", "IN"))
|
||||
|
||||
now = timezone.localdate(timezone.now())
|
||||
expected_date = datetime.datetime(
|
||||
day=now.day if month == now.month and year == now.year else 1,
|
||||
month=month,
|
||||
year=year,
|
||||
).date()
|
||||
|
||||
if request.method == "POST":
|
||||
form = TransactionForm(request.POST)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Transaction added successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={"HX-Trigger": "transaction_updated, hide_offcanvas, toast"},
|
||||
)
|
||||
else:
|
||||
form = TransactionForm(
|
||||
initial={
|
||||
"date": expected_date,
|
||||
"type": transaction_type,
|
||||
}
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"transactions/fragments/add.html",
|
||||
{"form": form},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def transaction_edit(request, transaction_id, **kwargs):
|
||||
transaction = get_object_or_404(Transaction, id=transaction_id)
|
||||
|
||||
if request.method == "POST":
|
||||
form = TransactionForm(request.POST, instance=transaction)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Transaction updated successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={"HX-Trigger": "transaction_updated, hide_offcanvas, toast"},
|
||||
)
|
||||
else:
|
||||
form = TransactionForm(instance=transaction)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"transactions/fragments/edit.html",
|
||||
{"form": form, "transaction": transaction},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@csrf_exempt
|
||||
@require_http_methods(["DELETE"])
|
||||
def transaction_delete(request, transaction_id, **kwargs):
|
||||
transaction = get_object_or_404(Transaction, id=transaction_id)
|
||||
|
||||
if transaction.installment_plan:
|
||||
messages.error(
|
||||
request,
|
||||
_(
|
||||
"This transaction is part of a Installment Plan, you can't delete it directly."
|
||||
),
|
||||
)
|
||||
else:
|
||||
transaction.delete()
|
||||
|
||||
messages.success(request, _("Transaction deleted successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={"HX-Trigger": "transaction_updated, toast"},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def transactions_transfer(request):
|
||||
month = int(request.GET.get("month", timezone.localdate(timezone.now()).month))
|
||||
year = int(request.GET.get("year", timezone.localdate(timezone.now()).year))
|
||||
|
||||
now = timezone.localdate(timezone.now())
|
||||
expected_date = datetime.datetime(
|
||||
day=now.day if month == now.month and year == now.year else 1,
|
||||
month=month,
|
||||
year=year,
|
||||
).date()
|
||||
|
||||
if request.method == "POST":
|
||||
form = TransferForm(request.POST)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Transfer added successfully."))
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={"HX-Trigger": "transaction_updated, toast, hide_offcanvas"},
|
||||
)
|
||||
else:
|
||||
form = TransferForm(
|
||||
initial={
|
||||
"reference_date": expected_date,
|
||||
"date": expected_date,
|
||||
}
|
||||
)
|
||||
|
||||
return render(request, "transactions/fragments/transfer.html", {"form": form})
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def transaction_pay(request, transaction_id):
|
||||
transaction = get_object_or_404(Transaction, pk=transaction_id)
|
||||
new_is_paid = False if transaction.is_paid else True
|
||||
transaction.is_paid = new_is_paid
|
||||
transaction.save()
|
||||
|
||||
response = render(
|
||||
request,
|
||||
"transactions/fragments/item.html",
|
||||
context={"transaction": transaction},
|
||||
)
|
||||
response.headers["HX-Trigger"] = (
|
||||
f'{"paid" if new_is_paid else "unpaid"}, monthly_summary_update'
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
class AddInstallmentPlanView(View):
|
||||
template_name = "transactions/fragments/add_installment_plan.html"
|
||||
|
||||
def get(self, request):
|
||||
form = InstallmentPlanForm()
|
||||
return render(request, self.template_name, {"form": form})
|
||||
|
||||
def post(self, request):
|
||||
form = InstallmentPlanForm(request.POST)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Installment plan created successfully."))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={"HX-Trigger": "transaction_updated, hide_offcanvas, toast"},
|
||||
)
|
||||
|
||||
return render(request, self.template_name, {"form": form})
|
||||
@@ -1,13 +1,13 @@
|
||||
from datetime import datetime, date
|
||||
|
||||
from django import forms
|
||||
from decimal import Decimal
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from django.template.defaultfilters import floatformat
|
||||
from django.utils.formats import get_format
|
||||
from django.utils.formats import get_format, number_format
|
||||
|
||||
|
||||
def convert_to_decimal(value):
|
||||
def convert_to_decimal(value: str):
|
||||
# Remove any whitespace
|
||||
value = value.strip()
|
||||
|
||||
@@ -41,8 +41,9 @@ class MonthYearWidget(forms.DateInput):
|
||||
class ArbitraryDecimalDisplayNumberInput(forms.TextInput):
|
||||
"""A widget for displaying and inputing decimal numbers with the least amount of trailing zeros possible. You
|
||||
must set this on your Form's __init__ method."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.decimal_places = kwargs.pop("decimal_places", 2)
|
||||
self.decimal_places = kwargs.pop("decimal_places", None)
|
||||
self.type = "text"
|
||||
super().__init__(*args, **kwargs)
|
||||
self.attrs.update(
|
||||
@@ -53,15 +54,28 @@ class ArbitraryDecimalDisplayNumberInput(forms.TextInput):
|
||||
)
|
||||
|
||||
def format_value(self, value):
|
||||
if value is not None and isinstance(value, Decimal):
|
||||
# Strip trailing 0s, leaving a minimum of 2 decimal places
|
||||
while (
|
||||
abs(value.as_tuple().exponent) > self.decimal_places
|
||||
and value.as_tuple().digits[-1] == 0
|
||||
):
|
||||
value = Decimal(str(value)[:-1])
|
||||
if value is not None and isinstance(value, (Decimal, float, str)):
|
||||
try:
|
||||
# Convert to Decimal if it's a float or string
|
||||
if isinstance(value, float):
|
||||
value = Decimal(value)
|
||||
elif isinstance(value, str):
|
||||
value = Decimal(convert_to_decimal(value))
|
||||
|
||||
value = floatformat(value, f"{self.decimal_places}g")
|
||||
# Remove trailing zeros
|
||||
value = value.normalize()
|
||||
|
||||
# Format the number using Django's localization
|
||||
formatted_value = number_format(
|
||||
value,
|
||||
force_grouping=False,
|
||||
decimal_pos=self.decimal_places,
|
||||
)
|
||||
|
||||
return formatted_value
|
||||
except (InvalidOperation, ValueError):
|
||||
# If there's an error in conversion, return the original value
|
||||
return value
|
||||
return value
|
||||
|
||||
def value_from_datadict(self, data, files, name):
|
||||
|
||||
Reference in New Issue
Block a user