Merge branch 'eitchtee-main'

This commit is contained in:
Dimitri Decrock
2025-01-25 19:29:07 +01:00
43 changed files with 12667 additions and 503 deletions

View File

@@ -163,7 +163,7 @@ AUTH_USER_MODEL = "users.User"
LANGUAGE_CODE = "en" LANGUAGE_CODE = "en"
LANGUAGES = ( LANGUAGES = (
("en", "English"), ("en", "English"),
("nl", "Nederlands"), # ("nl", "Nederlands"),
("pt-br", "Português (Brasil)"), ("pt-br", "Português (Brasil)"),
) )
@@ -363,7 +363,13 @@ PWA_APP_SPLASH_SCREEN = [
] ]
PWA_APP_DIR = "ltr" PWA_APP_DIR = "ltr"
PWA_APP_LANG = "en-US" PWA_APP_LANG = "en-US"
PWA_APP_SHORTCUTS = [] PWA_APP_SHORTCUTS = [
{
"name": "New Transaction",
"url": "/add/",
"description": "Add new transaction",
}
]
PWA_APP_SCREENSHOTS = [ PWA_APP_SCREENSHOTS = [
{ {
"src": "/static/img/pwa/splash-750x1334.png", "src": "/static/img/pwa/splash-750x1334.png",

View File

@@ -2,9 +2,7 @@ from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404 from django.shortcuts import render, get_object_or_404
from django.urls import reverse
from django.utils.translation import gettext_lazy as _ 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 django.views.decorators.http import require_http_methods
from apps.accounts.forms import AccountGroupForm from apps.accounts.forms import AccountGroupForm
@@ -89,7 +87,6 @@ def account_group_edit(request, pk):
@only_htmx @only_htmx
@login_required @login_required
@csrf_exempt
@require_http_methods(["DELETE"]) @require_http_methods(["DELETE"])
def account_group_delete(request, pk): def account_group_delete(request, pk):
account_group = get_object_or_404(AccountGroup, id=pk) account_group = get_object_or_404(AccountGroup, id=pk)

View File

@@ -2,9 +2,7 @@ from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404 from django.shortcuts import render, get_object_or_404
from django.urls import reverse
from django.utils.translation import gettext_lazy as _ 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 django.views.decorators.http import require_http_methods
from apps.accounts.forms import AccountForm from apps.accounts.forms import AccountForm
@@ -89,7 +87,6 @@ def account_edit(request, pk):
@only_htmx @only_htmx
@login_required @login_required
@csrf_exempt
@require_http_methods(["DELETE"]) @require_http_methods(["DELETE"])
def account_delete(request, pk): def account_delete(request, pk):
account = get_object_or_404(Account, id=pk) account = get_object_or_404(Account, id=pk)

View File

@@ -2,9 +2,7 @@ from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404 from django.shortcuts import render, get_object_or_404
from django.urls import reverse
from django.utils.translation import gettext_lazy as _ 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 django.views.decorators.http import require_http_methods
from apps.common.decorators.htmx import only_htmx from apps.common.decorators.htmx import only_htmx
@@ -89,7 +87,6 @@ def currency_edit(request, pk):
@only_htmx @only_htmx
@login_required @login_required
@csrf_exempt
@require_http_methods(["DELETE"]) @require_http_methods(["DELETE"])
def currency_delete(request, pk): def currency_delete(request, pk):
currency = get_object_or_404(Currency, id=pk) currency = get_object_or_404(Currency, id=pk)

View File

@@ -1,12 +1,11 @@
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.db.models import F, CharField, Value from django.db.models import CharField, Value
from django.db.models.functions import Concat from django.db.models.functions import Concat
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404 from django.shortcuts import render, get_object_or_404
from django.utils.translation import gettext_lazy as _ 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 django.views.decorators.http import require_http_methods
from apps.common.decorators.htmx import only_htmx from apps.common.decorators.htmx import only_htmx
@@ -135,7 +134,6 @@ def exchange_rate_edit(request, pk):
@only_htmx @only_htmx
@login_required @login_required
@csrf_exempt
@require_http_methods(["DELETE"]) @require_http_methods(["DELETE"])
def exchange_rate_delete(request, pk): def exchange_rate_delete(request, pk):
exchange_rate = get_object_or_404(ExchangeRate, id=pk) exchange_rate = get_object_or_404(ExchangeRate, id=pk)

View File

@@ -6,12 +6,11 @@ from django.db.models.functions import TruncMonth
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404 from django.shortcuts import render, get_object_or_404
from django.utils.translation import gettext_lazy as _ 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 django.views.decorators.http import require_http_methods
from apps.common.decorators.htmx import only_htmx from apps.common.decorators.htmx import only_htmx
from apps.dca.models import DCAStrategy, DCAEntry
from apps.dca.forms import DCAEntryForm, DCAStrategyForm from apps.dca.forms import DCAEntryForm, DCAStrategyForm
from apps.dca.models import DCAStrategy, DCAEntry
@login_required @login_required
@@ -82,7 +81,6 @@ def strategy_edit(request, strategy_id):
@only_htmx @only_htmx
@login_required @login_required
@csrf_exempt
@require_http_methods(["DELETE"]) @require_http_methods(["DELETE"])
def strategy_delete(request, strategy_id): def strategy_delete(request, strategy_id):
dca_strategy = get_object_or_404(DCAStrategy, id=strategy_id) dca_strategy = get_object_or_404(DCAStrategy, id=strategy_id)
@@ -209,7 +207,6 @@ def strategy_entry_edit(request, strategy_id, entry_id):
@only_htmx @only_htmx
@login_required @login_required
@csrf_exempt
@require_http_methods(["DELETE"]) @require_http_methods(["DELETE"])
def strategy_entry_delete(request, entry_id, strategy_id): def strategy_entry_delete(request, entry_id, strategy_id):
dca_entry = get_object_or_404(DCAEntry, id=entry_id, strategy__id=strategy_id) dca_entry = get_object_or_404(DCAEntry, id=entry_id, strategy__id=strategy_id)

View File

@@ -9,7 +9,7 @@ from apps.import_app.schemas import version_1
class ImportProfile(models.Model): class ImportProfile(models.Model):
class Versions(models.IntegerChoices): class Versions(models.IntegerChoices):
VERSION_1 = 1, _("Version") + " 1" VERSION_1 = 1, "Version 1"
name = models.CharField(max_length=100, verbose_name=_("Name"), unique=True) name = models.CharField(max_length=100, verbose_name=_("Name"), unique=True)
yaml_config = models.TextField(verbose_name=_("YAML Configuration")) yaml_config = models.TextField(verbose_name=_("YAML Configuration"))
@@ -25,6 +25,10 @@ class ImportProfile(models.Model):
class Meta: class Meta:
ordering = ["name"] ordering = ["name"]
def get_version_display(self):
version_number = self.Versions(self.version).name.split("_")[1]
return _("Version {number}").format(number=version_number)
def clean(self): def clean(self):
if self.version and self.version == self.Versions.VERSION_1: if self.version and self.version == self.Versions.VERSION_1:
try: try:

View File

@@ -5,15 +5,14 @@ from django.contrib.auth.decorators import login_required
from django.core.files.storage import FileSystemStorage from django.core.files.storage import FileSystemStorage
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404 from django.shortcuts import render, get_object_or_404
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from django.utils.translation import gettext_lazy as _ 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.common.decorators.htmx import only_htmx
from apps.import_app.forms import ImportRunFileUploadForm, ImportProfileForm from apps.import_app.forms import ImportRunFileUploadForm, ImportProfileForm
from apps.import_app.models import ImportRun, ImportProfile from apps.import_app.models import ImportRun, ImportProfile
from apps.import_app.tasks import process_import
from apps.import_app.services import PresetService from apps.import_app.services import PresetService
from apps.import_app.tasks import process_import
def import_view(request): def import_view(request):
@@ -66,9 +65,9 @@ def import_profile_list(request):
@login_required @login_required
@require_http_methods(["GET", "POST"]) @require_http_methods(["GET", "POST"])
def import_profile_add(request): def import_profile_add(request):
message = request.GET.get("message", None) or request.POST.get("message", None) message = request.POST.get("message", None)
if request.method == "POST": if request.method == "POST" and request.POST.get("submit"):
form = ImportProfileForm(request.POST) form = ImportProfileForm(request.POST)
if form.is_valid(): if form.is_valid():
@@ -84,9 +83,9 @@ def import_profile_add(request):
else: else:
form = ImportProfileForm( form = ImportProfileForm(
initial={ initial={
"name": request.GET.get("name"), "name": request.POST.get("name"),
"version": int(request.GET.get("version", 1)), "version": int(request.POST.get("version", 1)),
"yaml_config": request.GET.get("yaml_config"), "yaml_config": request.POST.get("yaml_config"),
} }
) )
@@ -128,7 +127,6 @@ def import_profile_edit(request, profile_id):
@only_htmx @only_htmx
@login_required @login_required
@csrf_exempt
@require_http_methods(["DELETE"]) @require_http_methods(["DELETE"])
def import_profile_delete(request, profile_id): def import_profile_delete(request, profile_id):
profile = ImportProfile.objects.get(id=profile_id) profile = ImportProfile.objects.get(id=profile_id)
@@ -213,7 +211,6 @@ def import_run_add(request, profile_id):
@only_htmx @only_htmx
@login_required @login_required
@csrf_exempt
@require_http_methods(["DELETE"]) @require_http_methods(["DELETE"])
def import_run_delete(request, profile_id, run_id): def import_run_delete(request, profile_id, run_id):
run = ImportRun.objects.get(profile__id=profile_id, id=run_id) run = ImportRun.objects.get(profile__id=profile_id, id=run_id)

View File

@@ -52,19 +52,4 @@ urlpatterns = [
views.transaction_rule_action_delete, views.transaction_rule_action_delete,
name="transaction_rule_action_delete", name="transaction_rule_action_delete",
), ),
# path(
# "rules/<int:installment_plan_id>/transactions/",
# views.installment_plan_transactions,
# name="rule_view",
# ),
# path(
# "rules/<int:installment_plan_id>/edit/",
# views.installment_plan_edit,
# name="rule_edit",
# ),
# path(
# "rules/<int:installment_plan_id>/delete/",
# views.installment_plan_delete,
# name="rule_delete",
# ),
] ]

View File

@@ -3,7 +3,6 @@ from django.contrib.auth.decorators import login_required
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404, redirect from django.shortcuts import render, get_object_or_404, redirect
from django.utils.translation import gettext_lazy as _ 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 django.views.decorators.http import require_http_methods
from apps.common.decorators.htmx import only_htmx from apps.common.decorators.htmx import only_htmx
@@ -118,7 +117,6 @@ def transaction_rule_view(request, transaction_rule_id):
@only_htmx @only_htmx
@login_required @login_required
@csrf_exempt
@require_http_methods(["DELETE"]) @require_http_methods(["DELETE"])
def transaction_rule_delete(request, transaction_rule_id): def transaction_rule_delete(request, transaction_rule_id):
transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id) transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id)
@@ -201,7 +199,6 @@ def transaction_rule_action_edit(request, transaction_rule_action_id):
@only_htmx @only_htmx
@login_required @login_required
@csrf_exempt
@require_http_methods(["DELETE"]) @require_http_methods(["DELETE"])
def transaction_rule_action_delete(request, transaction_rule_action_id): def transaction_rule_action_delete(request, transaction_rule_action_id):
transaction_rule_action = get_object_or_404( transaction_rule_action = get_object_or_404(

View File

@@ -1,5 +1,5 @@
from crispy_bootstrap5.bootstrap5 import Switch from crispy_bootstrap5.bootstrap5 import Switch, BS5Accordion
from crispy_forms.bootstrap import FormActions from crispy_forms.bootstrap import FormActions, AccordionGroup
from crispy_forms.helper import FormHelper from crispy_forms.helper import FormHelper
from crispy_forms.layout import ( from crispy_forms.layout import (
Layout, Layout,
@@ -115,7 +115,7 @@ class TransactionForm(forms.ModelForm):
"type", "type",
template="transactions/widgets/income_expense_toggle_buttons.html", template="transactions/widgets/income_expense_toggle_buttons.html",
), ),
Switch("is_paid"), Field("is_paid", template="transactions/widgets/paid_toggle_button.html"),
Row( Row(
Column("account", css_class="form-group col-md-6 mb-0"), Column("account", css_class="form-group col-md-6 mb-0"),
Column("entities", css_class="form-group col-md-6 mb-0"), Column("entities", css_class="form-group col-md-6 mb-0"),
@@ -136,6 +136,46 @@ class TransactionForm(forms.ModelForm):
"notes", "notes",
) )
self.helper_simple = FormHelper()
self.helper_simple.form_tag = False
self.helper_simple.form_method = "post"
self.helper_simple.layout = Layout(
Field(
"type",
template="transactions/widgets/income_expense_toggle_buttons.html",
),
Field("is_paid", template="transactions/widgets/paid_toggle_button.html"),
"account",
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"),
BS5Accordion(
AccordionGroup(
_("More"),
"entities",
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",
active=False,
),
flush=False,
always_open=False,
css_class="mb-3",
),
FormActions(
NoClassSubmit(
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
),
),
)
self.fields["reference_date"].required = False self.fields["reference_date"].required = False
self.fields["date"].widget = AirDatePickerInput(clear_button=False, user=user) self.fields["date"].widget = AirDatePickerInput(clear_button=False, user=user)
@@ -183,6 +223,43 @@ class TransactionForm(forms.ModelForm):
return instance return instance
class BulkEditTransactionForm(TransactionForm):
is_paid = forms.NullBooleanField(required=False)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Make all fields optional
for field_name, field in self.fields.items():
field.required = False
del self.helper.layout[-1] # Remove button
del self.helper.layout[0:2] # Remove type, is_paid field
self.helper.layout.insert(
0,
Field(
"type",
template="transactions/widgets/unselectable_income_expense_toggle_buttons.html",
),
)
self.helper.layout.insert(
1,
Field(
"is_paid",
template="transactions/widgets/unselectable_paid_toggle_button.html",
),
)
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
),
),
)
class TransferForm(forms.Form): class TransferForm(forms.Form):
from_account = forms.ModelChoiceField( from_account = forms.ModelChoiceField(
queryset=Account.objects.filter(is_archived=False), queryset=Account.objects.filter(is_archived=False),

View File

@@ -12,7 +12,7 @@ urlpatterns = [
name="transactions_all_summary", name="transactions_all_summary",
), ),
path( path(
"transactions/actions/pay", "transactions/actions/pay/",
views.bulk_pay_transactions, views.bulk_pay_transactions,
name="transactions_bulk_pay", name="transactions_bulk_pay",
), ),
@@ -27,32 +27,47 @@ urlpatterns = [
name="transactions_bulk_delete", name="transactions_bulk_delete",
), ),
path( path(
"transaction/<int:transaction_id>/pay", "transactions/actions/duplicate/",
views.bulk_clone_transactions,
name="transactions_bulk_clone",
),
path(
"transaction/<int:transaction_id>/pay/",
views.transaction_pay, views.transaction_pay,
name="transaction_pay", name="transaction_pay",
), ),
path( path(
"transaction/<int:transaction_id>/delete", "transaction/<int:transaction_id>/delete/",
views.transaction_delete, views.transaction_delete,
name="transaction_delete", name="transaction_delete",
), ),
path( path(
"transaction/<int:transaction_id>/edit", "transaction/<int:transaction_id>/edit/",
views.transaction_edit, views.transaction_edit,
name="transaction_edit", name="transaction_edit",
), ),
path( path(
"transaction/<int:transaction_id>/clone", "transactions/bulk-edit/",
views.transactions_bulk_edit,
name="transactions_bulk_edit",
),
path(
"transaction/<int:transaction_id>/clone/",
views.transaction_clone, views.transaction_clone,
name="transaction_clone", name="transaction_clone",
), ),
path( path(
"transaction/add", "transaction/add/",
views.transaction_add, views.transaction_add,
name="transaction_add", name="transaction_add",
), ),
path( path(
"transactions/transfer", "add/",
views.transaction_simple_add,
name="transaction_simple_add",
),
path(
"transactions/transfer/",
views.transactions_transfer, views.transactions_transfer,
name="transactions_transfer", name="transactions_transfer",
), ),

