This commit is contained in:
Herculino Trotta
2024-10-09 00:31:21 -03:00
parent e78e4cc5e1
commit 3dde44b1cd
139 changed files with 4965 additions and 1004 deletions
+142
View File
@@ -0,0 +1,142 @@
from crispy_bootstrap5.bootstrap5 import Switch
from crispy_forms.bootstrap import FormActions
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Field, Column, Row
from django import forms
from django.utils.translation import gettext_lazy as _
from apps.accounts.models import Account
from apps.accounts.models import AccountGroup
from apps.common.fields.forms.dynamic_select import (
DynamicModelMultipleChoiceField,
DynamicModelChoiceField,
)
from apps.common.widgets.crispy.submit import NoClassSubmit
from apps.common.widgets.tom_select import TomSelect
from apps.transactions.models import TransactionCategory, TransactionTag
from apps.transactions.widgets import ArbitraryDecimalDisplayNumberInput
class AccountGroupForm(forms.ModelForm):
class Meta:
model = AccountGroup
fields = ["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(
"name",
)
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 AccountForm(forms.ModelForm):
group = DynamicModelChoiceField(
model=AccountGroup,
required=False,
)
class Meta:
model = Account
fields = ["name", "group", "currency", "exchange_currency", "is_asset"]
widgets = {
"currency": TomSelect(),
"exchange_currency": TomSelect(),
}
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(
"name",
"group",
Switch("is_asset"),
"currency",
"exchange_currency",
)
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 AccountBalanceForm(forms.Form):
account_id = forms.IntegerField(widget=forms.HiddenInput())
new_balance = forms.DecimalField(
max_digits=42, decimal_places=30, required=False, label=_("New balance")
)
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.currency_suffix = self.initial.get("suffix", "")
self.currency_prefix = self.initial.get("prefix", "")
self.currency_decimal_places = self.initial.get("decimal_places", 2)
self.account_name = self.initial.get("account_name", "")
self.current_balance = self.initial.get("current_balance", 0)
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.layout = Layout(
"new_balance",
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",
),
Field("account_id"),
)
self.fields["new_balance"].widget = ArbitraryDecimalDisplayNumberInput(
decimal_places=self.currency_decimal_places
)
AccountBalanceFormSet = forms.formset_factory(AccountBalanceForm, extra=0)
@@ -0,0 +1,28 @@
# Generated by Django 5.1.1 on 2024-10-04 03:33
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0001_initial"),
("currencies", "0004_exchangerate"),
]
operations = [
migrations.AddField(
model_name="account",
name="exchange_currency",
field=models.ForeignKey(
blank=True,
help_text="Default currency for exchange calculations",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="exchange_accounts",
to="currencies.currency",
verbose_name="Exchange Currency",
),
),
]
@@ -0,0 +1,41 @@
# Generated by Django 5.1.1 on 2024-10-08 23:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0002_account_exchange_currency"),
]
operations = [
migrations.CreateModel(
name="AccountGroup",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"name",
models.CharField(max_length=255, unique=True, verbose_name="Name"),
),
],
options={
"verbose_name": "Account Group",
"verbose_name_plural": "Account Groups",
"db_table": "account_groups",
},
),
migrations.AlterField(
model_name="account",
name="name",
field=models.CharField(max_length=255, verbose_name="Name"),
),
]
@@ -0,0 +1,19 @@
# Generated by Django 5.1.2 on 2024-10-08 23:47
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0003_accountgroup_alter_account_name'),
]
operations = [
migrations.AddField(
model_name='account',
name='group',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='accounts.accountgroup', verbose_name='Account Group'),
),
]
+32 -2
View File
@@ -1,15 +1,46 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
from apps.transactions.models import Transaction
class AccountGroup(models.Model):
name = models.CharField(max_length=255, verbose_name=_("Name"), unique=True)
class Meta:
verbose_name = _("Account Group")
verbose_name_plural = _("Account Groups")
db_table = "account_groups"
def __str__(self):
return self.name
class Account(models.Model):
name = models.CharField(max_length=40, verbose_name=_("Name"))
name = models.CharField(max_length=255, verbose_name=_("Name"))
group = models.ForeignKey(
AccountGroup,
on_delete=models.SET_NULL,
verbose_name=_("Account Group"),
blank=True,
null=True,
)
currency = models.ForeignKey(
"currencies.Currency",
verbose_name=_("Currency"),
on_delete=models.PROTECT,
related_name="accounts",
)
exchange_currency = models.ForeignKey(
"currencies.Currency",
verbose_name=_("Exchange Currency"),
on_delete=models.SET_NULL,
related_name="exchange_accounts",
null=True,
blank=True,
help_text=_("Default currency for exchange calculations"),
)
is_asset = models.BooleanField(
default=False,
verbose_name=_("Is an asset account?"),
@@ -17,7 +48,6 @@ class Account(models.Model):
"Asset accounts count towards your Net Worth, but not towards your month."
),
)
a = models.BigIntegerField
class Meta:
verbose_name = _("Account")
+35
View File
@@ -0,0 +1,35 @@
# urls.py
from django.urls import path
from apps.accounts import views
urlpatterns = [
path(
"account-reconciliation/",
views.account_reconciliation,
name="account_reconciliation",
),
path("accounts/", views.accounts_list, name="accounts_list"),
path("account/add/", views.account_add, name="account_add"),
path(
"account/<int:pk>/edit/",
views.account_edit,
name="account_edit",
),
path(
"account/<int:pk>/delete/",
views.account_delete,
name="account_delete",
),
path("account-groups/", views.account_groups_list, name="account_groups_list"),
path("account-groups/add/", views.account_group_add, name="account_group_add"),
path(
"account-groups/<int:pk>/edit/",
views.account_group_edit,
name="account_group_edit",
),
path(
"account-groups/<int:pk>/delete/",
views.account_group_delete,
name="account_group_delete",
),
]
-3
View File
@@ -1,3 +0,0 @@
from django.shortcuts import render
# Create your views here.
+3
View File
@@ -0,0 +1,3 @@
from .accounts import *
from .account_groups import *
from .balance import *
+94
View File
@@ -0,0 +1,94 @@
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.accounts.forms import AccountGroupForm
from apps.accounts.models import AccountGroup
from apps.common.decorators.htmx import only_htmx
@login_required
@require_http_methods(["GET"])
def account_groups_list(request):
account_groups = AccountGroup.objects.all().order_by("id")
return render(
request, "account_groups/pages/list.html", {"account_groups": account_groups}
)
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def account_group_add(request, **kwargs):
if request.method == "POST":
form = AccountGroupForm(request.POST)
if form.is_valid():
form.save()
messages.success(request, _("Account Group added successfully"))
return HttpResponse(
status=204,
headers={
"HX-Location": reverse("account_groups_list"),
"HX-Trigger": "hide_offcanvas, toasts",
},
)
else:
form = AccountGroupForm()
return render(
request,
"account_groups/fragments/add.html",
{"form": form},
)
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def account_group_edit(request, pk):
account_group = get_object_or_404(AccountGroup, id=pk)
if request.method == "POST":
form = AccountGroupForm(request.POST, instance=account_group)
if form.is_valid():
form.save()
messages.success(request, _("Account Group updated successfully"))
return HttpResponse(
status=204,
headers={
"HX-Location": reverse("account_groups_list"),
"HX-Trigger": "hide_offcanvas, toasts",
},
)
else:
form = AccountGroupForm(instance=account_group)
return render(
request,
"account_groups/fragments/edit.html",
{"form": form, "account_group": account_group},
)
@only_htmx
@login_required
@csrf_exempt
@require_http_methods(["DELETE"])
def account_group_delete(request, pk):
account_group = get_object_or_404(AccountGroup, id=pk)
account_group.delete()
messages.success(request, _("Account Group deleted successfully"))
return HttpResponse(
status=204,
headers={"HX-Location": reverse("account_groups_list")},
)
+92
View 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.accounts.forms import AccountForm
from apps.accounts.models import Account
from apps.common.decorators.htmx import only_htmx
@login_required
@require_http_methods(["GET"])
def accounts_list(request):
accounts = Account.objects.all().order_by("id")
return render(request, "accounts/pages/list.html", {"accounts": accounts})
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def account_add(request, **kwargs):
if request.method == "POST":
form = AccountForm(request.POST)
if form.is_valid():
form.save()
messages.success(request, _("Account added successfully"))
return HttpResponse(
status=204,
headers={
"HX-Location": reverse("accounts_list"),
"HX-Trigger": "hide_offcanvas, toasts",
},
)
else:
form = AccountForm()
return render(
request,
"accounts/fragments/add.html",
{"form": form},
)
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def account_edit(request, pk):
account = get_object_or_404(Account, id=pk)
if request.method == "POST":
form = AccountForm(request.POST, instance=account)
if form.is_valid():
form.save()
messages.success(request, _("Account updated successfully"))
return HttpResponse(
status=204,
headers={
"HX-Location": reverse("accounts_list"),
"HX-Trigger": "hide_offcanvas, toasts",
},
)
else:
form = AccountForm(instance=account)
return render(
request,
"accounts/fragments/edit.html",
{"form": form, "account": account},
)
@only_htmx
@login_required
@csrf_exempt
@require_http_methods(["DELETE"])
def account_delete(request, pk):
account = get_object_or_404(Account, id=pk)
account.delete()
messages.success(request, _("Account deleted successfully"))
return HttpResponse(
status=204,
headers={"HX-Location": reverse("accounts_list")},
)
+93
View File
@@ -0,0 +1,93 @@
from decimal import Decimal
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.db import models
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 apps.accounts.forms import AccountBalanceFormSet
from apps.accounts.models import Account, Transaction
from apps.common.decorators.htmx import only_htmx
@only_htmx
@login_required
def account_reconciliation(request):
def get_account_balance(account):
income = Transaction.objects.filter(
account=account, type=Transaction.Type.INCOME, is_paid=True
).aggregate(total=models.Sum("amount"))["total"] or Decimal("0")
expense = Transaction.objects.filter(
account=account, type=Transaction.Type.EXPENSE, is_paid=True
).aggregate(total=models.Sum("amount"))["total"] or Decimal("0")
return income - expense
initial_data = [
{
"account_id": account.id,
"account_name": account.name,
"decimal_places": account.currency.decimal_places,
"suffix": account.currency.suffix,
"prefix": account.currency.prefix,
"current_balance": get_account_balance(account),
}
for account in Account.objects.all().select_related("currency")
]
if request.method == "POST":
formset = AccountBalanceFormSet(request.POST, initial=initial_data)
if formset.is_valid():
with transaction.atomic():
for form in formset:
if form.is_valid():
account_id = form.cleaned_data["account_id"]
new_balance = form.cleaned_data["new_balance"]
account = Account.objects.get(id=account_id)
category = form.cleaned_data["category"]
tags = form.cleaned_data.get("tags", [])
if new_balance is None:
continue
current_balance = get_account_balance(account)
difference = new_balance - current_balance
if difference != 0:
new_transaction = Transaction.objects.create(
account=account,
type=(
Transaction.Type.INCOME
if difference > 0
else Transaction.Type.EXPENSE
),
amount=abs(difference),
date=timezone.localdate(timezone.now()),
reference_date=timezone.localdate(
timezone.now()
).replace(day=1),
description=_("Balance reconciliation"),
is_paid=True,
category=category,
)
new_transaction.tags.set(tags)
messages.success(
request, _("Account balances have been reconciled successfully.")
)
return HttpResponse(
status=204,
headers={"HX-Trigger": "transaction_updated, hide_offcanvas, toast"},
)
else:
formset = AccountBalanceFormSet(initial=initial_data)
return render(
request, "accounts/fragments/account_reconciliation.html", {"form": formset}
)
+14
View File
@@ -0,0 +1,14 @@
from functools import wraps
from django.core.exceptions import PermissionDenied
def only_htmx(view):
@wraps(view)
def _view(request, *args, **kwargs):
if not request.META.get("HTTP_HX_REQUEST"):
raise PermissionDenied
return view(request, *args, **kwargs)
return _view
View File
@@ -0,0 +1,205 @@
from django import forms
from django.core.exceptions import ValidationError
from django.db import transaction
from apps.common.widgets.tom_select import TomSelect, TomSelectMultiple
# class DynamicModelChoiceField(forms.ModelChoiceField):
# def __init__(self, model, *args, **kwargs):
# self.model = model
# 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)
#
# def to_python(self, value):
# if value in self.empty_values:
# return None
# try:
# key = self.to_field_name or "pk"
# return self.model.objects.get(**{key: value})
# except (ValueError, TypeError, self.model.DoesNotExist):
# return value # Return the raw value; we'll handle creation in clean()
#
# def clean(self, value):
# if isinstance(value, self.model):
# return value
# if isinstance(value, str):
# try:
# if value.isdigit():
# return self.model.objects.get(id=value)
# else:
# raise self.model.DoesNotExist
# except self.model.DoesNotExist:
# try:
# with transaction.atomic():
# instance = self.model.objects.create(name=value)
# self._created_instance = instance
# return instance
# except Exception as e:
# raise ValidationError(
# self.error_messages["invalid_choice"], code="invalid_choice"
# )
# return super().clean(value)
#
# def bound_data(self, data, initial):
# if self._created_instance and isinstance(data, str):
# if data == self._created_instance.name:
# return self._created_instance.pk
# return super().bound_data(data, initial)
class DynamicModelChoiceField(forms.ModelChoiceField):
def __init__(self, model, *args, **kwargs):
self.model = model
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)
def to_python(self, value):
if value in self.empty_values:
return None
try:
key = self.to_field_name or "pk"
return self.model.objects.get(**{key: value})
except (ValueError, TypeError, self.model.DoesNotExist):
return value # Return the raw value; we'll handle creation in clean()
def clean(self, value):
if value in self.empty_values:
if self.required:
raise ValidationError(self.error_messages["required"], code="required")
return None
if isinstance(value, self.model):
return value
if isinstance(value, str):
value = value.strip()
if not value:
if self.required:
raise ValidationError(
self.error_messages["required"], code="required"
)
return None
try:
if value.isdigit():
return self.model.objects.get(id=value)
else:
raise self.model.DoesNotExist
except self.model.DoesNotExist:
try:
with transaction.atomic():
instance = self.model.objects.create(name=value)
self._created_instance = instance
return instance
except Exception as e:
raise ValidationError(
self.error_messages["invalid_choice"], code="invalid_choice"
)
return super().clean(value)
def bound_data(self, data, initial):
if self._created_instance and isinstance(data, str):
if data == self._created_instance.name:
return self._created_instance.pk
return super().bound_data(data, initial)
class DynamicModelMultipleChoiceField(forms.ModelMultipleChoiceField):
"""
A custom ModelMultipleChoiceField that creates new entries if they don't exist.
This field allows users to select multiple existing options or add new ones.
If a selected option doesn't exist, it will be created in the database.
Attributes:
create_field (str): The name of the field to use when creating new instances.
"""
def __init__(self, model, **kwargs):
"""
Initialize the CreateIfNotExistsModelMultipleChoiceField.
Args:
create_field (str): The name of the field to use when creating new instances.
*args: Variable length argument list.
**kwargs: Arbitrary keyword arguments.
"""
self.create_field = kwargs.pop("create_field", None)
if not self.create_field:
raise ValueError("The 'create_field' parameter is required.")
self.model = model
self.queryset = kwargs.pop("queryset", model.objects.all())
super().__init__(queryset=self.queryset, **kwargs)
self.widget = TomSelectMultiple(
remove_button=True, clear_button=True, create=True, checkboxes=True
)
def _create_new_instance(self, value):
"""
Create a new instance of the model with the given value.
Args:
value: The value to use for creating the new instance.
Returns:
Model: The newly created model instance.
Raises:
ValidationError: If there's an error creating the new instance.
"""
try:
with transaction.atomic():
new_instance = self.queryset.model(**{self.create_field: value})
new_instance.full_clean()
new_instance.save()
return new_instance
except Exception as e:
raise ValidationError(f"Error creating new instance: {str(e)}")
def clean(self, value):
"""
Clean and validate the field value.
This method checks if each selected choice exists in the database.
If a choice doesn't exist, it creates a new instance of the model.
Args:
value (list): List of selected values.
Returns:
list: A list containing all selected and newly created model instances.
Raises:
ValidationError: If there's an error during the cleaning process.
"""
if not value:
return []
print(value)
string_values = set(str(v) for v in value)
existing_objects = list(
self.queryset.filter(**{f"{self.create_field}__in": string_values})
)
existing_values = set(
str(getattr(obj, self.create_field)) for obj in existing_objects
)
new_values = string_values - existing_values
new_objects = []
for new_value in new_values:
try:
new_objects.append(self._create_new_instance(new_value))
except ValidationError as e:
raise ValidationError(f"Error creating '{new_value}': {str(e)}")
return existing_objects + new_objects
+16
View File
@@ -0,0 +1,16 @@
import zoneinfo
from django.utils import timezone
from django.shortcuts import render
class TimezoneMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
tz = request.COOKIES.get("mytz")
if tz:
timezone.activate(zoneinfo.ZoneInfo(tz))
else:
timezone.activate(zoneinfo.ZoneInfo("UTC"))
return self.get_response(request)
@@ -0,0 +1,41 @@
from django import template
register = template.Library()
# https://github.com/valerymelou/django-active-link/
@register.simple_tag(takes_context=True)
def active_link(
context, views="", namespaces="", css_class="active", inactive_class=""
):
"""
Renders the given CSS class if the request path matches the path of the view.
:param context: The context where the tag was called. Used to access the request object.
:param views: The name of the view or views separated by || (include namespaces if any).
:param namespaces: The name of the namespace or namespaces separated by ||.
:param css_class: The CSS class to render.
:param inactive_class: The CSS class to render if the views is not active.
:return:
"""
request = context.get("request")
if request is None:
# Can't work without the request object.
return ""
if views:
views = views.split("||")
current_view = request.resolver_match.view_name
if current_view in views:
return css_class
elif namespaces:
namespaces = namespaces.split("||")
current_namespace = request.resolver_match.namespaces
if any(item in current_namespace for item in namespaces):
return css_class
else:
return ""
return inactive_class
+60
View File
@@ -0,0 +1,60 @@
from django import template
from django.utils import timezone
from datetime import date
from django.utils.translation import gettext_lazy as _, ngettext_lazy
from django.utils.formats import date_format
register = template.Library()
@register.filter(name="customnaturaldate")
def naturaldate(value):
if not isinstance(value, date):
return value
today = timezone.localdate(timezone.now())
delta = value - today
if delta.days == 0:
return _("today")
elif delta.days == 1:
return _("tomorrow")
elif delta.days == -1:
return _("yesterday")
elif -7 <= delta.days < 0:
return _("last 7 days")
elif 0 < delta.days <= 7:
return _("in the next 7 days")
elif delta.days < -365:
years = abs(delta.days) // 365
return ngettext_lazy("%(years)s year ago", "%(years)s years ago", years) % {
"years": years
}
elif delta.days < -30:
months = abs(delta.days) // 30
return ngettext_lazy(
"%(months)s month ago", "%(months)s months ago", months
) % {"months": months}
elif delta.days < -7:
weeks = abs(delta.days) // 7
return ngettext_lazy("%(weeks)s week ago", "%(weeks)s weeks ago", weeks) % {
"weeks": weeks
}
elif delta.days > 365:
years = delta.days // 365
return ngettext_lazy("in %(years)s year", "in %(years)s years", years) % {
"years": years
}
elif delta.days > 30:
months = delta.days // 30
return ngettext_lazy("in %(months)s month", "in %(months)s months", months) % {
"months": months
}
elif delta.days > 7:
weeks = delta.days // 7
return ngettext_lazy("in %(weeks)s week", "in %(weeks)s weeks", weeks) % {
"weeks": weeks
}
else:
return date_format(value, format="DATE_FORMAT")
+13
View File
@@ -0,0 +1,13 @@
from django import template
from django.conf import settings
register = template.Library()
@register.filter
def site_title(value):
value = value.strip()
if value:
return f"{value} {settings.TITLE_SEPARATOR or '::'} {settings.SITE_TITLE or "SITE_TITLE NOT SET"}"
else:
return settings.SITE_TITLE or "SITE_TITLE NOT SET"
+65
View File
@@ -0,0 +1,65 @@
from crispy_forms.layout import BaseInput
class NoClassSubmit(BaseInput):
"""
Used to create a Submit button descriptor for the {% crispy %} template tag.
Attributes
----------
template: str
The default template which this Layout Object will be rendered
with.
field_classes: str
CSS classes to be applied to the ``<input>``.
input_type: str
The ``type`` attribute of the ``<input>``.
Parameters
----------
name : str
The name attribute of the button.
value : str
The value attribute of the button.
css_id : str, optional
A custom DOM id for the layout object. If not provided the name
argument is slugified and turned into the id for the submit button.
By default None.
css_class : str, optional
Additional CSS classes to be applied to the ``<input>``. By default
None.
template : str, optional
Overrides the default template, if provided. By default None.
**kwargs : dict, optional
Additional attributes are passed to `flatatt` and converted into
key="value", pairs. These attributes are added to the ``<input>``.
Examples
--------
Note: ``form`` arg to ``render()`` is not required for ``BaseInput``
inherited objects.
>>> submit = Submit('Search the Site', 'search this site')
>>> submit.render("", "", Context())
'<input type="submit" name="search-the-site" value="search this site" '
'class="btn btn-primary" id="submit-id-search-the-site"/>'
>>> submit = Submit('Search the Site', 'search this site', css_id="custom-id",
css_class="custom class", my_attr=True, data="my-data")
>>> submit.render("", "", Context())
'<input type="submit" name="search-the-site" value="search this site" '
'class="btn btn-primary custom class" id="custom-id" data="my-data" my-attr/>'
Usually you will not call the render method on the object directly. Instead
add it to your ``Layout`` manually or use the `add_input` method::
class ExampleForm(forms.Form):
[...]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.add_input(Submit('submit', 'Submit'))
"""
input_type = "submit"
field_classes = ""
+54
View File
@@ -0,0 +1,54 @@
from django.forms import widgets, SelectMultiple
from django.utils.translation import gettext_lazy as _
class TomSelect(widgets.Select):
def __init__(
self,
attrs=None,
remove_button=False,
remove_button_text=_("Remove"),
create=False,
create_text=_("Add"),
clear_button=True,
clear_text=_("Clear"),
no_results_text=_("No results..."),
checkboxes=False,
*args,
**kwargs
):
super().__init__(attrs, *args, **kwargs)
self.remove_button = remove_button
self.remove_button_text = remove_button_text
self.clear_button = clear_button
self.create = create
self.create_text = create_text
self.clear_text = clear_text
self.no_results_text = no_results_text
self.checkboxes = checkboxes
def build_attrs(self, base_attrs, extra_attrs=None):
attrs = super().build_attrs(base_attrs, extra_attrs)
attrs["data-txt-no-results"] = self.no_results_text
if self.remove_button:
attrs["data-remove-button"] = "true"
attrs["data-txt-remove"] = self.remove_button_text
if self.create:
attrs["data-create"] = "true"
attrs["data-txt-create"] = self.create_text
if self.clear_button:
attrs["data-clear-button"] = "true"
attrs["data-txt-clear"] = self.clear_text
if self.checkboxes:
attrs["data-checkboxes"] = "true"
return attrs
class TomSelectMultiple(SelectMultiple, TomSelect):
pass
+4 -1
View File
@@ -1,6 +1,6 @@
from django.contrib import admin
from apps.currencies.models import Currency
from apps.currencies.models import Currency, ExchangeRate
@admin.register(Currency)
@@ -9,3 +9,6 @@ class CurrencyAdmin(admin.ModelAdmin):
if db_field.name == "suffix" or db_field.name == "prefix":
kwargs["strip"] = False
return super().formfield_for_dbfield(db_field, request, **kwargs)
admin.site.register(ExchangeRate)
+45
View File
@@ -0,0 +1,45 @@
from crispy_forms.bootstrap import FormActions
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout
from django import forms
from django.forms import CharField
from django.utils.translation import gettext_lazy as _
from apps.common.widgets.crispy.submit import NoClassSubmit
from apps.currencies.models import Currency
class CurrencyForm(forms.ModelForm):
prefix = CharField(strip=False, required=False)
suffix = CharField(strip=False, required=False)
class Meta:
model = Currency
fields = ["name", "decimal_places", "prefix", "suffix", "code"]
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(
"code", "name", "decimal_places", "prefix", "suffix"
)
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,58 @@
# Generated by Django 5.1.1 on 2024-10-04 03:33
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("currencies", "0003_alter_currency_decimal_places"),
]
operations = [
migrations.CreateModel(
name="ExchangeRate",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"rate",
models.DecimalField(
decimal_places=30, max_digits=42, verbose_name="Exchange Rate"
),
),
("date", models.DateTimeField(verbose_name="Date and Time")),
(
"from_currency",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="from_exchange_rates",
to="currencies.currency",
verbose_name="From Currency",
),
),
(
"to_currency",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="to_exchange_rates",
to="currencies.currency",
verbose_name="To Currency",
),
),
],
options={
"verbose_name": "Exchange Rate",
"verbose_name_plural": "Exchange Rates",
"unique_together": {("from_currency", "to_currency", "date")},
},
),
]
+27
View File
@@ -20,3 +20,30 @@ class Currency(models.Model):
class Meta:
verbose_name = _("Currency")
verbose_name_plural = _("Currencies")
class ExchangeRate(models.Model):
from_currency = models.ForeignKey(
Currency,
on_delete=models.CASCADE,
related_name="from_exchange_rates",
verbose_name=_("From Currency"),
)
to_currency = models.ForeignKey(
Currency,
on_delete=models.CASCADE,
related_name="to_exchange_rates",
verbose_name=_("To Currency"),
)
rate = models.DecimalField(
max_digits=42, decimal_places=30, verbose_name=_("Exchange Rate")
)
date = models.DateTimeField(verbose_name=_("Date and Time"))
class Meta:
verbose_name = _("Exchange Rate")
verbose_name_plural = _("Exchange Rates")
unique_together = ("from_currency", "to_currency", "date")
def __str__(self):
return f"{self.from_currency.code} to {self.to_currency.code} on {self.date}"
+18
View File
@@ -0,0 +1,18 @@
from django.urls import path
from . import views
urlpatterns = [
path("currencies/", views.currency_list, name="currencies_list"),
path("currencies/add/", views.currency_add, name="currency_add"),
path(
"currencies/<int:pk>/edit/",
views.currency_edit,
name="currency_edit",
),
path(
"currencies/<int:pk>/delete/",
views.currency_delete,
name="currency_delete",
),
]
+42
View File
@@ -0,0 +1,42 @@
from django.db.models import Func, F, Value, DurationField, Case, When, DecimalField, Q
from django.db.models.functions import Abs, Extract
from django.utils import timezone
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from apps.currencies.models import ExchangeRate
def convert(amount, from_currency, to_currency, date=None):
if from_currency == to_currency:
return amount
if date is None:
date = timezone.localtime(timezone.now())
try:
exchange_rate = (
ExchangeRate.objects.filter(
Q(from_currency=from_currency, to_currency=to_currency)
| Q(from_currency=to_currency, to_currency=from_currency)
)
.annotate(
date_diff=Func(
Extract(F("date") - Value(date), "epoch"), function="ABS"
),
effective_rate=Case(
When(from_currency=from_currency, then=F("rate")),
default=1 / F("rate"),
output_field=DecimalField(),
),
)
.order_by("date_diff")
.first()
)
if exchange_rate is None:
return None
return amount * exchange_rate.effective_rate
except ExchangeRate.DoesNotExist:
return None
+91 -2
View File
@@ -1,3 +1,92 @@
from django.shortcuts import render
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
# Create your views here.
from apps.common.decorators.htmx import only_htmx
from apps.currencies.forms import CurrencyForm
from apps.currencies.models import Currency
@login_required
@require_http_methods(["GET"])
def currency_list(request):
currencies = Currency.objects.all().order_by("id")
return render(request, "currencies/pages/list.html", {"currencies": currencies})
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def currency_add(request, **kwargs):
if request.method == "POST":
form = CurrencyForm(request.POST)
if form.is_valid():
form.save()
messages.success(request, _("Currency added successfully"))
return HttpResponse(
status=204,
headers={
"HX-Location": reverse("currencies_list"),
"HX-Trigger": "hide_offcanvas, toasts",
},
)
else:
form = CurrencyForm()
return render(
request,
"currencies/fragments/add.html",
{"form": form},
)
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def currency_edit(request, pk):
currency = get_object_or_404(Currency, id=pk)
if request.method == "POST":
form = CurrencyForm(request.POST, instance=currency)
if form.is_valid():
form.save()
messages.success(request, _("Currency updated successfully"))
return HttpResponse(
status=204,
headers={
"HX-Location": reverse("currencies_list"),
"HX-Trigger": "hide_offcanvas, toasts",
},
)
else:
form = CurrencyForm(instance=currency)
return render(
request,
"currencies/fragments/edit.html",
{"form": form, "currency": currency},
)
@only_htmx
@login_required
@csrf_exempt
@require_http_methods(["DELETE"])
def currency_delete(request, pk):
currency = get_object_or_404(Currency, id=pk)
currency.delete()
messages.success(request, _("Currency deleted successfully"))
return HttpResponse(
status=204,
headers={"HX-Location": reverse("currencies_list")},
)
+3
View File
@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class MonthlyOverviewConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.monthly_overview"
+3
View File
@@ -0,0 +1,3 @@
from django.db import models
# Create your models here.
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+27
View File
@@ -0,0 +1,27 @@
from django.urls import path
from . import views
urlpatterns = [
path("monthly/", views.index, name="monthly_index"),
path(
"monthly/<int:month>/<int:year>/transactions/list/",
views.transactions_list,
name="monthly_transactions_list",
),
path(
"monthly/<int:month>/<int:year>/",
views.monthly_overview,
name="monthly_overview",
),
path(
"monthly/<int:month>/<int:year>/summary/",
views.monthly_summary,
name="monthly_summary",
),
path(
"available_dates/",
views.month_year_picker,
name="available_dates",
),
]
@@ -0,0 +1,2 @@
from .ui import *
from .main import *
+309
View File
@@ -0,0 +1,309 @@
from decimal import Decimal
from django.contrib.auth.decorators import login_required
from django.db.models import (
Case,
When,
Value,
IntegerField,
Sum,
Q,
)
from django.shortcuts import render, redirect
from django.utils import timezone
from django.views.decorators.http import require_http_methods
from apps.common.decorators.htmx import only_htmx
from apps.common.functions.dates import remaining_days_in_month
from apps.transactions.filters import TransactionsFilter
from apps.transactions.models import Transaction
@login_required
def index(request):
now = timezone.localdate(timezone.now())
return redirect(to="monthly_overview", month=now.month, year=now.year)
@login_required
@require_http_methods(["GET"])
def monthly_overview(request, month: int, year: int):
transactions = (
Transaction.objects.all()
.filter(
reference_date__year=year,
reference_date__month=month,
)
.order_by("date", "id")
.select_related()
)
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
f = TransactionsFilter(request.GET, queryset=transactions)
return render(
request,
"monthly_overview/pages/overview.html",
context={
"month": month,
"year": year,
"next_month": next_month,
"next_year": next_year,
"previous_month": previous_month,
"previous_year": previous_year,
"filter": f,
},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def transactions_list(request, month: int, year: int):
today = timezone.localdate(timezone.now())
yesterday = today - timezone.timedelta(days=1)
tomorrow = today + timezone.timedelta(days=1)
f = TransactionsFilter(request.GET)
transactions_filtered = (
f.qs.filter()
.filter(
reference_date__year=year,
reference_date__month=month,
)
.annotate(
date_order=Case(
When(date=tomorrow, then=Value(0)),
When(date=today, then=Value(1)),
When(date=yesterday, then=Value(2)),
default=Value(3),
output_field=IntegerField(),
)
)
.order_by("date_order", "date", "id")
.prefetch_related(
"account",
"category",
"tags",
"account__exchange_currency",
"account__currency",
)
)
return render(
request,
"monthly_overview/fragments/list.html",
context={"transactions": transactions_filtered},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
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": 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()
]
# 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())
)
if (
timezone.localdate(timezone.now()).month == month
and timezone.localdate(timezone.now()).year == year
):
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
]
else:
daily_spending_allowance = []
# Construct the response dictionary
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,
}
# account_summary = (
# Account.objects.annotate(
# balance=Coalesce(
# Sum(
# Case(
# When(
# transaction__type=Transaction.Type.INCOME,
# transaction__is_paid=True,
# transaction__reference_date__year=year,
# transaction__reference_date__month=month,
# then=F("transaction__amount"),
# ),
# When(
# transaction__type=Transaction.Type.EXPENSE,
# transaction__is_paid=True,
# transaction__reference_date__year=year,
# transaction__reference_date__month=month,
# then=-F("transaction__amount"),
# ),
# output_field=DecimalField(),
# )
# ),
# Decimal(0),
# )
# )
# .values(
# "id",
# "name",
# "balance",
# "currency__prefix",
# "currency__suffix",
# "currency__decimal_places",
# )
# .order_by("id")
# )
return render(
request,
"monthly_overview/fragments/monthly_summary.html",
context={"totals": data},
)
+71
View File
@@ -0,0 +1,71 @@
from dateutil.relativedelta import relativedelta
from django.db.models import (
Count,
)
from django.db.models.functions import ExtractYear, ExtractMonth
from django.shortcuts import render
from django.utils import timezone
from apps.transactions.models import Transaction
def month_year_picker(request):
# Get current month and year from request or use current date
current_date = timezone.localdate(timezone.now())
current_month = int(request.GET.get("month", current_date.month))
current_year = int(request.GET.get("year", current_date.year))
# Set start and end dates
start_date = timezone.datetime(current_year - 1, 1, 1).date()
end_date = timezone.datetime(current_year + 1, 12, 31).date()
# Get years from transactions
transaction_years = Transaction.objects.dates("reference_date", "year", order="ASC")
# Extend start_date and end_date if necessary
if transaction_years:
start_date = min(start_date, transaction_years.first().replace(month=1, day=1))
end_date = max(end_date, transaction_years.last().replace(month=12, day=31))
# Generate all months between start_date and end_date
all_months = []
current_month_date = start_date
while current_month_date <= end_date:
all_months.append(current_month_date)
current_month_date += relativedelta(months=1)
# Get transaction counts for each month
transaction_counts = (
Transaction.objects.annotate(
year=ExtractYear("reference_date"), month=ExtractMonth("reference_date")
)
.values("year", "month")
.annotate(transaction_count=Count("id"))
.order_by("year", "month")
)
# Create a dictionary for quick lookup
count_dict = {
(item["year"], item["month"]): item["transaction_count"]
for item in transaction_counts
}
# Create the final result
result = [
{
"year": date.year,
"month": date.month,
"transaction_count": count_dict.get((date.year, date.month), 0),
}
for date in all_months
]
return render(
request,
"monthly_overview/fragments/month_year_picker.html",
{
"month_year_data": result,
"current_month": current_month,
"current_year": current_year,
},
)
View File
+3
View File
@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class NetWorthConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.net_worth"
+3
View File
@@ -0,0 +1,3 @@
from django.db import models
# Create your models here.
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+7
View File
@@ -0,0 +1,7 @@
from django.urls import path
from . import views
urlpatterns = [
path("net-worth/", views.net_worth_main, name="net_worth"),
]
@@ -0,0 +1,77 @@
from django.db.models import Sum
from decimal import Decimal
from django.db.models.functions import TruncMonth
from apps.transactions.models import Transaction
from apps.accounts.models import Account
from apps.currencies.models import Currency
def calculate_net_worth():
accounts = Account.objects.all()
net_worth = {}
for account in accounts:
currency = account.currency
if currency.code not in net_worth:
net_worth[currency.code] = Decimal("0")
income = Transaction.objects.filter(
account=account, type=Transaction.Type.INCOME, is_paid=True
).aggregate(total=Sum("amount"))["total"] or Decimal("0")
expenses = Transaction.objects.filter(
account=account, type=Transaction.Type.EXPENSE, is_paid=True
).aggregate(total=Sum("amount"))["total"] or Decimal("0")
account_balance = income - expenses
net_worth[currency.code] += account_balance
return net_worth
def calculate_historical_net_worth(start_date, end_date):
asset_accounts = Account.objects.all()
currencies = Currency.objects.all()
# Initialize the result dictionary
historical_net_worth = {}
# Get all months between start_date and end_date
months = (
Transaction.objects.filter(account__in=asset_accounts)
.annotate(month=TruncMonth("date"))
.values("month")
.distinct()
.order_by("month")
)
for month_data in months:
month = month_data["month"]
month_str = month.strftime("%Y-%m")
historical_net_worth[month_str] = {
currency.code: Decimal("0.00") for currency in currencies
}
for account in asset_accounts:
currency = account.currency
income = Transaction.objects.filter(
account=account,
type=Transaction.Type.INCOME,
is_paid=True,
date__lte=month,
).aggregate(total=Sum("amount"))["total"] or Decimal("0.00")
expenses = Transaction.objects.filter(
account=account,
type=Transaction.Type.EXPENSE,
is_paid=True,
date__lte=month,
).aggregate(total=Sum("amount"))["total"] or Decimal("0.00")
account_balance = income - expenses
historical_net_worth[month_str][currency.code] += account_balance
return historical_net_worth
+39
View File
@@ -0,0 +1,39 @@
from datetime import datetime
from django.http import JsonResponse
from django.shortcuts import render
from apps.net_worth.utils.calculate_net_worth import (
calculate_net_worth,
calculate_historical_net_worth,
)
from apps.currencies.models import Currency
# Create your views here.
def net_worth_main(request):
net_worth = calculate_net_worth()
# historical = calculate_historical_net_worth(
# start_date=datetime(day=1, month=1, year=2021).date(),
# end_date=datetime(day=1, month=1, year=2025).date(),
# )
# print(historical)
# Format the net worth with currency details
formatted_net_worth = []
for currency_code, amount in net_worth.items():
currency = Currency.objects.get(code=currency_code)
formatted_net_worth.append(
{
"amount": amount,
"code": currency.code,
"name": currency.name,
"prefix": currency.prefix,
"suffix": currency.suffix,
"decimal_places": currency.decimal_places,
}
)
return render(
request, "net_worth/net_worth.html", {"currency_net_worth": formatted_net_worth}
)
+18 -1
View File
@@ -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
View 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),
)
+323 -47
View File
@@ -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"),
),
]
+58 -3
View File
@@ -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)
+29 -21
View File
@@ -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,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()
]
-419
View File
@@ -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
View File
@@ -0,0 +1,3 @@
from .transactions import *
from .tags import *
from .categories import *
+92
View 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
View 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
View 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})
+26 -12
View File
@@ -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):
+13 -3
View File
@@ -5,24 +5,31 @@ from django.contrib.auth.forms import (
AdminPasswordChangeForm,
)
from django.utils.translation import gettext_lazy as _
from django.contrib import admin
from django.contrib.auth.admin import GroupAdmin as BaseGroupAdmin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.models import Group
from apps.users.models import User
from apps.users.models import User, UserSettings
admin.site.unregister(Group)
class UserSettingsInline(admin.StackedInline):
model = UserSettings
can_delete = False
extra = 0
verbose_name_plural = _("User Settings")
verbose_name = _("User Setting")
@admin.register(User)
class UserAdmin(BaseUserAdmin, ModelAdmin):
ordering = ("email",)
exclude = ("username",)
list_display = ("email", "is_staff")
search_fields = ("first_name", "last_name", "email")
inlines = (UserSettingsInline,)
form = UserChangeForm
add_form = UserCreationForm
@@ -67,3 +74,6 @@ class UserAdmin(BaseUserAdmin, ModelAdmin):
@admin.register(Group)
class GroupAdmin(BaseGroupAdmin, ModelAdmin):
pass
admin.site.register(UserSettings)
+15 -7
View File
@@ -14,20 +14,18 @@ from django.contrib.auth.forms import (
SetPasswordForm,
PasswordChangeForm,
UserCreationForm,
AuthenticationForm,
)
from django.core.exceptions import ValidationError
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from django_recaptcha.fields import ReCaptchaField
from django_recaptcha.widgets import ReCaptchaV3
from unfold.forms import AuthenticationForm
class LoginForm(AuthenticationForm):
username = UsernameField(
label="Seu e-mail",
widget=forms.TextInput(
attrs={"class": "form-control", "placeholder": "E-mail"}
widget=forms.EmailInput(
attrs={"class": "form-control", "placeholder": "E-mail", "name": "email"}
),
)
password = forms.CharField(
@@ -39,6 +37,16 @@ class LoginForm(AuthenticationForm):
)
error_messages = {
"invalid_login": _("E-mail ou senha inválidos."),
"inactive": _("Esta conta esta desativada."),
"invalid_login": _("Invalid e-mail or password"),
"inactive": _("This account is deactivated"),
}
def __init__(self, request=None, *args, **kwargs):
super().__init__(request, *args, **kwargs)
self.helper = FormHelper()
self.helper.layout = Layout(
"username",
"password",
Submit("Submit", "Login"),
)
@@ -0,0 +1,23 @@
# Generated by Django 5.1.1 on 2024-10-07 00:17
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='UserSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('hide_amounts', models.BooleanField(default=False)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='settings', to=settings.AUTH_USER_MODEL)),
],
),
]
+11
View File
@@ -1,4 +1,5 @@
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import models
from django.contrib.auth.models import AbstractUser, Group
from django.utils.translation import gettext_lazy as _
@@ -17,3 +18,13 @@ class User(AbstractUser):
def __str__(self):
return f"{self.first_name} {self.last_name} ({self.email})"
class UserSettings(models.Model):
user = models.OneToOneField(
get_user_model(), on_delete=models.CASCADE, related_name="settings"
)
hide_amounts = models.BooleanField(default=False)
def __str__(self):
return f"{self.user.email}'s settings"
+18
View File
@@ -0,0 +1,18 @@
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.contrib.auth import get_user_model
from apps.users.models import UserSettings
User = get_user_model()
@receiver(post_save, sender=User)
def create_user_settings(sender, instance, created, **kwargs):
if created:
UserSettings.objects.create(user=instance)
@receiver(post_save, sender=User)
def save_user_settings(sender, instance, **kwargs):
instance.settings.save()
+5
View File
@@ -6,4 +6,9 @@ urlpatterns = [
path("login/", views.UserLoginView.as_view(), name="login"),
# path("login/fallback/", views.UserLoginView.as_view(), name="fallback_login"),
path("logout/", views.logout_view, name="logout"),
path(
"user/toggle-amount-visibility/",
views.toggle_amount_visibility,
name="toggle_amount_visibility",
),
]
+29 -4
View File
@@ -1,23 +1,48 @@
from django.contrib import messages
from django.contrib.auth import logout
from django.contrib.auth.decorators import login_required
from django.contrib.auth.views import (
LoginView,
)
from django.http import HttpResponse
from django.shortcuts import redirect, render
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from apps.users.forms import (
LoginForm,
EmailLoginForm,
)
from apps.common.decorators.htmx import only_htmx
def logout_view(request):
logout(request)
return redirect(reverse("inicio"))
return redirect(reverse("login"))
class UserLoginView(LoginView):
form_class = LoginForm
# template_name = "users/login.html"
template_name = "users/login.html"
redirect_authenticated_user = True
@only_htmx
@login_required
def toggle_amount_visibility(request):
current_hide_amounts = request.user.settings.hide_amounts
new_hide_amounts = not current_hide_amounts
request.user.settings.hide_amounts = new_hide_amounts
request.user.settings.save()
if new_hide_amounts is True:
messages.info(request, _("Transaction amounts are now hidden"))
response = HttpResponse(
'<i class="fa-solid fa-eye-slash fa-fw"></i><div id="settings-hide-amounts" class="d-inline tw-invisible"></div>'
)
else:
messages.info(request, _("Transaction amounts are now displayed"))
response = HttpResponse('<i class="fa-solid fa-eye fa-fw"></i>')
response.headers["HX-Trigger"] = "transaction_updated, toast"
return response