import datetime from copy import deepcopy from dateutil.relativedelta import relativedelta from django.contrib import messages from django.contrib.auth.decorators import login_required from django.core.paginator import Paginator from django.db.models import Q, When, Case, Value, IntegerField from django.http import HttpResponse, JsonResponse from django.shortcuts import render, get_object_or_404 from django.utils import timezone from django.utils.translation import gettext_lazy as _, ngettext_lazy from django.views.decorators.http import require_http_methods from apps.common.decorators.htmx import only_htmx from apps.rules.signals import transaction_created, transaction_updated from apps.transactions.filters import TransactionsFilter from apps.transactions.forms import ( TransactionForm, TransferForm, BulkEditTransactionForm, ) from apps.transactions.models import Transaction from apps.transactions.utils.calculations import ( calculate_currency_totals, calculate_account_totals, calculate_percentage_distribution, ) from apps.transactions.utils.default_ordering import default_order @only_htmx @login_required @require_http_methods(["GET", "POST"]) def transaction_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() update = False if request.method == "POST": form = TransactionForm(request.POST) if form.is_valid(): saved_instance = form.save() messages.success(request, _("Transaction added successfully")) if "submit" in request.POST: return HttpResponse( status=204, headers={"HX-Trigger": "updated, hide_offcanvas"}, ) elif "submit_and_another" in request.POST: form = TransactionForm( initial={ "date": expected_date, "type": transaction_type, }, ) update = True elif "submit_and_similar" in request.POST: initial_data = {} # Define fields to copy from the SAVED instance direct_fields_to_copy = [ "account", # ForeignKey -> will copy the ID "type", # ChoiceField -> will copy the value "is_paid", # BooleanField -> will copy True/False "date", # DateField -> will copy the date object "reference_date", # DateField -> will copy the date object "amount", # DecimalField -> will copy the decimal "description", # CharField -> will copy the string "notes", # TextField -> will copy the string "category", # ForeignKey -> will copy the ID ] m2m_fields_to_copy = [ "tags", # ManyToManyField -> will copy list of IDs "entities", # ManyToManyField -> will copy list of IDs ] # Copy direct fields from the saved instance for field_name in direct_fields_to_copy: value = getattr(saved_instance, field_name, None) if value is not None: # Handle ForeignKey: use the pk if hasattr(value, "pk"): initial_data[field_name] = value.pk # Handle Date/DateTime/Decimal/Boolean/etc.: use the Python object directly else: initial_data[field_name] = ( value # This correctly handles date objects! ) # Copy M2M fields: provide a list of related object pks for field_name in m2m_fields_to_copy: m2m_manager = getattr(saved_instance, field_name) initial_data[field_name] = list( m2m_manager.values_list("name", flat=True) ) # Create a new form instance pre-filled with the correctly typed initial data form = TransactionForm(initial=initial_data) update = True # Signal HTMX to update the form area else: form = TransactionForm( initial={ "date": expected_date, "type": transaction_type, }, ) response = render( request, "transactions/fragments/add.html", {"form": form}, ) if update: response["HX-Trigger"] = "updated" return response @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() # Build initial data from query parameters initial_data = { "date": expected_date, "type": transaction_type, } # Handle date param (ISO format: YYYY-MM-DD) - overrides expected_date date_param = request.GET.get("date") if date_param: try: initial_data["date"] = datetime.datetime.strptime( date_param, "%Y-%m-%d" ).date() except ValueError: pass # Handle reference_date param (ISO format: YYYY-MM-DD) reference_date_param = request.GET.get("reference_date") if reference_date_param: try: initial_data["reference_date"] = datetime.datetime.strptime( reference_date_param, "%Y-%m-%d" ).date() except ValueError: pass # Handle account param (by ID or name) account_param = request.GET.get("account") if account_param: try: initial_data["account"] = int(account_param) except (ValueError, TypeError): # Try to find by name from apps.accounts.models import Account account = Account.objects.filter( name__iexact=account_param, is_archived=False ).first() if account: initial_data["account"] = account.pk # Handle is_paid param (boolean) is_paid = request.GET.get("is_paid") if is_paid is not None: initial_data["is_paid"] = is_paid.lower() in ("true", "1", "yes") # Handle amount param (decimal) amount = request.GET.get("amount") if amount: try: initial_data["amount"] = amount except (ValueError, TypeError): pass # Handle description param (string) description = request.GET.get("description") if description: initial_data["description"] = description # Handle notes param (string) notes = request.GET.get("notes") if notes: initial_data["notes"] = notes # Handle category param (by ID or name) category_param = request.GET.get("category") if category_param: try: initial_data["category"] = int(category_param) except (ValueError, TypeError): # Try to find by name from apps.transactions.models import TransactionCategory category = TransactionCategory.objects.filter( name__iexact=category_param, active=True ).first() if category: initial_data["category"] = category.pk # Handle tags param (comma-separated names) tags = request.GET.get("tags") if tags: initial_data["tags"] = [t.strip() for t in tags.split(",") if t.strip()] # Handle entities param (comma-separated names) entities = request.GET.get("entities") if entities: initial_data["entities"] = [e.strip() for e in entities.split(",") if e.strip()] if request.method == "POST": form = TransactionForm(request.POST) if form.is_valid(): form.save() messages.success(request, _("Transaction added successfully")) # Only reset form after successful save form = TransactionForm(initial=initial_data) else: form = TransactionForm(initial=initial_data) return render( request, "transactions/pages/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": "updated, hide_offcanvas"}, ) else: form = TransactionForm(instance=transaction) return render( request, "transactions/fragments/edit.html", {"form": form, "transaction": transaction}, ) @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) if form.is_valid(): # Apply changes from the form to all selected transactions for transaction in transactions: old_data = deepcopy(transaction) 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, old_data=old_data) 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}) context = { "form": form, "transactions": transactions, } return render(request, "transactions/fragments/bulk_edit.html", context) @only_htmx @login_required @require_http_methods(["GET", "POST"]) def transaction_clone(request, transaction_id, **kwargs): transaction = get_object_or_404(Transaction, id=transaction_id) 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, _("Transaction duplicated successfully")) transaction_created.send(sender=transaction) # THIS HAS BEEN DISABLE DUE TO HTMX INCOMPATIBILITY # SEE https://github.com/bigskysoftware/htmx/issues/3115 and https://github.com/bigskysoftware/htmx/issues/2706 # if request.GET.get("edit") == "true": # return HttpResponse( # status=200, # headers={ # "HX-Trigger": "updated", # "HX-Push-Url": "false", # "HX-Location": json.dumps( # { # "path": reverse( # "transaction_edit", # kwargs={"transaction_id": new_transaction.id}, # ), # "target": "#generic-offcanvas", # "swap": "innerHTML", # } # ), # }, # ) # else: # transaction_created.send(sender=transaction) return HttpResponse( status=204, headers={"HX-Trigger": "updated"}, ) @only_htmx @login_required @require_http_methods(["DELETE"]) def transaction_delete(request, transaction_id, **kwargs): transaction = get_object_or_404(Transaction.all_objects, id=transaction_id) transaction.delete() messages.success(request, _("Transaction deleted successfully")) return HttpResponse( status=204, headers={"HX-Trigger": "updated"}, ) @only_htmx @login_required @require_http_methods(["GET"]) def transaction_undelete(request, transaction_id, **kwargs): transaction = get_object_or_404(Transaction.deleted_objects, id=transaction_id) transaction.deleted = False transaction.deleted_at = None transaction.save() messages.success(request, _("Transaction restored successfully")) return HttpResponse( status=204, headers={"HX-Trigger": "updated"}, ) @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": "updated, 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) old_data = deepcopy(transaction) new_is_paid = False if transaction.is_paid else True transaction.is_paid = new_is_paid transaction.save() transaction_updated.send(sender=transaction, old_data=old_data) response = render( request, "transactions/fragments/item.html", context={"transaction": transaction, **request.GET}, ) response.headers["HX-Trigger"] = ( f"{'paid' if new_is_paid else 'unpaid'}, selective_update" ) return response @only_htmx @login_required @require_http_methods(["GET"]) def transaction_mute(request, transaction_id): transaction = get_object_or_404(Transaction, pk=transaction_id) old_data = deepcopy(transaction) new_mute = False if transaction.mute else True transaction.mute = new_mute transaction.save() transaction_updated.send(sender=transaction, old_data=old_data) response = render( request, "transactions/fragments/item.html", context={"transaction": transaction, **request.GET}, ) response.headers["HX-Trigger"] = "selective_update" return response @only_htmx @login_required @require_http_methods(["GET"]) def transaction_change_month(request, transaction_id, change_type): transaction: Transaction = get_object_or_404(Transaction, pk=transaction_id) old_data = deepcopy(transaction) if change_type == "next": transaction.reference_date = transaction.reference_date + relativedelta( months=1 ) transaction.save() transaction_updated.send(sender=transaction, old_data=old_data) elif change_type == "previous": transaction.reference_date = transaction.reference_date - relativedelta( months=1 ) transaction.save() transaction_updated.send(sender=transaction, old_data=old_data) return HttpResponse( status=204, headers={"HX-Trigger": "updated"}, ) @only_htmx @login_required @require_http_methods(["GET"]) def transaction_move_to_today(request, transaction_id): transaction: Transaction = get_object_or_404(Transaction, pk=transaction_id) old_data = deepcopy(transaction) transaction.date = timezone.localdate(timezone.now()) transaction.save() transaction_updated.send(sender=transaction, old_data=old_data) return HttpResponse( status=204, headers={"HX-Trigger": "updated"}, ) @login_required @require_http_methods(["GET"]) def transaction_all_index(request): order = request.session.get("all_transactions_order", "default") summary_tab = request.session.get("transaction_all_summary_tab", "currency") f = TransactionsFilter(request.GET) return render( request, "transactions/pages/transactions.html", {"filter": f, "order": order, "summary_tab": summary_tab}, ) @only_htmx @login_required @require_http_methods(["GET"]) def transaction_all_list(request): order = request.session.get("all_transactions_order", "default") if "order" in request.GET: order = request.GET["order"] if order != request.session.get("all_transactions_order", "default"): request.session["all_transactions_order"] = order today = timezone.localdate(timezone.now()) transactions = Transaction.objects.prefetch_related( "account", "account__group", "category", "tags", "account__exchange_currency", "account__currency", "installment_plan", "entities", "dca_expense_entries", "dca_income_entries", ).all() f = TransactionsFilter(request.GET, queryset=transactions) # Late transactions: date < today and is_paid = False (only shown for default ordering on first page) late_transactions = None page_number = request.GET.get("page", 1) if order == "default" and str(page_number) == "1": late_transactions = f.qs.filter( date__lt=today, is_paid=False, ).order_by("date", "id") # Exclude late transactions from the main paginated list main_transactions = f.qs.exclude( date__lt=today, is_paid=False, ) else: main_transactions = f.qs main_transactions = default_order(main_transactions, order=order) paginator = Paginator(main_transactions, 100) page_obj = paginator.get_page(page_number) return render( request, "transactions/fragments/list_all.html", { "page_obj": page_obj, "paginator": paginator, "late_transactions": late_transactions, }, ) @only_htmx @login_required @require_http_methods(["GET"]) def transaction_all_summary(request): transactions = Transaction.objects.prefetch_related( "account", "account__group", "category", "tags", "account__exchange_currency", "account__currency", "installment_plan", "entities", "dca_expense_entries", "dca_income_entries", ).all() f = TransactionsFilter(request.GET, queryset=transactions) currency_data = calculate_currency_totals(f.qs.all(), ignore_empty=True) currency_percentages = calculate_percentage_distribution(currency_data) account_data = calculate_account_totals(transactions_queryset=f.qs.all()) account_percentages = calculate_percentage_distribution(account_data) context = { "currency_data": currency_data, "currency_percentages": currency_percentages, "account_data": account_data, "account_percentages": account_percentages, } return render(request, "transactions/fragments/summary.html", context) @only_htmx @login_required @require_http_methods(["GET"]) def transaction_all_account_summary(request): transactions = Transaction.objects.prefetch_related( "account", "account__group", "category", "tags", "account__exchange_currency", "account__currency", "installment_plan", "entities", "dca_expense_entries", "dca_income_entries", ).all() f = TransactionsFilter(request.GET, queryset=transactions) account_data = calculate_account_totals(transactions_queryset=f.qs.all()) account_percentages = calculate_percentage_distribution(account_data) context = { "account_data": account_data, "account_percentages": account_percentages, } return render(request, "transactions/fragments/all_account_summary.html", context) @only_htmx @login_required @require_http_methods(["GET"]) def transaction_all_currency_summary(request): transactions = Transaction.objects.prefetch_related( "account", "account__group", "category", "tags", "account__exchange_currency", "account__currency", "installment_plan", "entities", "dca_expense_entries", "dca_income_entries", ).all() f = TransactionsFilter(request.GET, queryset=transactions) currency_data = calculate_currency_totals( f.qs.exclude(account__in=request.user.untracked_accounts.all()), ignore_empty=True, ) currency_percentages = calculate_percentage_distribution(currency_data) context = { "currency_data": currency_data, "currency_percentages": currency_percentages, } return render(request, "transactions/fragments/all_currency_summary.html", context) @login_required @require_http_methods(["GET"]) def transaction_all_summary_select(request, selected): request.session["transaction_all_summary_tab"] = selected return HttpResponse( status=204, ) @login_required @require_http_methods(["GET"]) def transactions_trash_can_index(request): return render(request, "transactions/pages/trash.html") @only_htmx @login_required @require_http_methods(["GET"]) def transactions_trash_can_list(request): transactions = Transaction.deleted_objects.prefetch_related( "account", "account__group", "category", "tags", "account__exchange_currency", "account__currency", "installment_plan", "entities", "entities", "dca_expense_entries", "dca_income_entries", ).all() return render( request, "transactions/fragments/trash_list.html", {"transactions": transactions}, ) @login_required @require_http_methods(["GET"]) def get_recent_transactions(request, filter_type=None): """Return the 100 most recent non-deleted transactions with optional search.""" # Get search term from query params search_term = request.GET.get("q", "").strip() today = timezone.localdate(timezone.now()) yesterday = today - timezone.timedelta(days=1) tomorrow = today + timezone.timedelta(days=1) # Base queryset with selected fields queryset = ( Transaction.objects.filter(deleted=False) .annotate( date_order=Case( When(date=today, then=Value(0)), When(date=tomorrow, then=Value(1)), When(date=yesterday, then=Value(2)), When(date__gt=tomorrow, then=Value(3)), When(date__lt=yesterday, then=Value(4)), default=Value(5), output_field=IntegerField(), ) ) .select_related("account", "category") .order_by("date_order", "date", "id") ) if filter_type: if filter_type == "expenses": queryset = queryset.filter(type=Transaction.Type.EXPENSE) elif filter_type == "income": queryset = queryset.filter(type=Transaction.Type.INCOME) # Apply search if provided if search_term: queryset = queryset.filter( Q(description__icontains=search_term) | Q(notes__icontains=search_term) | Q(internal_note__icontains=search_term) | Q(tags__name__icontains=search_term) | Q(category__name__icontains=search_term) ) # Prepare data for JSON response data = [] for t in queryset: data.append({"text": str(t), "value": str(t.id)}) return JsonResponse(data, safe=False)