feat: add quick transactions

This commit is contained in:
Herculino Trotta
2025-06-20 02:01:09 -03:00
parent 3395a96949
commit d3b354e2b8
17 changed files with 644 additions and 4 deletions

View File

@@ -65,6 +65,18 @@ class SharedObject(models.Model):
super().save(*args, **kwargs)
class OwnedObjectManager(models.Manager):
def get_queryset(self):
"""Return only objects the user can access"""
user = get_current_user()
base_qs = super().get_queryset()
if user and user.is_authenticated:
return base_qs.filter(Q(owner=user) | Q(owner=None)).distinct()
return base_qs
class OwnedObject(models.Model):
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,

View File

@@ -7,6 +7,7 @@ from crispy_forms.layout import (
Column,
Field,
Div,
HTML,
)
from django import forms
from django.db.models import Q
@@ -29,8 +30,8 @@ from apps.transactions.models import (
InstallmentPlan,
RecurringTransaction,
TransactionEntity,
QuickTransaction,
)
from apps.common.middleware.thread_local import get_current_user
class TransactionForm(forms.ModelForm):
@@ -247,6 +248,140 @@ class TransactionForm(forms.ModelForm):
return instance
class QuickTransactionForm(forms.ModelForm):
category = DynamicModelChoiceField(
create_field="name",
model=TransactionCategory,
required=False,
label=_("Category"),
queryset=TransactionCategory.objects.filter(active=True),
)
tags = DynamicModelMultipleChoiceField(
model=TransactionTag,
to_field_name="name",
create_field="name",
required=False,
label=_("Tags"),
queryset=TransactionTag.objects.filter(active=True),
)
entities = DynamicModelMultipleChoiceField(
model=TransactionEntity,
to_field_name="name",
create_field="name",
required=False,
label=_("Entities"),
)
account = forms.ModelChoiceField(
queryset=Account.objects.filter(is_archived=False),
label=_("Account"),
widget=TomSelect(clear_button=False, group_by="group"),
)
class Meta:
model = QuickTransaction
fields = [
"name",
"account",
"type",
"is_paid",
"amount",
"description",
"notes",
"category",
"tags",
"entities",
]
widgets = {
"notes": forms.Textarea(attrs={"rows": 3}),
"account": TomSelect(clear_button=False, group_by="group"),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# if editing a transaction display non-archived items and it's own item even if it's archived
if self.instance.id:
self.fields["account"].queryset = Account.objects.filter(
Q(is_archived=False) | Q(transactions=self.instance.id),
)
self.fields["category"].queryset = TransactionCategory.objects.filter(
Q(active=True) | Q(transaction=self.instance.id)
)
self.fields["tags"].queryset = TransactionTag.objects.filter(
Q(active=True) | Q(transaction=self.instance.id)
)
self.fields["entities"].queryset = TransactionEntity.objects.filter(
Q(active=True) | Q(transactions=self.instance.id)
)
else:
self.fields["account"].queryset = Account.objects.filter(
is_archived=False,
)
self.fields["category"].queryset = TransactionCategory.objects.filter(
active=True
)
self.fields["tags"].queryset = TransactionTag.objects.filter(active=True)
self.fields["entities"].queryset = TransactionEntity.objects.all()
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",
),
Field("is_paid", template="transactions/widgets/paid_toggle_button.html"),
"name",
HTML("<hr />"),
Row(
Column("account", css_class="form-group col-md-6 mb-0"),
Column("entities", css_class="form-group col-md-6 mb-0"),
css_class="form-row",
),
Row(
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("amount", inputmode="decimal"),
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",
)
if self.instance and self.instance.pk:
decimal_places = self.instance.account.currency.decimal_places
self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput(
decimal_places=decimal_places
)
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
),
),
)
else:
self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput()
self.helper.layout.append(
Div(
NoClassSubmit(
"submit", _("Add"), css_class="btn btn-outline-primary"
),
css_class="d-grid gap-2",
),
)
class BulkEditTransactionForm(TransactionForm):
is_paid = forms.NullBooleanField(required=False)

View File