View File

@@ -1,5 +1,9 @@
from copy import deepcopy
from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.http import HttpResponse from django.http import HttpResponse
from django.utils.translation import gettext_lazy as _, ngettext_lazy
from apps.common.decorators.htmx import only_htmx from apps.common.decorators.htmx import only_htmx
from apps.transactions.models import Transaction from apps.transactions.models import Transaction
@@ -9,7 +13,19 @@ from apps.transactions.models import Transaction
@login_required @login_required
def bulk_pay_transactions(request): def bulk_pay_transactions(request):
selected_transactions = request.GET.getlist("transactions", []) selected_transactions = request.GET.getlist("transactions", [])
Transaction.objects.filter(id__in=selected_transactions).update(is_paid=True) transactions = Transaction.objects.filter(id__in=selected_transactions)
count = transactions.count()
transactions.update(is_paid=True)
messages.success(
request,
ngettext_lazy(
"%(count)s transaction marked as paid",
"%(count)s transactions marked as paid",
count,
)
% {"count": count},
)
return HttpResponse( return HttpResponse(
status=204, status=204,
@@ -21,7 +37,19 @@ def bulk_pay_transactions(request):
@login_required @login_required
def bulk_unpay_transactions(request): def bulk_unpay_transactions(request):
selected_transactions = request.GET.getlist("transactions", []) selected_transactions = request.GET.getlist("transactions", [])
Transaction.objects.filter(id__in=selected_transactions).update(is_paid=False) transactions = Transaction.objects.filter(id__in=selected_transactions)
count = transactions.count()
transactions.update(is_paid=False)
messages.success(
request,
ngettext_lazy(
"%(count)s transaction marked as not paid",
"%(count)s transactions marked as not paid",
count,
)
% {"count": count},
)
return HttpResponse( return HttpResponse(
status=204, status=204,
@@ -33,7 +61,54 @@ def bulk_unpay_transactions(request):
@login_required @login_required
def bulk_delete_transactions(request): def bulk_delete_transactions(request):
selected_transactions = request.GET.getlist("transactions", []) selected_transactions = request.GET.getlist("transactions", [])
Transaction.objects.filter(id__in=selected_transactions).delete() transactions = Transaction.objects.filter(id__in=selected_transactions)
count = transactions.count()
transactions.delete()
messages.success(
request,
ngettext_lazy(
"%(count)s transaction deleted successfully",
"%(count)s transactions deleted successfully",
count,
)
% {"count": count},
)
return HttpResponse(
status=204,
headers={"HX-Trigger": "updated"},
)
@only_htmx
@login_required
def bulk_clone_transactions(request):
selected_transactions = request.GET.getlist("transactions", [])
transactions = Transaction.objects.filter(id__in=selected_transactions)
count = transactions.count()
for transaction in transactions:
new_transaction = deepcopy(transaction)
new_transaction.pk = None
new_transaction.installment_plan = None
new_transaction.installment_id = None
new_transaction.recurring_transaction = None
new_transaction.internal_id = None
new_transaction.save()
new_transaction.tags.add(*transaction.tags.all())
new_transaction.entities.add(*transaction.entities.all())
messages.success(
request,
ngettext_lazy(
"%(count)s transaction duplicated successfully",
"%(count)s transactions duplicated successfully",
count,
)
% {"count": count},
)
return HttpResponse( return HttpResponse(
status=204, status=204,

View File

@@ -2,9 +2,7 @@ from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404 from django.shortcuts import render, get_object_or_404
from django.urls import reverse
from django.utils.translation import gettext_lazy as _ 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 django.views.decorators.http import require_http_methods
from apps.common.decorators.htmx import only_htmx from apps.common.decorators.htmx import only_htmx
@@ -111,7 +109,6 @@ def category_edit(request, category_id):
@only_htmx @only_htmx
@login_required @login_required
@csrf_exempt
@require_http_methods(["DELETE"]) @require_http_methods(["DELETE"])
def category_delete(request, category_id): def category_delete(request, category_id):
category = get_object_or_404(TransactionCategory, id=category_id) category = get_object_or_404(TransactionCategory, id=category_id)

View File

@@ -3,7 +3,6 @@ from django.contrib.auth.decorators import login_required
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404 from django.shortcuts import render, get_object_or_404
from django.utils.translation import gettext_lazy as _ 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 django.views.decorators.http import require_http_methods
from apps.common.decorators.htmx import only_htmx from apps.common.decorators.htmx import only_htmx
@@ -110,7 +109,6 @@ def entity_edit(request, entity_id):
@only_htmx @only_htmx
@login_required @login_required
@csrf_exempt
@require_http_methods(["DELETE"]) @require_http_methods(["DELETE"])
def entity_delete(request, entity_id): def entity_delete(request, entity_id):
entity = get_object_or_404(TransactionEntity, id=entity_id) entity = get_object_or_404(TransactionEntity, id=entity_id)

View File

@@ -4,7 +4,6 @@ from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404 from django.shortcuts import render, get_object_or_404
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ 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 django.views.decorators.http import require_http_methods
from apps.common.decorators.htmx import only_htmx from apps.common.decorators.htmx import only_htmx
@@ -152,7 +151,6 @@ def installment_plan_refresh(request, installment_plan_id):
@only_htmx @only_htmx
@login_required @login_required
@csrf_exempt
@require_http_methods(["DELETE"]) @require_http_methods(["DELETE"])
def installment_plan_delete(request, installment_plan_id): def installment_plan_delete(request, installment_plan_id):
installment_plan = get_object_or_404(InstallmentPlan, id=installment_plan_id) installment_plan = get_object_or_404(InstallmentPlan, id=installment_plan_id)

View File

@@ -1,5 +1,4 @@
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.db.models import Q from django.db.models import Q
@@ -7,7 +6,6 @@ from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404 from django.shortcuts import render, get_object_or_404
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ 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 django.views.decorators.http import require_http_methods
from apps.common.decorators.htmx import only_htmx from apps.common.decorators.htmx import only_htmx
@@ -230,7 +228,6 @@ def recurring_transaction_finish(request, recurring_transaction_id):
@only_htmx @only_htmx
@login_required @login_required
@csrf_exempt
@require_http_methods(["DELETE"]) @require_http_methods(["DELETE"])
def recurring_transaction_delete(request, recurring_transaction_id): def recurring_transaction_delete(request, recurring_transaction_id):
recurring_transaction = get_object_or_404( recurring_transaction = get_object_or_404(

View File

@@ -3,7 +3,6 @@ from django.contrib.auth.decorators import login_required
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404 from django.shortcuts import render, get_object_or_404
from django.utils.translation import gettext_lazy as _ 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 django.views.decorators.http import require_http_methods
from apps.common.decorators.htmx import only_htmx from apps.common.decorators.htmx import only_htmx
@@ -110,7 +109,6 @@ def tag_edit(request, tag_id):
@only_htmx @only_htmx
@login_required @login_required
@csrf_exempt
@require_http_methods(["DELETE"]) @require_http_methods(["DELETE"])
def tag_delete(request, tag_id): def tag_delete(request, tag_id):
tag = get_object_or_404(TransactionTag, id=tag_id) tag = get_object_or_404(TransactionTag, id=tag_id)

View File

@@ -7,15 +7,18 @@ from django.core.paginator import Paginator
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404 from django.shortcuts import render, get_object_or_404
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _, ngettext_lazy
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods from django.views.decorators.http import require_http_methods
from apps.common.decorators.htmx import only_htmx from apps.common.decorators.htmx import only_htmx
from apps.common.utils.dicts import remove_falsey_entries from apps.common.utils.dicts import remove_falsey_entries
from apps.rules.signals import transaction_created from apps.rules.signals import transaction_created, transaction_updated
from apps.transactions.filters import TransactionsFilter from apps.transactions.filters import TransactionsFilter
from apps.transactions.forms import TransactionForm, TransferForm from apps.transactions.forms import (
TransactionForm,
TransferForm,
BulkEditTransactionForm,
)
from apps.transactions.models import Transaction from apps.transactions.models import Transaction
from apps.transactions.utils.calculations import ( from apps.transactions.utils.calculations import (
calculate_currency_totals, calculate_currency_totals,
@@ -66,6 +69,50 @@ def transaction_add(request):
) )
@login_required
@require_http_methods(["GET", "POST"])
def transaction_simple_add(request):
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, user=request.user)
if form.is_valid():
form.save()
messages.success(request, _("Transaction added successfully"))
form = TransactionForm(
user=request.user,
initial={
"date": expected_date,
"type": transaction_type,
},
)
else:
form = TransactionForm(
user=request.user,
initial={
"date": expected_date,
"type": transaction_type,
},
)
return render(
request,
"transactions/pages/add.html",
{"form": form},
)
@only_htmx @only_htmx
@login_required @login_required
@require_http_methods(["GET", "POST"]) @require_http_methods(["GET", "POST"])
@@ -92,6 +139,62 @@ def transaction_edit(request, transaction_id, **kwargs):
) )
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def transactions_bulk_edit(request):
# Get selected transaction IDs from the URL parameter
transaction_ids = request.GET.getlist("transactions") or request.POST.getlist(
"transactions"
)
# Load the selected transactions
transactions = Transaction.objects.filter(id__in=transaction_ids)
count = transactions.count()
if request.method == "POST":
form = BulkEditTransactionForm(request.POST, user=request.user)
if form.is_valid():
# Apply changes from the form to all selected transactions
for transaction in transactions:
for field_name, value in form.cleaned_data.items():
if value or isinstance(
value, bool
): # Only update fields that have been filled in the form
if field_name == "tags":
transaction.tags.set(value)
elif field_name == "entities":
transaction.entities.set(value)
else:
setattr(transaction, field_name, value)
transaction.save()
transaction_updated.send(sender=transaction)
messages.success(
request,
ngettext_lazy(
"%(count)s transaction updated successfully",
"%(count)s transactions updated successfully",
count,
)
% {"count": count},
)
return HttpResponse(
status=204,
headers={"HX-Trigger": "updated, hide_offcanvas"},
)
else:
form = BulkEditTransactionForm(
initial={"is_paid": None, "type": None}, user=request.user
)
context = {
"form": form,
"transactions": transactions,
}
return render(request, "transactions/fragments/bulk_edit.html", context)
@only_htmx @only_htmx
@login_required @login_required
@require_http_methods(["GET", "POST"]) @require_http_methods(["GET", "POST"])
@@ -102,6 +205,7 @@ def transaction_clone(request, transaction_id, **kwargs):
new_transaction.installment_plan = None new_transaction.installment_plan = None
new_transaction.installment_id = None new_transaction.installment_id = None
new_transaction.recurring_transaction = None new_transaction.recurring_transaction = None
new_transaction.internal_id = None
new_transaction.save() new_transaction.save()
new_transaction.tags.add(*transaction.tags.all()) new_transaction.tags.add(*transaction.tags.all())
@@ -143,7 +247,6 @@ def transaction_clone(request, transaction_id, **kwargs):
@only_htmx @only_htmx
@login_required @login_required
@csrf_exempt
@require_http_methods(["DELETE"]) @require_http_methods(["DELETE"])
def transaction_delete(request, transaction_id, **kwargs): def transaction_delete(request, transaction_id, **kwargs):
transaction = get_object_or_404(Transaction, id=transaction_id) transaction = get_object_or_404(Transaction, id=transaction_id)

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.5 on 2025-01-24 19:29
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0014_alter_usersettings_date_format_and_more'),
]
operations = [
migrations.AlterField(
model_name='usersettings',
name='language',
field=models.CharField(choices=[('auto', 'Auto'), ('en', 'English'), ('pt-br', 'Português (Brasil)')], default='auto', max_length=10, verbose_name='Language'),
),
]

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -2,46 +2,76 @@
<div class="tw-sticky tw-bottom-4 tw-left-0 tw-right-0 tw-z-50 tw-hidden mx-auto tw-w-fit" id="actions-bar" <div class="tw-sticky tw-bottom-4 tw-left-0 tw-right-0 tw-z-50 tw-hidden mx-auto tw-w-fit" id="actions-bar"
_="on change from #transactions-list or htmx:afterSettle from window _="on change from #transactions-list or htmx:afterSettle from window
if no <input[type='checkbox']:checked/> in #transactions-list if no <input[type='checkbox']:checked/> in #transactions-list
add .tw-hidden to #actions-bar add .slide-in-bottom-reverse then settle
then add .tw-hidden to #actions-bar
then remove .slide-in-bottom-reverse
else else
remove .tw-hidden from #actions-bar remove .tw-hidden from #actions-bar
then trigger selected_transactions_updated then trigger selected_transactions_updated
end end
end"> end">
<div class="card slide-in-left"> <div class="card slide-in-bottom">
<div class="card-body p-2"> <div class="card-body p-2 d-flex justify-content-between align-items-center gap-3">
{% spaceless %} {% spaceless %}
<div class="btn-group" role="group"> <div class="dropdown">
<button class="btn btn-secondary btn-sm" <button class="btn btn-secondary btn-sm dropdown-toggle" type="button" data-bs-toggle="dropdown"
data-bs-toggle="tooltip" aria-expanded="false">
data-bs-title="{% translate 'Select All' %}" <i class="fa-regular fa-square-check fa-fw"></i>
_="on click set <#transactions-list input[type='checkbox']/>'s checked to true then call me.blur() then trigger change">
<i class="fa-regular fa-square-check tw-text-green-400"></i>
</button>
<button class="btn btn-secondary btn-sm"
data-bs-toggle="tooltip"
data-bs-title="{% translate 'Unselect All' %}"
_="on click set <#transactions-list input[type='checkbox']/>'s checked to false then call me.blur() then trigger change">
<i class="fa-regular fa-square tw-text-red-400"></i>
</button> </button>
<ul class="dropdown-menu">
<li>
<div class="dropdown-item px-3 tw-cursor-pointer"
_="on click set <#transactions-list input[type='checkbox']/>'s checked to true then call me.blur() then trigger change">
<i class="fa-regular fa-square-check tw-text-green-400 me-3"></i>{% translate 'Select All' %}
</div>
</li>
<li>
<div class="dropdown-item px-3 tw-cursor-pointer"
_="on click set <#transactions-list input[type='checkbox']/>'s checked to false then call me.blur() then trigger change">
<i class="fa-regular fa-square tw-text-red-400 me-3"></i>{% translate 'Unselect All' %}
</div>
</li>
</ul>
</div> </div>
<div class="vr mx-3 tw-align-middle"></div> <div class="vr tw-align-middle"></div>
<div class="btn-group me-3" role="group"> <div class="btn-group">
<button class="btn btn-secondary btn-sm" <button class="btn btn-secondary btn-sm"
hx-get="{% url 'transactions_bulk_pay' %}" hx-get="{% url 'transactions_bulk_edit' %}"
hx-target="#generic-offcanvas"
hx-include=".transaction" hx-include=".transaction"
data-bs-toggle="tooltip" data-bs-toggle="tooltip"
data-bs-title="{% translate 'Mark as paid' %}"> data-bs-title="{% translate 'Edit' %}">
<i class="fa-regular fa-circle-check tw-text-green-400"></i> <i class="fa-solid fa-pencil"></i>
</button> </button>
<button class="btn btn-secondary btn-sm" <button type="button" class="btn btn-sm btn-secondary dropdown-toggle dropdown-toggle-split"
hx-get="{% url 'transactions_bulk_unpay' %}" data-bs-toggle="dropdown" aria-expanded="false" data-bs-auto-close="outside">
hx-include=".transaction" <span class="visually-hidden">{% trans "Toggle Dropdown" %}</span>
data-bs-toggle="tooltip"
data-bs-title="{% translate 'Mark as unpaid' %}">
<i class="fa-regular fa-circle tw-text-red-400"></i>
</button> </button>
<ul class="dropdown-menu">
<li>
<div class="dropdown-item px-3 tw-cursor-pointer"
hx-get="{% url 'transactions_bulk_unpay' %}"
hx-include=".transaction">
<i class="fa-regular fa-circle tw-text-red-400 fa-fw me-3"></i>{% translate 'Mark as unpaid' %}
</div>
</li>
<li>
<div class="dropdown-item px-3 tw-cursor-pointer"
hx-get="{% url 'transactions_bulk_pay' %}"
hx-include=".transaction">
<i class="fa-regular fa-circle-check tw-text-green-400 fa-fw me-3"></i>{% translate 'Mark as paid' %}
</div>
</li>
</ul>
</div> </div>
<button class="btn btn-secondary btn-sm"
hx-get="{% url 'transactions_bulk_clone' %}"
hx-include=".transaction"
data-bs-toggle="tooltip"
data-bs-title="{% translate 'Duplicate' %}">
<i class="fa-solid fa-clone fa-fw"></i>
</button>
<button class="btn btn-secondary btn-sm" <button class="btn btn-secondary btn-sm"
hx-get="{% url 'transactions_bulk_delete' %}" hx-get="{% url 'transactions_bulk_delete' %}"
hx-include=".transaction" hx-include=".transaction"
@@ -55,9 +85,9 @@
_="install prompt_swal"> _="install prompt_swal">
<i class="fa-solid fa-trash text-danger"></i> <i class="fa-solid fa-trash text-danger"></i>
</button> </button>
<div class="vr mx-3 tw-align-middle"></div> <div class="vr tw-align-middle"></div>
<div class="btn-group" <div class="btn-group"
_="on selected_transactions_updated from #actions-bar _="on selected_transactions_updated from #actions-bar
set realTotal to math.bignumber(0) set realTotal to math.bignumber(0)
set flatTotal to math.bignumber(0) set flatTotal to math.bignumber(0)
set transactions to <.transaction:has(input[name='transactions']:checked)/> set transactions to <.transaction:has(input[name='transactions']:checked)/>
@@ -93,8 +123,7 @@
put Math.min.apply(Math, realAmountValues).toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #calc-menu-min's innerText put Math.min.apply(Math, realAmountValues).toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #calc-menu-min's innerText
put mean.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #calc-menu-mean's innerText put mean.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #calc-menu-mean's innerText
put flatAmountValues.length.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #calc-menu-count's innerText put flatAmountValues.length.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #calc-menu-count's innerText
end" end">
>
<button class="btn btn-secondary btn-sm" _="on click <button class="btn btn-secondary btn-sm" _="on click
set original_value to #real-total-front's innerText set original_value to #real-total-front's innerText
writeText(original_value) on navigator.clipboard writeText(original_value) on navigator.clipboard
@@ -102,8 +131,8 @@
wait 1s wait 1s
put original_value into #real-total-front's innerText put original_value into #real-total-front's innerText
end"> end">
<i class="fa-solid fa-plus fa-fw me-2 text-primary"></i> <i class="fa-solid fa-plus fa-fw me-md-2 text-primary"></i>
<span id="real-total-front">0</span> <span class="d-none d-md-inline-block" id="real-total-front">0</span>
</button> </button>
<button type="button" class="btn btn-sm btn-secondary dropdown-toggle dropdown-toggle-split" <button type="button" class="btn btn-sm btn-secondary dropdown-toggle dropdown-toggle-split"
data-bs-toggle="dropdown" aria-expanded="false" data-bs-auto-close="outside"> data-bs-toggle="dropdown" aria-expanded="false" data-bs-auto-close="outside">

View File

@@ -20,7 +20,7 @@
{% for preset in presets %} {% for preset in presets %}
<a class="text-decoration-none" <a class="text-decoration-none"
role="button" role="button"
hx-get="{% url 'import_profiles_add' %}" hx-post="{% url 'import_profiles_add' %}"
hx-vals='{"yaml_config": {{ preset.config }}, "name": "{{ preset.name }}", "version": "{{ preset.schema_version }}", "message": {{ preset.message }}}' hx-vals='{"yaml_config": {{ preset.config }}, "name": "{{ preset.name }}", "version": "{{ preset.schema_version }}", "message": {{ preset.message }}}'
hx-target="#generic-offcanvas"> hx-target="#generic-offcanvas">

View File

@@ -2,7 +2,7 @@
{% load i18n %} {% load i18n %}
{% load crispy_forms_tags %} {% load crispy_forms_tags %}
{% block title %}{% translate 'Runs for ' %}{{ profile.name }}{% endblock %} {% block title %}{% translate 'Runs for' %} {{ profile.name }}{% endblock %}
{% block body %} {% block body %}
<div hx-get="{% url "import_profile_runs_list" profile_id=profile.id %}" <div hx-get="{% url "import_profile_runs_list" profile_id=profile.id %}"

View File

@@ -1,8 +0,0 @@
{% extends "layouts/base.html" %}
{% load i18n %}
{% block title %}{% translate 'Import Runs' %}{% endblock %}
{% block content %}
<div hx-get="{% url 'impor' %}" hx-trigger="load, updated from:window" class="show-loading"></div>
{% endblock %}

View File

@@ -9,4 +9,10 @@
end end
end end
end end
on reset
for elm in <select/> in event.target
call elm.tomselect.clear()
end
end
</script> </script>

View File

@@ -28,7 +28,8 @@
<body class="font-monospace"> <body class="font-monospace">
<div _="install hide_amounts <div _="install hide_amounts
install htmx_error_handler install htmx_error_handler
{% block body_hyperscript %}{% endblock %}"> {% block body_hyperscript %}{% endblock %}"
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
{% include 'includes/navbar.html' %} {% include 'includes/navbar.html' %}
<div id="content"> <div id="content">

View File

@@ -129,6 +129,7 @@
id="filter"> id="filter">
{% crispy filter.form %} {% crispy filter.form %}
</form> </form>
<button class="btn btn-outline-danger btn-sm" _="on click call #filter.reset() then trigger change on #filter">{% translate 'Clear' %}</button>
</div> </div>
</div> </div>
{# Transactions list#} {# Transactions list#}

View File

@@ -0,0 +1,17 @@
{% extends 'extends/offcanvas.html' %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% translate 'Bulk Editing' %}{% endblock %}
{% block body %}
<p>{% trans 'Editing' %} {{ transactions|length }} {% trans 'transactions' %}</p>
<div class="editing-transactions">
{% for transaction in transactions %}
<input type="hidden" name="transactions" value="{{ transaction.id }}"/>
{% endfor %}
</div>
<form hx-post="{% url 'transactions_bulk_edit' %}" hx-target="#generic-offcanvas" hx-include=".editing-transactions" novalidate>
{% crispy form %}
</form>
{% endblock %}

View File

@@ -0,0 +1,15 @@
{% extends 'layouts/base.html' %}
{% load crispy_forms_tags %}
{% load i18n %}
{% block title %}{% translate 'New transaction' %}{% endblock %}
{% block content %}
<div class="container py-3 column-gap-5"
_="install init_tom_select
install init_datepicker">
<form hx-post="{% url 'transaction_simple_add' %}" hx-swap="outerHTML" hx-target="body" novalidate>
{% crispy form form.helper_simple %}
</form>
</div>
{% endblock %}

View File

@@ -14,8 +14,7 @@
<div class="d-flex mb-3 align-self-center"> <div class="d-flex mb-3 align-self-center">
<div class="me-auto"><h4><i class="fa-solid fa-filter me-2"></i>{% translate 'Filter' %}</h4></div> <div class="me-auto"><h4><i class="fa-solid fa-filter me-2"></i>{% translate 'Filter' %}</h4></div>
<div class="align-self-center"> <div class="align-self-center">
<a href="{% url 'transactions_all_index' %}" type="button" class="btn btn-outline-danger btn-sm" <button class="btn btn-outline-danger btn-sm" _="on click call #filter.reset() then trigger change on #filter">{% translate 'Clear' %}</button>
hx-target="body" hx-boost="true">{% translate 'Clear' %}</a>
</div> </div>
</div> </div>
<hr> <hr>

View File

@@ -9,7 +9,7 @@
id="{{ field.html_name }}_{{ forloop.counter }}_tr" id="{{ field.html_name }}_{{ forloop.counter }}_tr"
value="{{ choice.0 }}" value="{{ choice.0 }}"
{% if choice.0 == field.value %}checked{% endif %}> {% if choice.0 == field.value %}checked{% endif %}>
<label class="btn {% if forloop.first %}btn-outline-success{% elif forloop.last %}btn-outline-danger{% else %}btn-outline-primary{% endif %} {% if field.errors %}is-invalid{% endif %}" <label class="btn {% if forloop.first %}btn-outline-success{% elif forloop.last %}btn-outline-danger{% else %}btn-outline-primary{% endif %} {% if field.errors %}is-invalid{% endif %} w-100"
for="{{ field.html_name }}_{{ forloop.counter }}_tr"> for="{{ field.html_name }}_{{ forloop.counter }}_tr">
{{ choice.1 }} {{ choice.1 }}
</label> </label>

View File

@@ -0,0 +1,18 @@
{% load i18n %}
{% load crispy_forms_field %}
<div class="form-group mb-3">
<div class="btn-group w-100" role="group" aria-label="{{ field.label }}">
<input type="radio" class="btn-check" name="{{ field.name }}" id="{{ field.id_for_label }}_false"
value="false" {% if not field.value %}checked{% endif %}>
<label class="btn btn-outline-primary w-50" for="{{ field.id_for_label }}_false">{% trans 'Projected' %}</label>
<input type="radio" class="btn-check" name="{{ field.name }}" id="{{ field.id_for_label }}_true"
value="true" {% if field.value %}checked{% endif %}>
<label class="btn btn-outline-success w-50" for="{{ field.id_for_label }}_true">{% trans 'Paid' %}</label>
</div>
{% if field.help_text %}
<div class="help-text">{{ field.help_text|safe }}</div>
{% endif %}
</div>

View File

@@ -15,7 +15,7 @@
id="{{ field.html_name }}_{{ forloop.counter }}" id="{{ field.html_name }}_{{ forloop.counter }}"
value="{{ choice.0 }}" value="{{ choice.0 }}"
{% if choice.0 in field.value %}checked{% endif %}> {% if choice.0 in field.value %}checked{% endif %}>
<label class="btn btn-outline-dark" <label class="btn btn-outline-dark w-100"
for="{{ field.html_name }}_{{ forloop.counter }}"> for="{{ field.html_name }}_{{ forloop.counter }}">
{{ choice.1 }} {{ choice.1 }}
</label> </label>

View File

@@ -0,0 +1,40 @@
{% load i18n %}
{% load crispy_forms_field %}
<div class="form-group mb-3">
<div class="btn-group w-100" role="group" aria-label="{{ field.label }}">
<input type="radio"
class="btn-check"
name="{{ field.html_name }}"
id="{{ field.html_name }}_none_tr"
value=""
{% if field.value is None %}checked{% endif %}>
<label class="btn btn-outline-secondary {% if field.errors %}is-invalid{% endif %} w-100"
for="{{ field.html_name }}_none_tr">
{% trans 'Unchanged' %}
</label>
{% for choice in field.field.choices %}
<input type="radio"
class="btn-check"
name="{{ field.html_name }}"
id="{{ field.html_name }}_{{ forloop.counter }}_tr"
value="{{ choice.0 }}"
{% if choice.0 == field.value %}checked{% endif %}>
<label class="btn {% if forloop.first %}btn-outline-success{% elif forloop.last %}btn-outline-danger{% else %}btn-outline-primary{% endif %} {% if field.errors %}is-invalid{% endif %} w-100"
for="{{ field.html_name }}_{{ forloop.counter }}_tr">
{{ choice.1 }}
</label>
{% endfor %}
</div>
{% if field.errors %}
<div class="invalid-feedback d-block">
{% for error in field.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
{% if field.help_text %}
<small class="form-text text-muted">{{ field.help_text }}</small>
{% endif %}
</div>

View File

@@ -0,0 +1,22 @@
{% load i18n %}
{% load crispy_forms_field %}
<div class="form-group mb-3">
<div class="btn-group w-100" role="group" aria-label="{{ field.label }}">
<input type="radio" class="btn-check" name="{{ field.name }}" id="{{ field.id_for_label }}_null"
value="" {% if field.value is None %}checked{% endif %}>
<label class="btn btn-outline-secondary w-100" for="{{ field.id_for_label }}_null">{% trans 'Unchanged' %}</label>
<input type="radio" class="btn-check" name="{{ field.name }}" id="{{ field.id_for_label }}_false"
value="false" {% if field.value is False %}checked{% endif %}">
<label class="btn btn-outline-primary w-100" for="{{ field.id_for_label }}_false">{% trans 'Projected' %}</label>
<input type="radio" class="btn-check" name="{{ field.name }}" id="{{ field.id_for_label }}_true"
value="true" {% if field.value is True %}checked{% endif %}>
<label class="btn btn-outline-success w-100" for="{{ field.id_for_label }}_true">{% trans 'Paid' %}</label>
</div>
{% if field.help_text %}
<div class="help-text">{{ field.help_text|safe }}</div>
{% endif %}
</div>

View File

@@ -205,3 +205,35 @@
.flashing { .flashing {
animation: flash 1s infinite; animation: flash 1s infinite;
} }
.slide-in-bottom {
animation: slide-in-bottom 0.3s cubic-bezier(0.250, 0.460, 0.450, 0.940) both;
}
.slide-in-bottom-reverse {
animation: slide-in-bottom 0.3s cubic-bezier(0.250, 0.460, 0.450, 0.940) reverse both;
}
/* ----------------------------------------------
* Generated by Animista on 2025-1-25 12:30:4
* Licensed under FreeBSD License.
* See http://animista.net/license for more info.
* w: http://animista.net, t: @cssanimista
* ---------------------------------------------- */
/**
* ----------------------------------------
* animation slide-in-bottom
* ----------------------------------------
*/
@keyframes slide-in-bottom {
0% {
transform: translateY(1000px);
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}