mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-02-25 08:54:52 +01:00
Compare commits
74 Commits
0.16.2
...
internal_p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d7dd622f5 | ||
|
|
f2abeff31a | ||
|
|
666eaff167 | ||
|
|
d72454f854 | ||
|
|
333aa81923 | ||
|
|
41b8cfd1e7 | ||
|
|
1fa7985b01 | ||
|
|
38392a6322 | ||
|
|
637c62319b | ||
|
|
f91fe67629 | ||
|
|
9eb1818a20 | ||
|
|
50ac679e33 | ||
|
|
2a463c63b8 | ||
|
|
dce65f2faf | ||
|
|
a053cb3947 | ||
|
|
2d43072120 | ||
|
|
70bdee065e | ||
|
|
95db27a32f | ||
|
|
d6d4e6a102 | ||
|
|
bc0f30fead | ||
|
|
a9a86fc491 | ||
|
|
c3b5f2bf39 | ||
|
|
19128e5aed | ||
|
|
9b5c6d3413 | ||
|
|
73c873a2ad | ||
|
|
9d2be22a77 | ||
|
|
6a3d31f37d | ||
|
|
3be3a3c14b | ||
|
|
a5b0f4efb7 | ||
|
|
6da50db417 | ||
|
|
a6c1daf902 | ||
|
|
6a271fb3d7 | ||
|
|
2cf9a9dd0f | ||
|
|
64b32316ca | ||
|
|
0deaabe719 | ||
|
|
b14342af2e | ||
|
|
efe020efb3 | ||
|
|
2c14ce6366 | ||
|
|
8c133f92ce | ||
|
|
2dd887b0d9 | ||
|
|
f3c9d8faea | ||
|
|
8be7758dc0 | ||
|
|
8f5204a17b | ||
|
|
05dd782df5 | ||
|
|
187fe43283 | ||
|
|
cae73376db | ||
|
|
7225454a6e | ||
|
|
70c8c1e07c | ||
|
|
2235bdeabb | ||
|
|
d724300513 | ||
|
|
eacafa1def | ||
|
|
c738f5ee29 | ||
|
|
c392a2c988 | ||
|
|
17ea859fd2 | ||
|
|
8aae6f928f | ||
|
|
7c43b06b9f | ||
|
|
72904266bf | ||
|
|
e16e279911 | ||
|
|
670bee4325 | ||
|
|
3e2c1184ce | ||
|
|
731f351eef | ||
|
|
b7056e7aa1 | ||
|
|
accceed630 | ||
|
|
76346cb503 | ||
|
|
3df8952ea2 | ||
|
|
9bd067da96 | ||
|
|
1abe9e9f62 | ||
|
|
1a86b5dea4 | ||
|
|
8f2f5a16c2 | ||
|
|
4565dc770b | ||
|
|
23673def09 | ||
|
|
dd2b9ead7e | ||
|
|
2078e9f3e4 | ||
|
|
e6bab57ab4 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -160,3 +160,6 @@ cython_debug/
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
.idea/
|
||||
|
||||
postgres_data/
|
||||
.prod.env
|
||||
@@ -126,6 +126,7 @@ To create the first user, open the container's console using Unraid's UI, by cli
|
||||
|
||||
| variable | type | default | explanation |
|
||||
|-------------------------------|-------------|-----------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| INTERNAL_PORT | int | 8000 | The port on which the app listens on. Defaults to 8000 if not set. |
|
||||
| DJANGO_ALLOWED_HOSTS | string | localhost 127.0.0.1 | A list of space separated domains and IPs representing the host/domain names that WYGIWYH site can serve. [Click here](https://docs.djangoproject.com/en/5.1/ref/settings/#allowed-hosts) for more details |
|
||||
| HTTPS_ENABLED | true\|false | false | Whether to use secure cookies. If this is set to true, the cookie will be marked as “secure”, which means browsers may ensure that the cookie is only sent under an HTTPS connection |
|
||||
| URL | string | http://localhost http://127.0.0.1 | A list of space separated domains and IPs (with the protocol) representing the trusted origins for unsafe requests (e.g. POST). [Click here](https://docs.djangoproject.com/en/5.1/ref/settings/#csrf-trusted-origins ) for more details |
|
||||
|
||||
@@ -3,6 +3,7 @@ from crispy_forms.bootstrap import FormActions
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Layout, Field, Column, Row
|
||||
from django import forms
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.accounts.models import Account
|
||||
@@ -15,6 +16,7 @@ from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||
from apps.common.widgets.tom_select import TomSelect
|
||||
from apps.transactions.models import TransactionCategory, TransactionTag
|
||||
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
|
||||
from apps.currencies.models import Currency
|
||||
|
||||
|
||||
class AccountGroupForm(forms.ModelForm):
|
||||
@@ -79,6 +81,18 @@ class AccountForm(forms.ModelForm):
|
||||
|
||||
self.fields["group"].queryset = AccountGroup.objects.all()
|
||||
|
||||
if self.instance.id:
|
||||
qs = Currency.objects.filter(
|
||||
Q(is_archived=False) | Q(accounts=self.instance.id)
|
||||
).distinct()
|
||||
self.fields["currency"].queryset = qs
|
||||
self.fields["exchange_currency"].queryset = qs
|
||||
|
||||
else:
|
||||
qs = Currency.objects.filter(Q(is_archived=False))
|
||||
self.fields["currency"].queryset = qs
|
||||
self.fields["exchange_currency"].queryset = qs
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
self.helper.form_method = "post"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from copy import deepcopy
|
||||
|
||||
from rest_framework import viewsets
|
||||
|
||||
from apps.api.custom.pagination import CustomPageNumberPagination
|
||||
@@ -30,8 +32,9 @@ class TransactionViewSet(viewsets.ModelViewSet):
|
||||
transaction_created.send(sender=instance)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
old_data = deepcopy(Transaction.objects.get(pk=serializer.data["pk"]))
|
||||
instance = serializer.save()
|
||||
transaction_updated.send(sender=instance)
|
||||
transaction_updated.send(sender=instance, old_data=old_data)
|
||||
|
||||
def partial_update(self, request, *args, **kwargs):
|
||||
kwargs["partial"] = True
|
||||
|
||||
@@ -9,5 +9,8 @@ def truncate_decimal(value, decimal_places):
|
||||
:param decimal_places: The number of decimal places to keep
|
||||
:return: Truncated Decimal value
|
||||
"""
|
||||
if isinstance(value, (int, float)):
|
||||
value = Decimal(str(value))
|
||||
|
||||
multiplier = Decimal(10**decimal_places)
|
||||
return (value * multiplier).to_integral_value(rounding=ROUND_DOWN) / multiplier
|
||||
|
||||
@@ -23,7 +23,7 @@ async def remove_old_jobs(context, timestamp):
|
||||
return await builtin_tasks.remove_old_jobs(
|
||||
context,
|
||||
max_hours=744,
|
||||
remove_error=True,
|
||||
remove_failed=True,
|
||||
remove_cancelled=True,
|
||||
remove_aborted=True,
|
||||
)
|
||||
|
||||
@@ -36,7 +36,7 @@ class ArbitraryDecimalDisplayNumberInput(forms.TextInput):
|
||||
{
|
||||
"x-data": "",
|
||||
"x-mask:dynamic": f"$money($input, '{get_format('DECIMAL_SEPARATOR')}', '{get_format('THOUSAND_SEPARATOR')}', '30')",
|
||||
"x-on:keyup": "$el.dispatchEvent(new Event('input'))",
|
||||
"x-on:keyup": "if (!['Control', 'Shift', 'Alt', 'Meta'].includes($event.key) && !(($event.ctrlKey || $event.metaKey) && $event.key.toLowerCase() === 'a')) $el.dispatchEvent(new Event('input'))",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ class CurrencyForm(forms.ModelForm):
|
||||
"suffix",
|
||||
"code",
|
||||
"exchange_currency",
|
||||
"is_archived",
|
||||
]
|
||||
widgets = {
|
||||
"exchange_currency": TomSelect(),
|
||||
@@ -40,6 +41,7 @@ class CurrencyForm(forms.ModelForm):
|
||||
self.helper.layout = Layout(
|
||||
"code",
|
||||
"name",
|
||||
Switch("is_archived"),
|
||||
"decimal_places",
|
||||
"prefix",
|
||||
"suffix",
|
||||
|
||||
18
app/apps/currencies/migrations/0022_currency_is_archived.py
Normal file
18
app/apps/currencies/migrations/0022_currency_is_archived.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-30 00:47
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('currencies', '0021_alter_exchangerateservice_service_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='currency',
|
||||
name='is_archived',
|
||||
field=models.BooleanField(default=False, verbose_name='Archived'),
|
||||
),
|
||||
]
|
||||
@@ -32,6 +32,11 @@ class Currency(models.Model):
|
||||
help_text=_("Default currency for exchange calculations"),
|
||||
)
|
||||
|
||||
is_archived = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_("Archived"),
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
@@ -40,12 +40,6 @@ class CurrencyTests(TestCase):
|
||||
with self.assertRaises(ValidationError):
|
||||
currency.full_clean()
|
||||
|
||||
def test_currency_unique_code(self):
|
||||
"""Test that currency codes must be unique"""
|
||||
Currency.objects.create(code="USD", name="US Dollar", decimal_places=2)
|
||||
with self.assertRaises(IntegrityError):
|
||||
Currency.objects.create(code="USD", name="Another Dollar", decimal_places=2)
|
||||
|
||||
def test_currency_unique_name(self):
|
||||
"""Test that currency names must be unique"""
|
||||
Currency.objects.create(code="USD", name="US Dollar", decimal_places=2)
|
||||
|
||||
@@ -12,7 +12,10 @@ from apps.currencies.utils.convert import convert
|
||||
def get_categories_totals(
|
||||
transactions_queryset, ignore_empty=False, show_entities=False
|
||||
):
|
||||
# First get the category totals as before
|
||||
# Step 1: Aggregate transaction data by category and currency.
|
||||
# This query calculates the total current and projected income/expense for each
|
||||
# category by grouping transactions and summing up their amounts based on their
|
||||
# type (income/expense) and payment status (paid/unpaid).
|
||||
category_currency_metrics = (
|
||||
transactions_queryset.values(
|
||||
"category",
|
||||
@@ -76,7 +79,10 @@ def get_categories_totals(
|
||||
.order_by("category__name")
|
||||
)
|
||||
|
||||
# Get tag totals within each category with currency details
|
||||
# Step 2: Aggregate transaction data by tag, category, and currency.
|
||||
# This is similar to the category metrics but adds tags to the grouping,
|
||||
# allowing for a breakdown of totals by tag within each category. It also
|
||||
# handles untagged transactions, where the 'tags' field is None.
|
||||
tag_metrics = transactions_queryset.values(
|
||||
"category",
|
||||
"tags",
|
||||
@@ -131,10 +137,12 @@ def get_categories_totals(
|
||||
),
|
||||
)
|
||||
|
||||
# Process the results to structure by category
|
||||
# Step 3: Initialize the main dictionary to structure the final results.
|
||||
# The data will be organized hierarchically: category -> currency -> tags -> entities.
|
||||
result = {}
|
||||
|
||||
# Process category totals first
|
||||
# Step 4: Process the aggregated category metrics to build the initial result structure.
|
||||
# This loop iterates through each category's metrics and populates the `result` dict.
|
||||
for metric in category_currency_metrics:
|
||||
# Skip empty categories if ignore_empty is True
|
||||
if ignore_empty and all(
|
||||
@@ -185,7 +193,7 @@ def get_categories_totals(
|
||||
"total_final": total_final,
|
||||
}
|
||||
|
||||
# Add exchanged values if exchange_currency exists
|
||||
# Step 4a: Handle currency conversion for category totals if an exchange currency is defined.
|
||||
if metric["account__currency__exchange_currency"]:
|
||||
from_currency = Currency.objects.get(id=currency_id)
|
||||
exchange_currency = Currency.objects.get(
|
||||
@@ -224,7 +232,7 @@ def get_categories_totals(
|
||||
|
||||
result[category_id]["currencies"][currency_id] = currency_data
|
||||
|
||||
# Process tag totals and add them to the result, including untagged
|
||||
# Step 5: Process the aggregated tag metrics and integrate them into the result structure.
|
||||
for tag_metric in tag_metrics:
|
||||
category_id = tag_metric["category"]
|
||||
tag_id = tag_metric["tags"] # Will be None for untagged transactions
|
||||
@@ -281,7 +289,7 @@ def get_categories_totals(
|
||||
"total_final": tag_total_final,
|
||||
}
|
||||
|
||||
# Add exchange currency support for tags
|
||||
# Step 5a: Handle currency conversion for tag totals.
|
||||
if tag_metric["account__currency__exchange_currency"]:
|
||||
from_currency = Currency.objects.get(id=currency_id)
|
||||
exchange_currency = Currency.objects.get(
|
||||
@@ -322,6 +330,7 @@ def get_categories_totals(
|
||||
currency_id
|
||||
] = tag_currency_data
|
||||
|
||||
# Step 6: If requested, aggregate and process entity-level data.
|
||||
if show_entities:
|
||||
entity_metrics = transactions_queryset.values(
|
||||
"category",
|
||||
@@ -389,14 +398,15 @@ def get_categories_totals(
|
||||
tag_id = entity_metric["tags"]
|
||||
entity_id = entity_metric["entities"]
|
||||
|
||||
if not entity_id:
|
||||
continue
|
||||
|
||||
if category_id in result:
|
||||
tag_key = tag_id if tag_id is not None else "untagged"
|
||||
if tag_key in result[category_id]["tags"]:
|
||||
entity_key = entity_id
|
||||
entity_name = entity_metric["entities__name"]
|
||||
entity_key = entity_id if entity_id is not None else "no_entity"
|
||||
entity_name = (
|
||||
entity_metric["entities__name"]
|
||||
if entity_id is not None
|
||||
else None
|
||||
)
|
||||
|
||||
if "entities" not in result[category_id]["tags"][tag_key]:
|
||||
result[category_id]["tags"][tag_key]["entities"] = {}
|
||||
|
||||
@@ -102,4 +102,6 @@ def get_transactions(
|
||||
account__in=request.user.untracked_accounts.all()
|
||||
)
|
||||
|
||||
transactions = transactions.exclude(account__currency__is_archived=True)
|
||||
|
||||
return transactions
|
||||
|
||||
@@ -30,6 +30,7 @@ def calculate_historical_currency_net_worth(queryset):
|
||||
| Q(accounts__visibility="private", accounts__owner=None),
|
||||
accounts__is_archived=False,
|
||||
accounts__isnull=False,
|
||||
is_archived=False,
|
||||
)
|
||||
.values_list("name", flat=True)
|
||||
.distinct()
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
from crispy_bootstrap5.bootstrap5 import Switch, BS5Accordion
|
||||
from crispy_forms.bootstrap import FormActions, AccordionGroup
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Layout, Field, Row, Column
|
||||
from crispy_forms.layout import Layout, Field, Row, Column, HTML
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||
from apps.common.widgets.tom_select import TomSelect
|
||||
from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||
from apps.common.widgets.tom_select import TomSelect, TransactionSelect
|
||||
from apps.rules.models import TransactionRule, UpdateOrCreateTransactionRuleAction
|
||||
from apps.rules.models import TransactionRuleAction
|
||||
from apps.common.fields.forms.dynamic_select import DynamicModelChoiceField
|
||||
from apps.transactions.forms import BulkEditTransactionForm
|
||||
from apps.transactions.models import Transaction
|
||||
|
||||
|
||||
class TransactionRuleForm(forms.ModelForm):
|
||||
@@ -40,6 +44,8 @@ class TransactionRuleForm(forms.ModelForm):
|
||||
Column(Switch("on_create")),
|
||||
Column(Switch("on_delete")),
|
||||
),
|
||||
"order",
|
||||
Switch("sequenced"),
|
||||
"description",
|
||||
"trigger",
|
||||
)
|
||||
@@ -65,10 +71,11 @@ class TransactionRuleForm(forms.ModelForm):
|
||||
class TransactionRuleActionForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = TransactionRuleAction
|
||||
fields = ("value", "field")
|
||||
fields = ("value", "field", "order")
|
||||
labels = {
|
||||
"field": _("Set field"),
|
||||
"value": _("To"),
|
||||
"order": _("Order"),
|
||||
}
|
||||
widgets = {"field": TomSelect(clear_button=False)}
|
||||
|
||||
@@ -82,6 +89,7 @@ class TransactionRuleActionForm(forms.ModelForm):
|
||||
self.helper.form_method = "post"
|
||||
# TO-DO: Add helper with available commands
|
||||
self.helper.layout = Layout(
|
||||
"order",
|
||||
"field",
|
||||
"value",
|
||||
)
|
||||
@@ -147,9 +155,11 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
|
||||
"search_category_operator": TomSelect(clear_button=False),
|
||||
"search_internal_note_operator": TomSelect(clear_button=False),
|
||||
"search_internal_id_operator": TomSelect(clear_button=False),
|
||||
"search_mute_operator": TomSelect(clear_button=False),
|
||||
}
|
||||
|
||||
labels = {
|
||||
"order": _("Order"),
|
||||
"search_account_operator": _("Operator"),
|
||||
"search_type_operator": _("Operator"),
|
||||
"search_is_paid_operator": _("Operator"),
|
||||
@@ -163,6 +173,7 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
|
||||
"search_internal_id_operator": _("Operator"),
|
||||
"search_tags_operator": _("Operator"),
|
||||
"search_entities_operator": _("Operator"),
|
||||
"search_mute_operator": _("Operator"),
|
||||
"search_account": _("Account"),
|
||||
"search_type": _("Type"),
|
||||
"search_is_paid": _("Paid"),
|
||||
@@ -176,6 +187,7 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
|
||||
"search_internal_id": _("Internal ID"),
|
||||
"search_tags": _("Tags"),
|
||||
"search_entities": _("Entities"),
|
||||
"search_mute": _("Mute"),
|
||||
"set_account": _("Account"),
|
||||
"set_type": _("Type"),
|
||||
"set_is_paid": _("Paid"),
|
||||
@@ -189,6 +201,7 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
|
||||
"set_category": _("Category"),
|
||||
"set_internal_note": _("Internal Note"),
|
||||
"set_internal_id": _("Internal ID"),
|
||||
"set_mute": _("Mute"),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -200,6 +213,7 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
|
||||
self.helper.form_method = "post"
|
||||
|
||||
self.helper.layout = Layout(
|
||||
"order",
|
||||
BS5Accordion(
|
||||
AccordionGroup(
|
||||
_("Search Criteria"),
|
||||
@@ -224,6 +238,16 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
|
||||
css_class="form-group col-md-8",
|
||||
),
|
||||
),
|
||||
Row(
|
||||
Column(
|
||||
Field("search_mute_operator"),
|
||||
css_class="form-group col-md-4",
|
||||
),
|
||||
Column(
|
||||
Field("search_mute", rows=1),
|
||||
css_class="form-group col-md-8",
|
||||
),
|
||||
),
|
||||
Row(
|
||||
Column(
|
||||
Field("search_account_operator"),
|
||||
@@ -340,6 +364,7 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
|
||||
_("Set Values"),
|
||||
Field("set_type", rows=1),
|
||||
Field("set_is_paid", rows=1),
|
||||
Field("set_mute", rows=1),
|
||||
Field("set_account", rows=1),
|
||||
Field("set_entities", rows=1),
|
||||
Field("set_date", rows=1),
|
||||
@@ -381,3 +406,112 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
|
||||
if commit:
|
||||
instance.save()
|
||||
return instance
|
||||
|
||||
|
||||
class DryRunCreatedTransacion(forms.Form):
|
||||
transaction = DynamicModelChoiceField(
|
||||
model=Transaction,
|
||||
to_field_name="id",
|
||||
label=_("Transaction"),
|
||||
required=True,
|
||||
queryset=Transaction.objects.none(),
|
||||
widget=TransactionSelect(clear_button=False, income=True, expense=True),
|
||||
help_text=_("Type to search for a transaction"),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
self.helper.layout = Layout(
|
||||
"transaction",
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Test"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
if self.data.get("transaction"):
|
||||
try:
|
||||
transaction = Transaction.objects.get(id=self.data.get("transaction"))
|
||||
except Transaction.DoesNotExist:
|
||||
transaction = None
|
||||
|
||||
if transaction:
|
||||
self.fields["transaction"].queryset = Transaction.objects.filter(
|
||||
id=transaction.id
|
||||
)
|
||||
|
||||
|
||||
class DryRunDeletedTransacion(forms.Form):
|
||||
transaction = DynamicModelChoiceField(
|
||||
model=Transaction,
|
||||
to_field_name="id",
|
||||
label=_("Transaction"),
|
||||
required=True,
|
||||
queryset=Transaction.objects.none(),
|
||||
widget=TransactionSelect(clear_button=False, income=True, expense=True),
|
||||
help_text=_("Type to search for a transaction"),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
self.helper.layout = Layout(
|
||||
"transaction",
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Test"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
if self.data.get("transaction"):
|
||||
try:
|
||||
transaction = Transaction.objects.get(id=self.data.get("transaction"))
|
||||
except Transaction.DoesNotExist:
|
||||
transaction = None
|
||||
|
||||
if transaction:
|
||||
self.fields["transaction"].queryset = Transaction.objects.filter(
|
||||
id=transaction.id
|
||||
)
|
||||
|
||||
|
||||
class DryRunUpdatedTransactionForm(BulkEditTransactionForm):
|
||||
transaction = DynamicModelChoiceField(
|
||||
model=Transaction,
|
||||
to_field_name="id",
|
||||
label=_("Transaction"),
|
||||
required=True,
|
||||
queryset=Transaction.objects.none(),
|
||||
widget=TransactionSelect(clear_button=False, income=True, expense=True),
|
||||
help_text=_("Type to search for a transaction"),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.helper.layout.insert(0, "transaction")
|
||||
self.helper.layout.insert(1, HTML("<hr/>"))
|
||||
|
||||
# Change submit button
|
||||
self.helper.layout[-1] = FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Test"), css_class="btn btn-outline-primary w-100"
|
||||
)
|
||||
)
|
||||
|
||||
if self.data.get("transaction"):
|
||||
try:
|
||||
transaction = Transaction.objects.get(id=self.data.get("transaction"))
|
||||
except Transaction.DoesNotExist:
|
||||
transaction = None
|
||||
|
||||
if transaction:
|
||||
self.fields["transaction"].queryset = Transaction.objects.filter(
|
||||
id=transaction.id
|
||||
)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# Generated by Django 5.2 on 2025-08-30 18:48
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("rules", "0014_alter_transactionrule_owner_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name="transactionruleaction",
|
||||
options={
|
||||
"ordering": ["order"],
|
||||
"verbose_name": "Edit transaction action",
|
||||
"verbose_name_plural": "Edit transaction actions",
|
||||
},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name="updateorcreatetransactionruleaction",
|
||||
options={
|
||||
"ordering": ["order"],
|
||||
"verbose_name": "Update or create transaction action",
|
||||
"verbose_name_plural": "Update or create transaction actions",
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="transactionruleaction",
|
||||
name="order",
|
||||
field=models.PositiveIntegerField(default=0, verbose_name="Order"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="updateorcreatetransactionruleaction",
|
||||
name="order",
|
||||
field=models.PositiveIntegerField(default=0, verbose_name="Order"),
|
||||
),
|
||||
]
|
||||
18
app/apps/rules/migrations/0016_transactionrule_sequenced.py
Normal file
18
app/apps/rules/migrations/0016_transactionrule_sequenced.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-31 18:42
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('rules', '0015_alter_transactionruleaction_options_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='transactionrule',
|
||||
name='sequenced',
|
||||
field=models.BooleanField(default=False, verbose_name='Sequenced'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-31 19:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('rules', '0016_transactionrule_sequenced'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='search_mute',
|
||||
field=models.TextField(blank=True, verbose_name='Search Mute'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='search_mute_operator',
|
||||
field=models.CharField(choices=[('exact', 'is exactly'), ('contains', 'contains'), ('startswith', 'starts with'), ('endswith', 'ends with'), ('eq', 'equals'), ('gt', 'greater than'), ('lt', 'less than'), ('gte', 'greater than or equal'), ('lte', 'less than or equal')], default='exact', max_length=10, verbose_name='Mute Operator'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='set_mute',
|
||||
field=models.TextField(blank=True, verbose_name='Mute'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='transactionruleaction',
|
||||
name='field',
|
||||
field=models.CharField(choices=[('account', 'Account'), ('type', 'Type'), ('is_paid', 'Paid'), ('date', 'Date'), ('reference_date', 'Reference Date'), ('mute', 'Mute'), ('amount', 'Amount'), ('description', 'Description'), ('notes', 'Notes'), ('category', 'Category'), ('tags', 'Tags'), ('entities', 'Entities'), ('internal_nome', 'Internal Note'), ('internal_id', 'Internal ID')], max_length=50, verbose_name='Field'),
|
||||
),
|
||||
]
|
||||
18
app/apps/rules/migrations/0018_transactionrule_order.py
Normal file
18
app/apps/rules/migrations/0018_transactionrule_order.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.5 on 2025-09-02 14:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('rules', '0017_updateorcreatetransactionruleaction_search_mute_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='transactionrule',
|
||||
name='order',
|
||||
field=models.PositiveIntegerField(default=0, verbose_name='Order'),
|
||||
),
|
||||
]
|
||||
@@ -13,6 +13,11 @@ class TransactionRule(SharedObject):
|
||||
name = models.CharField(max_length=100, verbose_name=_("Name"))
|
||||
description = models.TextField(blank=True, null=True, verbose_name=_("Description"))
|
||||
trigger = models.TextField(verbose_name=_("Trigger"))
|
||||
sequenced = models.BooleanField(
|
||||
verbose_name=_("Sequenced"),
|
||||
default=False,
|
||||
)
|
||||
order = models.PositiveIntegerField(default=0, verbose_name=_("Order"))
|
||||
|
||||
objects = SharedObjectManager()
|
||||
all_objects = models.Manager() # Unfiltered manager
|
||||
@@ -32,12 +37,15 @@ class TransactionRuleAction(models.Model):
|
||||
is_paid = "is_paid", _("Paid")
|
||||
date = "date", _("Date")
|
||||
reference_date = "reference_date", _("Reference Date")
|
||||
mute = "mute", _("Mute")
|
||||
amount = "amount", _("Amount")
|
||||
description = "description", _("Description")
|
||||
notes = "notes", _("Notes")
|
||||
category = "category", _("Category")
|
||||
tags = "tags", _("Tags")
|
||||
entities = "entities", _("Entities")
|
||||
internal_note = "internal_nome", _("Internal Note")
|
||||
internal_id = "internal_id", _("Internal ID")
|
||||
|
||||
rule = models.ForeignKey(
|
||||
TransactionRule,
|
||||
@@ -51,6 +59,7 @@ class TransactionRuleAction(models.Model):
|
||||
verbose_name=_("Field"),
|
||||
)
|
||||
value = models.TextField(verbose_name=_("Value"))
|
||||
order = models.PositiveIntegerField(default=0, verbose_name=_("Order"))
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.rule} - {self.field} - {self.value}"
|
||||
@@ -59,6 +68,11 @@ class TransactionRuleAction(models.Model):
|
||||
verbose_name = _("Edit transaction action")
|
||||
verbose_name_plural = _("Edit transaction actions")
|
||||
unique_together = (("rule", "field"),)
|
||||
ordering = ["order"]
|
||||
|
||||
@property
|
||||
def action_type(self):
|
||||
return "edit_transaction"
|
||||
|
||||
|
||||
class UpdateOrCreateTransactionRuleAction(models.Model):
|
||||
@@ -237,6 +251,17 @@ class UpdateOrCreateTransactionRuleAction(models.Model):
|
||||
verbose_name="Internal ID Operator",
|
||||
)
|
||||
|
||||
search_mute = models.TextField(
|
||||
verbose_name="Search Mute",
|
||||
blank=True,
|
||||
)
|
||||
search_mute_operator = models.CharField(
|
||||
max_length=10,
|
||||
choices=SearchOperator.choices,
|
||||
default=SearchOperator.EXACT,
|
||||
verbose_name="Mute Operator",
|
||||
)
|
||||
|
||||
# Set fields
|
||||
set_account = models.TextField(
|
||||
verbose_name=_("Account"),
|
||||
@@ -290,10 +315,21 @@ class UpdateOrCreateTransactionRuleAction(models.Model):
|
||||
verbose_name=_("Tags"),
|
||||
blank=True,
|
||||
)
|
||||
set_mute = models.TextField(
|
||||
verbose_name=_("Mute"),
|
||||
blank=True,
|
||||
)
|
||||
|
||||
order = models.PositiveIntegerField(default=0, verbose_name=_("Order"))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Update or create transaction action")
|
||||
verbose_name_plural = _("Update or create transaction actions")
|
||||
ordering = ["order"]
|
||||
|
||||
@property
|
||||
def action_type(self):
|
||||
return "update_or_create_transaction"
|
||||
|
||||
def __str__(self):
|
||||
return f"Update or create transaction action for {self.rule}"
|
||||
@@ -325,6 +361,10 @@ class UpdateOrCreateTransactionRuleAction(models.Model):
|
||||
value = simple.eval(self.search_is_paid)
|
||||
search_query &= add_to_query("is_paid", value, self.search_is_paid_operator)
|
||||
|
||||
if self.search_mute:
|
||||
value = simple.eval(self.search_mute)
|
||||
search_query &= add_to_query("mute", value, self.search_mute_operator)
|
||||
|
||||
if self.search_date:
|
||||
value = simple.eval(self.search_date)
|
||||
search_query &= add_to_query("date", value, self.search_date_operator)
|
||||
|
||||
@@ -9,40 +9,17 @@ from apps.transactions.models import (
|
||||
)
|
||||
from apps.rules.tasks import check_for_transaction_rules
|
||||
from apps.common.middleware.thread_local import get_current_user
|
||||
from apps.rules.utils.transactions import serialize_transaction
|
||||
|
||||
|
||||
@receiver(transaction_created)
|
||||
@receiver(transaction_updated)
|
||||
@receiver(transaction_deleted)
|
||||
def transaction_changed_receiver(sender: Transaction, signal, **kwargs):
|
||||
old_data = kwargs.get("old_data")
|
||||
if signal is transaction_deleted:
|
||||
# Serialize transaction data for processing
|
||||
transaction_data = {
|
||||
"id": sender.id,
|
||||
"account": (sender.account.id, sender.account.name),
|
||||
"account_group": (
|
||||
sender.account.group.id if sender.account.group else None,
|
||||
sender.account.group.name if sender.account.group else None,
|
||||
),
|
||||
"type": str(sender.type),
|
||||
"is_paid": sender.is_paid,
|
||||
"is_asset": sender.account.is_asset,
|
||||
"is_archived": sender.account.is_archived,
|
||||
"category": (
|
||||
sender.category.id if sender.category else None,
|
||||
sender.category.name if sender.category else None,
|
||||
),
|
||||
"date": sender.date.isoformat(),
|
||||
"reference_date": sender.reference_date.isoformat(),
|
||||
"amount": str(sender.amount),
|
||||
"description": sender.description,
|
||||
"notes": sender.notes,
|
||||
"tags": list(sender.tags.values_list("id", "name")),
|
||||
"entities": list(sender.entities.values_list("id", "name")),
|
||||
"deleted": True,
|
||||
"internal_note": sender.internal_note,
|
||||
"internal_id": sender.internal_id,
|
||||
}
|
||||
transaction_data = serialize_transaction(sender, deleted=True)
|
||||
|
||||
check_for_transaction_rules.defer(
|
||||
transaction_data=transaction_data,
|
||||
@@ -59,6 +36,9 @@ def transaction_changed_receiver(sender: Transaction, signal, **kwargs):
|
||||
dca_entry.amount_received = sender.amount
|
||||
dca_entry.save()
|
||||
|
||||
if signal is transaction_updated and old_data:
|
||||
old_data = serialize_transaction(old_data, deleted=False)
|
||||
|
||||
check_for_transaction_rules.defer(
|
||||
instance_id=sender.id,
|
||||
user_id=get_current_user().id,
|
||||
@@ -67,4 +47,5 @@ def transaction_changed_receiver(sender: Transaction, signal, **kwargs):
|
||||
if signal is transaction_created
|
||||
else "transaction_updated"
|
||||
),
|
||||
old_data=old_data,
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -42,6 +42,21 @@ urlpatterns = [
|
||||
views.transaction_rule_take_ownership,
|
||||
name="transaction_rule_take_ownership",
|
||||
),
|
||||
path(
|
||||
"rules/transaction/<int:pk>/dry-run/created/",
|
||||
views.dry_run_rule_created,
|
||||
name="transaction_rule_dry_run_created",
|
||||
),
|
||||
path(
|
||||
"rules/transaction/<int:pk>/dry-run/deleted/",
|
||||
views.dry_run_rule_deleted,
|
||||
name="transaction_rule_dry_run_deleted",
|
||||
),
|
||||
path(
|
||||
"rules/transaction/<int:pk>/dry-run/updated/",
|
||||
views.dry_run_rule_updated,
|
||||
name="transaction_rule_dry_run_updated",
|
||||
),
|
||||
path(
|
||||
"rules/transaction/<int:pk>/share/",
|
||||
views.transaction_rule_share,
|
||||
|
||||
0
app/apps/rules/utils/__init__.py
Normal file
0
app/apps/rules/utils/__init__.py
Normal file
101
app/apps/rules/utils/transactions.py
Normal file
101
app/apps/rules/utils/transactions.py
Normal file
@@ -0,0 +1,101 @@
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db.models import Sum, Value, DecimalField, Case, When, F
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
from apps.transactions.models import (
|
||||
Transaction,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TransactionsGetter:
|
||||
def __init__(self, **filters):
|
||||
self.__queryset = Transaction.objects.filter(**filters)
|
||||
|
||||
def exclude(self, **exclude_filters):
|
||||
self.__queryset = self.__queryset.exclude(**exclude_filters)
|
||||
|
||||
return self
|
||||
|
||||
@property
|
||||
def sum(self):
|
||||
return self.__queryset.aggregate(
|
||||
total=Coalesce(
|
||||
Sum("amount"), Value(Decimal("0")), output_field=DecimalField()
|
||||
)
|
||||
)["total"]
|
||||
|
||||
@property
|
||||
def balance(self):
|
||||
return abs(
|
||||
self.__queryset.aggregate(
|
||||
balance=Coalesce(
|
||||
Sum(
|
||||
Case(
|
||||
When(type=Transaction.Type.EXPENSE, then=-F("amount")),
|
||||
default=F("amount"),
|
||||
output_field=DecimalField(),
|
||||
)
|
||||
),
|
||||
Value(Decimal("0")),
|
||||
output_field=DecimalField(),
|
||||
)
|
||||
)["balance"]
|
||||
)
|
||||
|
||||
@property
|
||||
def raw_balance(self):
|
||||
return self.__queryset.aggregate(
|
||||
balance=Coalesce(
|
||||
Sum(
|
||||
Case(
|
||||
When(type=Transaction.Type.EXPENSE, then=-F("amount")),
|
||||
default=F("amount"),
|
||||
output_field=DecimalField(),
|
||||
)
|
||||
),
|
||||
Value(Decimal("0")),
|
||||
output_field=DecimalField(),
|
||||
)
|
||||
)["balance"]
|
||||
|
||||
|
||||
def serialize_transaction(sender: Transaction, deleted: bool):
|
||||
return {
|
||||
"id": sender.id,
|
||||
"account": (sender.account.id, sender.account.name),
|
||||
"account_group": (
|
||||
sender.account.group.id if sender.account.group else None,
|
||||
sender.account.group.name if sender.account.group else None,
|
||||
),
|
||||
"type": str(sender.type),
|
||||
"is_paid": sender.is_paid,
|
||||
"is_asset": sender.account.is_asset,
|
||||
"is_archived": sender.account.is_archived,
|
||||
"category": (
|
||||
sender.category.id if sender.category else None,
|
||||
sender.category.name if sender.category else None,
|
||||
),
|
||||
"date": sender.date.isoformat(),
|
||||
"reference_date": sender.reference_date.isoformat(),
|
||||
"amount": str(sender.amount),
|
||||
"description": sender.description,
|
||||
"notes": sender.notes,
|
||||
"tags": list(sender.tags.values_list("id", "name")),
|
||||
"entities": list(sender.entities.values_list("id", "name")),
|
||||
"deleted": deleted,
|
||||
"internal_note": sender.internal_note,
|
||||
"internal_id": sender.internal_id,
|
||||
"mute": sender.mute,
|
||||
"installment_id": sender.installment_id if sender.installment_plan else None,
|
||||
"installment_total": (
|
||||
sender.installment_plan.number_of_installments
|
||||
if sender.installment_plan is not None
|
||||
else None
|
||||
),
|
||||
"installment": sender.installment_plan is not None,
|
||||
"recurring_transaction": sender.recurring_transaction is not None,
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
from itertools import chain
|
||||
|
||||
from copy import deepcopy
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db import transaction
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import render, get_object_or_404, redirect
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -10,6 +15,9 @@ from apps.rules.forms import (
|
||||
TransactionRuleForm,
|
||||
TransactionRuleActionForm,
|
||||
UpdateOrCreateTransactionRuleActionForm,
|
||||
DryRunCreatedTransacion,
|
||||
DryRunDeletedTransacion,
|
||||
DryRunUpdatedTransactionForm,
|
||||
)
|
||||
from apps.rules.models import (
|
||||
TransactionRule,
|
||||
@@ -19,6 +27,11 @@ from apps.rules.models import (
|
||||
from apps.common.models import SharedObject
|
||||
from apps.common.forms import SharedObjectForm
|
||||
from apps.common.decorators.demo import disabled_on_demo
|
||||
from apps.rules.tasks import check_for_transaction_rules
|
||||
from apps.common.middleware.thread_local import get_current_user
|
||||
from apps.rules.signals import transaction_created, transaction_updated
|
||||
from apps.rules.utils.transactions import serialize_transaction
|
||||
from apps.transactions.models import Transaction
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -36,7 +49,7 @@ def rules_index(request):
|
||||
@disabled_on_demo
|
||||
@require_http_methods(["GET"])
|
||||
def rules_list(request):
|
||||
transaction_rules = TransactionRule.objects.all().order_by("id")
|
||||
transaction_rules = TransactionRule.objects.all().order_by("order", "id")
|
||||
return render(
|
||||
request,
|
||||
"rules/fragments/list.html",
|
||||
@@ -140,10 +153,20 @@ def transaction_rule_edit(request, transaction_rule_id):
|
||||
def transaction_rule_view(request, transaction_rule_id):
|
||||
transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id)
|
||||
|
||||
edit_actions = transaction_rule.transaction_actions.all()
|
||||
update_or_create_actions = (
|
||||
transaction_rule.update_or_create_transaction_actions.all()
|
||||
)
|
||||
|
||||
all_actions = sorted(
|
||||
chain(edit_actions, update_or_create_actions),
|
||||
key=lambda a: a.order,
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"rules/fragments/transaction_rule/view.html",
|
||||
{"transaction_rule": transaction_rule},
|
||||
{"transaction_rule": transaction_rule, "all_actions": all_actions},
|
||||
)
|
||||
|
||||
|
||||
@@ -406,3 +429,156 @@ def update_or_create_transaction_rule_action_delete(request, pk):
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@disabled_on_demo
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def dry_run_rule_created(request, pk):
|
||||
rule = get_object_or_404(TransactionRule, id=pk)
|
||||
logs = None
|
||||
results = None
|
||||
|
||||
if request.method == "POST":
|
||||
form = DryRunCreatedTransacion(request.POST)
|
||||
if form.is_valid():
|
||||
try:
|
||||
with transaction.atomic():
|
||||
logs, results = check_for_transaction_rules(
|
||||
instance_id=form.cleaned_data["transaction"].id,
|
||||
signal="transaction_created",
|
||||
dry_run=True,
|
||||
rule_id=rule.id,
|
||||
user_id=get_current_user().id,
|
||||
)
|
||||
logs = "\n".join(logs)
|
||||
|
||||
response = render(
|
||||
request,
|
||||
"rules/fragments/transaction_rule/dry_run/created.html",
|
||||
{"form": form, "rule": rule, "logs": logs, "results": results},
|
||||
)
|
||||
|
||||
raise Exception("ROLLBACK")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return response
|
||||
|
||||
else:
|
||||
form = DryRunCreatedTransacion()
|
||||
|
||||
return render(
|
||||
request,
|
||||
"rules/fragments/transaction_rule/dry_run/created.html",
|
||||
{"form": form, "rule": rule, "logs": logs, "results": results},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@disabled_on_demo
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def dry_run_rule_deleted(request, pk):
|
||||
rule = get_object_or_404(TransactionRule, id=pk)
|
||||
logs = None
|
||||
results = None
|
||||
|
||||
if request.method == "POST":
|
||||
form = DryRunDeletedTransacion(request.POST)
|
||||
if form.is_valid():
|
||||
try:
|
||||
with transaction.atomic():
|
||||
logs, results = check_for_transaction_rules(
|
||||
instance_id=form.cleaned_data["transaction"].id,
|
||||
signal="transaction_deleted",
|
||||
dry_run=True,
|
||||
rule_id=rule.id,
|
||||
user_id=get_current_user().id,
|
||||
)
|
||||
logs = "\n".join(logs)
|
||||
|
||||
response = render(
|
||||
request,
|
||||
"rules/fragments/transaction_rule/dry_run/created.html",
|
||||
{"form": form, "rule": rule, "logs": logs, "results": results},
|
||||
)
|
||||
|
||||
raise Exception("ROLLBACK")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return response
|
||||
|
||||
else:
|
||||
form = DryRunDeletedTransacion()
|
||||
|
||||
return render(
|
||||
request,
|
||||
"rules/fragments/transaction_rule/dry_run/deleted.html",
|
||||
{"form": form, "rule": rule, "logs": logs, "results": results},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@disabled_on_demo
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def dry_run_rule_updated(request, pk):
|
||||
rule = get_object_or_404(TransactionRule, id=pk)
|
||||
logs = None
|
||||
results = None
|
||||
|
||||
if request.method == "POST":
|
||||
form = DryRunUpdatedTransactionForm(request.POST)
|
||||
if form.is_valid():
|
||||
base_transaction = Transaction.objects.get(
|
||||
id=request.POST.get("transaction")
|
||||
)
|
||||
old_data = deepcopy(base_transaction)
|
||||
try:
|
||||
with transaction.atomic():
|
||||
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":
|
||||
base_transaction.tags.set(value)
|
||||
elif field_name == "entities":
|
||||
base_transaction.entities.set(value)
|
||||
else:
|
||||
setattr(base_transaction, field_name, value)
|
||||
|
||||
base_transaction.save()
|
||||
|
||||
logs, results = check_for_transaction_rules(
|
||||
instance_id=base_transaction.id,
|
||||
signal="transaction_updated",
|
||||
dry_run=True,
|
||||
rule_id=rule.id,
|
||||
user_id=get_current_user().id,
|
||||
old_data=old_data,
|
||||
)
|
||||
logs = "\n".join(logs) if logs else ""
|
||||
|
||||
response = render(
|
||||
request,
|
||||
"rules/fragments/transaction_rule/dry_run/created.html",
|
||||
{"form": form, "rule": rule, "logs": logs, "results": results},
|
||||
)
|
||||
|
||||
# This will rollback the transaction
|
||||
raise Exception("ROLLBACK")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return response
|
||||
else:
|
||||
form = DryRunUpdatedTransactionForm(initial={"is_paid": None, "type": None})
|
||||
|
||||
return render(
|
||||
request,
|
||||
"rules/fragments/transaction_rule/dry_run/updated.html",
|
||||
{"form": form, "rule": rule, "logs": logs, "results": results},
|
||||
)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from copy import deepcopy
|
||||
|
||||
from crispy_bootstrap5.bootstrap5 import Switch, BS5Accordion
|
||||
from crispy_forms.bootstrap import FormActions, AccordionGroup, AppendedText
|
||||
from crispy_forms.helper import FormHelper
|
||||
@@ -239,11 +241,16 @@ class TransactionForm(forms.ModelForm):
|
||||
def save(self, **kwargs):
|
||||
is_new = not self.instance.id
|
||||
|
||||
if not is_new:
|
||||
old_data = deepcopy(Transaction.objects.get(pk=self.instance.id))
|
||||
else:
|
||||
old_data = None
|
||||
|
||||
instance = super().save(**kwargs)
|
||||
if is_new:
|
||||
transaction_created.send(sender=instance)
|
||||
else:
|
||||
transaction_updated.send(sender=instance)
|
||||
transaction_updated.send(sender=instance, old_data=old_data)
|
||||
|
||||
return instance
|
||||
|
||||
@@ -347,11 +354,6 @@ class QuickTransactionForm(forms.ModelForm):
|
||||
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(
|
||||
@@ -387,35 +389,115 @@ class QuickTransactionForm(forms.ModelForm):
|
||||
)
|
||||
|
||||
|
||||
class BulkEditTransactionForm(TransactionForm):
|
||||
is_paid = forms.NullBooleanField(required=False)
|
||||
class BulkEditTransactionForm(forms.Form):
|
||||
type = forms.ChoiceField(
|
||||
choices=(Transaction.Type.choices),
|
||||
required=False,
|
||||
label=_("Type"),
|
||||
)
|
||||
is_paid = forms.NullBooleanField(
|
||||
required=False,
|
||||
label=_("Paid"),
|
||||
)
|
||||
account = DynamicModelChoiceField(
|
||||
model=Account,
|
||||
required=False,
|
||||
label=_("Account"),
|
||||
queryset=Account.objects.filter(is_archived=False),
|
||||
widget=TomSelect(clear_button=False, group_by="group"),
|
||||
)
|
||||
date = forms.DateField(
|
||||
label=_("Date"),
|
||||
required=False,
|
||||
widget=AirDatePickerInput(clear_button=False),
|
||||
)
|
||||
reference_date = forms.DateField(
|
||||
widget=AirMonthYearPickerInput(),
|
||||
label=_("Reference Date"),
|
||||
required=False,
|
||||
)
|
||||
amount = forms.DecimalField(
|
||||
max_digits=42,
|
||||
decimal_places=30,
|
||||
required=False,
|
||||
label=_("Amount"),
|
||||
widget=ArbitraryDecimalDisplayNumberInput(),
|
||||
)
|
||||
description = forms.CharField(
|
||||
max_length=500, required=False, label=_("Description")
|
||||
)
|
||||
notes = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.Textarea(attrs={"rows": 3}),
|
||||
label=_("Notes"),
|
||||
)
|
||||
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"),
|
||||
queryset=TransactionEntity.objects.all(),
|
||||
)
|
||||
|
||||
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.fields["account"].queryset = Account.objects.filter(
|
||||
is_archived=False,
|
||||
)
|
||||
|
||||
self.helper.layout.insert(
|
||||
0,
|
||||
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/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(
|
||||
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",
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
|
||||
@@ -423,6 +505,9 @@ class BulkEditTransactionForm(TransactionForm):
|
||||
),
|
||||
)
|
||||
|
||||
self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||
self.fields["date"].widget = AirDatePickerInput(clear_button=False)
|
||||
|
||||
|
||||
class TransferForm(forms.Form):
|
||||
from_account = forms.ModelChoiceField(
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import decimal
|
||||
import logging
|
||||
from copy import deepcopy
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.conf import settings
|
||||
@@ -33,13 +35,13 @@ transaction_deleted = Signal()
|
||||
|
||||
class SoftDeleteQuerySet(models.QuerySet):
|
||||
@staticmethod
|
||||
def _emit_signals(instances, created=False):
|
||||
def _emit_signals(instances, created=False, old_data=None):
|
||||
"""Helper to emit signals for multiple instances"""
|
||||
for instance in instances:
|
||||
for i, instance in enumerate(instances):
|
||||
if created:
|
||||
transaction_created.send(sender=instance)
|
||||
else:
|
||||
transaction_updated.send(sender=instance)
|
||||
transaction_updated.send(sender=instance, old_data=old_data[i])
|
||||
|
||||
def bulk_create(self, objs, emit_signal=True, **kwargs):
|
||||
instances = super().bulk_create(objs, **kwargs)
|
||||
@@ -50,22 +52,25 @@ class SoftDeleteQuerySet(models.QuerySet):
|
||||
return instances
|
||||
|
||||
def bulk_update(self, objs, fields, emit_signal=True, **kwargs):
|
||||
old_data = deepcopy(objs)
|
||||
result = super().bulk_update(objs, fields, **kwargs)
|
||||
|
||||
if emit_signal:
|
||||
self._emit_signals(objs, created=False)
|
||||
self._emit_signals(objs, created=False, old_data=old_data)
|
||||
|
||||
return result
|
||||
|
||||
def update(self, emit_signal=True, **kwargs):
|
||||
# Get instances before update
|
||||
instances = list(self)
|
||||
old_data = deepcopy(instances)
|
||||
|
||||
result = super().update(**kwargs)
|
||||
|
||||
if emit_signal:
|
||||
# Refresh instances to get new values
|
||||
refreshed = self.model.objects.filter(pk__in=[obj.pk for obj in instances])
|
||||
self._emit_signals(refreshed, created=False)
|
||||
self._emit_signals(refreshed, created=False, old_data=old_data)
|
||||
|
||||
return result
|
||||
|
||||
@@ -376,7 +381,10 @@ class Transaction(OwnedObject):
|
||||
db_table = "transactions"
|
||||
default_manager_name = "objects"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
def clean_fields(self, *args, **kwargs):
|
||||
if isinstance(self.amount, (str, int, float)):
|
||||
self.amount = decimal.Decimal(str(self.amount))
|
||||
|
||||
self.amount = truncate_decimal(
|
||||
value=self.amount, decimal_places=self.account.currency.decimal_places
|
||||
)
|
||||
@@ -386,6 +394,11 @@ class Transaction(OwnedObject):
|
||||
elif not self.reference_date and self.date:
|
||||
self.reference_date = self.date.replace(day=1)
|
||||
|
||||
super().clean_fields(*args, **kwargs)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# This is not recommended as it will run twice on some cases like form and API saves.
|
||||
# We only do this here because we forgot to independently call it on multiple places.
|
||||
self.full_clean()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@@ -443,12 +456,58 @@ class Transaction(OwnedObject):
|
||||
type_display = self.get_type_display()
|
||||
frmt_date = date(self.date, "SHORT_DATE_FORMAT")
|
||||
account = self.account
|
||||
tags = ", ".join([x.name for x in self.tags.all()]) or _("No tags")
|
||||
tags = (
|
||||
", ".join([x.name for x in self.tags.all()])
|
||||
if self.id
|
||||
else None or _("No tags")
|
||||
)
|
||||
category = self.category or _("No category")
|
||||
amount = localize_number(drop_trailing_zeros(self.amount))
|
||||
description = self.description or _("No description")
|
||||
return f"[{frmt_date}][{type_display}][{account}] {description} • {category} • {tags} • {amount}"
|
||||
|
||||
def deepcopy(self, memo=None):
|
||||
"""
|
||||
Creates a deep copy of the transaction instance.
|
||||
|
||||
This method returns a new, unsaved Transaction instance with the same
|
||||
values as the original, including its many-to-many relationships.
|
||||
The primary key and any other unique fields are reset to avoid
|
||||
database integrity errors upon saving.
|
||||
"""
|
||||
if memo is None:
|
||||
memo = {}
|
||||
|
||||
# Create a new instance of the class
|
||||
new_obj = self.__class__()
|
||||
memo[id(self)] = new_obj
|
||||
|
||||
# Copy all concrete fields from the original to the new object
|
||||
for field in self._meta.concrete_fields:
|
||||
# Skip the primary key to allow the database to generate a new one
|
||||
if field.primary_key:
|
||||
continue
|
||||
|
||||
# Reset any unique fields to None to avoid constraint violations
|
||||
if field.unique and field.name == "internal_id":
|
||||
setattr(new_obj, field.name, None)
|
||||
continue
|
||||
|
||||
# Copy the value of the field
|
||||
setattr(new_obj, field.name, getattr(self, field.name))
|
||||
|
||||
# Save the new object to the database to get a primary key
|
||||
new_obj.save()
|
||||
|
||||
# Copy the many-to-many relationships
|
||||
for field in self._meta.many_to_many:
|
||||
source_manager = getattr(self, field.name)
|
||||
destination_manager = getattr(new_obj, field.name)
|
||||
# Set the M2M relationships for the new object
|
||||
destination_manager.set(source_manager.all())
|
||||
|
||||
return new_obj
|
||||
|
||||
|
||||
class InstallmentPlan(models.Model):
|
||||
class Recurrence(models.TextChoices):
|
||||
|
||||
@@ -175,6 +175,6 @@ class RecurringTransactionTests(TestCase):
|
||||
recurrence_type=RecurringTransaction.RecurrenceType.MONTH,
|
||||
recurrence_interval=1,
|
||||
)
|
||||
self.assertFalse(recurring.paused)
|
||||
self.assertFalse(recurring.is_paused)
|
||||
self.assertEqual(recurring.recurrence_interval, 1)
|
||||
self.assertEqual(recurring.account.currency.code, "USD")
|
||||
|
||||
@@ -213,6 +213,7 @@ def transactions_bulk_edit(request):
|
||||
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
|
||||
@@ -225,7 +226,7 @@ def transactions_bulk_edit(request):
|
||||
setattr(transaction, field_name, value)
|
||||
|
||||
transaction.save()
|
||||
transaction_updated.send(sender=transaction)
|
||||
transaction_updated.send(sender=transaction, old_data=old_data)
|
||||
|
||||
messages.success(
|
||||
request,
|
||||
@@ -373,10 +374,13 @@ def transactions_transfer(request):
|
||||
@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)
|
||||
|
||||
transaction_updated.send(sender=transaction, old_data=old_data)
|
||||
|
||||
response = render(
|
||||
request,
|
||||
@@ -394,11 +398,12 @@ def transaction_pay(request, transaction_id):
|
||||
@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)
|
||||
transaction_updated.send(sender=transaction, old_data=old_data)
|
||||
|
||||
response = render(
|
||||
request,
|
||||
@@ -414,19 +419,20 @@ def transaction_mute(request, transaction_id):
|
||||
@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)
|
||||
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)
|
||||
transaction_updated.send(sender=transaction, old_data=old_data)
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
@@ -440,9 +446,11 @@ def transaction_change_month(request, transaction_id, change_type):
|
||||
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)
|
||||
transaction_updated.send(sender=transaction, old_data=old_data)
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
|
||||
@@ -79,7 +79,7 @@ def yearly_overview_by_currency(request, year: int):
|
||||
currency = request.GET.get("currency")
|
||||
|
||||
# Base query filter
|
||||
filter_params = {"reference_date__year": year, "account__is_archived": False}
|
||||
filter_params = {"reference_date__year": year}
|
||||
|
||||
# Add month filter if provided
|
||||
if month:
|
||||
|
||||
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
3450
app/locale/id/LC_MESSAGES/django.po
Normal file
3450
app/locale/id/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
3510
app/locale/it/LC_MESSAGES/django.po
Normal file
3510
app/locale/it/LC_MESSAGES/django.po
Normal file
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
@@ -1,8 +1,9 @@
|
||||
{% load markdown %}
|
||||
{% load i18n %}
|
||||
<div class="transaction {% if transaction.type == "EX" %}expense{% else %}income{% endif %} tw:group/transaction tw:relative tw:hover:z-10">
|
||||
<div
|
||||
class="transaction {% if transaction.type == "EX" %}expense{% else %}income{% endif %} tw:group/transaction tw:relative tw:hover:z-10">
|
||||
<div class="d-flex my-1">
|
||||
{% if not disable_selection %}
|
||||
{% if not disable_selection or not dummy %}
|
||||
<label class="px-3 d-flex align-items-center justify-content-center">
|
||||
<input class="form-check-input" type="checkbox" name="transactions" value="{{ transaction.id }}"
|
||||
id="check-{{ transaction.id }}" aria-label="{% translate 'Select' %}" hx-preserve>
|
||||
@@ -19,9 +20,10 @@
|
||||
<a class="text-decoration-none p-3 tw:text-gray-500!"
|
||||
title="{% if transaction.is_paid %}{% trans 'Paid' %}{% else %}{% trans 'Projected' %}{% endif %}"
|
||||
role="button"
|
||||
{% if not dummy %}
|
||||
hx-get="{% url 'transaction_pay' transaction_id=transaction.id %}"
|
||||
hx-target="closest .transaction"
|
||||
hx-swap="outerHTML">
|
||||
hx-swap="outerHTML"{% endif %}>
|
||||
{% if transaction.is_paid %}<i class="fa-regular fa-circle-check"></i>{% else %}<i
|
||||
class="fa-regular fa-circle"></i>{% endif %}
|
||||
</a>
|
||||
@@ -33,7 +35,8 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-lg col-12 {% if transaction.account.is_untracked_by or transaction.category.mute or transaction.mute %}tw:brightness-80{% endif %}">
|
||||
<div
|
||||
class="col-lg col-12 {% if transaction.account.is_untracked_by or transaction.category.mute or transaction.mute %}tw:brightness-80{% endif %}">
|
||||
{# Date#}
|
||||
<div class="row mb-2 mb-lg-1 tw:text-gray-400">
|
||||
<div class="col-auto pe-1"><i class="fa-solid fa-calendar fa-fw me-1 fa-xs"></i></div>
|
||||
@@ -58,14 +61,20 @@
|
||||
</div>
|
||||
<div class="tw:text-gray-400 tw:text-sm">
|
||||
{# Entities #}
|
||||
{% with transaction.entities.all as entities %}
|
||||
{% if entities %}
|
||||
<div class="row mb-2 mb-lg-1 tw:text-gray-400">
|
||||
<div class="col-auto pe-1"><i class="fa-solid fa-user-group fa-fw me-1 fa-xs"></i></div>
|
||||
<div class="col ps-0">{{ entities|join:", " }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% comment %} First, check for the highest priority: a valid 'overriden_entities' list. {% endcomment %}
|
||||
{% if overriden_entities %}
|
||||
<div class="row mb-2 mb-lg-1 tw:text-gray-400">
|
||||
<div class="col-auto pe-1"><i class="fa-solid fa-user-group fa-fw me-1 fa-xs"></i></div>
|
||||
<div class="col ps-0">{{ overriden_entities|join:", " }}</div>
|
||||
</div>
|
||||
|
||||
{% comment %} If no override, fall back to transaction entities, but ONLY if the transaction has an ID. {% endcomment %}
|
||||
{% elif transaction.id and transaction.entities.all %}
|
||||
<div class="row mb-2 mb-lg-1 tw:text-gray-400">
|
||||
<div class="col-auto pe-1"><i class="fa-solid fa-user-group fa-fw me-1 fa-xs"></i></div>
|
||||
<div class="col ps-0">{{ transaction.entities.all|join:", " }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{# Notes#}
|
||||
{% if transaction.notes %}
|
||||
<div class="row mb-2 mb-lg-1 tw:text-gray-400">
|
||||
@@ -81,17 +90,24 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
{# Tags#}
|
||||
{% with transaction.tags.all as tags %}
|
||||
{% if tags %}
|
||||
<div class="row mb-2 mb-lg-1 tw:text-gray-400">
|
||||
<div class="col-auto pe-1"><i class="fa-solid fa-hashtag fa-fw me-1 fa-xs"></i></div>
|
||||
<div class="col ps-0">{{ tags|join:", " }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% comment %} First, check for the highest priority: a valid 'overriden_tags' list. {% endcomment %}
|
||||
{% if overriden_tags %}
|
||||
<div class="row mb-2 mb-lg-1 tw:text-gray-400">
|
||||
<div class="col-auto pe-1"><i class="fa-solid fa-hashtag fa-fw me-1 fa-xs"></i></div>
|
||||
<div class="col ps-0">{{ overriden_tags|join:", " }}</div>
|
||||
</div>
|
||||
|
||||
{% comment %} If no override, fall back to transaction tags, but ONLY if the transaction has an ID. {% endcomment %}
|
||||
{% elif transaction.id and transaction.tags.all %}
|
||||
<div class="row mb-2 mb-lg-1 tw:text-gray-400">
|
||||
<div class="col-auto pe-1"><i class="fa-solid fa-hashtag fa-fw me-1 fa-xs"></i></div>
|
||||
<div class="col ps-0">{{ transaction.tags.all|join:", " }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-auto col-12 text-lg-end align-self-end {% if transaction.account.is_untracked_by or transaction.category.mute or transaction.mute %}tw:brightness-80{% endif %}">
|
||||
<div
|
||||
class="col-lg-auto col-12 text-lg-end align-self-end {% if transaction.account.is_untracked_by or transaction.category.mute or transaction.mute %}tw:brightness-80{% endif %}">
|
||||
<div class="main-amount mb-2 mb-lg-0">
|
||||
<c-amount.display
|
||||
:amount="transaction.amount"
|
||||
@@ -101,107 +117,136 @@
|
||||
color="{% if transaction.type == "EX" %}red{% else %}green{% endif %}"></c-amount.display>
|
||||
</div>
|
||||
{# Exchange Rate#}
|
||||
{% with exchanged=transaction.exchanged_amount %}
|
||||
{% if exchanged %}
|
||||
<div class="exchanged-amount mb-2 mb-lg-0">
|
||||
<c-amount.display
|
||||
:amount="exchanged.amount"
|
||||
:prefix="exchanged.prefix"
|
||||
:suffix="exchanged.suffix"
|
||||
:decimal_places="exchanged.decimal_places"
|
||||
color="grey"></c-amount.display>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% if not dummy %}
|
||||
{% with exchanged=transaction.exchanged_amount %}
|
||||
{% if exchanged %}
|
||||
<div class="exchanged-amount mb-2 mb-lg-0">
|
||||
<c-amount.display
|
||||
:amount="exchanged.amount"
|
||||
:prefix="exchanged.prefix"
|
||||
:suffix="exchanged.suffix"
|
||||
:decimal_places="exchanged.decimal_places"
|
||||
color="grey"></c-amount.display>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
<div>
|
||||
{% if transaction.account.group %}{{ transaction.account.group.name }} • {% endif %}{{ transaction.account.name }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{# Item actions#}
|
||||
<div
|
||||
class="transaction-actions tw:absolute! tw:left-1/2 tw:top-0 tw:-translate-x-1/2 tw:-translate-y-1/2 tw:invisible tw:group-hover/transaction:visible d-flex flex-row card">
|
||||
<div class="card-body p-1 shadow-lg d-flex flex-row gap-1">
|
||||
{% if not transaction.deleted %}
|
||||
<a class="btn btn-secondary btn-sm transaction-action"
|
||||
role="button"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate "Edit" %}"
|
||||
hx-get="{% url 'transaction_edit' transaction_id=transaction.id %}"
|
||||
hx-target="#generic-offcanvas" hx-swap="innerHTML">
|
||||
<i class="fa-solid fa-pencil fa-fw"></i></a>
|
||||
<a class="btn btn-secondary btn-sm transaction-action"
|
||||
role="button"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate "Delete" %}"
|
||||
hx-delete="{% url 'transaction_delete' transaction_id=transaction.id %}"
|
||||
hx-trigger='confirmed'
|
||||
data-bypass-on-ctrl="true"
|
||||
data-title="{% translate "Are you sure?" %}"
|
||||
data-text="{% translate "You won't be able to revert this!" %}"
|
||||
data-confirm-text="{% translate "Yes, delete it!" %}"
|
||||
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw text-danger"></i>
|
||||
</a>
|
||||
<button class="btn btn-secondary btn-sm transaction-action" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="fa-solid fa-ellipsis fa-fw"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-md-start">
|
||||
{% if transaction.account.is_untracked_by %}
|
||||
<li>
|
||||
<a class="dropdown-item disabled d-flex align-items-center" aria-disabled="true">
|
||||
<i class="fa-solid fa-eye fa-fw me-2"></i>
|
||||
<div>
|
||||
{% translate 'Show on summaries' %}
|
||||
<div class="d-block text-body-secondary tw:text-xs tw:font-medium">{% translate 'Controlled by account' %}</div>
|
||||
</div>
|
||||
</a>
|
||||
{% if not dummy %}
|
||||
<div>
|
||||
{# Item actions#}
|
||||
<div
|
||||
class="transaction-actions tw:absolute! tw:left-1/2 tw:top-0 tw:-translate-x-1/2 tw:-translate-y-1/2 tw:invisible tw:group-hover/transaction:visible d-flex flex-row card">
|
||||
<div class="card-body p-1 shadow-lg d-flex flex-row gap-1">
|
||||
{% if not transaction.deleted %}
|
||||
<a class="btn btn-secondary btn-sm transaction-action"
|
||||
role="button"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate "Edit" %}"
|
||||
hx-get="{% url 'transaction_edit' transaction_id=transaction.id %}"
|
||||
hx-target="#generic-offcanvas" hx-swap="innerHTML">
|
||||
<i class="fa-solid fa-pencil fa-fw"></i></a>
|
||||
<a class="btn btn-secondary btn-sm transaction-action"
|
||||
role="button"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate "Delete" %}"
|
||||
hx-delete="{% url 'transaction_delete' transaction_id=transaction.id %}"
|
||||
hx-trigger='confirmed'
|
||||
data-bypass-on-ctrl="true"
|
||||
data-title="{% translate "Are you sure?" %}"
|
||||
data-text="{% translate "You won't be able to revert this!" %}"
|
||||
data-confirm-text="{% translate "Yes, delete it!" %}"
|
||||
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw text-danger"></i>
|
||||
</a>
|
||||
<button class="btn btn-secondary btn-sm transaction-action" type="button" data-bs-toggle="dropdown"
|
||||
aria-expanded="false">
|
||||
<i class="fa-solid fa-ellipsis fa-fw"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-md-start">
|
||||
{% if transaction.account.is_untracked_by %}
|
||||
<li>
|
||||
<a class="dropdown-item disabled d-flex align-items-center" aria-disabled="true">
|
||||
<i class="fa-solid fa-eye fa-fw me-2"></i>
|
||||
<div>
|
||||
{% translate 'Show on summaries' %}
|
||||
<div
|
||||
class="d-block text-body-secondary tw:text-xs tw:font-medium">{% translate 'Controlled by account' %}</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
{% elif transaction.category.mute %}
|
||||
<li>
|
||||
<a class="dropdown-item disabled d-flex align-items-center" aria-disabled="true">
|
||||
<i class="fa-solid fa-eye fa-fw me-2"></i>
|
||||
<div>
|
||||
{% translate 'Show on summaries' %}
|
||||
<div
|
||||
class="d-block text-body-secondary tw:text-xs tw:font-medium">{% translate 'Controlled by category' %}</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
{% elif transaction.mute %}
|
||||
<li><a class="dropdown-item" href="#"
|
||||
hx-get="{% url 'transaction_mute' transaction_id=transaction.id %}"
|
||||
hx-target="closest .transaction" hx-swap="outerHTML"><i
|
||||
class="fa-solid fa-eye fa-fw me-2"></i>{% translate 'Show on summaries' %}</a></li>
|
||||
{% else %}
|
||||
<li><a class="dropdown-item" href="#"
|
||||
hx-get="{% url 'transaction_mute' transaction_id=transaction.id %}"
|
||||
hx-target="closest .transaction" hx-swap="outerHTML"><i
|
||||
class="fa-solid fa-eye-slash fa-fw me-2"></i>{% translate 'Hide from summaries' %}</a></li>
|
||||
{% endif %}
|
||||
<li><a class="dropdown-item" href="#"
|
||||
hx-get="{% url 'quick_transaction_add_as_quick_transaction' transaction_id=transaction.id %}"><i
|
||||
class="fa-solid fa-person-running fa-fw me-2"></i>{% translate 'Add as quick transaction' %}</a>
|
||||
</li>
|
||||
{% elif transaction.category.mute %}
|
||||
<li>
|
||||
<a class="dropdown-item disabled d-flex align-items-center" aria-disabled="true">
|
||||
<i class="fa-solid fa-eye fa-fw me-2"></i>
|
||||
<div>
|
||||
{% translate 'Show on summaries' %}
|
||||
<div class="d-block text-body-secondary tw:text-xs tw:font-medium">{% translate 'Controlled by category' %}</div>
|
||||
</div>
|
||||
</a>
|
||||
<hr class="dropdown-divider">
|
||||
</li>
|
||||
{% elif transaction.mute %}
|
||||
<li><a class="dropdown-item" href="#" hx-get="{% url 'transaction_mute' transaction_id=transaction.id %}" hx-target="closest .transaction" hx-swap="outerHTML"><i class="fa-solid fa-eye fa-fw me-2"></i>{% translate 'Show on summaries' %}</a></li>
|
||||
{% else %}
|
||||
<li><a class="dropdown-item" href="#" hx-get="{% url 'transaction_mute' transaction_id=transaction.id %}" hx-target="closest .transaction" hx-swap="outerHTML"><i class="fa-solid fa-eye-slash fa-fw me-2"></i>{% translate 'Hide from summaries' %}</a></li>
|
||||
{% endif %}
|
||||
<li><a class="dropdown-item" href="#" hx-get="{% url 'quick_transaction_add_as_quick_transaction' transaction_id=transaction.id %}"><i class="fa-solid fa-person-running fa-fw me-2"></i>{% translate 'Add as quick transaction' %}</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="#" hx-get="{% url 'transaction_change_month' transaction_id=transaction.id change_type='previous' %}"><i class="fa-solid fa-calendar-minus fa-fw me-2"></i>{% translate 'Move to previous month' %}</a></li>
|
||||
<li><a class="dropdown-item" href="#" hx-get="{% url 'transaction_change_month' transaction_id=transaction.id change_type='next' %}"><i class="fa-solid fa-calendar-plus fa-fw me-2"></i>{% translate 'Move to next month' %}</a></li>
|
||||
<li><a class="dropdown-item" href="#" hx-get="{% url 'transaction_move_to_today' transaction_id=transaction.id %}"><i class="fa-solid fa-calendar-day fa-fw me-2"></i>{% translate 'Move to today' %}</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="#" hx-get="{% url 'transaction_clone' transaction_id=transaction.id %}"><i class="fa-solid fa-clone fa-fw me-2"></i>{% translate 'Duplicate' %}</a></li>
|
||||
</ul>
|
||||
{% else %}
|
||||
<a class="btn btn-secondary btn-sm transaction-action"
|
||||
role="button"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate "Restore" %}"
|
||||
hx-get="{% url 'transaction_undelete' transaction_id=transaction.id %}"><i
|
||||
class="fa-solid fa-trash-arrow-up"></i></a>
|
||||
<a class="btn btn-secondary btn-sm transaction-action"
|
||||
role="button"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate "Delete" %}"
|
||||
hx-delete="{% url 'transaction_delete' transaction_id=transaction.id %}"
|
||||
hx-trigger='confirmed'
|
||||
data-bypass-on-ctrl="true"
|
||||
data-title="{% translate "Are you sure?" %}"
|
||||
data-text="{% translate "You won't be able to revert this!" %}"
|
||||
data-confirm-text="{% translate "Yes, delete it!" %}"
|
||||
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw text-danger"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
<li><a class="dropdown-item" href="#"
|
||||
hx-get="{% url 'transaction_change_month' transaction_id=transaction.id change_type='previous' %}"><i
|
||||
class="fa-solid fa-calendar-minus fa-fw me-2"></i>{% translate 'Move to previous month' %}</a>
|
||||
</li>
|
||||
<li><a class="dropdown-item" href="#"
|
||||
hx-get="{% url 'transaction_change_month' transaction_id=transaction.id change_type='next' %}"><i
|
||||
class="fa-solid fa-calendar-plus fa-fw me-2"></i>{% translate 'Move to next month' %}</a></li>
|
||||
<li><a class="dropdown-item" href="#"
|
||||
hx-get="{% url 'transaction_move_to_today' transaction_id=transaction.id %}"><i
|
||||
class="fa-solid fa-calendar-day fa-fw me-2"></i>{% translate 'Move to today' %}</a></li>
|
||||
<li>
|
||||
<hr class="dropdown-divider">
|
||||
</li>
|
||||
<li><a class="dropdown-item" href="#"
|
||||
hx-get="{% url 'transaction_clone' transaction_id=transaction.id %}"><i
|
||||
class="fa-solid fa-clone fa-fw me-2"></i>{% translate 'Duplicate' %}</a></li>
|
||||
</ul>
|
||||
{% else %}
|
||||
<a class="btn btn-secondary btn-sm transaction-action"
|
||||
role="button"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate "Restore" %}"
|
||||
hx-get="{% url 'transaction_undelete' transaction_id=transaction.id %}"><i
|
||||
class="fa-solid fa-trash-arrow-up"></i></a>
|
||||
<a class="btn btn-secondary btn-sm transaction-action"
|
||||
role="button"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate "Delete" %}"
|
||||
hx-delete="{% url 'transaction_delete' transaction_id=transaction.id %}"
|
||||
hx-trigger='confirmed'
|
||||
data-bypass-on-ctrl="true"
|
||||
data-title="{% translate "Are you sure?" %}"
|
||||
data-text="{% translate "You won't be able to revert this!" %}"
|
||||
data-confirm-text="{% translate "Yes, delete it!" %}"
|
||||
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw text-danger"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<th scope="col" class="col-auto"></th>
|
||||
<th scope="col" class="col-auto">{% translate 'Code' %}</th>
|
||||
<th scope="col" class="col">{% translate 'Name' %}</th>
|
||||
<th scope="col" class="col">{% translate 'Archived' %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -53,6 +54,7 @@
|
||||
</td>
|
||||
<td class="col-auto">{{ currency.code }}</td>
|
||||
<td class="col">{{ currency.name }}</td>
|
||||
<td class="col">{% if currency.is_archived %}<i class="fa-solid fa-solid fa-check text-success"></i>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
@@ -12,6 +12,4 @@
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{% static 'img/favicon/favicon-32x32.png' %}">
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="{% static 'img/favicon/favicon-96x96.png' %}">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="{% static 'img/favicon/favicon-16x16.png' %}">
|
||||
<meta name="msapplication-TileColor" content="#ffffff">
|
||||
<meta name="msapplication-TileImage" content="{% static 'img/favicon/ms-icon-144x144.png' %}">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
|
||||
@@ -10,18 +10,10 @@
|
||||
<nav
|
||||
id="sidebar"
|
||||
hx-boost="true"
|
||||
hx-swap="transition:true"
|
||||
data-bs-scroll="true"
|
||||
class="offcanvas-lg offcanvas-start d-lg-flex flex-column position-fixed top-0 start-0 h-100 bg-body-tertiary shadow-sm tw:z-1020">
|
||||
|
||||
{# <div>#}
|
||||
{# <a href="{% url 'index' %}" class="d-none d-lg-flex tw:justify-start p-3 text-decoration-none sidebar-title">#}
|
||||
{# <img src="{% static 'img/logo-icon.svg' %}" alt="WYGIWYH Logo" height="30" width="30" title="WYGIWYH"/>#}
|
||||
{# <span class="fs-4 fw-bold ms-3">WYGIWYH</span>#}
|
||||
{# </a>#}
|
||||
{##}
|
||||
{##}
|
||||
{# </div>#}
|
||||
|
||||
<div class="d-none d-lg-flex tw:justify-between tw:items-center tw:border-b tw:border-gray-600 tw:lg:flex">
|
||||
<a href="{% url 'index' %}" class="m-0 d-none d-lg-flex tw:justify-start p-3 text-decoration-none sidebar-title">
|
||||
<img src="{% static 'img/logo-icon.svg' %}" alt="WYGIWYH Logo" height="30" width="30" title="WYGIWYH"/>
|
||||
@@ -154,16 +146,16 @@
|
||||
aria-controls="collapsible-panel"
|
||||
class="sidebar-menu-item tw:text-wrap tw:lg:text-nowrap tw:lg:text-sm d-flex align-items-center text-decoration-none p-2 rounded-3 sidebar-item {% active_link views='tags_index||entities_index||categories_index||accounts_index||account_groups_index||currencies_index||exchange_rates_index||rules_index||import_profiles_index||automatic_exchange_rates_index||export_index||users_index' css_class="sidebar-active" %}">
|
||||
<i class="fa-solid fa-toolbox fa-fw"></i>
|
||||
<span
|
||||
class="ms-3 fw-medium tw:lg:group-hover:truncate tw:lg:group-focus:truncate tw:lg:group-hover:text-ellipsis tw:lg:group-focus:text-ellipsis">
|
||||
{% translate 'Management' %}
|
||||
</span>
|
||||
<span class="ms-3 fw-medium tw:lg:group-hover:truncate tw:lg:group-focus:truncate tw:lg:group-hover:text-ellipsis tw:lg:group-focus:text-ellipsis">
|
||||
{% translate 'Management' %}
|
||||
</span>
|
||||
<i class="fa-solid fa-chevron-right fa-fw ms-auto pe-2"></i>
|
||||
</div>
|
||||
</ul>
|
||||
|
||||
<div class="mt-auto p-2 w-100">
|
||||
<div id="collapsible-panel"
|
||||
class="p-0 collapse tw:absolute tw:bottom-0 tw:left-0 tw:w-full tw:z-30 tw:max-h-dvh">
|
||||
class="p-0 collapse tw:absolute tw:bottom-0 tw:left-0 tw:w-full tw:z-30 tw:max-h-dvh {% active_link views='tags_index||entities_index||categories_index||accounts_index||account_groups_index||currencies_index||exchange_rates_index||rules_index||import_profiles_index||automatic_exchange_rates_index||export_index||users_index' css_class="show" %}">
|
||||
<div class="tw:h-dvh tw:backdrop-blur-3xl tw:flex tw:flex-col">
|
||||
<div
|
||||
class="tw:justify-between tw:items-center tw:p-4 tw:border-b tw:border-gray-600 sidebar-submenu-header">
|
||||
|
||||
@@ -268,95 +268,97 @@
|
||||
<!-- Entity rows -->
|
||||
{% if show_entities %}
|
||||
{% for entity_id, entity in tag.entities.items %}
|
||||
<tr class="table-row-nested-2">
|
||||
<td class="ps-5">
|
||||
<i class="fa-solid fa-user-group fa-fw me-2 text-muted"></i>{{ entity.name }}
|
||||
</td>
|
||||
<td>
|
||||
{% for currency in entity.currencies.values %}
|
||||
{% if showing == 'current' and currency.income_current != 0 %}
|
||||
<c-amount.display
|
||||
:amount="currency.income_current"
|
||||
:prefix="currency.currency.prefix"
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"
|
||||
color="green"></c-amount.display>
|
||||
{% elif showing == 'projected' and currency.income_projected != 0 %}
|
||||
<c-amount.display
|
||||
:amount="currency.income_projected"
|
||||
:prefix="currency.currency.prefix"
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"
|
||||
color="green"></c-amount.display>
|
||||
{% elif showing == 'final' and currency.total_income != 0 %}
|
||||
<c-amount.display
|
||||
:amount="currency.total_income"
|
||||
:prefix="currency.currency.prefix"
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"
|
||||
color="green"></c-amount.display>
|
||||
{% else %}
|
||||
<div>-</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td>
|
||||
{% for currency in entity.currencies.values %}
|
||||
{% if showing == 'current' and currency.expense_current != 0 %}
|
||||
<c-amount.display
|
||||
:amount="currency.expense_current"
|
||||
:prefix="currency.currency.prefix"
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"
|
||||
color="red"></c-amount.display>
|
||||
{% elif showing == 'projected' and currency.expense_projected != 0 %}
|
||||
<c-amount.display
|
||||
:amount="currency.expense_projected"
|
||||
:prefix="currency.currency.prefix"
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"
|
||||
color="red"></c-amount.display>
|
||||
{% elif showing == 'final' and currency.total_expense != 0 %}
|
||||
<c-amount.display
|
||||
:amount="currency.total_expense"
|
||||
:prefix="currency.currency.prefix"
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"
|
||||
color="red"></c-amount.display>
|
||||
{% else %}
|
||||
<div>-</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td>
|
||||
{% for currency in entity.currencies.values %}
|
||||
{% if showing == 'current' and currency.total_current != 0 %}
|
||||
<c-amount.display
|
||||
:amount="currency.total_current"
|
||||
:prefix="currency.currency.prefix"
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"
|
||||
color="{% if currency.total_final < 0 %}red{% else %}green{% endif %}"></c-amount.display>
|
||||
{% elif showing == 'projected' and currency.total_projected != 0 %}
|
||||
<c-amount.display
|
||||
:amount="currency.total_projected"
|
||||
:prefix="currency.currency.prefix"
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"
|
||||
color="{% if currency.total_final < 0 %}red{% else %}green{% endif %}"></c-amount.display>
|
||||
{% elif showing == 'final' and currency.total_final != 0 %}
|
||||
<c-amount.display
|
||||
:amount="currency.total_final"
|
||||
:prefix="currency.currency.prefix"
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"
|
||||
color="{% if currency.total_final < 0 %}red{% else %}green{% endif %}"></c-amount.display>
|
||||
{% else %}
|
||||
<div>-</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
{% if entity.name or not entity.name and tag.entities.values|length > 1 %}
|
||||
<tr class="table-row-nested-2">
|
||||
<td class="ps-5">
|
||||
<i class="fa-solid fa-user-group fa-fw me-2 text-muted"></i>{% if entity.name %}{{ entity.name }}{% else %}{% trans 'No entity' %}{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% for currency in entity.currencies.values %}
|
||||
{% if showing == 'current' and currency.income_current != 0 %}
|
||||
<c-amount.display
|
||||
:amount="currency.income_current"
|
||||
:prefix="currency.currency.prefix"
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"
|
||||
color="green"></c-amount.display>
|
||||
{% elif showing == 'projected' and currency.income_projected != 0 %}
|
||||
<c-amount.display
|
||||
:amount="currency.income_projected"
|
||||
:prefix="currency.currency.prefix"
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"
|
||||
color="green"></c-amount.display>
|
||||
{% elif showing == 'final' and currency.total_income != 0 %}
|
||||
<c-amount.display
|
||||
:amount="currency.total_income"
|
||||
:prefix="currency.currency.prefix"
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"
|
||||
color="green"></c-amount.display>
|
||||
{% else %}
|
||||
<div>-</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td>
|
||||
{% for currency in entity.currencies.values %}
|
||||
{% if showing == 'current' and currency.expense_current != 0 %}
|
||||
<c-amount.display
|
||||
:amount="currency.expense_current"
|
||||
:prefix="currency.currency.prefix"
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"
|
||||
color="red"></c-amount.display>
|
||||
{% elif showing == 'projected' and currency.expense_projected != 0 %}
|
||||
<c-amount.display
|
||||
:amount="currency.expense_projected"
|
||||
:prefix="currency.currency.prefix"
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"
|
||||
color="red"></c-amount.display>
|
||||
{% elif showing == 'final' and currency.total_expense != 0 %}
|
||||
<c-amount.display
|
||||
:amount="currency.total_expense"
|
||||
:prefix="currency.currency.prefix"
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"
|
||||
color="red"></c-amount.display>
|
||||
{% else %}
|
||||
<div>-</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td>
|
||||
{% for currency in entity.currencies.values %}
|
||||
{% if showing == 'current' and currency.total_current != 0 %}
|
||||
<c-amount.display
|
||||
:amount="currency.total_current"
|
||||
:prefix="currency.currency.prefix"
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"
|
||||
color="{% if currency.total_final < 0 %}red{% else %}green{% endif %}"></c-amount.display>
|
||||
{% elif showing == 'projected' and currency.total_projected != 0 %}
|
||||
<c-amount.display
|
||||
:amount="currency.total_projected"
|
||||
:prefix="currency.currency.prefix"
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"
|
||||
color="{% if currency.total_final < 0 %}red{% else %}green{% endif %}"></c-amount.display>
|
||||
{% elif showing == 'final' and currency.total_final != 0 %}
|
||||
<c-amount.display
|
||||
:amount="currency.total_final"
|
||||
:prefix="currency.currency.prefix"
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"
|
||||
color="{% if currency.total_final < 0 %}red{% else %}green{% endif %}"></c-amount.display>
|
||||
{% else %}
|
||||
<div>-</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
<tr>
|
||||
<th scope="col" class="col-auto"></th>
|
||||
<th scope="col" class="col-auto"></th>
|
||||
<th scope="col" class="col-auto">{% translate 'Order' %}</th>
|
||||
<th scope="col" class="col">{% translate 'Name' %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -80,6 +81,9 @@
|
||||
<i class="fa-solid fa-toggle-off tw:text-red-400"></i>{% endif %}
|
||||
</a>
|
||||
</td>
|
||||
<td class="col text-center">
|
||||
<div>{{ rule.order }}</div>
|
||||
</td>
|
||||
<td class="col">
|
||||
<div>{{ rule.name }}</div>
|
||||
<div class="tw:text-gray-400">{{ rule.description }}</div>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{% extends 'extends/offcanvas.html' %}
|
||||
{% load i18n %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block title %}{% trans 'Test' %} - {% trans 'Create' %}{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<form hx-post="{% url 'transaction_rule_dry_run_created' pk=rule.id %}" hx-target="#generic-offcanvas"
|
||||
hx-indicator="#dry-run-created-result, closest form" class="show-loading" novalidate>
|
||||
{% crispy form %}
|
||||
</form>
|
||||
<hr>
|
||||
<div id="dry-run-created-result" class="show-loading">
|
||||
{% include 'rules/fragments/transaction_rule/dry_run/visual.html' with logs=logs results=results %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,16 @@
|
||||
{% extends 'extends/offcanvas.html' %}
|
||||
{% load i18n %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block title %}{% trans 'Test' %} - {% trans 'Delete' %}{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<form hx-post="{% url 'transaction_rule_dry_run_deleted' pk=rule.id %}" hx-target="#generic-offcanvas"
|
||||
hx-indicator="#dry-run-deleted-result, closest form" class="show-loading" novalidate>
|
||||
{% crispy form %}
|
||||
</form>
|
||||
<hr>
|
||||
<div id="dry-run-deleted-result" class="show-loading">
|
||||
{% include 'rules/fragments/transaction_rule/dry_run/visual.html' with logs=logs results=results %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,16 @@
|
||||
{% extends 'extends/offcanvas.html' %}
|
||||
{% load i18n %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block title %}{% trans 'Test' %} - {% trans 'Update' %}{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<form hx-post="{% url 'transaction_rule_dry_run_updated' pk=rule.id %}" hx-target="#generic-offcanvas"
|
||||
hx-indicator="#dry-run-updated-result, closest form" class="show-loading" novalidate>
|
||||
{% crispy form %}
|
||||
</form>
|
||||
<hr>
|
||||
<div id="dry-run-updated-result" class="show-loading">
|
||||
{% include 'rules/fragments/transaction_rule/dry_run/visual.html' with logs=logs results=results %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,101 @@
|
||||
{% load i18n %}
|
||||
<div class="card tw:max-h-full tw:overflow-auto tw:overflow-x-auto">
|
||||
<div class="card-header">
|
||||
<ul class="nav nav-tabs card-header-tabs">
|
||||
<li class="nav-item">
|
||||
<button class="nav-link active" id="visual-tab" data-bs-toggle="tab" data-bs-target="#visual-tab-pane"
|
||||
type="button" role="tab" aria-controls="visual-tab-pane"
|
||||
aria-selected="true">{% translate 'Visual' %}</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="logs-tab" data-bs-toggle="tab" data-bs-target="#logs-tab-pane" type="button"
|
||||
role="tab" aria-controls="logs-tab-pane" aria-selected="false">{% translate 'Logs' %}</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="tab-content" id="myTabContent">
|
||||
<div class="tab-pane fade show active" id="visual-tab-pane" role="tabpanel" aria-labelledby="home-tab"
|
||||
tabindex="0">
|
||||
{% if not results %}
|
||||
{% translate 'Run a test to see...' %}
|
||||
{% else %}
|
||||
{% for result in results %}
|
||||
|
||||
{% if result.type == 'header' %}
|
||||
<div class="my-3">
|
||||
<h6 class="text-center mb-3">
|
||||
<span class="badge text-bg-secondary">
|
||||
{% if result.header_type == "edit_transaction" %}
|
||||
{% translate 'Edit transaction' %}
|
||||
{% elif result.header_type == "update_or_create_transaction" %}
|
||||
{% translate 'Update or create transaction' %}
|
||||
{% endif %}
|
||||
</span>
|
||||
</h6>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if result.type == 'triggering_transaction' %}
|
||||
<div class="mt-4">
|
||||
<h6 class="text-center mb-3"><span class="badge text-bg-secondary">{% translate 'Start' %}</span></h6>
|
||||
<c-transaction.item :transaction="result.transaction" :dummy="True"
|
||||
:disable-selection="True"></c-transaction.item>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if result.type == 'edit_transaction' %}
|
||||
<div>
|
||||
<div>
|
||||
{% translate 'Set' %} <span
|
||||
class="badge text-bg-secondary">{{ result.field }}</span> {% translate 'to' %}
|
||||
<span class="badge text-bg-secondary">{{ result.new_value }}</span>
|
||||
</div>
|
||||
<c-transaction.item :transaction="result.transaction" :dummy="True"
|
||||
:disable-selection="True"></c-transaction.item>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if result.type == 'update_or_create_transaction' %}
|
||||
<div>
|
||||
<div class="alert alert-info" role="alert">
|
||||
{% translate 'Search' %}: {{ result.query }}
|
||||
</div>
|
||||
{% if result.start_transaction %}
|
||||
<c-transaction.item :transaction="result.start_transaction" :dummy="True"
|
||||
:disable-selection="True"></c-transaction.item>
|
||||
{% else %}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
{% translate 'No transaction found, a new one will be created' %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="text-center h3 my-2"><i class="fa-solid fa-arrow-down"></i></div>
|
||||
<c-transaction.item :transaction="result.end_transaction" :dummy="True"
|
||||
:disable-selection="True"></c-transaction.item>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if result.type == 'error' %}
|
||||
<div>
|
||||
<div class="alert alert-{% if result.level == 'error' %}danger{% elif result.level == 'warning' %}warning{% else %}info{% endif %}" role="alert">
|
||||
{{ result.error }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="tab-pane fade" id="logs-tab-pane" role="tabpanel" aria-labelledby="logs-tab" tabindex="0">
|
||||
{% if not logs %}
|
||||
{% translate 'Run a test to see...' %}
|
||||
{% else %}
|
||||
<pre>
|
||||
{{ logs|linebreaks }}
|
||||
</pre>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -30,95 +30,128 @@
|
||||
|
||||
<div class="my-3">
|
||||
<div class="tw:text-xl mb-2">{% translate 'Then...' %}</div>
|
||||
{% for action in transaction_rule.transaction_actions.all %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<div><span class="badge text-bg-primary">{% trans 'Edit transaction' %}</span></div>
|
||||
{% for action in all_actions %}
|
||||
{% if action.action_type == "edit_transaction" %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
{% if action.order != 0 %}<span class="badge text-bg-secondary">{{ action.order }}</span>{% endif %}
|
||||
<span class="badge text-bg-primary">{% trans 'Edit transaction' %}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div>
|
||||
{% translate 'Set' %} <span
|
||||
class="badge text-bg-secondary">{{ action.get_field_display }}</span> {% translate 'to' %}
|
||||
</div>
|
||||
<div class="text-bg-secondary rounded-3 mt-3 p-2">{{ action.value }}</div>
|
||||
</div>
|
||||
<div class="card-footer text-end">
|
||||
<a class="text-decoration-none tw:text-gray-400 p-1"
|
||||
role="button"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate 'Edit' %}"
|
||||
hx-get="{% url 'transaction_rule_action_edit' transaction_rule_action_id=action.id %}"
|
||||
hx-target="#generic-offcanvas">
|
||||
<i class="fa-solid fa-pencil fa-fw"></i>
|
||||
</a>
|
||||
<a class="text-danger text-decoration-none p-1"
|
||||
role="button"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate 'Delete' %}"
|
||||
hx-delete="{% url 'transaction_rule_action_delete' transaction_rule_action_id=action.id %}"
|
||||
hx-trigger='confirmed'
|
||||
data-bypass-on-ctrl="true"
|
||||
data-title="{% translate 'Are you sure?' %}"
|
||||
data-text="{% translate "You won't be able to revert this!" %}"
|
||||
data-confirm-text="{% translate 'Yes, delete it!' %}"
|
||||
_="install prompt_swal">
|
||||
<i class="fa-solid fa-trash fa-fw"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div>{% translate 'Set' %} <span
|
||||
class="badge text-bg-secondary">{{ action.get_field_display }}</span> {% translate 'to' %}</div>
|
||||
<div class="text-bg-secondary rounded-3 mt-3 p-2">{{ action.value }}</div>
|
||||
{% elif action.action_type == "update_or_create_transaction" %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
{% if action.order != 0 %}<span class="badge text-bg-secondary">{{ action.order }}</span>{% endif %}
|
||||
<span class="badge text-bg-primary">{% trans 'Update or create transaction' %}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div>{% trans 'Edit to view' %}</div>
|
||||
</div>
|
||||
<div class="card-footer text-end">
|
||||
<a class="text-decoration-none tw:text-gray-400 p-1"
|
||||
role="button"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate 'Edit' %}"
|
||||
hx-get="{% url 'update_or_create_transaction_rule_action_edit' pk=action.id %}"
|
||||
hx-target="#generic-offcanvas">
|
||||
<i class="fa-solid fa-pencil fa-fw"></i>
|
||||
</a>
|
||||
<a class="text-danger text-decoration-none p-1"
|
||||
role="button"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate 'Delete' %}"
|
||||
hx-delete="{% url 'update_or_create_transaction_rule_action_delete' pk=action.id %}"
|
||||
hx-trigger='confirmed'
|
||||
data-bypass-on-ctrl="true"
|
||||
data-title="{% translate 'Are you sure?' %}"
|
||||
data-text="{% translate "You won't be able to revert this!" %}"
|
||||
data-confirm-text="{% translate 'Yes, delete it!' %}"
|
||||
_="install prompt_swal">
|
||||
<i class="fa-solid fa-trash fa-fw"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer text-end">
|
||||
<a class="text-decoration-none tw:text-gray-400 p-1"
|
||||
role="button"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate "Edit" %}"
|
||||
hx-get="{% url 'transaction_rule_action_edit' transaction_rule_action_id=action.id %}"
|
||||
hx-target="#generic-offcanvas">
|
||||
<i class="fa-solid fa-pencil fa-fw"></i>
|
||||
</a>
|
||||
<a class="text-danger text-decoration-none p-1"
|
||||
role="button"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate "Delete" %}"
|
||||
hx-delete="{% url 'transaction_rule_action_delete' transaction_rule_action_id=action.id %}"
|
||||
hx-trigger='confirmed'
|
||||
data-bypass-on-ctrl="true"
|
||||
data-title="{% translate "Are you sure?" %}"
|
||||
data-text="{% translate "You won't be able to revert this!" %}"
|
||||
data-confirm-text="{% translate "Yes, delete it!" %}"
|
||||
_="install prompt_swal">
|
||||
<i class="fa-solid fa-trash fa-fw"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% for action in transaction_rule.update_or_create_transaction_actions.all %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<div><span class="badge text-bg-primary">{% trans 'Update or create transaction' %}</span></div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div>{% trans 'Edit to view' %}</div>
|
||||
</div>
|
||||
<div class="card-footer text-end">
|
||||
<a class="text-decoration-none tw:text-gray-400 p-1"
|
||||
role="button"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate "Edit" %}"
|
||||
hx-get="{% url 'update_or_create_transaction_rule_action_edit' pk=action.id %}"
|
||||
hx-target="#generic-offcanvas">
|
||||
<i class="fa-solid fa-pencil fa-fw"></i>
|
||||
</a>
|
||||
<a class="text-danger text-decoration-none p-1"
|
||||
role="button"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate "Delete" %}"
|
||||
hx-delete="{% url 'update_or_create_transaction_rule_action_delete' pk=action.id %}"
|
||||
hx-trigger='confirmed'
|
||||
data-bypass-on-ctrl="true"
|
||||
data-title="{% translate "Are you sure?" %}"
|
||||
data-text="{% translate "You won't be able to revert this!" %}"
|
||||
data-confirm-text="{% translate "Yes, delete it!" %}"
|
||||
_="install prompt_swal">
|
||||
<i class="fa-solid fa-trash fa-fw"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if not transaction_rule.update_or_create_transaction_actions.all and not transaction_rule.transaction_actions.all %}
|
||||
{% endif %}
|
||||
{% empty %}
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
{% translate 'This rule has no actions' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<hr>
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-outline-primary text-decoration-none w-100" type="button" data-bs-toggle="dropdown"
|
||||
aria-expanded="false">
|
||||
<i class="fa-solid fa-circle-plus me-2"></i>{% translate 'Add new' %}
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end w-100">
|
||||
<li><a class="dropdown-item" role="link"
|
||||
hx-get="{% url 'transaction_rule_action_add' transaction_rule_id=transaction_rule.id %}"
|
||||
hx-target="#generic-offcanvas">{% trans 'Edit Transaction' %}</a></li>
|
||||
<li><a class="dropdown-item" role="link"
|
||||
hx-get="{% url 'update_or_create_transaction_rule_action_add' transaction_rule_id=transaction_rule.id %}"
|
||||
hx-target="#generic-offcanvas">{% trans 'Update or Create Transaction' %}</a></li>
|
||||
</ul>
|
||||
<div class="d-grid d-lg-flex gap-2">
|
||||
<div class="dropdown flex-fill">
|
||||
<button class="btn btn-outline-primary text-decoration-none w-100" type="button" data-bs-toggle="dropdown"
|
||||
aria-expanded="false">
|
||||
<i class="fa-solid fa-flask-vial me-2"></i>{% translate 'Test' %}
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
{% if transaction_rule.on_create %}
|
||||
<li><a class="dropdown-item" role="link" href="#"
|
||||
hx-get="{% url 'transaction_rule_dry_run_created' pk=transaction_rule.id %}"
|
||||
hx-target="#generic-offcanvas">{% trans 'Create' %}</a></li>
|
||||
{% endif %}
|
||||
{% if transaction_rule.on_update %}
|
||||
<li><a class="dropdown-item" role="link" href="#"
|
||||
hx-get="{% url 'transaction_rule_dry_run_updated' pk=transaction_rule.id %}"
|
||||
hx-target="#generic-offcanvas">{% trans 'Update' %}</a></li>
|
||||
{% endif %}
|
||||
{% if transaction_rule.on_delete %}
|
||||
<li><a class="dropdown-item" role="link" href="#"
|
||||
hx-get="{% url 'transaction_rule_dry_run_deleted' pk=transaction_rule.id %}"
|
||||
hx-target="#generic-offcanvas">{% trans 'Delete' %}</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="dropdown flex-fill">
|
||||
<button class="btn btn-outline-primary text-decoration-none w-100" type="button" data-bs-toggle="dropdown"
|
||||
aria-expanded="false">
|
||||
<i class="fa-solid fa-circle-plus me-2"></i>{% translate 'Add new' %}
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" role="link" href="#"
|
||||
hx-get="{% url 'transaction_rule_action_add' transaction_rule_id=transaction_rule.id %}"
|
||||
hx-target="#generic-offcanvas">{% trans 'Edit Transaction' %}</a></li>
|
||||
<li><a class="dropdown-item" role="link" href="#"
|
||||
hx-get="{% url 'update_or_create_transaction_rule_action_add' transaction_rule_id=transaction_rule.id %}"
|
||||
hx-target="#generic-offcanvas">{% trans 'Update or Create Transaction' %}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,7 @@ services:
|
||||
- ./frontend/:/usr/src/frontend:z
|
||||
- wygiwyh_temp:/usr/src/app/temp/
|
||||
ports:
|
||||
- "${OUTBOUND_PORT}:8000"
|
||||
- "${OUTBOUND_PORT:-8000}:${INTERNAL_PORT:-8000}"
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
|
||||
@@ -4,7 +4,7 @@ services:
|
||||
container_name: ${SERVER_NAME}
|
||||
command: /start-single
|
||||
ports:
|
||||
- "${OUTBOUND_PORT}:8000"
|
||||
- "${OUTBOUND_PORT:-8000}:${INTERNAL_PORT:-8000}"
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
|
||||
@@ -4,6 +4,9 @@ set -o errexit
|
||||
set -o pipefail
|
||||
set -o nounset
|
||||
|
||||
# Set INTERNAL_PORT with default value of 8000
|
||||
INTERNAL_PORT=${INTERNAL_PORT:-8000}
|
||||
|
||||
rm -f /tmp/migrations_complete
|
||||
|
||||
python manage.py migrate
|
||||
@@ -13,4 +16,4 @@ touch /tmp/migrations_complete
|
||||
|
||||
python manage.py setup_users
|
||||
|
||||
exec python manage.py runserver 0.0.0.0:8000
|
||||
exec python manage.py runserver 0.0.0.0:$INTERNAL_PORT
|
||||
|
||||
@@ -4,6 +4,9 @@ set -o errexit
|
||||
set -o pipefail
|
||||
set -o nounset
|
||||
|
||||
# Set INTERNAL_PORT with default value of 8000
|
||||
INTERNAL_PORT=${INTERNAL_PORT:-8000}
|
||||
|
||||
# Remove flag file if it exists from previous run
|
||||
rm -f /tmp/migrations_complete
|
||||
|
||||
@@ -15,4 +18,4 @@ touch /tmp/migrations_complete
|
||||
|
||||
python manage.py setup_users
|
||||
|
||||
exec gunicorn WYGIWYH.wsgi:application --bind 0.0.0.0:8000 --timeout 600
|
||||
exec gunicorn WYGIWYH.wsgi:application --bind 0.0.0.0:$INTERNAL_PORT --timeout 600
|
||||
|
||||
Reference in New Issue
Block a user