@@ -0,0 +1,45 @@
# Generated by Django 5.1.11 on 2025-06-20 03:57
import apps.transactions.validators
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0014_alter_account_options_alter_accountgroup_options'),
('transactions', '0042_alter_transactioncategory_options_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='QuickTransaction',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, verbose_name='Name')),
('type', models.CharField(choices=[('IN', 'Income'), ('EX', 'Expense')], default='EX', max_length=2, verbose_name='Type')),
('is_paid', models.BooleanField(default=True, verbose_name='Paid')),
('amount', models.DecimalField(decimal_places=30, max_digits=42, validators=[apps.transactions.validators.validate_non_negative, apps.transactions.validators.validate_decimal_places], verbose_name='Amount')),
('description', models.CharField(blank=True, max_length=500, verbose_name='Description')),
('notes', models.TextField(blank=True, verbose_name='Notes')),
('internal_note', models.TextField(blank=True, verbose_name='Internal Note')),
('internal_id', models.TextField(blank=True, null=True, unique=True, verbose_name='Internal ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='quick_transactions', to='accounts.account', verbose_name='Account')),
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='transactions.transactioncategory', verbose_name='Category')),
('entities', models.ManyToManyField(blank=True, related_name='quick_transactions', to='transactions.transactionentity', verbose_name='Entities')),
('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL)),
('tags', models.ManyToManyField(blank=True, to='transactions.transactiontag', verbose_name='Tags')),
],
options={
'verbose_name': 'Quick Transaction',
'verbose_name_plural': 'Quick Transactions',
'db_table': 'quick_transactions',
'default_manager_name': 'objects',
},
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 5.1.11 on 2025-06-20 04:02
from django.conf import settings
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('transactions', '0043_quicktransaction'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterUniqueTogether(
name='quicktransaction',
unique_together={('name', 'owner')},
),
]

View File

@@ -16,7 +16,12 @@ from apps.common.templatetags.decimal import localize_number, drop_trailing_zero
from apps.currencies.utils.convert import convert
from apps.transactions.validators import validate_decimal_places, validate_non_negative
from apps.common.middleware.thread_local import get_current_user
from apps.common.models import SharedObject, SharedObjectManager, OwnedObject
from apps.common.models import (
SharedObject,
SharedObjectManager,
OwnedObject,
OwnedObjectManager,
)
logger = logging.getLogger()
@@ -886,3 +891,86 @@ class RecurringTransaction(models.Model):
"""
today = timezone.localdate(timezone.now())
self.transactions.filter(is_paid=False, date__gt=today).delete()
class QuickTransaction(OwnedObject):
class Type(models.TextChoices):
INCOME = "IN", _("Income")
EXPENSE = "EX", _("Expense")
name = models.CharField(
max_length=100,
null=False,
blank=False,
verbose_name=_("Name"),
)
account = models.ForeignKey(
"accounts.Account",
on_delete=models.CASCADE,
verbose_name=_("Account"),
related_name="quick_transactions",
)
type = models.CharField(
max_length=2,
choices=Type,
default=Type.EXPENSE,
verbose_name=_("Type"),
)
is_paid = models.BooleanField(default=True, verbose_name=_("Paid"))
amount = models.DecimalField(
max_digits=42,
decimal_places=30,
verbose_name=_("Amount"),
validators=[validate_non_negative, validate_decimal_places],
)
description = models.CharField(
max_length=500, verbose_name=_("Description"), blank=True
)
notes = models.TextField(blank=True, verbose_name=_("Notes"))
category = models.ForeignKey(
TransactionCategory,
on_delete=models.SET_NULL,
verbose_name=_("Category"),
blank=True,
null=True,
)
tags = models.ManyToManyField(
TransactionTag,
verbose_name=_("Tags"),
blank=True,
)
entities = models.ManyToManyField(
TransactionEntity,
verbose_name=_("Entities"),
blank=True,
related_name="quick_transactions",
)
internal_note = models.TextField(blank=True, verbose_name=_("Internal Note"))
internal_id = models.TextField(
blank=True, null=True, unique=True, verbose_name=_("Internal ID")
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
objects = OwnedObjectManager()
all_objects = models.Manager() # Unfiltered manager
class Meta:
verbose_name = _("Quick Transaction")
verbose_name_plural = _("Quick Transactions")
unique_together = ("name", "owner")
db_table = "quick_transactions"
default_manager_name = "objects"
def save(self, *args, **kwargs):
self.amount = truncate_decimal(
value=self.amount, decimal_places=self.account.currency.decimal_places
)
self.full_clean()
super().save(*args, **kwargs)

View File

@@ -307,4 +307,39 @@ urlpatterns = [
views.recurring_transaction_finish,
name="recurring_transaction_finish",
),
path(
"quick-transactions/",
views.quick_transactions_index,
name="quick_transactions_index",
),
path(
"quick-transactions/list/",
views.quick_transactions_list,
name="quick_transactions_list",
),
path(
"quick-transactions/add/",
views.quick_transaction_add,
name="quick_transaction_add",
),
path(
"quick-transactions/<int:quick_transaction_id>/edit/",
views.quick_transaction_edit,
name="quick_transaction_edit",
),
path(
"quick-transactions/<int:quick_transaction_id>/delete/",
views.quick_transaction_delete,
name="quick_transaction_delete",
),
path(
"quick-transactions/create-menu/",
views.quick_transactions_create_menu,
name="quick_transactions_create_menu",
),
path(
"quick-transactions/<int:quick_transaction_id>/create/",
views.quick_transaction_add_as_transaction,
name="quick_transaction_add_as_transaction",
),
]

View File

@@ -5,3 +5,4 @@ from .categories import *
from .actions import *
from .installment_plans import *
from .recurring_transactions import *
from .quick_transactions import *

View File

@@ -0,0 +1,152 @@
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.forms import model_to_dict
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.decorators.http import require_http_methods
from apps.common.decorators.htmx import only_htmx
from apps.transactions.forms import QuickTransactionForm
from apps.transactions.models import QuickTransaction
from apps.transactions.models import Transaction
@login_required
@require_http_methods(["GET"])
def quick_transactions_index(request):
return render(
request,
"quick_transactions/pages/index.html",
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def quick_transactions_list(request):
quick_transactions = QuickTransaction.objects.all().order_by("name")
return render(
request,
"quick_transactions/fragments/list.html",
context={"quick_transactions": quick_transactions},
)
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def quick_transaction_add(request):
if request.method == "POST":
form = QuickTransactionForm(request.POST)
if form.is_valid():
form.save()
messages.success(request, _("Item added successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
else:
form = QuickTransactionForm()
return render(
request,
"quick_transactions/fragments/add.html",
{"form": form},
)
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def quick_transaction_edit(request, quick_transaction_id):
quick_transaction = get_object_or_404(QuickTransaction, id=quick_transaction_id)
if request.method == "POST":
form = QuickTransactionForm(request.POST, instance=quick_transaction)
if form.is_valid():
form.save()
messages.success(request, _("Item updated successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
else:
form = QuickTransactionForm(instance=quick_transaction)
return render(
request,
"quick_transactions/fragments/edit.html",
{"form": form, "quick_transaction": quick_transaction},
)
@only_htmx
@login_required
@require_http_methods(["DELETE"])
def quick_transaction_delete(request, quick_transaction_id):
quick_transaction = get_object_or_404(QuickTransaction, id=quick_transaction_id)
quick_transaction.delete()
messages.success(request, _("Item deleted successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def quick_transactions_create_menu(request):
quick_transactions = QuickTransaction.objects.all().order_by("name")
return render(
request,
"quick_transactions/fragments/create_menu.html",
context={"quick_transactions": quick_transactions},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def quick_transaction_add_as_transaction(request, quick_transaction_id):
quick_transaction: QuickTransaction = get_object_or_404(
QuickTransaction, id=quick_transaction_id
)
today = timezone.localdate(timezone.now())
quick_transaction_data = model_to_dict(
quick_transaction,
exclude=["id", "name", "owner", "account", "category", "tags", "entities"],
)
new_transaction = Transaction(**quick_transaction_data)
new_transaction.account = quick_transaction.account
new_transaction.category = quick_transaction.category
new_transaction.date = today
new_transaction.reference_date = today.replace(day=1)
new_transaction.save()
new_transaction.tags.set(quick_transaction.tags.all())
new_transaction.entities.set(quick_transaction.entities.all())
messages.success(request, _("Transaction added successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)

File diff suppressed because one or more lines are too long

View File

@@ -15,7 +15,8 @@
_="on mouseover remove .tw-invisible from the first .transaction-actions in me end
on mouseout add .tw-invisible to the first .transaction-actions in me end">
<div class="row font-monospace tw-text-sm align-items-center">
<div class="col-lg-auto col-12 d-flex align-items-center tw-text-2xl lg:tw-text-xl text-lg-center text-center p-0 ps-1">
<div
class="col-lg-auto col-12 d-flex align-items-center tw-text-2xl lg:tw-text-xl text-lg-center text-center p-0 ps-1">
{% if not transaction.deleted %}
<a class="text-decoration-none p-3 tw-text-gray-500"
title="{% if transaction.is_paid %}{% trans 'Paid' %}{% else %}{% trans 'Projected' %}{% endif %}"

View File

@@ -50,4 +50,11 @@
url="{% url 'account_reconciliation' %}"
icon="fa-solid fa-scale-balanced"
title="{% translate "Balance" %}"></c-components.fab_menu_button>
<c-components.fab_menu_button
color="secondary"
hx_target="#generic-offcanvas"
hx_trigger="click, quick_transaction from:window"
url="{% url 'quick_transactions_create_menu' %}"
icon="fa-solid fa-person-running"
title="{% translate "Quick Transaction" %}"></c-components.fab_menu_button>
</c-components.fab>

View File

@@ -50,7 +50,7 @@
<a class="nav-link {% active_link views='insights_index' %}" href="{% url 'insights_index' %}">{% trans 'Insights' %}</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {% active_link views='installment_plans_index||recurring_trasanctions_index||transactions_all_index||transactions_trash_index' %}"
<a class="nav-link dropdown-toggle {% active_link views='installment_plans_index||quick_transactions_index||recurring_trasanctions_index||transactions_all_index||transactions_trash_index' %}"
href="#" role="button"
data-bs-toggle="dropdown"
aria-expanded="false">
@@ -68,6 +68,8 @@
{% endif %}
<hr class="dropdown-divider">
</li>
<li><a class="dropdown-item {% active_link views='quick_transactions_index' %}"
href="{% url 'quick_transactions_index' %}">{% translate 'Quick Transactions' %}</a></li>
<li><a class="dropdown-item {% active_link views='installment_plans_index' %}"
href="{% url 'installment_plans_index' %}">{% translate 'Installment Plans' %}</a></li>
<li><a class="dropdown-item {% active_link views='recurring_trasanctions_index' %}"

View File

@@ -0,0 +1,11 @@
{% extends 'extends/offcanvas.html' %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% translate 'Add quick transaction' %}{% endblock %}
{% block body %}
<form hx-post="{% url 'quick_transaction_add' %}" hx-target="#generic-offcanvas" novalidate>
{% crispy form %}
</form>
{% endblock %}

View File

@@ -0,0 +1,17 @@
{% extends 'extends/offcanvas.html' %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% translate 'Add quick transaction' %}{% endblock %}
{% block body %}
<div class="list-group list-group-flush">
{% for qt in quick_transactions %}
<a hx-get="{% url 'quick_transaction_add_as_transaction' quick_transaction_id=qt.id %}"
class="list-group-item list-group-item-action tw-cursor-pointer {% if qt.type == 'EX' %}!tw-text-red-400{% else %}!tw-text-green-400{% endif %}">{{ qt.name }}</a>
{% empty %}
<c-msg.empty title="{% translate "Nothing to see here..." %}" remove-padding></c-msg.empty>
{% endfor %}
</div>
{% endblock %}

View File

@@ -0,0 +1,13 @@
{% extends 'extends/offcanvas.html' %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% translate 'Edit quick transaction' %}{% endblock %}
{% block body %}
<form hx-post="{% url 'quick_transaction_edit' quick_transaction_id=quick_transaction.id %}"
hx-target="#generic-offcanvas"
novalidate>
{% crispy form %}
</form>
{% endblock %}

View File

@@ -0,0 +1,59 @@
{% load i18n %}
<div class="card">
<div class="card-body">
<div id="quick-transactions-table">
{% if quick_transactions %}
<c-config.search></c-config.search>
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th scope="col" class="col-auto"></th>
<th scope="col" class="col">{% translate 'Name' %}</th>
</tr>
</thead>
<tbody>
{% for qt in quick_transactions %}
<tr class="recurring_transaction">
<td class="col-auto text-center">
<div class="btn-group" role="group" aria-label="{% translate 'Actions' %}">
<a class="btn btn-secondary btn-sm"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Edit" %}"
hx-get="{% url 'quick_transaction_edit' quick_transaction_id=qt.id %}"
hx-swap="innerHTML"
hx-target="#generic-offcanvas">
<i class="fa-solid fa-pencil fa-fw"></i></a>
<a class="btn btn-secondary btn-sm text-danger"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Delete" %}"
hx-delete="{% url 'quick_transaction_delete' quick_transaction_id=qt.id %}"
hx-trigger='confirmed'
hx-swap="innerHTML"
data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}"
data-text="{% translate "This will delete this item" %}"
data-confirm-text="{% translate "Yes, delete it!" %}"
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw"></i></a>
</div>
</td>
<td class="col">
<div
class="{% if qt.type == 'EX' %}tw-text-red-400{% else %}tw-text-green-400{% endif %}">
{{ qt.name }}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<c-msg.empty title="{% translate "Nothing to see here..." %}" remove-padding></c-msg.empty>
{% endif %}
</div>
</div>
</div>

View File

@@ -0,0 +1,25 @@
{% extends "layouts/base.html" %}
{% load i18n %}
{% block title %}{% translate 'Quick Transactions' %}{% endblock %}
{% block content %}
<div class="container px-md-3 py-3 column-gap-5">
<div class="tw-text-3xl fw-bold font-monospace tw-w-full mb-3">
{% spaceless %}
<div>{% translate 'Quick Transactions' %}<span>
<a class="text-decoration-none tw-text-2xl p-1 category-action"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Add" %}"
hx-get="{% url 'quick_transaction_add' %}"
hx-target="#generic-offcanvas">
<i class="fa-solid fa-circle-plus fa-fw"></i></a>
</span></div>
{% endspaceless %}
</div>
<div id="quick-transactions-table" class="show-loading" hx-get="{% url 'quick_transactions_list' %}" hx-trigger="load, updated from:window"></div>
</div>
{% endblock %}