Merge pull request #202

feat: multi tenancy support
This commit is contained in:
Herculino Trotta
2025-03-08 12:03:54 -03:00
committed by GitHub
79 changed files with 2401 additions and 399 deletions
+10 -2
View File
@@ -1,6 +1,14 @@
from django.contrib import admin from django.contrib import admin
from apps.accounts.models import Account from apps.accounts.models import Account, AccountGroup
from apps.common.admin import SharedObjectModelAdmin
admin.site.register(Account) @admin.register(Account)
class AccountModelAdmin(SharedObjectModelAdmin):
pass
@admin.register(AccountGroup)
class AccountGroupModelAdmin(SharedObjectModelAdmin):
pass
+8
View File
@@ -77,6 +77,8 @@ class AccountForm(forms.ModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields["group"].queryset = AccountGroup.objects.all()
self.helper = FormHelper() self.helper = FormHelper()
self.helper.form_tag = False self.helper.form_tag = False
self.helper.form_method = "post" self.helper.form_method = "post"
@@ -151,5 +153,11 @@ class AccountBalanceForm(forms.Form):
decimal_places=self.currency_decimal_places decimal_places=self.currency_decimal_places
) )
self.fields["category"].queryset = TransactionCategory.objects.filter(
active=True
)
self.fields["tags"].queryset = TransactionTag.objects.filter(active=True)
AccountBalanceFormSet = forms.formset_factory(AccountBalanceForm, extra=0) AccountBalanceFormSet = forms.formset_factory(AccountBalanceForm, extra=0)
@@ -0,0 +1,31 @@
# Generated by Django 5.1.6 on 2025-03-04 15:07
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0008_alter_account_name'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='account',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_accounts', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
),
migrations.AddField(
model_name='account',
name='shared_with',
field=models.ManyToManyField(blank=True, related_name='shared_accounts', to=settings.AUTH_USER_MODEL, verbose_name='Shared With'),
),
migrations.AddField(
model_name='accountgroup',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_account_groups', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
),
]
@@ -0,0 +1,46 @@
# Generated by Django 5.1.6 on 2025-03-05 02:42
import django.db.models.manager
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0009_account_owner_account_shared_with_accountgroup_owner'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterModelManagers(
name='account',
managers=[
('all_objects', django.db.models.manager.Manager()),
],
),
migrations.AlterModelManagers(
name='accountgroup',
managers=[
('all_objects', django.db.models.manager.Manager()),
],
),
migrations.AlterField(
model_name='account',
name='name',
field=models.CharField(max_length=255, verbose_name='Name'),
),
migrations.AlterField(
model_name='accountgroup',
name='name',
field=models.CharField(max_length=255, verbose_name='Name'),
),
migrations.AlterUniqueTogether(
name='account',
unique_together={('owner', 'name')},
),
migrations.AlterUniqueTogether(
name='accountgroup',
unique_together={('owner', 'name')},
),
]
@@ -0,0 +1,26 @@
# Generated by Django 5.1.6 on 2025-03-05 04:19
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0010_alter_account_managers_alter_accountgroup_managers_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='account',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='owned_accounts', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
),
migrations.AlterField(
model_name='accountgroup',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='owned_account_groups', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
),
]
@@ -0,0 +1,56 @@
# Generated by Django 5.1.6 on 2025-03-05 23:46
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0011_alter_account_owner_alter_accountgroup_owner'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterModelManagers(
name='account',
managers=[
],
),
migrations.AlterModelManagers(
name='accountgroup',
managers=[
],
),
migrations.AddField(
model_name='account',
name='visibility',
field=models.CharField(choices=[('private', 'Private'), ('shared', 'Shared'), ('public', 'Public')], default='private', max_length=10),
),
migrations.AddField(
model_name='accountgroup',
name='shared_with',
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='accountgroup',
name='visibility',
field=models.CharField(choices=[('private', 'Private'), ('shared', 'Shared'), ('public', 'Public')], default='private', max_length=10),
),
migrations.AlterField(
model_name='account',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='account',
name='shared_with',
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='accountgroup',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 5.1.6 on 2025-03-06 01:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0012_alter_account_managers_alter_accountgroup_managers_and_more'),
]
operations = [
migrations.AlterField(
model_name='account',
name='visibility',
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10),
),
migrations.AlterField(
model_name='accountgroup',
name='visibility',
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10),
),
]
+15 -4
View File
@@ -1,24 +1,31 @@
from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.db.models import Q
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from apps.transactions.models import Transaction from apps.transactions.models import Transaction
from apps.common.models import SharedObject, SharedObjectManager
class AccountGroup(models.Model): class AccountGroup(SharedObject):
name = models.CharField(max_length=255, verbose_name=_("Name"), unique=True) name = models.CharField(max_length=255, verbose_name=_("Name"))
objects = SharedObjectManager()
all_objects = models.Manager() # Unfiltered manager
class Meta: class Meta:
verbose_name = _("Account Group") verbose_name = _("Account Group")
verbose_name_plural = _("Account Groups") verbose_name_plural = _("Account Groups")
db_table = "account_groups" db_table = "account_groups"
unique_together = (("owner", "name"),)
def __str__(self): def __str__(self):
return self.name return self.name
class Account(models.Model): class Account(SharedObject):
name = models.CharField(max_length=255, verbose_name=_("Name"), unique=True) name = models.CharField(max_length=255, verbose_name=_("Name"))
group = models.ForeignKey( group = models.ForeignKey(
AccountGroup, AccountGroup,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
@@ -55,9 +62,13 @@ class Account(models.Model):
help_text=_("Archived accounts don't show up nor count towards your net worth"), help_text=_("Archived accounts don't show up nor count towards your net worth"),
) )
objects = SharedObjectManager()
all_objects = models.Manager() # Unfiltered manager
class Meta: class Meta:
verbose_name = _("Account") verbose_name = _("Account")
verbose_name_plural = _("Accounts") verbose_name_plural = _("Accounts")
unique_together = (("owner", "name"),)
def __str__(self): def __str__(self):
return self.name return self.name
+20
View File
@@ -16,11 +16,21 @@ urlpatterns = [
views.account_edit, views.account_edit,
name="account_edit", name="account_edit",
), ),
path(
"account/<int:pk>/share/",
views.account_share,
name="account_share_settings",
),
path( path(
"account/<int:pk>/delete/", "account/<int:pk>/delete/",
views.account_delete, views.account_delete,
name="account_delete", name="account_delete",
), ),
path(
"account/<int:pk>/take-ownership/",
views.account_take_ownership,
name="account_take_ownership",
),
path("account-groups/", views.account_groups_index, name="account_groups_index"), path("account-groups/", views.account_groups_index, name="account_groups_index"),
path("account-groups/list/", views.account_groups_list, name="account_groups_list"), path("account-groups/list/", views.account_groups_list, name="account_groups_list"),
path("account-groups/add/", views.account_group_add, name="account_group_add"), path("account-groups/add/", views.account_group_add, name="account_group_add"),
@@ -34,4 +44,14 @@ urlpatterns = [
views.account_group_delete, views.account_group_delete,
name="account_group_delete", name="account_group_delete",
), ),
path(
"account-groups/<int:pk>/take-ownership/",
views.account_group_take_ownership,
name="account_group_take_ownership",
),
path(
"account-groups/<int:pk>/share/",
views.account_share,
name="account_group_share_settings",
),
] ]
+80 -3
View File
@@ -8,6 +8,8 @@ from django.views.decorators.http import require_http_methods
from apps.accounts.forms import AccountGroupForm from apps.accounts.forms import AccountGroupForm
from apps.accounts.models import AccountGroup from apps.accounts.models import AccountGroup
from apps.common.decorators.htmx import only_htmx from apps.common.decorators.htmx import only_htmx
from apps.common.models import SharedObject
from apps.common.forms import SharedObjectForm
@login_required @login_required
@@ -63,6 +65,16 @@ def account_group_add(request, **kwargs):
def account_group_edit(request, pk): def account_group_edit(request, pk):
account_group = get_object_or_404(AccountGroup, id=pk) account_group = get_object_or_404(AccountGroup, id=pk)
if account_group.owner and account_group.owner != request.user:
messages.error(request, _("Only the owner can edit this"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
if request.method == "POST": if request.method == "POST":
form = AccountGroupForm(request.POST, instance=account_group) form = AccountGroupForm(request.POST, instance=account_group)
if form.is_valid(): if form.is_valid():
@@ -91,9 +103,15 @@ def account_group_edit(request, pk):
def account_group_delete(request, pk): def account_group_delete(request, pk):
account_group = get_object_or_404(AccountGroup, id=pk) account_group = get_object_or_404(AccountGroup, id=pk)
account_group.delete() if (
account_group.owner != request.user
messages.success(request, _("Account Group deleted successfully")) and request.user in account_group.shared_with.all()
):
account_group.shared_with.remove(request.user)
messages.success(request, _("Item no longer shared with you"))
else:
account_group.delete()
messages.success(request, _("Account Group deleted successfully"))
return HttpResponse( return HttpResponse(
status=204, status=204,
@@ -101,3 +119,62 @@ def account_group_delete(request, pk):
"HX-Trigger": "updated, hide_offcanvas", "HX-Trigger": "updated, hide_offcanvas",
}, },
) )
@only_htmx
@login_required
@require_http_methods(["GET"])
def account_group_take_ownership(request, pk):
account_group = get_object_or_404(AccountGroup, id=pk)
if not account_group.owner:
account_group.owner = request.user
account_group.visibility = SharedObject.Visibility.private
account_group.save()
messages.success(request, _("Ownership taken successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def account_share(request, pk):
obj = get_object_or_404(AccountGroup, id=pk)
if obj.owner and obj.owner != request.user:
messages.error(request, _("Only the owner can edit this"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
if request.method == "POST":
form = SharedObjectForm(request.POST, instance=obj, user=request.user)
if form.is_valid():
form.save()
messages.success(request, _("Configuration saved successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
else:
form = SharedObjectForm(instance=obj, user=request.user)
return render(
request,
"accounts/fragments/share.html",
{"form": form, "object": obj},
)
+76 -3
View File
@@ -8,6 +8,8 @@ from django.views.decorators.http import require_http_methods
from apps.accounts.forms import AccountForm from apps.accounts.forms import AccountForm
from apps.accounts.models import Account from apps.accounts.models import Account
from apps.common.decorators.htmx import only_htmx from apps.common.decorators.htmx import only_htmx
from apps.common.models import SharedObject
from apps.common.forms import SharedObjectForm
@login_required @login_required
@@ -62,6 +64,15 @@ def account_add(request, **kwargs):
@require_http_methods(["GET", "POST"]) @require_http_methods(["GET", "POST"])
def account_edit(request, pk): def account_edit(request, pk):
account = get_object_or_404(Account, id=pk) account = get_object_or_404(Account, id=pk)
if account.owner and account.owner != request.user:
messages.error(request, _("Only the owner can edit this"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
if request.method == "POST": if request.method == "POST":
form = AccountForm(request.POST, instance=account) form = AccountForm(request.POST, instance=account)
@@ -85,15 +96,77 @@ def account_edit(request, pk):
) )
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def account_share(request, pk):
obj = get_object_or_404(Account, id=pk)
if obj.owner and obj.owner != request.user:
messages.error(request, _("Only the owner can edit this"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
if request.method == "POST":
form = SharedObjectForm(request.POST, instance=obj, user=request.user)
if form.is_valid():
form.save()
messages.success(request, _("Configuration saved successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
else:
form = SharedObjectForm(instance=obj, user=request.user)
return render(
request,
"accounts/fragments/share.html",
{"form": form, "object": obj},
)
@only_htmx @only_htmx
@login_required @login_required
@require_http_methods(["DELETE"]) @require_http_methods(["DELETE"])
def account_delete(request, pk): def account_delete(request, pk):
account = get_object_or_404(Account, id=pk) account = get_object_or_404(Account, id=pk)
account.delete() if account.owner != request.user and request.user in account.shared_with.all():
account.shared_with.remove(request.user)
messages.success(request, _("Account deleted successfully")) messages.success(request, _("Item no longer shared with you"))
else:
account.delete()
messages.success(request, _("Account deleted successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def account_take_ownership(request, pk):
account = get_object_or_404(Account, id=pk)
if not account.owner:
account.owner = request.user
account.visibility = SharedObject.Visibility.private
account.save()
messages.success(request, _("Ownership taken successfully"))
return HttpResponse( return HttpResponse(
status=204, status=204,
+3 -3
View File
@@ -38,9 +38,9 @@ def account_reconciliation(request):
"prefix": account.currency.prefix, "prefix": account.currency.prefix,
"current_balance": get_account_balance(account), "current_balance": get_account_balance(account),
} }
for account in Account.objects.filter(is_archived=False).select_related( for account in Account.objects.filter(is_archived=False)
"currency", "group" .select_related("currency", "group")
) .order_by("group", "name")
] ]
if request.method == "POST": if request.method == "POST":
View File
+6
View File
@@ -0,0 +1,6 @@
from rest_framework.pagination import PageNumberPagination
class CustomPageNumberPagination(PageNumberPagination):
page_size = 100
page_size_query_param = "page_size"
+23 -6
View File
@@ -1,8 +1,6 @@
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers from rest_framework import serializers
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from apps.transactions.models import ( from apps.transactions.models import (
TransactionCategory, TransactionCategory,
TransactionTag, TransactionTag,
@@ -29,7 +27,11 @@ class TransactionCategoryField(serializers.Field):
_("Category with this ID does not exist.") _("Category with this ID does not exist.")
) )
elif isinstance(data, str): elif isinstance(data, str):
category, created = TransactionCategory.objects.get_or_create(name=data) try:
category = TransactionCategory.objects.get(name=data)
except TransactionCategory.DoesNotExist:
category = TransactionCategory(name=data)
category.save()
return category return category
raise serializers.ValidationError( raise serializers.ValidationError(
_("Invalid category data. Provide an ID or name.") _("Invalid category data. Provide an ID or name.")
@@ -65,7 +67,11 @@ class TransactionTagField(serializers.Field):
_("Tag with this ID does not exist.") _("Tag with this ID does not exist.")
) )
elif isinstance(item, str): elif isinstance(item, str):
tag, created = TransactionTag.objects.get_or_create(name=item) try:
tag = TransactionTag.objects.get(name=item)
except TransactionTag.DoesNotExist:
tag = TransactionTag(name=item)
tag.save()
else: else:
raise serializers.ValidationError( raise serializers.ValidationError(
_("Invalid tag data. Provide an ID or name.") _("Invalid tag data. Provide an ID or name.")
@@ -74,6 +80,13 @@ class TransactionTagField(serializers.Field):
return tags return tags
@extend_schema_field(
{
"type": "array",
"items": {"oneOf": [{"type": "string"}, {"type": "integer"}]},
"description": "TransactionEntity ID or name. If the name doesn't exist, a new one will be created",
}
)
class TransactionEntityField(serializers.Field): class TransactionEntityField(serializers.Field):
def to_representation(self, value): def to_representation(self, value):
return [{"id": entity.id, "name": entity.name} for entity in value.all()] return [{"id": entity.id, "name": entity.name} for entity in value.all()]
@@ -84,12 +97,16 @@ class TransactionEntityField(serializers.Field):
if isinstance(item, int): if isinstance(item, int):
try: try:
entity = TransactionEntity.objects.get(pk=item) entity = TransactionEntity.objects.get(pk=item)
except TransactionTag.DoesNotExist: except TransactionEntity.DoesNotExist:
raise serializers.ValidationError( raise serializers.ValidationError(
_("Entity with this ID does not exist.") _("Entity with this ID does not exist.")
) )
elif isinstance(item, str): elif isinstance(item, str):
entity, created = TransactionEntity.objects.get_or_create(name=item) try:
entity = TransactionEntity.objects.get(name=item)
except TransactionEntity.DoesNotExist:
entity = TransactionEntity(name=item)
entity.save()
else: else:
raise serializers.ValidationError( raise serializers.ValidationError(
_("Invalid entity data. Provide an ID or name.") _("Invalid entity data. Provide an ID or name.")
+12 -10
View File
@@ -1,3 +1,5 @@
from decimal import Decimal
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from drf_spectacular import openapi from drf_spectacular import openapi
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
@@ -48,9 +50,9 @@ class TransactionEntitySerializer(serializers.ModelSerializer):
class InstallmentPlanSerializer(serializers.ModelSerializer): class InstallmentPlanSerializer(serializers.ModelSerializer):
category = TransactionCategoryField(required=False) category: str | int = TransactionCategoryField(required=False)
tags = TransactionTagField(required=False) tags: str | int = TransactionTagField(required=False)
entities = TransactionEntityField(required=False) entities: str | int = TransactionEntityField(required=False)
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
@@ -88,9 +90,9 @@ class InstallmentPlanSerializer(serializers.ModelSerializer):
class RecurringTransactionSerializer(serializers.ModelSerializer): class RecurringTransactionSerializer(serializers.ModelSerializer):
category = TransactionCategoryField(required=False) category: str | int = TransactionCategoryField(required=False)
tags = TransactionTagField(required=False) tags: str | int = TransactionTagField(required=False)
entities = TransactionEntityField(required=False) entities: str | int = TransactionEntityField(required=False)
class Meta: class Meta:
model = RecurringTransaction model = RecurringTransaction
@@ -127,9 +129,9 @@ class RecurringTransactionSerializer(serializers.ModelSerializer):
class TransactionSerializer(serializers.ModelSerializer): class TransactionSerializer(serializers.ModelSerializer):
category = TransactionCategoryField(required=False) category: str | int = TransactionCategoryField(required=False)
tags = TransactionTagField(required=False) tags: str | int = TransactionTagField(required=False)
entities = TransactionEntityField(required=False) entities: str | int = TransactionEntityField(required=False)
exchanged_amount = serializers.SerializerMethodField() exchanged_amount = serializers.SerializerMethodField()
@@ -192,5 +194,5 @@ class TransactionSerializer(serializers.ModelSerializer):
return instance return instance
@staticmethod @staticmethod
def get_exchanged_amount(obj): def get_exchanged_amount(obj) -> Decimal:
return obj.exchanged_amount() return obj.exchanged_amount()
+10 -2
View File
@@ -1,4 +1,6 @@
from rest_framework import viewsets from rest_framework import viewsets
from apps.api.custom.pagination import CustomPageNumberPagination
from apps.accounts.models import AccountGroup, Account from apps.accounts.models import AccountGroup, Account
from apps.api.serializers import AccountGroupSerializer, AccountSerializer from apps.api.serializers import AccountGroupSerializer, AccountSerializer
@@ -6,12 +8,18 @@ from apps.api.serializers import AccountGroupSerializer, AccountSerializer
class AccountGroupViewSet(viewsets.ModelViewSet): class AccountGroupViewSet(viewsets.ModelViewSet):
queryset = AccountGroup.objects.all() queryset = AccountGroup.objects.all()
serializer_class = AccountGroupSerializer serializer_class = AccountGroupSerializer
pagination_class = CustomPageNumberPagination
def get_queryset(self):
return AccountGroup.objects.all().order_by("id")
class AccountViewSet(viewsets.ModelViewSet): class AccountViewSet(viewsets.ModelViewSet):
queryset = Account.objects.all() queryset = Account.objects.all()
serializer_class = AccountSerializer serializer_class = AccountSerializer
pagination_class = CustomPageNumberPagination
def get_queryset(self): def get_queryset(self):
queryset = super().get_queryset() return Account.objects.all().select_related(
return queryset.select_related("group", "currency", "exchange_currency") "group", "currency", "exchange_currency"
)
+29 -4
View File
@@ -1,5 +1,6 @@
from rest_framework import viewsets from rest_framework import viewsets
from apps.api.custom.pagination import CustomPageNumberPagination
from apps.api.serializers import ( from apps.api.serializers import (
TransactionSerializer, TransactionSerializer,
TransactionCategorySerializer, TransactionCategorySerializer,
@@ -22,6 +23,7 @@ from apps.rules.signals import transaction_updated, transaction_created
class TransactionViewSet(viewsets.ModelViewSet): class TransactionViewSet(viewsets.ModelViewSet):
queryset = Transaction.objects.all() queryset = Transaction.objects.all()
serializer_class = TransactionSerializer serializer_class = TransactionSerializer
pagination_class = CustomPageNumberPagination
def perform_create(self, serializer): def perform_create(self, serializer):
instance = serializer.save() instance = serializer.save()
@@ -35,27 +37,50 @@ class TransactionViewSet(viewsets.ModelViewSet):
kwargs["partial"] = True kwargs["partial"] = True
return self.update(request, *args, **kwargs) return self.update(request, *args, **kwargs)
def get_queryset(self):
return Transaction.objects.all().order_by("id")
class TransactionCategoryViewSet(viewsets.ModelViewSet): class TransactionCategoryViewSet(viewsets.ModelViewSet):
queryset = TransactionCategory.objects.all() queryset = TransactionCategory.objects.all()
serializer_class = TransactionCategorySerializer serializer_class = TransactionCategorySerializer
pagination_class = CustomPageNumberPagination
def get_queryset(self):
return TransactionCategory.objects.all().order_by("id")
class TransactionTagViewSet(viewsets.ModelViewSet): class TransactionTagViewSet(viewsets.ModelViewSet):
queryset = TransactionTag.objects.all() queryset = TransactionTag.objects.all().order_by("id")
serializer_class = TransactionTagSerializer serializer_class = TransactionTagSerializer
pagination_class = CustomPageNumberPagination
def get_queryset(self):
return TransactionTag.objects.all().order_by("id")
class TransactionEntityViewSet(viewsets.ModelViewSet): class TransactionEntityViewSet(viewsets.ModelViewSet):
queryset = TransactionEntity.objects.all() queryset = TransactionEntity.objects.all().order_by("id")
serializer_class = TransactionEntitySerializer serializer_class = TransactionEntitySerializer
pagination_class = CustomPageNumberPagination
def get_queryset(self):
return TransactionEntity.objects.all().order_by("id")
class InstallmentPlanViewSet(viewsets.ModelViewSet): class InstallmentPlanViewSet(viewsets.ModelViewSet):
queryset = InstallmentPlan.objects.all() queryset = InstallmentPlan.objects.all().order_by("id")
serializer_class = InstallmentPlanSerializer serializer_class = InstallmentPlanSerializer
pagination_class = CustomPageNumberPagination
def get_queryset(self):
return InstallmentPlan.objects.all().order_by("id")
class RecurringTransactionViewSet(viewsets.ModelViewSet): class RecurringTransactionViewSet(viewsets.ModelViewSet):
queryset = RecurringTransaction.objects.all() queryset = RecurringTransaction.objects.all().order_by("id")
serializer_class = RecurringTransactionSerializer serializer_class = RecurringTransactionSerializer
pagination_class = CustomPageNumberPagination
def get_queryset(self):
return RecurringTransaction.objects.all().order_by("id")
+7
View File
@@ -0,0 +1,7 @@
from django.contrib import admin
class SharedObjectModelAdmin(admin.ModelAdmin):
def get_queryset(self, request):
# Use the all_objects manager to show all transactions, including deleted ones
return self.model.all_objects.all()
+28 -31
View File
@@ -4,6 +4,7 @@ from django.db import transaction
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from apps.common.widgets.tom_select import TomSelect, TomSelectMultiple from apps.common.widgets.tom_select import TomSelect, TomSelectMultiple
from apps.common.middleware.thread_local import get_current_user
class DynamicModelChoiceField(forms.ModelChoiceField): class DynamicModelChoiceField(forms.ModelChoiceField):
@@ -55,19 +56,24 @@ class DynamicModelChoiceField(forms.ModelChoiceField):
if self.create_field: if self.create_field:
try: try:
with transaction.atomic(): with transaction.atomic():
instance, _ = self.model.objects.update_or_create( # First try to get the object
**{self.create_field: value} lookup = {self.create_field: value}
) try:
instance = self.model.objects.get(**lookup)
except self.model.DoesNotExist:
# Create a new instance directly
instance = self.model(**lookup)
instance.save()
self._created_instance = instance self._created_instance = instance
return instance return instance
except Exception as e: except Exception as e:
raise ValidationError( raise ValidationError(_("Error creating new instance"))
self.error_messages["invalid_choice"], code="invalid_choice"
)
else: else:
raise ValidationError( raise ValidationError(
self.error_messages["invalid_choice"], code="invalid_choice" self.error_messages["invalid_choice"], code="invalid_choice"
) )
return super().clean(value) return super().clean(value)
def bound_data(self, data, initial): def bound_data(self, data, initial):
@@ -90,8 +96,6 @@ class DynamicModelMultipleChoiceField(forms.ModelMultipleChoiceField):
def __init__(self, model, **kwargs): def __init__(self, model, **kwargs):
""" """
Initialize the CreateIfNotExistsModelMultipleChoiceField.
Args: Args:
create_field (str): The name of the field to use when creating new instances. create_field (str): The name of the field to use when creating new instances.
*args: Variable length argument list. *args: Variable length argument list.
@@ -123,33 +127,28 @@ class DynamicModelMultipleChoiceField(forms.ModelMultipleChoiceField):
""" """
try: try:
with transaction.atomic(): with transaction.atomic():
instance, _ = self.model.objects.update_or_create( # Check if exists first without using update_or_create
**{self.create_field: value} lookup = {self.create_field: value}
) try:
return instance # Use base manager to bypass distinct filters
instance = self.model.objects.get(**lookup)
return instance
except self.model.DoesNotExist:
# Create a new instance directly
instance = self.model(**lookup)
instance.save()
return instance
except Exception as e: except Exception as e:
print(e)
raise ValidationError(_("Error creating new instance")) raise ValidationError(_("Error creating new instance"))
def clean(self, value): def clean(self, value):
"""
Clean and validate the field value.
This method checks if each selected choice exists in the database.
If a choice doesn't exist, it creates a new instance of the model.
Args:
value (list): List of selected values.
Returns:
list: A list containing all selected and newly created model instances.
Raises:
ValidationError: If there's an error during the cleaning process.
"""
if not value: if not value:
return [] return []
string_values = set(str(v) for v in value) string_values = set(str(v) for v in value)
# Get existing objects first
existing_objects = list( existing_objects = list(
self.queryset.filter(**{f"{self.create_field}__in": string_values}) self.queryset.filter(**{f"{self.create_field}__in": string_values})
) )
@@ -157,13 +156,11 @@ class DynamicModelMultipleChoiceField(forms.ModelMultipleChoiceField):
str(getattr(obj, self.create_field)) for obj in existing_objects str(getattr(obj, self.create_field)) for obj in existing_objects
) )
# Create new objects for missing values
new_values = string_values - existing_values new_values = string_values - existing_values
new_objects = [] new_objects = []
for new_value in new_values: for new_value in new_values:
try: new_objects.append(self._create_new_instance(new_value))
new_objects.append(self._create_new_instance(new_value))
except ValidationError as e:
raise ValidationError(_("Error creating new instance"))
return existing_objects + new_objects return existing_objects + new_objects
+95
View File
@@ -0,0 +1,95 @@
from crispy_forms.bootstrap import FormActions
from django import forms
from django.contrib.auth import get_user_model
from django.utils.translation import gettext_lazy as _
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Field, Submit, Div, HTML
from apps.common.widgets.tom_select import TomSelect, TomSelectMultiple
from apps.common.models import SharedObject
from apps.common.widgets.crispy.submit import NoClassSubmit
User = get_user_model()
class SharedObjectForm(forms.Form):
"""
Generic form for editing visibility and sharing settings
for models inheriting from SharedObject.
"""
owner = forms.ModelChoiceField(
queryset=User.objects.all(),
required=False,
label=_("Owner"),
widget=TomSelect(clear_button=False),
help_text=_(
"The owner of this object, if empty all users can see, edit and take ownership."
),
)
shared_with_users = forms.ModelMultipleChoiceField(
queryset=User.objects.all(),
required=False,
widget=TomSelectMultiple(clear_button=True),
label=_("Shared with users"),
help_text=_("Select users to share this object with"),
)
visibility = forms.ChoiceField(
choices=SharedObject.Visibility.choices,
required=True,
label=_("Visibility"),
help_text=_(
"Private: Only shown for the owner and shared users. Only editable by the owner."
"<br/>"
"Public: Shown for all users. Only editable by the owner."
),
)
class Meta:
fields = ["visibility", "shared_with_users"]
widgets = {
"visibility": TomSelect(clear_button=False),
}
def __init__(self, *args, **kwargs):
# Get the current user to filter available sharing options
self.user = kwargs.pop("user", None)
self.instance = kwargs.pop("instance", None)
super().__init__(*args, **kwargs)
# Pre-populate shared users if instance exists
if self.instance:
self.fields["shared_with_users"].initial = self.instance.shared_with.all()
self.fields["visibility"].initial = self.instance.visibility
self.fields["owner"].initial = self.instance.owner
# Set up crispy form helper
self.helper = FormHelper()
self.helper.form_method = "post"
self.helper.form_tag = False
self.helper.layout = Layout(
Field("owner"),
Field("visibility"),
HTML("<hr>"),
Field("shared_with_users"),
FormActions(
NoClassSubmit(
"submit", _("Save"), css_class="btn btn-outline-primary w-100"
),
),
)
def save(self):
instance = self.instance
instance.visibility = self.cleaned_data["visibility"]
instance.owner = self.cleaned_data["owner"]
instance.save()
# Clear and set shared_with users
instance.shared_with.set(self.cleaned_data.get("shared_with_users", []))
return instance
@@ -56,6 +56,16 @@ def get_current_user():
if request: if request:
return getattr(request, "user", None) return getattr(request, "user", None)
return getattr(_thread_locals, "user", None)
def write_current_user(user):
_thread_locals.user = user
def delete_current_user():
del _thread_locals.user
class ThreadLocalMiddleware(MiddlewareMixin): class ThreadLocalMiddleware(MiddlewareMixin):
"""Simple middleware that adds the request object in thread local storage.""" """Simple middleware that adds the request object in thread local storage."""
+84
View File
@@ -0,0 +1,84 @@
from django.db import models
from django.conf import settings
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from apps.common.middleware.thread_local import get_current_user
class SharedObjectManager(models.Manager):
def get_queryset(self):
"""Return only objects the user can access"""
user = get_current_user()
base_qs = super().get_queryset()
if user and user.is_authenticated:
return base_qs.filter(
Q(visibility="public")
| Q(owner=user)
| Q(shared_with=user)
| Q(visibility="private", owner=None)
).distinct()
return base_qs.filter(visibility="public")
class SharedObject(models.Model):
# Access control enum
class Visibility(models.TextChoices):
private = "private", _("Private")
is_paid = "public", _("Public")
# Core sharing fields
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="%(class)s_owned",
null=True,
blank=True,
)
visibility = models.CharField(
max_length=10, choices=Visibility.choices, default=Visibility.private
)
shared_with = models.ManyToManyField(
settings.AUTH_USER_MODEL, related_name="%(class)s_shared", blank=True
)
# Use as abstract base class
class Meta:
abstract = True
indexes = [
models.Index(fields=["visibility"]),
]
def is_accessible_by(self, user):
"""Check if a user can access this object"""
return (
self.visibility == "public"
or self.owner == user
or (self.visibility == "shared" and user in self.shared_with.all())
)
def save(self, *args, **kwargs):
if not self.pk and not self.owner:
self.owner = get_current_user()
super().save(*args, **kwargs)
class OwnedObject(models.Model):
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="%(class)s_owned",
null=True,
blank=True,
)
# Use as abstract base class
class Meta:
abstract = True
def save(self, *args, **kwargs):
if not self.pk and not self.owner:
self.owner = get_current_user()
super().save(*args, **kwargs)
+8 -2
View File
@@ -1,7 +1,13 @@
from django.contrib import admin from django.contrib import admin
from apps.dca.models import DCAStrategy, DCAEntry from apps.dca.models import DCAStrategy, DCAEntry
from apps.common.admin import SharedObjectModelAdmin
# Register your models here.
admin.site.register(DCAStrategy)
admin.site.register(DCAEntry) admin.site.register(DCAEntry)
@admin.register(DCAStrategy)
class TransactionEntityModelAdmin(SharedObjectModelAdmin):
def get_queryset(self, request):
return DCAStrategy.all_objects.all()
+20 -2
View File
@@ -168,7 +168,7 @@ class DCAEntryForm(forms.ModelForm):
Row( Row(
Column( Column(
"from_account", "from_account",
css_class="form-group col-md-6 mb-0", css_class="form-group",
), ),
css_class="form-row", css_class="form-row",
), ),
@@ -190,7 +190,7 @@ class DCAEntryForm(forms.ModelForm):
Row( Row(
Column( Column(
"to_account", "to_account",
css_class="form-group col-md-6 mb-0", css_class="form-group",
), ),
css_class="form-row", css_class="form-row",
), ),
@@ -266,6 +266,24 @@ class DCAEntryForm(forms.ModelForm):
id=expense_transaction.id id=expense_transaction.id
) )
self.fields["from_account"].queryset = Account.objects.filter(
is_archived=False,
)
self.fields["from_category"].queryset = TransactionCategory.objects.filter(
active=True
)
self.fields["from_tags"].queryset = TransactionTag.objects.filter(active=True)
self.fields["to_account"].queryset = Account.objects.filter(
is_archived=False,
)
self.fields["to_category"].queryset = TransactionCategory.objects.filter(
active=True
)
self.fields["to_tags"].queryset = TransactionTag.objects.filter(active=True)
def clean(self): def clean(self):
cleaned_data = super().clean() cleaned_data = super().clean()
@@ -0,0 +1,31 @@
# Generated by Django 5.1.6 on 2025-03-07 18:20
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dca', '0002_alter_dcaentry_amount_paid_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='dcastrategy',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='dcastrategy',
name='shared_with',
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='dcastrategy',
name='visibility',
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10),
),
]
+5 -3
View File
@@ -1,16 +1,15 @@
from datetime import timedelta
from decimal import Decimal from decimal import Decimal
from statistics import mean, stdev
from django.db import models from django.db import models
from django.template.defaultfilters import date from django.template.defaultfilters import date
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from apps.common.models import SharedObject, SharedObjectManager
from apps.currencies.utils.convert import convert, get_exchange_rate from apps.currencies.utils.convert import convert, get_exchange_rate
class DCAStrategy(models.Model): class DCAStrategy(SharedObject):
name = models.CharField(max_length=255, verbose_name=_("Name")) name = models.CharField(max_length=255, verbose_name=_("Name"))
target_currency = models.ForeignKey( target_currency = models.ForeignKey(
"currencies.Currency", "currencies.Currency",
@@ -28,6 +27,9 @@ class DCAStrategy(models.Model):
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
objects = SharedObjectManager()
all_objects = models.Manager() # Unfiltered manager
class Meta: class Meta:
verbose_name = _("DCA Strategy") verbose_name = _("DCA Strategy")
verbose_name_plural = _("DCA Strategies") verbose_name_plural = _("DCA Strategies")
+10
View File
@@ -12,6 +12,16 @@ urlpatterns = [
views.strategy_delete, views.strategy_delete,
name="dca_strategy_delete", name="dca_strategy_delete",
), ),
path(
"dca/<int:strategy_id>/take-ownership/",
views.strategy_take_ownership,
name="dca_strategy_take_ownership",
),
path(
"dca/<int:pk>/share/",
views.strategy_share,
name="dca_strategy_share_settings",
),
path( path(
"dca/<int:strategy_id>/", "dca/<int:strategy_id>/",
views.strategy_detail_index, views.strategy_detail_index,
+80 -3
View File
@@ -11,6 +11,8 @@ from django.views.decorators.http import require_http_methods
from apps.common.decorators.htmx import only_htmx from apps.common.decorators.htmx import only_htmx
from apps.dca.forms import DCAEntryForm, DCAStrategyForm from apps.dca.forms import DCAEntryForm, DCAStrategyForm
from apps.dca.models import DCAStrategy, DCAEntry from apps.dca.models import DCAStrategy, DCAEntry
from apps.common.models import SharedObject
from apps.common.forms import SharedObjectForm
@login_required @login_required
@@ -57,6 +59,16 @@ def strategy_add(request):
def strategy_edit(request, strategy_id): def strategy_edit(request, strategy_id):
dca_strategy = get_object_or_404(DCAStrategy, id=strategy_id) dca_strategy = get_object_or_404(DCAStrategy, id=strategy_id)
if dca_strategy.owner and dca_strategy.owner != request.user:
messages.error(request, _("Only the owner can edit this"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
if request.method == "POST": if request.method == "POST":
form = DCAStrategyForm(request.POST, instance=dca_strategy) form = DCAStrategyForm(request.POST, instance=dca_strategy)
if form.is_valid(): if form.is_valid():
@@ -85,9 +97,15 @@ def strategy_edit(request, strategy_id):
def strategy_delete(request, strategy_id): def strategy_delete(request, strategy_id):
dca_strategy = get_object_or_404(DCAStrategy, id=strategy_id) dca_strategy = get_object_or_404(DCAStrategy, id=strategy_id)
dca_strategy.delete() if (
dca_strategy.owner != request.user
messages.success(request, _("DCA strategy deleted successfully")) and request.user in dca_strategy.shared_with.all()
):
dca_strategy.shared_with.remove(request.user)
messages.success(request, _("Item no longer shared with you"))
else:
dca_strategy.delete()
messages.success(request, _("DCA strategy deleted successfully"))
return HttpResponse( return HttpResponse(
status=204, status=204,
@@ -97,6 +115,65 @@ def strategy_delete(request, strategy_id):
) )
@only_htmx
@login_required
@require_http_methods(["GET"])
def strategy_take_ownership(request, strategy_id):
dca_strategy = get_object_or_404(DCAStrategy, id=strategy_id)
if not dca_strategy.owner:
dca_strategy.owner = request.user
dca_strategy.visibility = SharedObject.Visibility.private
dca_strategy.save()
messages.success(request, _("Ownership taken successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def strategy_share(request, pk):
obj = get_object_or_404(DCAStrategy, id=pk)
if obj.owner and obj.owner != request.user:
messages.error(request, _("Only the owner can edit this"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
if request.method == "POST":
form = SharedObjectForm(request.POST, instance=obj, user=request.user)
if form.is_valid():
form.save()
messages.success(request, _("Configuration saved successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
else:
form = SharedObjectForm(instance=obj, user=request.user)
return render(
request,
"dca/fragments/strategy/share.html",
{"form": form, "object": obj},
)
@login_required @login_required
def strategy_detail_index(request, strategy_id): def strategy_detail_index(request, strategy_id):
strategy = get_object_or_404(DCAStrategy, id=strategy_id) strategy = get_object_or_404(DCAStrategy, id=strategy_id)
+9
View File
@@ -8,6 +8,12 @@ from apps.common.widgets.crispy.submit import NoClassSubmit
class ExportForm(forms.Form): class ExportForm(forms.Form):
users = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("Users"),
initial=True,
)
accounts = forms.BooleanField( accounts = forms.BooleanField(
required=False, required=False,
widget=forms.CheckboxInput(), widget=forms.CheckboxInput(),
@@ -94,6 +100,7 @@ class ExportForm(forms.Form):
self.helper.form_tag = False self.helper.form_tag = False
self.helper.form_method = "post" self.helper.form_method = "post"
self.helper.layout = Layout( self.helper.layout = Layout(
"users",
"accounts", "accounts",
"currencies", "currencies",
"transactions", "transactions",
@@ -121,6 +128,7 @@ class RestoreForm(forms.Form):
help_text=_("Import a ZIP file exported from WYGIWYH"), help_text=_("Import a ZIP file exported from WYGIWYH"),
label=_("ZIP File"), label=_("ZIP File"),
) )
users = forms.FileField(required=False, label=_("Users"))
accounts = forms.FileField(required=False, label=_("Accounts")) accounts = forms.FileField(required=False, label=_("Accounts"))
currencies = forms.FileField(required=False, label=_("Currencies")) currencies = forms.FileField(required=False, label=_("Currencies"))
transactions_categories = forms.FileField(required=False, label=_("Categories")) transactions_categories = forms.FileField(required=False, label=_("Categories"))
@@ -155,6 +163,7 @@ class RestoreForm(forms.Form):
self.helper.layout = Layout( self.helper.layout = Layout(
"zip_file", "zip_file",
HTML("<hr />"), HTML("<hr />"),
"users",
"accounts", "accounts",
"currencies", "currencies",
"transactions", "transactions",
@@ -24,3 +24,6 @@ class AccountResource(resources.ModelResource):
class Meta: class Meta:
model = Account model = Account
def get_queryset(self):
return Account.all_objects.all()
+16 -1
View File
@@ -55,23 +55,32 @@ class TransactionResource(resources.ModelResource):
model = Transaction model = Transaction
def get_queryset(self): def get_queryset(self):
return Transaction.all_objects.all() return Transaction.userless_all_objects.all()
class TransactionTagResource(resources.ModelResource): class TransactionTagResource(resources.ModelResource):
class Meta: class Meta:
model = TransactionTag model = TransactionTag
def get_queryset(self):
return TransactionTag.all_objects.all()
class TransactionEntityResource(resources.ModelResource): class TransactionEntityResource(resources.ModelResource):
class Meta: class Meta:
model = TransactionEntity model = TransactionEntity
def get_queryset(self):
return TransactionEntity.all_objects.all()
class TransactionCategoyResource(resources.ModelResource): class TransactionCategoyResource(resources.ModelResource):
class Meta: class Meta:
model = TransactionCategory model = TransactionCategory
def get_queryset(self):
return TransactionCategory.all_objects.all()
class RecurringTransactionResource(resources.ModelResource): class RecurringTransactionResource(resources.ModelResource):
account = fields.Field( account = fields.Field(
@@ -107,6 +116,9 @@ class RecurringTransactionResource(resources.ModelResource):
class Meta: class Meta:
model = RecurringTransaction model = RecurringTransaction
def get_queryset(self):
return RecurringTransaction.all_objects.all()
class InstallmentPlanResource(resources.ModelResource): class InstallmentPlanResource(resources.ModelResource):
account = fields.Field( account = fields.Field(
@@ -141,3 +153,6 @@ class InstallmentPlanResource(resources.ModelResource):
class Meta: class Meta:
model = InstallmentPlan model = InstallmentPlan
def get_queryset(self):
return InstallmentPlan.all_objects.all()
+161
View File
@@ -0,0 +1,161 @@
from import_export import resources, fields
from django.contrib.auth import get_user_model
from django.conf import settings
from apps.users.models import UserSettings
User = get_user_model()
class UserResource(resources.ModelResource):
# User fields
email = fields.Field(attribute="email", column_name="Email")
# UserSettings fields - for export only
hide_amounts = fields.Field(
attribute="settings__hide_amounts", column_name="Hide Amounts", readonly=True
)
mute_sounds = fields.Field(
attribute="settings__mute_sounds", column_name="Mute Sounds", readonly=True
)
date_format = fields.Field(
attribute="settings__date_format", column_name="Date Format", readonly=True
)
datetime_format = fields.Field(
attribute="settings__datetime_format",
column_name="Datetime Format",
readonly=True,
)
number_format = fields.Field(
attribute="settings__number_format", column_name="Number Format", readonly=True
)
language = fields.Field(
attribute="settings__language", column_name="Language", readonly=True
)
timezone = fields.Field(
attribute="settings__timezone", column_name="Timezone", readonly=True
)
start_page = fields.Field(
attribute="settings__start_page", column_name="Start Page", readonly=True
)
# Human-readable fields for choice values
start_page_display = fields.Field(column_name="Start Page Display", readonly=True)
language_display = fields.Field(column_name="Language Display", readonly=True)
timezone_display = fields.Field(column_name="Timezone Display", readonly=True)
@staticmethod
def dehydrate_start_page_display(user):
if hasattr(user, "settings"):
return dict(UserSettings.StartPage.choices).get(
user.settings.start_page, ""
)
return ""
@staticmethod
def dehydrate_language_display(user):
if hasattr(user, "settings"):
languages = dict([("auto", "Auto")] + list(settings.LANGUAGES))
return languages.get(user.settings.language, user.settings.language)
return ""
@staticmethod
def dehydrate_timezone_display(user):
if hasattr(user, "settings"):
if user.settings.timezone == "auto":
return "Auto"
return user.settings.timezone
return ""
def after_init_instance(self, instance, new, row, **kwargs):
"""
Store settings data on the instance to be used after save
"""
# Process boolean fields properly
hide_amounts = row.get("Hide Amounts", "").lower() == "true"
mute_sounds = row.get("Mute Sounds", "").lower() == "true"
# Store settings data on the instance for later use
instance._settings_data = {
"hide_amounts": hide_amounts,
"mute_sounds": mute_sounds,
"date_format": row.get("Date Format", "SHORT_DATE_FORMAT"),
"datetime_format": row.get("Datetime Format", "SHORT_DATETIME_FORMAT"),
"number_format": row.get("Number Format", "AA"),
"language": row.get("Language", "auto"),
"timezone": row.get("Timezone", "auto"),
"start_page": row.get("Start Page", UserSettings.StartPage.MONTHLY),
}
return instance
def after_save_instance(self, instance, row, **kwargs):
"""
Create or update UserSettings after User is saved
"""
if not hasattr(instance, "_settings_data"):
return
settings_data = instance._settings_data
# Create or update UserSettings
try:
user_settings = UserSettings.objects.get(user=instance)
# Update existing settings
for key, value in settings_data.items():
setattr(user_settings, key, value)
user_settings.save()
except UserSettings.DoesNotExist:
# Create new settings
UserSettings.objects.create(user=instance, **settings_data)
def get_queryset(self):
"""
Ensure settings are prefetched when exporting users
"""
return super().get_queryset().select_related("settings")
class Meta:
model = User
import_id_fields = ["id"]
fields = (
"id",
"email",
"first_name",
"last_name",
"is_active",
"date_joined",
"password",
"hide_amounts",
"mute_sounds",
"date_format",
"datetime_format",
"number_format",
"language",
"language_display",
"timezone",
"timezone_display",
"start_page",
"start_page_display",
)
export_order = (
"id",
"email",
"first_name",
"last_name",
"is_active",
"date_joined",
"password",
"hide_amounts",
"mute_sounds",
"date_format",
"datetime_format",
"number_format",
"language",
"language_display",
"timezone",
"timezone_display",
"start_page",
"start_page_display",
)
+24 -16
View File
@@ -1,9 +1,9 @@
import logging import logging
import zipfile import zipfile
from io import BytesIO, TextIOWrapper from io import BytesIO
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required, user_passes_test
from django.db import transaction from django.db import transaction
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import render from django.shortcuts import render
@@ -12,26 +12,14 @@ from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_http_methods from django.views.decorators.http import require_http_methods
from tablib import Dataset from tablib import Dataset
from apps.common.decorators.htmx import only_htmx
from apps.export_app.forms import ExportForm, RestoreForm from apps.export_app.forms import ExportForm, RestoreForm
from apps.export_app.resources.accounts import AccountResource from apps.export_app.resources.accounts import AccountResource
from apps.export_app.resources.transactions import (
TransactionResource,
TransactionTagResource,
TransactionEntityResource,
TransactionCategoyResource,
InstallmentPlanResource,
RecurringTransactionResource,
)
from apps.export_app.resources.currencies import ( from apps.export_app.resources.currencies import (
CurrencyResource, CurrencyResource,
ExchangeRateResource, ExchangeRateResource,
ExchangeRateServiceResource, ExchangeRateServiceResource,
) )
from apps.export_app.resources.rules import (
TransactionRuleResource,
TransactionRuleActionResource,
UpdateOrCreateTransactionRuleResource,
)
from apps.export_app.resources.dca import ( from apps.export_app.resources.dca import (
DCAStrategyResource, DCAStrategyResource,
DCAEntryResource, DCAEntryResource,
@@ -39,18 +27,33 @@ from apps.export_app.resources.dca import (
from apps.export_app.resources.import_app import ( from apps.export_app.resources.import_app import (
ImportProfileResource, ImportProfileResource,
) )
from apps.common.decorators.htmx import only_htmx from apps.export_app.resources.rules import (
TransactionRuleResource,
TransactionRuleActionResource,
UpdateOrCreateTransactionRuleResource,
)
from apps.export_app.resources.transactions import (
TransactionResource,
TransactionTagResource,
TransactionEntityResource,
TransactionCategoyResource,
InstallmentPlanResource,
RecurringTransactionResource,
)
from apps.export_app.resources.users import UserResource
logger = logging.getLogger() logger = logging.getLogger()
@login_required @login_required
@user_passes_test(lambda u: u.is_superuser)
@require_http_methods(["GET"]) @require_http_methods(["GET"])
def export_index(request): def export_index(request):
return render(request, "export_app/pages/index.html") return render(request, "export_app/pages/index.html")
@login_required @login_required
@user_passes_test(lambda u: u.is_superuser)
@require_http_methods(["GET", "POST"]) @require_http_methods(["GET", "POST"])
def export_form(request): def export_form(request):
timestamp = timezone.localtime(timezone.now()).strftime("%Y-%m-%dT%H-%M-%S") timestamp = timezone.localtime(timezone.now()).strftime("%Y-%m-%dT%H-%M-%S")
@@ -60,6 +63,7 @@ def export_form(request):
if form.is_valid(): if form.is_valid():
zip_buffer = BytesIO() zip_buffer = BytesIO()
export_users = form.cleaned_data.get("users", False)
export_accounts = form.cleaned_data.get("accounts", False) export_accounts = form.cleaned_data.get("accounts", False)
export_currencies = form.cleaned_data.get("currencies", False) export_currencies = form.cleaned_data.get("currencies", False)
export_transactions = form.cleaned_data.get("transactions", False) export_transactions = form.cleaned_data.get("transactions", False)
@@ -80,6 +84,8 @@ def export_form(request):
export_import_profiles = form.cleaned_data.get("import_profiles", False) export_import_profiles = form.cleaned_data.get("import_profiles", False)
exports = [] exports = []
if export_users:
exports.append((UserResource().export(), "users"))
if export_accounts: if export_accounts:
exports.append((AccountResource().export(), "accounts")) exports.append((AccountResource().export(), "accounts"))
if export_currencies: if export_currencies:
@@ -176,6 +182,7 @@ def export_form(request):
@only_htmx @only_htmx
@login_required @login_required
@user_passes_test(lambda u: u.is_superuser)
@require_http_methods(["GET", "POST"]) @require_http_methods(["GET", "POST"])
def import_form(request): def import_form(request):
if request.method == "POST": if request.method == "POST":
@@ -209,6 +216,7 @@ def import_form(request):
def process_imports(request, cleaned_data): def process_imports(request, cleaned_data):
# Define import order to handle dependencies # Define import order to handle dependencies
import_order = [ import_order = [
("users", UserResource),
("currencies", CurrencyResource), ("currencies", CurrencyResource),
( (
"currencies", "currencies",
+26 -11
View File
@@ -268,14 +268,17 @@ class ImportService:
category = TransactionCategory.objects.get(id=category_name) category = TransactionCategory.objects.get(id=category_name)
else: # name else: # name
if getattr(category_mapping, "create", False): if getattr(category_mapping, "create", False):
category, _ = TransactionCategory.objects.get_or_create( try:
name=category_name category = TransactionCategory.objects.get(
) name=category_name
)
except TransactionCategory.DoesNotExist:
category = TransactionCategory(name=category_name)
category.save()
else: else:
category = TransactionCategory.objects.filter( category = TransactionCategory.objects.filter(
name=category_name name=category_name
).first() ).first()
if category: if category:
data["category"] = category data["category"] = category
self.import_run.categories.add(category) self.import_run.categories.add(category)
@@ -325,9 +328,13 @@ class ImportService:
tag = TransactionTag.objects.filter(id=tag_name).first() tag = TransactionTag.objects.filter(id=tag_name).first()
else: # name else: # name
if getattr(tags_mapping, "create", False): if getattr(tags_mapping, "create", False):
tag, _ = TransactionTag.objects.get_or_create( try:
name=tag_name.strip() tag = TransactionTag.objects.get(
) name=tag_name.strip()
)
except TransactionTag.DoesNotExist:
tag = TransactionTag(name=tag_name.strip())
tag.save()
else: else:
tag = TransactionTag.objects.filter( tag = TransactionTag.objects.filter(
name=tag_name.strip() name=tag_name.strip()
@@ -361,9 +368,13 @@ class ImportService:
).first() ).first()
else: # name else: # name
if getattr(entities_mapping, "create", False): if getattr(entities_mapping, "create", False):
entity, _ = TransactionEntity.objects.get_or_create( try:
name=entity_name.strip() entity = TransactionEntity.objects.get(
) name=entity_name.strip()
)
except TransactionEntity.DoesNotExist:
entity = TransactionEntity(name=entity_name.strip())
entity.save()
else: else:
entity = TransactionEntity.objects.filter( entity = TransactionEntity.objects.filter(
name=entity_name.strip() name=entity_name.strip()
@@ -394,7 +405,11 @@ class ImportService:
def _create_account(self, data: Dict[str, Any]) -> Account: def _create_account(self, data: Dict[str, Any]) -> Account:
if "group" in data: if "group" in data:
group_name = data.pop("group") group_name = data.pop("group")
group, _ = AccountGroup.objects.get_or_create(name=group_name) try:
group = AccountGroup.objects.get(name=group_name)
except AccountGroup.DoesNotExist:
group = AccountGroup(name=group_name)
group.save()
data["group"] = group data["group"] = group
# Handle currency references # Handle currency references
+8 -1
View File
@@ -1,7 +1,9 @@
import logging import logging
from django.contrib.auth import get_user_model
from procrastinate.contrib.django import app from procrastinate.contrib.django import app
from apps.common.middleware.thread_local import write_current_user, delete_current_user
from apps.import_app.models import ImportRun from apps.import_app.models import ImportRun
from apps.import_app.services import ImportServiceV1 from apps.import_app.services import ImportServiceV1
@@ -9,10 +11,15 @@ logger = logging.getLogger(__name__)
@app.task(name="process_import") @app.task(name="process_import")
def process_import(import_run_id: int, file_path: str): def process_import(import_run_id: int, file_path: str, user_id: int):
user = get_user_model().objects.get(id=user_id)
write_current_user(user)
try: try:
import_run = ImportRun.objects.get(id=import_run_id) import_run = ImportRun.objects.get(id=import_run_id)
import_service = ImportServiceV1(import_run) import_service = ImportServiceV1(import_run)
import_service.process_file(file_path) import_service.process_file(file_path)
delete_current_user()
except ImportRun.DoesNotExist: except ImportRun.DoesNotExist:
delete_current_user()
raise ValueError(f"ImportRun with id {import_run_id} not found") raise ValueError(f"ImportRun with id {import_run_id} not found")
-1
View File
@@ -2,7 +2,6 @@ from django.urls import path
import apps.import_app.views as views import apps.import_app.views as views
urlpatterns = [ urlpatterns = [
path("import/", views.import_view, name="import"),
path( path(
"import/presets/", "import/presets/",
views.import_presets_list, views.import_presets_list,
+5 -14
View File
@@ -15,19 +15,6 @@ from apps.import_app.services import PresetService
from apps.import_app.tasks import process_import from apps.import_app.tasks import process_import
def import_view(request):
import_profile = ImportProfile.objects.get(id=2)
shutil.copyfile(
"/usr/src/app/apps/import_app/teste2.csv", "/usr/src/app/temp/teste2.csv"
)
ir = ImportRun.objects.create(profile=import_profile, file_name="teste.csv")
process_import.defer(
import_run_id=ir.id,
file_path="/usr/src/app/temp/teste2.csv",
)
return HttpResponse("Hello, world. You're at the polls page.")
@login_required @login_required
@require_http_methods(["GET"]) @require_http_methods(["GET"])
def import_presets_list(request): def import_presets_list(request):
@@ -189,7 +176,11 @@ def import_run_add(request, profile_id):
import_run = ImportRun.objects.create(profile=profile, file_name=filename) import_run = ImportRun.objects.create(profile=profile, file_name=filename)
# Defer the procrastinate task # Defer the procrastinate task
process_import.defer(import_run_id=import_run.id, file_path=file_path) process_import.defer(
import_run_id=import_run.id,
file_path=file_path,
user_id=request.user.id,
)
messages.success(request, _("Import Run queued successfully")) messages.success(request, _("Import Run queued successfully"))
+14 -18
View File
@@ -77,24 +77,20 @@ def transactions_list(request, month: int, year: int):
request.session["monthly_transactions_order"] = order request.session["monthly_transactions_order"] = order
f = TransactionsFilter(request.GET) f = TransactionsFilter(request.GET)
transactions_filtered = ( transactions_filtered = f.qs.filter(
f.qs.filter() reference_date__year=year,
.filter( reference_date__month=month,
reference_date__year=year, ).prefetch_related(
reference_date__month=month, "account",
) "account__group",
.prefetch_related( "category",
"account", "tags",
"account__group", "account__exchange_currency",
"category", "account__currency",
"tags", "installment_plan",
"account__exchange_currency", "entities",
"account__currency", "dca_expense_entries",
"installment_plan", "dca_income_entries",
"entities",
"dca_expense_entries",
"dca_income_entries",
)
) )
transactions_filtered = default_order(transactions_filtered, order=order) transactions_filtered = default_order(transactions_filtered, order=order)
@@ -2,20 +2,13 @@ from collections import OrderedDict, defaultdict
from decimal import Decimal from decimal import Decimal
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django.db.models import (
OuterRef,
Subquery,
)
from django.db.models import Sum, Min, Max, Case, When, F, Value, DecimalField from django.db.models import Sum, Min, Max, Case, When, F, Value, DecimalField
from django.db.models.functions import Coalesce
from django.db.models.functions import TruncMonth from django.db.models.functions import TruncMonth
from django.template.defaultfilters import date as date_filter from django.template.defaultfilters import date as date_filter
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from apps.accounts.models import Account from apps.accounts.models import Account
from apps.currencies.models import Currency from apps.currencies.models import Currency
from apps.currencies.utils.convert import convert
from apps.transactions.models import Transaction from apps.transactions.models import Transaction
@@ -104,7 +97,9 @@ def calculate_historical_currency_net_worth(is_paid=True):
def calculate_historical_account_balance(is_paid=True): def calculate_historical_account_balance(is_paid=True):
transactions_params = {**{k: v for k, v in [("is_paid", True)] if is_paid}} transactions_params = {**{k: v for k, v in [("is_paid", True)] if is_paid}}
# Get all accounts # Get all accounts
accounts = Account.objects.filter(is_archived=False) accounts = Account.objects.filter(
is_archived=False,
)
# Get the date range # Get the date range
date_range = Transaction.objects.filter(**transactions_params).aggregate( date_range = Transaction.objects.filter(**transactions_params).aggregate(
+6
View File
@@ -1,7 +1,9 @@
import json import json
from django.contrib.auth.decorators import login_required
from django.core.serializers.json import DjangoJSONEncoder from django.core.serializers.json import DjangoJSONEncoder
from django.shortcuts import render from django.shortcuts import render
from django.views.decorators.http import require_http_methods
from apps.net_worth.utils.calculate_net_worth import ( from apps.net_worth.utils.calculate_net_worth import (
calculate_historical_currency_net_worth, calculate_historical_currency_net_worth,
@@ -14,6 +16,8 @@ from apps.transactions.utils.calculations import (
) )
@login_required
@require_http_methods(["GET"])
def net_worth_current(request): def net_worth_current(request):
transactions_currency_queryset = Transaction.objects.filter( transactions_currency_queryset = Transaction.objects.filter(
is_paid=True, account__is_archived=False is_paid=True, account__is_archived=False
@@ -113,6 +117,8 @@ def net_worth_current(request):
) )
@login_required
@require_http_methods(["GET"])
def net_worth_projected(request): def net_worth_projected(request):
transactions_currency_queryset = Transaction.objects.filter( transactions_currency_queryset = Transaction.objects.filter(
account__is_archived=False account__is_archived=False
@@ -0,0 +1,31 @@
# Generated by Django 5.1.6 on 2025-03-07 02:46
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('rules', '0011_alter_updateorcreatetransactionruleaction_set_is_paid'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='transactionrule',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='transactionrule',
name='shared_with',
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='transactionrule',
name='visibility',
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10),
),
]
+6 -1
View File
@@ -2,8 +2,10 @@ from django.db import models
from django.db.models import Q from django.db.models import Q
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from apps.common.models import SharedObject, SharedObjectManager
class TransactionRule(models.Model):
class TransactionRule(SharedObject):
active = models.BooleanField(default=True) active = models.BooleanField(default=True)
on_update = models.BooleanField(default=False) on_update = models.BooleanField(default=False)
on_create = models.BooleanField(default=True) on_create = models.BooleanField(default=True)
@@ -11,6 +13,9 @@ class TransactionRule(models.Model):
description = models.TextField(blank=True, null=True, verbose_name=_("Description")) description = models.TextField(blank=True, null=True, verbose_name=_("Description"))
trigger = models.TextField(verbose_name=_("Trigger")) trigger = models.TextField(verbose_name=_("Trigger"))
objects = SharedObjectManager()
all_objects = models.Manager() # Unfiltered manager
class Meta: class Meta:
verbose_name = _("Transaction rule") verbose_name = _("Transaction rule")
verbose_name_plural = _("Transaction rules") verbose_name_plural = _("Transaction rules")
+2
View File
@@ -6,6 +6,7 @@ from apps.transactions.models import (
transaction_updated, transaction_updated,
) )
from apps.rules.tasks import check_for_transaction_rules from apps.rules.tasks import check_for_transaction_rules
from apps.common.middleware.thread_local import get_current_user
@receiver(transaction_created) @receiver(transaction_created)
@@ -20,6 +21,7 @@ def transaction_changed_receiver(sender: Transaction, signal, **kwargs):
check_for_transaction_rules.defer( check_for_transaction_rules.defer(
instance_id=sender.id, instance_id=sender.id,
user_id=get_current_user().id,
signal=( signal=(
"transaction_created" "transaction_created"
if signal is transaction_created if signal is transaction_created
+9
View File
@@ -4,6 +4,7 @@ from datetime import datetime, date
from cachalot.api import cachalot_disabled from cachalot.api import cachalot_disabled
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django.contrib.auth import get_user_model
from procrastinate.contrib.django import app from procrastinate.contrib.django import app
from simpleeval import EvalWithCompoundTypes from simpleeval import EvalWithCompoundTypes
@@ -18,6 +19,7 @@ from apps.transactions.models import (
TransactionTag, TransactionTag,
TransactionEntity, TransactionEntity,
) )
from apps.common.middleware.thread_local import write_current_user, delete_current_user
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -25,8 +27,12 @@ logger = logging.getLogger(__name__)
@app.task(name="check_for_transaction_rules") @app.task(name="check_for_transaction_rules")
def check_for_transaction_rules( def check_for_transaction_rules(
instance_id: int, instance_id: int,
user_id: int,
signal, signal,
): ):
user = get_user_model().objects.get(id=user_id)
write_current_user(user)
try: try:
with cachalot_disabled(): with cachalot_disabled():
instance = Transaction.objects.get(id=instance_id) instance = Transaction.objects.get(id=instance_id)
@@ -91,8 +97,11 @@ def check_for_transaction_rules(
"Error while executing 'check_for_transaction_rules' task", "Error while executing 'check_for_transaction_rules' task",
exc_info=True, exc_info=True,
) )
delete_current_user()
raise e raise e
delete_current_user()
def _get_names(instance): def _get_names(instance):
return { return {
+10
View File
@@ -37,6 +37,16 @@ urlpatterns = [
views.transaction_rule_delete, views.transaction_rule_delete,
name="transaction_rule_delete", name="transaction_rule_delete",
), ),
path(
"rules/transaction/<int:transaction_rule_id>/take-ownership/",
views.transaction_rule_take_ownership,
name="transaction_rule_take_ownership",
),
path(
"rules/transaction/<int:pk>/share/",
views.transaction_rule_share,
name="transaction_rule_share_settings",
),
path( path(
"rules/transaction/<int:transaction_rule_id>/transaction-action/add/", "rules/transaction/<int:transaction_rule_id>/transaction-action/add/",
views.transaction_rule_action_add, views.transaction_rule_action_add,
+80 -3
View File
@@ -16,6 +16,8 @@ from apps.rules.models import (
TransactionRuleAction, TransactionRuleAction,
UpdateOrCreateTransactionRuleAction, UpdateOrCreateTransactionRuleAction,
) )
from apps.common.models import SharedObject
from apps.common.forms import SharedObjectForm
@login_required @login_required
@@ -93,6 +95,16 @@ def transaction_rule_add(request, **kwargs):
def transaction_rule_edit(request, transaction_rule_id): def transaction_rule_edit(request, transaction_rule_id):
transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id) transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id)
if transaction_rule.owner and transaction_rule.owner != request.user:
messages.error(request, _("Only the owner can edit this"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
if request.method == "POST": if request.method == "POST":
form = TransactionRuleForm(request.POST, instance=transaction_rule) form = TransactionRuleForm(request.POST, instance=transaction_rule)
if form.is_valid(): if form.is_valid():
@@ -134,9 +146,15 @@ def transaction_rule_view(request, transaction_rule_id):
def transaction_rule_delete(request, transaction_rule_id): def transaction_rule_delete(request, transaction_rule_id):
transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id) transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id)
transaction_rule.delete() if (
transaction_rule.owner != request.user
messages.success(request, _("Rule deleted successfully")) and request.user in transaction_rule.shared_with.all()
):
transaction_rule.shared_with.remove(request.user)
messages.success(request, _("Item no longer shared with you"))
else:
transaction_rule.delete()
messages.success(request, _("Rule deleted successfully"))
return HttpResponse( return HttpResponse(
status=204, status=204,
@@ -146,6 +164,65 @@ def transaction_rule_delete(request, transaction_rule_id):
) )
@only_htmx
@login_required
@require_http_methods(["GET"])
def transaction_rule_take_ownership(request, transaction_rule_id):
transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id)
if not transaction_rule.owner:
transaction_rule.owner = request.user
transaction_rule.visibility = SharedObject.Visibility.private
transaction_rule.save()
messages.success(request, _("Ownership taken successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def transaction_rule_share(request, pk):
obj = get_object_or_404(TransactionRule, id=pk)
if obj.owner and obj.owner != request.user:
messages.error(request, _("Only the owner can edit this"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
if request.method == "POST":
form = SharedObjectForm(request.POST, instance=obj, user=request.user)
if form.is_valid():
form.save()
messages.success(request, _("Configuration saved successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
else:
form = SharedObjectForm(instance=obj, user=request.user)
return render(
request,
"rules/fragments/share.html",
{"form": form, "object": obj},
)
@only_htmx @only_htmx
@login_required @login_required
@require_http_methods(["GET", "POST"]) @require_http_methods(["GET", "POST"])
+17 -6
View File
@@ -8,13 +8,14 @@ from apps.transactions.models import (
RecurringTransaction, RecurringTransaction,
TransactionEntity, TransactionEntity,
) )
from apps.common.admin import SharedObjectModelAdmin
@admin.register(Transaction) @admin.register(Transaction)
class TransactionModelAdmin(admin.ModelAdmin): class TransactionModelAdmin(admin.ModelAdmin):
def get_queryset(self, request): def get_queryset(self, request):
# Use the all_objects manager to show all transactions, including deleted ones # Use the all_objects manager to show all transactions, including deleted ones
return self.model.all_objects.all() return self.model.userless_all_objects.all()
list_filter = ["deleted", "type", "is_paid", "date", "account"] list_filter = ["deleted", "type", "is_paid", "date", "account"]
@@ -48,19 +49,29 @@ class TransactionInline(admin.TabularInline):
@admin.register(InstallmentPlan) @admin.register(InstallmentPlan)
class InstallmentPlanAdmin(admin.ModelAdmin): class InstallmentPlanAdmin(SharedObjectModelAdmin):
inlines = [ inlines = [
TransactionInline, TransactionInline,
] ]
@admin.register(RecurringTransaction) @admin.register(RecurringTransaction)
class RecurringTransactionAdmin(admin.ModelAdmin): class RecurringTransactionAdmin(SharedObjectModelAdmin):
inlines = [ inlines = [
TransactionInline, TransactionInline,
] ]
admin.site.register(TransactionCategory) @admin.register(TransactionCategory)
admin.site.register(TransactionTag) class TransactionCategoryModelAdmin(SharedObjectModelAdmin):
admin.site.register(TransactionEntity) pass
@admin.register(TransactionTag)
class TransactionTagModelAdmin(SharedObjectModelAdmin):
pass
@admin.register(TransactionEntity)
class TransactionEntityModelAdmin(SharedObjectModelAdmin):
pass
+5
View File
@@ -184,3 +184,8 @@ class TransactionsFilter(django_filters.FilterSet):
self.form.fields["from_amount"].widget = ArbitraryDecimalDisplayNumberInput() self.form.fields["from_amount"].widget = ArbitraryDecimalDisplayNumberInput()
self.form.fields["date_start"].widget = AirDatePickerInput() self.form.fields["date_start"].widget = AirDatePickerInput()
self.form.fields["date_end"].widget = AirDatePickerInput() self.form.fields["date_end"].widget = AirDatePickerInput()
self.form.fields["account"].queryset = Account.objects.all()
self.form.fields["category"].queryset = TransactionCategory.objects.all()
self.form.fields["tags"].queryset = TransactionTag.objects.all()
self.form.fields["entities"].queryset = TransactionEntity.objects.all()
+58 -5
View File
@@ -29,6 +29,7 @@ from apps.transactions.models import (
RecurringTransaction, RecurringTransaction,
TransactionEntity, TransactionEntity,
) )
from apps.common.middleware.thread_local import get_current_user
class TransactionForm(forms.ModelForm): class TransactionForm(forms.ModelForm):
@@ -94,20 +95,30 @@ class TransactionForm(forms.ModelForm):
# if editing a transaction display non-archived items and it's own item even if it's archived # if editing a transaction display non-archived items and it's own item even if it's archived
if self.instance.id: if self.instance.id:
self.fields["account"].queryset = Account.objects.filter( self.fields["account"].queryset = Account.objects.filter(
Q(is_archived=False) | Q(transactions=self.instance.id) Q(is_archived=False) | Q(transactions=self.instance.id),
).distinct() )
self.fields["category"].queryset = TransactionCategory.objects.filter( self.fields["category"].queryset = TransactionCategory.objects.filter(
Q(active=True) | Q(transaction=self.instance.id) Q(active=True) | Q(transaction=self.instance.id)
).distinct() )
self.fields["tags"].queryset = TransactionTag.objects.filter( self.fields["tags"].queryset = TransactionTag.objects.filter(
Q(active=True) | Q(transaction=self.instance.id) Q(active=True) | Q(transaction=self.instance.id)
).distinct() )
self.fields["entities"].queryset = TransactionEntity.objects.filter( self.fields["entities"].queryset = TransactionEntity.objects.filter(
Q(active=True) | Q(transactions=self.instance.id) Q(active=True) | Q(transactions=self.instance.id)
).distinct() )
else:
self.fields["account"].queryset = Account.objects.filter(
is_archived=False,
)
self.fields["category"].queryset = TransactionCategory.objects.filter(
active=True
)
self.fields["tags"].queryset = TransactionTag.objects.filter(active=True)
self.fields["entities"].queryset = TransactionEntity.objects.all()
self.helper = FormHelper() self.helper = FormHelper()
self.helper.form_tag = False self.helper.form_tag = False
@@ -405,6 +416,24 @@ class TransferForm(forms.Form):
self.fields["to_amount"].widget = ArbitraryDecimalDisplayNumberInput() self.fields["to_amount"].widget = ArbitraryDecimalDisplayNumberInput()
self.fields["date"].widget = AirDatePickerInput(clear_button=False) self.fields["date"].widget = AirDatePickerInput(clear_button=False)
self.fields["from_account"].queryset = Account.objects.filter(
is_archived=False,
)
self.fields["from_category"].queryset = TransactionCategory.objects.filter(
active=True
)
self.fields["from_tags"].queryset = TransactionTag.objects.filter(active=True)
self.fields["to_account"].queryset = Account.objects.filter(
is_archived=False,
)
self.fields["to_category"].queryset = TransactionCategory.objects.filter(
active=True
)
self.fields["to_tags"].queryset = TransactionTag.objects.filter(active=True)
def clean(self): def clean(self):
cleaned_data = super().clean() cleaned_data = super().clean()
from_account = cleaned_data.get("from_account") from_account = cleaned_data.get("from_account")
@@ -536,6 +565,18 @@ class InstallmentPlanForm(forms.ModelForm):
self.fields["entities"].queryset = TransactionEntity.objects.filter( self.fields["entities"].queryset = TransactionEntity.objects.filter(
Q(active=True) | Q(installmentplan=self.instance.id) Q(active=True) | Q(installmentplan=self.instance.id)
).distinct() ).distinct()
else:
self.fields["account"].queryset = Account.objects.filter(is_archived=False)
self.fields["category"].queryset = TransactionCategory.objects.filter(
active=True
)
self.fields["tags"].queryset = TransactionTag.objects.filter(active=True)
self.fields["entities"].queryset = TransactionEntity.objects.filter(
active=True
)
self.helper = FormHelper() self.helper = FormHelper()
self.helper.form_tag = False self.helper.form_tag = False
@@ -781,6 +822,18 @@ class RecurringTransactionForm(forms.ModelForm):
self.fields["entities"].queryset = TransactionEntity.objects.filter( self.fields["entities"].queryset = TransactionEntity.objects.filter(
Q(active=True) | Q(recurringtransaction=self.instance.id) Q(active=True) | Q(recurringtransaction=self.instance.id)
).distinct() ).distinct()
else:
self.fields["account"].queryset = Account.objects.filter(is_archived=False)
self.fields["category"].queryset = TransactionCategory.objects.filter(
active=True
)
self.fields["tags"].queryset = TransactionTag.objects.filter(active=True)
self.fields["entities"].queryset = TransactionEntity.objects.filter(
active=True
)
self.helper = FormHelper() self.helper = FormHelper()
self.helper.form_method = "post" self.helper.form_method = "post"
@@ -0,0 +1,62 @@
# Generated by Django 5.1.6 on 2025-03-05 04:19
import django.db.models.deletion
import django.db.models.manager
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('transactions', '0033_transaction_internal_id'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterModelManagers(
name='installmentplan',
managers=[
('all_objects', django.db.models.manager.Manager()),
],
),
migrations.AlterModelManagers(
name='recurringtransaction',
managers=[
('all_objects', django.db.models.manager.Manager()),
],
),
migrations.AlterModelManagers(
name='transactioncategory',
managers=[
('all_objects', django.db.models.manager.Manager()),
],
),
migrations.AlterModelManagers(
name='transactionentity',
managers=[
('all_objects', django.db.models.manager.Manager()),
],
),
migrations.AlterModelManagers(
name='transactiontag',
managers=[
('all_objects', django.db.models.manager.Manager()),
],
),
migrations.AddField(
model_name='transactioncategory',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='owned_transaction_categories', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
),
migrations.AddField(
model_name='transactionentity',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='owned_transaction_entities', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
),
migrations.AddField(
model_name='transactiontag',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='owned_transaction_tags', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
),
]
@@ -0,0 +1,37 @@
# Generated by Django 5.1.6 on 2025-03-05 04:51
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('transactions', '0034_alter_installmentplan_managers_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='transactioncategory',
name='name',
field=models.CharField(max_length=255, verbose_name='Name'),
),
migrations.AlterField(
model_name='transactiontag',
name='name',
field=models.CharField(max_length=255, verbose_name='Name'),
),
migrations.AlterUniqueTogether(
name='transactioncategory',
unique_together={('owner', 'name')},
),
migrations.AlterUniqueTogether(
name='transactionentity',
unique_together={('owner', 'name')},
),
migrations.AlterUniqueTogether(
name='transactiontag',
unique_together={('owner', 'name')},
),
]
@@ -0,0 +1,76 @@
# Generated by Django 5.1.6 on 2025-03-06 00:10
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('transactions', '0035_alter_transactioncategory_name_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterModelManagers(
name='transactioncategory',
managers=[
],
),
migrations.AlterModelManagers(
name='transactionentity',
managers=[
],
),
migrations.AlterModelManagers(
name='transactiontag',
managers=[
],
),
migrations.AddField(
model_name='transactioncategory',
name='shared_with',
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='transactioncategory',
name='visibility',
field=models.CharField(choices=[('private', 'Private'), ('shared', 'Shared'), ('public', 'Public')], default='private', max_length=10),
),
migrations.AddField(
model_name='transactionentity',
name='shared_with',
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='transactionentity',
name='visibility',
field=models.CharField(choices=[('private', 'Private'), ('shared', 'Shared'), ('public', 'Public')], default='private', max_length=10),
),
migrations.AddField(
model_name='transactiontag',
name='shared_with',
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='transactiontag',
name='visibility',
field=models.CharField(choices=[('private', 'Private'), ('shared', 'Shared'), ('public', 'Public')], default='private', max_length=10),
),
migrations.AlterField(
model_name='transactioncategory',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='transactionentity',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='transactiontag',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL),
),
]
@@ -0,0 +1,28 @@
# Generated by Django 5.1.6 on 2025-03-06 01:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('transactions', '0036_alter_transactioncategory_managers_and_more'),
]
operations = [
migrations.AlterField(
model_name='transactioncategory',
name='visibility',
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10),
),
migrations.AlterField(
model_name='transactionentity',
name='visibility',
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10),
),
migrations.AlterField(
model_name='transactiontag',
name='visibility',
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10),
),
]
@@ -0,0 +1,21 @@
# Generated by Django 5.1.6 on 2025-03-07 03:14
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('transactions', '0037_alter_transactioncategory_visibility_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='transaction',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL),
),
]
@@ -0,0 +1,24 @@
# Generated by Django 5.1.6 on 2025-03-07 03:16
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('transactions', '0038_transaction_owner'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='transaction',
name='internal_id',
field=models.TextField(blank=True, null=True, verbose_name='Internal ID'),
),
migrations.AlterUniqueTogether(
name='transaction',
unique_together={('owner', 'internal_id')},
),
]
@@ -0,0 +1,22 @@
# Generated by Django 5.1.6 on 2025-03-07 03:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('transactions', '0039_alter_transaction_internal_id_and_more'),
]
operations = [
migrations.AlterUniqueTogether(
name='transaction',
unique_together=set(),
),
migrations.AlterField(
model_name='transaction',
name='internal_id',
field=models.TextField(blank=True, null=True, unique=True, verbose_name='Internal ID'),
),
]
+99 -11
View File
@@ -15,6 +15,8 @@ from apps.common.functions.decimals import truncate_decimal
from apps.common.templatetags.decimal import localize_number, drop_trailing_zeros from apps.common.templatetags.decimal import localize_number, drop_trailing_zeros
from apps.currencies.utils.convert import convert from apps.currencies.utils.convert import convert
from apps.transactions.validators import validate_decimal_places, validate_non_negative from apps.transactions.validators import validate_decimal_places, validate_non_negative
from apps.common.middleware.thread_local import get_current_user
from apps.common.models import SharedObject, SharedObjectManager, OwnedObject
logger = logging.getLogger() logger = logging.getLogger()
@@ -93,10 +95,40 @@ class SoftDeleteQuerySet(models.QuerySet):
class SoftDeleteManager(models.Manager): class SoftDeleteManager(models.Manager):
def get_queryset(self): def get_queryset(self):
qs = SoftDeleteQuerySet(self.model, using=self._db) qs = SoftDeleteQuerySet(self.model, using=self._db)
return qs.filter(deleted=False) user = get_current_user()
if user and not user.is_anonymous:
return qs.filter(
Q(account__visibility="public")
| Q(account__owner=user)
| Q(account__shared_with=user)
| Q(account__visibility="private", account__owner=None),
deleted=False,
).distinct()
else:
return qs.filter(
deleted=False,
)
class AllObjectsManager(models.Manager): class AllObjectsManager(models.Manager):
def get_queryset(self):
user = get_current_user()
if user and not user.is_anonymous:
return (
SoftDeleteQuerySet(self.model, using=self._db)
.filter(
Q(account__visibility="public")
| Q(account__owner=user)
| Q(account__shared_with=user)
| Q(account__visibility="private", account__owner=None),
)
.distinct()
)
else:
return SoftDeleteQuerySet(self.model, using=self._db)
class UserlessAllObjectsManager(models.Manager):
def get_queryset(self): def get_queryset(self):
return SoftDeleteQuerySet(self.model, using=self._db) return SoftDeleteQuerySet(self.model, using=self._db)
@@ -104,11 +136,45 @@ class AllObjectsManager(models.Manager):
class DeletedObjectsManager(models.Manager): class DeletedObjectsManager(models.Manager):
def get_queryset(self): def get_queryset(self):
qs = SoftDeleteQuerySet(self.model, using=self._db) qs = SoftDeleteQuerySet(self.model, using=self._db)
return qs.filter(deleted=True) user = get_current_user()
if user and not user.is_anonymous:
return qs.filter(
Q(account__visibility="public")
| Q(account__owner=user)
| Q(account__shared_with=user)
| Q(account__visibility="private", account__owner=None),
deleted=True,
).distinct()
else:
return qs.filter(
deleted=True,
)
class TransactionCategory(models.Model): class UserlessDeletedObjectsManager(models.Manager):
name = models.CharField(max_length=255, verbose_name=_("Name"), unique=True) def get_queryset(self):
qs = SoftDeleteQuerySet(self.model, using=self._db)
return qs.filter(
deleted=True,
)
class GenericAccountOwnerManager(models.Manager):
def get_queryset(self):
queryset = super().get_queryset()
user = get_current_user()
if user and not user.is_anonymous:
return queryset.filter(
Q(account__visibility="public")
| Q(account__owner=user)
| Q(account__shared_with=user)
| Q(account__visibility="private", account__owner=None),
).distinct()
return queryset.none()
class TransactionCategory(SharedObject):
name = models.CharField(max_length=255, verbose_name=_("Name"))
mute = models.BooleanField(default=False, verbose_name=_("Mute")) mute = models.BooleanField(default=False, verbose_name=_("Mute"))
active = models.BooleanField( active = models.BooleanField(
default=True, default=True,
@@ -118,17 +184,21 @@ class TransactionCategory(models.Model):
), ),
) )
objects = SharedObjectManager()
all_objects = models.Manager() # Unfiltered manager
class Meta: class Meta:
verbose_name = _("Transaction Category") verbose_name = _("Transaction Category")
verbose_name_plural = _("Transaction Categories") verbose_name_plural = _("Transaction Categories")
db_table = "t_categories" db_table = "t_categories"
unique_together = (("owner", "name"),)
def __str__(self): def __str__(self):
return self.name return self.name
class TransactionTag(models.Model): class TransactionTag(SharedObject):
name = models.CharField(max_length=255, verbose_name=_("Name"), unique=True) name = models.CharField(max_length=255, verbose_name=_("Name"))
active = models.BooleanField( active = models.BooleanField(
default=True, default=True,
verbose_name=_("Active"), verbose_name=_("Active"),
@@ -137,16 +207,20 @@ class TransactionTag(models.Model):
), ),
) )
objects = SharedObjectManager()
all_objects = models.Manager() # Unfiltered manager
class Meta: class Meta:
verbose_name = _("Transaction Tags") verbose_name = _("Transaction Tags")
verbose_name_plural = _("Transaction Tags") verbose_name_plural = _("Transaction Tags")
db_table = "tags" db_table = "tags"
unique_together = (("owner", "name"),)
def __str__(self): def __str__(self):
return self.name return self.name
class TransactionEntity(models.Model): class TransactionEntity(SharedObject):
name = models.CharField(max_length=255, verbose_name=_("Name")) name = models.CharField(max_length=255, verbose_name=_("Name"))
active = models.BooleanField( active = models.BooleanField(
default=True, default=True,
@@ -156,16 +230,20 @@ class TransactionEntity(models.Model):
), ),
) )
objects = SharedObjectManager()
all_objects = models.Manager() # Unfiltered manager
class Meta: class Meta:
verbose_name = _("Entity") verbose_name = _("Entity")
verbose_name_plural = _("Entities") verbose_name_plural = _("Entities")
db_table = "entities" db_table = "entities"
unique_together = (("owner", "name"),)
def __str__(self): def __str__(self):
return self.name return self.name
class Transaction(models.Model): class Transaction(OwnedObject):
class Type(models.TextChoices): class Type(models.TextChoices):
INCOME = "IN", _("Income") INCOME = "IN", _("Income")
EXPENSE = "EX", _("Expense") EXPENSE = "EX", _("Expense")
@@ -249,7 +327,11 @@ class Transaction(models.Model):
objects = SoftDeleteManager.from_queryset(SoftDeleteQuerySet)() objects = SoftDeleteManager.from_queryset(SoftDeleteQuerySet)()
all_objects = AllObjectsManager.from_queryset(SoftDeleteQuerySet)() all_objects = AllObjectsManager.from_queryset(SoftDeleteQuerySet)()
userless_all_objects = UserlessAllObjectsManager.from_queryset(SoftDeleteQuerySet)()
deleted_objects = DeletedObjectsManager.from_queryset(SoftDeleteQuerySet)() deleted_objects = DeletedObjectsManager.from_queryset(SoftDeleteQuerySet)()
userless_deleted_objects = UserlessDeletedObjectsManager.from_queryset(
SoftDeleteQuerySet
)()
class Meta: class Meta:
verbose_name = _("Transaction") verbose_name = _("Transaction")
@@ -386,6 +468,9 @@ class InstallmentPlan(models.Model):
notes = models.TextField(blank=True, verbose_name=_("Notes")) notes = models.TextField(blank=True, verbose_name=_("Notes"))
all_objects = models.Manager() # Unfiltered manager
objects = GenericAccountOwnerManager() # Default filtered manager
class Meta: class Meta:
verbose_name = _("Installment Plan") verbose_name = _("Installment Plan")
verbose_name_plural = _("Installment Plans") verbose_name_plural = _("Installment Plans")
@@ -440,7 +525,7 @@ class InstallmentPlan(models.Model):
transaction_date = self.start_date + delta transaction_date = self.start_date + delta
transaction_reference_date = (self.reference_date + delta).replace(day=1) transaction_reference_date = (self.reference_date + delta).replace(day=1)
new_transaction = Transaction.objects.create( new_transaction = Transaction.all_objects.create(
account=self.account, account=self.account,
type=self.type, type=self.type,
date=transaction_date, date=transaction_date,
@@ -500,7 +585,7 @@ class InstallmentPlan(models.Model):
existing_transaction.entities.set(self.entities.all()) existing_transaction.entities.set(self.entities.all())
else: else:
# If the transaction doesn't exist, create a new one # If the transaction doesn't exist, create a new one
new_transaction = Transaction.objects.create( new_transaction = Transaction.all_objects.create(
account=self.account, account=self.account,
type=self.type, type=self.type,
date=transaction_date, date=transaction_date,
@@ -587,6 +672,9 @@ class RecurringTransaction(models.Model):
verbose_name=_("Last Generated Reference Date"), null=True, blank=True verbose_name=_("Last Generated Reference Date"), null=True, blank=True
) )
all_objects = models.Manager() # Unfiltered manager
objects = GenericAccountOwnerManager() # Default filtered manager
class Meta: class Meta:
verbose_name = _("Recurring Transaction") verbose_name = _("Recurring Transaction")
verbose_name_plural = _("Recurring Transactions") verbose_name_plural = _("Recurring Transactions")
@@ -624,7 +712,7 @@ class RecurringTransaction(models.Model):
) )
def create_transaction(self, date, reference_date): def create_transaction(self, date, reference_date):
created_transaction = Transaction.objects.create( created_transaction = Transaction.all_objects.create(
account=self.account, account=self.account,
type=self.type, type=self.type,
date=date, date=date,
+4 -2
View File
@@ -34,7 +34,7 @@ def cleanup_deleted_transactions(timestamp=None):
if not settings.ENABLE_SOFT_DELETE: if not settings.ENABLE_SOFT_DELETE:
# Hard delete all soft-deleted transactions # Hard delete all soft-deleted transactions
deleted_count, _ = Transaction.deleted_objects.all().hard_delete() deleted_count, _ = Transaction.userless_deleted_objects.all().hard_delete()
return ( return (
f"Hard deleted {deleted_count} transactions (soft deletion disabled)." f"Hard deleted {deleted_count} transactions (soft deletion disabled)."
) )
@@ -47,7 +47,9 @@ def cleanup_deleted_transactions(timestamp=None):
invalidate() invalidate()
# Hard delete soft-deleted transactions older than the cutoff date # Hard delete soft-deleted transactions older than the cutoff date
old_transactions = Transaction.deleted_objects.filter(deleted_at__lt=cutoff_date) old_transactions = Transaction.userless_deleted_objects.filter(
deleted_at__lt=cutoff_date
)
deleted_count, _ = old_transactions.hard_delete() deleted_count, _ = old_transactions.hard_delete()
return f"Hard deleted {deleted_count} objects older than {settings.KEEP_DELETED_TRANSACTIONS_FOR} days." return f"Hard deleted {deleted_count} objects older than {settings.KEEP_DELETED_TRANSACTIONS_FOR} days."
+30
View File
@@ -131,6 +131,16 @@ urlpatterns = [
views.tag_delete, views.tag_delete,
name="tag_delete", name="tag_delete",
), ),
path(
"tags/<int:tag_id>/take-ownership/",
views.tag_take_ownership,
name="tag_take_ownership",
),
path(
"tags/<int:pk>/share/",
views.tag_share,
name="tag_share_settings",
),
path("entities/", views.entities_index, name="entities_index"), path("entities/", views.entities_index, name="entities_index"),
path("entities/list/", views.entities_list, name="entities_list"), path("entities/list/", views.entities_list, name="entities_list"),
path( path(
@@ -154,6 +164,16 @@ urlpatterns = [
views.entity_delete, views.entity_delete,
name="entity_delete", name="entity_delete",
), ),
path(
"entities/<int:entity_id>/take-ownership/",
views.entity_take_ownership,
name="entity_take_ownership",
),
path(
"entities/<int:pk>/share/",
views.entity_share,
name="entity_share_settings",
),
path("categories/", views.categories_index, name="categories_index"), path("categories/", views.categories_index, name="categories_index"),
path("categories/list/", views.categories_list, name="categories_list"), path("categories/list/", views.categories_list, name="categories_list"),
path( path(
@@ -177,6 +197,16 @@ urlpatterns = [
views.category_delete, views.category_delete,
name="category_delete", name="category_delete",
), ),
path(
"categories/<int:pk>/share/",
views.category_share,
name="category_share_settings",
),
path(
"categories/<int:category_id>/take-ownership/",
views.category_take_ownership,
name="category_take_ownership",
),
path( path(
"installment-plans/", "installment-plans/",
views.installment_plans_index, views.installment_plans_index,
+77 -3
View File
@@ -8,6 +8,8 @@ from django.views.decorators.http import require_http_methods
from apps.common.decorators.htmx import only_htmx from apps.common.decorators.htmx import only_htmx
from apps.transactions.forms import TransactionCategoryForm from apps.transactions.forms import TransactionCategoryForm
from apps.transactions.models import TransactionCategory from apps.transactions.models import TransactionCategory
from apps.common.models import SharedObject
from apps.common.forms import SharedObjectForm
@login_required @login_required
@@ -85,6 +87,16 @@ def category_add(request, **kwargs):
def category_edit(request, category_id): def category_edit(request, category_id):
category = get_object_or_404(TransactionCategory, id=category_id) category = get_object_or_404(TransactionCategory, id=category_id)
if category.owner and category.owner != request.user:
messages.error(request, _("Only the owner can edit this"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
if request.method == "POST": if request.method == "POST":
form = TransactionCategoryForm(request.POST, instance=category) form = TransactionCategoryForm(request.POST, instance=category)
if form.is_valid(): if form.is_valid():
@@ -107,15 +119,77 @@ def category_edit(request, category_id):
) )
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def category_share(request, pk):
obj = get_object_or_404(TransactionCategory, id=pk)
if obj.owner and obj.owner != request.user:
messages.error(request, _("Only the owner can edit this"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
if request.method == "POST":
form = SharedObjectForm(request.POST, instance=obj, user=request.user)
if form.is_valid():
form.save()
messages.success(request, _("Configuration saved successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
else:
form = SharedObjectForm(instance=obj, user=request.user)
return render(
request,
"categories/fragments/share.html",
{"form": form, "object": obj},
)
@only_htmx @only_htmx
@login_required @login_required
@require_http_methods(["DELETE"]) @require_http_methods(["DELETE"])
def category_delete(request, category_id): def category_delete(request, category_id):
category = get_object_or_404(TransactionCategory, id=category_id) category = get_object_or_404(TransactionCategory, id=category_id)
category.delete() if category.owner != request.user and request.user in category.shared_with.all():
category.shared_with.remove(request.user)
messages.success(request, _("Category deleted successfully")) messages.success(request, _("Item no longer shared with you"))
else:
category.delete()
messages.success(request, _("Category deleted successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def category_take_ownership(request, category_id):
category = get_object_or_404(TransactionCategory, id=category_id)
if not category.owner:
category.owner = request.user
category.visibility = SharedObject.Visibility.private
category.save()
messages.success(request, _("Ownership taken successfully"))
return HttpResponse( return HttpResponse(
status=204, status=204,
+77 -3
View File
@@ -8,6 +8,8 @@ from django.views.decorators.http import require_http_methods
from apps.common.decorators.htmx import only_htmx from apps.common.decorators.htmx import only_htmx
from apps.transactions.forms import TransactionEntityForm from apps.transactions.forms import TransactionEntityForm
from apps.transactions.models import TransactionEntity from apps.transactions.models import TransactionEntity
from apps.common.models import SharedObject
from apps.common.forms import SharedObjectForm
@login_required @login_required
@@ -85,6 +87,16 @@ def entity_add(request, **kwargs):
def entity_edit(request, entity_id): def entity_edit(request, entity_id):
entity = get_object_or_404(TransactionEntity, id=entity_id) entity = get_object_or_404(TransactionEntity, id=entity_id)
if entity.owner and entity.owner != request.user:
messages.error(request, _("Only the owner can edit this"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
if request.method == "POST": if request.method == "POST":
form = TransactionEntityForm(request.POST, instance=entity) form = TransactionEntityForm(request.POST, instance=entity)
if form.is_valid(): if form.is_valid():
@@ -113,9 +125,12 @@ def entity_edit(request, entity_id):
def entity_delete(request, entity_id): def entity_delete(request, entity_id):
entity = get_object_or_404(TransactionEntity, id=entity_id) entity = get_object_or_404(TransactionEntity, id=entity_id)
entity.delete() if entity.owner != request.user and request.user in entity.shared_with.all():
entity.shared_with.remove(request.user)
messages.success(request, _("Entity deleted successfully")) messages.success(request, _("Item no longer shared with you"))
else:
entity.delete()
messages.success(request, _("Entity deleted successfully"))
return HttpResponse( return HttpResponse(
status=204, status=204,
@@ -123,3 +138,62 @@ def entity_delete(request, entity_id):
"HX-Trigger": "updated, hide_offcanvas", "HX-Trigger": "updated, hide_offcanvas",
}, },
) )
@only_htmx
@login_required
@require_http_methods(["GET"])
def entity_take_ownership(request, entity_id):
entity = get_object_or_404(TransactionEntity, id=entity_id)
if not entity.owner:
entity.owner = request.user
entity.visibility = SharedObject.Visibility.private
entity.save()
messages.success(request, _("Ownership taken successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def entity_share(request, pk):
obj = get_object_or_404(TransactionEntity, id=pk)
if obj.owner and obj.owner != request.user:
messages.error(request, _("Only the owner can edit this"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
if request.method == "POST":
form = SharedObjectForm(request.POST, instance=obj, user=request.user)
if form.is_valid():
form.save()
messages.success(request, _("Configuration saved successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
else:
form = SharedObjectForm(instance=obj, user=request.user)
return render(
request,
"entities/fragments/share.html",
{"form": form, "object": obj},
)
+77 -3
View File
@@ -8,6 +8,8 @@ from django.views.decorators.http import require_http_methods
from apps.common.decorators.htmx import only_htmx from apps.common.decorators.htmx import only_htmx
from apps.transactions.forms import TransactionTagForm from apps.transactions.forms import TransactionTagForm
from apps.transactions.models import TransactionTag from apps.transactions.models import TransactionTag
from apps.common.models import SharedObject
from apps.common.forms import SharedObjectForm
@login_required @login_required
@@ -85,6 +87,16 @@ def tag_add(request, **kwargs):
def tag_edit(request, tag_id): def tag_edit(request, tag_id):
tag = get_object_or_404(TransactionTag, id=tag_id) tag = get_object_or_404(TransactionTag, id=tag_id)
if tag.owner and tag.owner != request.user:
messages.error(request, _("Only the owner can edit this"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
if request.method == "POST": if request.method == "POST":
form = TransactionTagForm(request.POST, instance=tag) form = TransactionTagForm(request.POST, instance=tag)
if form.is_valid(): if form.is_valid():
@@ -113,9 +125,12 @@ def tag_edit(request, tag_id):
def tag_delete(request, tag_id): def tag_delete(request, tag_id):
tag = get_object_or_404(TransactionTag, id=tag_id) tag = get_object_or_404(TransactionTag, id=tag_id)
tag.delete() if tag.owner != request.user and request.user in tag.shared_with.all():
tag.shared_with.remove(request.user)
messages.success(request, _("Tag deleted successfully")) messages.success(request, _("Item no longer shared with you"))
else:
tag.delete()
messages.success(request, _("Tag deleted successfully"))
return HttpResponse( return HttpResponse(
status=204, status=204,
@@ -123,3 +138,62 @@ def tag_delete(request, tag_id):
"HX-Trigger": "updated, hide_offcanvas", "HX-Trigger": "updated, hide_offcanvas",
}, },
) )
@only_htmx
@login_required
@require_http_methods(["GET"])
def tag_take_ownership(request, tag_id):
tag = get_object_or_404(TransactionTag, id=tag_id)
if not tag.owner:
tag.owner = request.user
tag.visibility = SharedObject.Visibility.private
tag.save()
messages.success(request, _("Ownership taken successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def tag_share(request, pk):
obj = get_object_or_404(TransactionTag, id=pk)
if obj.owner and obj.owner != request.user:
messages.error(request, _("Only the owner can edit this"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
if request.method == "POST":
form = SharedObjectForm(request.POST, instance=obj, user=request.user)
if form.is_valid():
form.save()
messages.success(request, _("Configuration saved successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
else:
form = SharedObjectForm(instance=obj, user=request.user)
return render(
request,
"tags/fragments/share.html",
{"form": form, "object": obj},
)
+1 -1
View File
@@ -18,7 +18,7 @@ class User(AbstractUser):
REQUIRED_FIELDS = [] REQUIRED_FIELDS = []
def __str__(self): def __str__(self):
return f"{self.first_name} {self.last_name} ({self.email})" return self.email
class UserSettings(models.Model): class UserSettings(models.Model):
@@ -2,13 +2,13 @@
<div class="container px-md-3 py-3 column-gap-5"> <div class="container px-md-3 py-3 column-gap-5">
<div class="tw-text-3xl fw-bold font-monospace tw-w-full mb-3"> <div class="tw-text-3xl fw-bold font-monospace tw-w-full mb-3">
{% spaceless %} {% spaceless %}
<div>{% translate 'Account Groups' %}<span> <div>{% translate 'Account Groups' %}<span>
<a class="text-decoration-none tw-text-2xl p-1 category-action" <a class="text-decoration-none tw-text-2xl p-1 category-action"
role="button" role="button"
data-bs-toggle="tooltip" data-bs-toggle="tooltip"
data-bs-title="{% translate "Add" %}" data-bs-title="{% translate "Add" %}"
hx-get="{% url 'account_group_add' %}" hx-get="{% url 'account_group_add' %}"
hx-target="#generic-offcanvas"> hx-target="#generic-offcanvas">
<i class="fa-solid fa-circle-plus fa-fw"></i></a> <i class="fa-solid fa-circle-plus fa-fw"></i></a>
</span></div> </span></div>
{% endspaceless %} {% endspaceless %}
@@ -17,46 +17,64 @@
<div class="card"> <div class="card">
<div class="card-body table-responsive"> <div class="card-body table-responsive">
{% if account_groups %} {% if account_groups %}
<c-config.search></c-config.search> <c-config.search></c-config.search>
<table class="table table-hover"> <table class="table table-hover">
<thead> <thead>
<tr> <tr>
<th scope="col" class="col-auto"></th> <th scope="col" class="col-auto"></th>
<th scope="col" class="col">{% translate 'Name' %}</th> <th scope="col" class="col">{% translate 'Name' %}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for account_group in account_groups %} {% for account_group in account_groups %}
<tr class="account_group"> <tr class="account_group">
<td class="col-auto"> <td class="col-auto">
<div class="btn-group" role="group" aria-label="{% translate 'Actions' %}"> <div class="btn-group" role="group" aria-label="{% translate 'Actions' %}">
<a class="btn btn-secondary btn-sm" <a class="btn btn-secondary btn-sm"
role="button" role="button"
data-bs-toggle="tooltip" data-bs-toggle="tooltip"
data-bs-title="{% translate "Edit" %}" data-bs-title="{% translate "Edit" %}"
hx-get="{% url 'account_group_edit' pk=account_group.id %}" hx-get="{% url 'account_group_edit' pk=account_group.id %}"
hx-target="#generic-offcanvas"> hx-target="#generic-offcanvas">
<i class="fa-solid fa-pencil fa-fw"></i></a> <i class="fa-solid fa-pencil fa-fw"></i></a>
<a class="btn btn-secondary btn-sm text-danger" <a class="btn btn-secondary btn-sm text-danger"
role="button" role="button"
data-bs-toggle="tooltip" data-bs-toggle="tooltip"
data-bs-title="{% translate "Delete" %}" data-bs-title="{% translate "Delete" %}"
hx-delete="{% url 'account_group_delete' pk=account_group.id %}" hx-delete="{% url 'account_group_delete' pk=account_group.id %}"
hx-trigger='confirmed' hx-trigger='confirmed'
data-bypass-on-ctrl="true" data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}" data-title="{% translate "Are you sure?" %}"
data-text="{% translate "You won't be able to revert this!" %}" data-text="{% translate "You won't be able to revert this!" %}"
data-confirm-text="{% translate "Yes, delete it!" %}" data-confirm-text="{% translate "Yes, delete it!" %}"
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw"></i></a> _="install prompt_swal"><i class="fa-solid fa-trash fa-fw"></i></a>
</div> {% if not account_group.owner %}
</td> <a class="btn btn-secondary btn-sm text-warning"
<td class="col">{{ account_group.name }}</td> role="button"
</tr> data-bs-toggle="tooltip"
data-bs-title="{% translate "Take ownership" %}"
hx-get="{% url 'account_group_take_ownership' pk=account_group.id %}">
<i class="fa-solid fa-crown fa-fw"></i></a>
{% endif %}
{% if user == account_group.owner %}
<a class="btn btn-secondary btn-sm text-primary"
role="button"
hx-target="#generic-offcanvas"
hx-swap="innerHTML"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Share" %}"
hx-get="{% url 'account_group_share_settings' pk=account_group.id %}">
<i class="fa-solid fa-share fa-fw"></i></a>
{% endif %}
</div>
</td>
<td class="col">{{ account_group.name }}</td>
</tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% else %} {% else %}
<c-msg.empty title="{% translate "No account groups" %}" remove-padding></c-msg.empty> <c-msg.empty title="{% translate "No account groups" %}" remove-padding></c-msg.empty>
{% endif %} {% endif %}
</div> </div>
</div> </div>
@@ -0,0 +1,11 @@
{% extends 'extends/offcanvas.html' %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% translate 'Share settings' %}{% endblock %}
{% block body %}
<form hx-post="{% url "account_groups_share_settings" pk=object.id %}" hx-target="#generic-offcanvas" novalidate>
{% crispy form %}
</form>
{% endblock %}
@@ -53,6 +53,24 @@
data-text="{% translate "You won't be able to revert this!" %}" data-text="{% translate "You won't be able to revert this!" %}"
data-confirm-text="{% translate "Yes, delete it!" %}" data-confirm-text="{% translate "Yes, delete it!" %}"
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw"></i></a> _="install prompt_swal"><i class="fa-solid fa-trash fa-fw"></i></a>
{% if not account.owner %}
<a class="btn btn-secondary btn-sm text-primary"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Take ownership" %}"
hx-get="{% url 'account_take_ownership' pk=account.id %}">
<i class="fa-solid fa-crown fa-fw"></i></a>
{% endif %}
{% if user == account.owner %}
<a class="btn btn-secondary btn-sm text-primary"
role="button"
hx-target="#generic-offcanvas"
hx-swap="innerHTML"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Share" %}"
hx-get="{% url 'account_share_settings' pk=account.id %}">
<i class="fa-solid fa-share fa-fw"></i></a>
{% endif %}
</div> </div>
</td> </td>
<td class="col">{{ account.name }}</td> <td class="col">{{ account.name }}</td>
@@ -0,0 +1,11 @@
{% extends 'extends/offcanvas.html' %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% translate 'Share settings' %}{% endblock %}
{% block body %}
<form hx-post="{% url "account_share_settings" pk=object.id %}" hx-target="#generic-offcanvas" novalidate>
{% crispy form %}
</form>
{% endblock %}
@@ -0,0 +1,11 @@
{% extends 'extends/offcanvas.html' %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% translate 'Share settings' %}{% endblock %}
{% block body %}
<form hx-post="{% url "category_share_settings" pk=object.id %}" hx-target="#generic-offcanvas" novalidate>
{% crispy form %}
</form>
{% endblock %}
@@ -42,6 +42,24 @@
data-text="{% translate "You won't be able to revert this!" %}" data-text="{% translate "You won't be able to revert this!" %}"
data-confirm-text="{% translate "Yes, delete it!" %}" data-confirm-text="{% translate "Yes, delete it!" %}"
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw"></i></a> _="install prompt_swal"><i class="fa-solid fa-trash fa-fw"></i></a>
{% if not category.owner %}
<a class="btn btn-secondary btn-sm text-primary"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Take ownership" %}"
hx-get="{% url 'category_take_ownership' category_id=category.id %}">
<i class="fa-solid fa-crown fa-fw"></i></a>
{% endif %}
{% if user == category.owner %}
<a class="btn btn-secondary btn-sm text-primary"
role="button"
hx-target="#generic-offcanvas"
hx-swap="innerHTML"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Share" %}"
hx-get="{% url 'category_share_settings' pk=category.id %}">
<i class="fa-solid fa-share fa-fw"></i></a>
{% endif %}
</div> </div>
</td> </td>
<td class="col">{{ category.name }}</td> <td class="col">{{ category.name }}</td>
+52 -33
View File
@@ -17,40 +17,59 @@
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 row-cols-xl-4 gy-3 gx-3"> <div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 row-cols-xl-4 gy-3 gx-3">
{% for strategy in strategies %} {% for strategy in strategies %}
<div class="col"> <div class="col">
<div class="card h-100"> <div class="card h-100">
<div class="card-header"> <div class="card-header">
<span class="badge rounded-pill text-bg-secondary">{{ strategy.payment_currency.name }}</span> x <span class="badge rounded-pill text-bg-secondary">{{ strategy.target_currency.name }}</span> <span class="badge rounded-pill text-bg-secondary">{{ strategy.payment_currency.name }}</span> x <span
</div> class="badge rounded-pill text-bg-secondary">{{ strategy.target_currency.name }}</span>
<a href="{% url 'dca_strategy_detail_index' strategy_id=strategy.id %}" hx-boost="true" class="text-decoration-none card-body">
<div class="">
<div class="card-title tw-text-xl">{{ strategy.name }}</div>
<div class="card-text tw-text-gray-400">{{ strategy.notes }}</div>
</div>
</a>
<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 'dca_strategy_edit' strategy_id=strategy.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 'dca_strategy_delete' strategy_id=strategy.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>
<a href="{% url 'dca_strategy_detail_index' strategy_id=strategy.id %}" hx-boost="true"
class="text-decoration-none card-body">
<div class="">
<div class="card-title tw-text-xl">{{ strategy.name }}</div>
<div class="card-text tw-text-gray-400">{{ strategy.notes }}</div>
</div>
</a>
<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 'dca_strategy_edit' strategy_id=strategy.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 'dca_strategy_delete' strategy_id=strategy.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>
{% if not strategy.owner %}
<a class="text-primary text-decoration-none p-1"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Take ownership" %}"
hx-get="{% url 'dca_strategy_take_ownership' strategy_id=strategy.id %}">
<i class="fa-solid fa-crown fa-fw"></i></a>
{% endif %}
{% if user == strategy.owner %}
<a class="text-primary text-decoration-none p-1"
role="button"
hx-target="#generic-offcanvas"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Share" %}"
hx-get="{% url 'dca_strategy_share_settings' pk=strategy.id %}">
<i class="fa-solid fa-share fa-fw"></i></a>
{% endif %}
</div>
</div>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
@@ -0,0 +1,11 @@
{% extends 'extends/offcanvas.html' %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% translate 'Share settings' %}{% endblock %}
{% block body %}
<form hx-post="{% url "dca_strategy_share_settings" pk=object.id %}" hx-target="#generic-offcanvas" novalidate>
{% crispy form %}
</form>
{% endblock %}
@@ -0,0 +1,11 @@
{% extends 'extends/offcanvas.html' %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% translate 'Share settings' %}{% endblock %}
{% block body %}
<form hx-post="{% url "entity_share_settings" pk=object.id %}" hx-target="#generic-offcanvas" novalidate>
{% crispy form %}
</form>
{% endblock %}
+63 -45
View File
@@ -6,50 +6,68 @@
<div class="show-loading" hx-get="{% url 'entities_table_archived' %}" hx-trigger="updated from:window" <div class="show-loading" hx-get="{% url 'entities_table_archived' %}" hx-trigger="updated from:window"
hx-swap="outerHTML"> hx-swap="outerHTML">
{% endif %} {% endif %}
{% if entities %} {% if entities %}
<div class="table-responsive"> <div class="table-responsive">
<c-config.search></c-config.search> <c-config.search></c-config.search>
<table class="table table-hover"> <table class="table table-hover">
<thead> <thead>
<tr> <tr>
<th scope="col" class="col-auto"></th> <th scope="col" class="col-auto"></th>
<th scope="col" class="col">{% translate 'Name' %}</th> <th scope="col" class="col">{% translate 'Name' %}</th>
</tr>
</thead>
<tbody>
{% for entity in entities %}
<tr class="entity">
<td class="col-auto">
<div class="btn-group" role="group" aria-label="{% translate 'Actions' %}">
<a class="btn btn-secondary btn-sm"
role="button"
hx-swap="innerHTML"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Edit" %}"
hx-get="{% url 'entity_edit' entity_id=entity.id %}"
hx-target="#generic-offcanvas">
<i class="fa-solid fa-pencil fa-fw"></i></a>
<a class="btn btn-secondary btn-sm text-danger"
role="button"
hx-swap="innerHTML"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Delete" %}"
hx-delete="{% url 'entity_delete' entity_id=entity.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>
{% if not entity.owner %}
<a class="btn btn-secondary btn-sm text-warning"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Take ownership" %}"
hx-get="{% url 'entity_take_ownership' entity_id=entity.id %}">
<i class="fa-solid fa-crown fa-fw"></i></a>
{% endif %}
{% if user == entity.owner %}
<a class="btn btn-secondary btn-sm text-primary"
role="button"
hx-target="#generic-offcanvas"
hx-swap="innerHTML"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Share" %}"
hx-get="{% url 'entity_share_settings' pk=entity.id %}">
<i class="fa-solid fa-share fa-fw"></i></a>
{% endif %}
</div>
</td>
<td class="col">{{ entity.name }}</td>
</tr> </tr>
</thead> {% endfor %}
<tbody> </tbody>
{% for entity in entities %} </table>
<tr class="entity"> </div>
<td class="col-auto"> {% else %}
<div class="btn-group" role="group" aria-label="{% translate 'Actions' %}"> <c-msg.empty title="{% translate "No entities" %}" remove-padding></c-msg.empty>
<a class="btn btn-secondary btn-sm" {% endif %}
role="button"
hx-swap="innerHTML"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Edit" %}"
hx-get="{% url 'entity_edit' entity_id=entity.id %}"
hx-target="#generic-offcanvas">
<i class="fa-solid fa-pencil fa-fw"></i></a>
<a class="btn btn-secondary btn-sm text-danger"
role="button"
hx-swap="innerHTML"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Delete" %}"
hx-delete="{% url 'entity_delete' entity_id=entity.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>
</td>
<td class="col">{{ entity.name }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<c-msg.empty title="{% translate "No entities" %}" remove-padding></c-msg.empty>
{% endif %}
</div> </div>
+2
View File
@@ -132,8 +132,10 @@
href="{% url 'rules_index' %}">{% translate 'Rules' %}</a></li> href="{% url 'rules_index' %}">{% translate 'Rules' %}</a></li>
<li><a class="dropdown-item {% active_link views='import_profiles_index' %}" <li><a class="dropdown-item {% active_link views='import_profiles_index' %}"
href="{% url 'import_profiles_index' %}">{% translate 'Import' %} <span class="badge text-bg-primary">beta</span></a></li> href="{% url 'import_profiles_index' %}">{% translate 'Import' %} <span class="badge text-bg-primary">beta</span></a></li>
{% if user.is_superuser %}
<li><a class="dropdown-item {% active_link views='export_index' %}" <li><a class="dropdown-item {% active_link views='export_index' %}"
href="{% url 'export_index' %}">{% translate 'Export and Restore' %}</a></li> href="{% url 'export_index' %}">{% translate 'Export and Restore' %}</a></li>
{% endif %}
<li><a class="dropdown-item {% active_link views='automatic_exchange_rates_index' %}" <li><a class="dropdown-item {% active_link views='automatic_exchange_rates_index' %}"
href="{% url 'automatic_exchange_rates_index' %}">{% translate 'Automatic Exchange Rates' %}</a></li> href="{% url 'automatic_exchange_rates_index' %}">{% translate 'Automatic Exchange Rates' %}</a></li>
<li> <li>
+71 -51
View File
@@ -2,13 +2,13 @@
<div class="container px-md-3 py-3 column-gap-5"> <div class="container px-md-3 py-3 column-gap-5">
<div class="tw-text-3xl fw-bold font-monospace tw-w-full mb-3"> <div class="tw-text-3xl fw-bold font-monospace tw-w-full mb-3">
{% spaceless %} {% spaceless %}
<div>{% translate 'Rules' %}<span> <div>{% translate 'Rules' %}<span>
<a class="text-decoration-none tw-text-2xl p-1 category-action" <a class="text-decoration-none tw-text-2xl p-1 category-action"
role="button" role="button"
data-bs-toggle="tooltip" data-bs-toggle="tooltip"
data-bs-title="{% translate "Add" %}" data-bs-title="{% translate "Add" %}"
hx-get="{% url 'transaction_rule_add' %}" hx-get="{% url 'transaction_rule_add' %}"
hx-target="#generic-offcanvas"> hx-target="#generic-offcanvas">
<i class="fa-solid fa-circle-plus fa-fw"></i></a> <i class="fa-solid fa-circle-plus fa-fw"></i></a>
</span></div> </span></div>
{% endspaceless %} {% endspaceless %}
@@ -17,59 +17,79 @@
<div class="card"> <div class="card">
<div class="card-body table-responsive"> <div class="card-body table-responsive">
{% if transaction_rules %} {% if transaction_rules %}
<c-config.search></c-config.search> <c-config.search></c-config.search>
<table class="table table-hover"> <table class="table table-hover">
<thead> <thead>
<tr> <tr>
<th scope="col" class="col-auto"></th> <th scope="col" class="col-auto"></th>
<th scope="col" class="col-auto"></th> <th scope="col" class="col-auto"></th>
<th scope="col" class="col">{% translate 'Name' %}</th> <th scope="col" class="col">{% translate 'Name' %}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for rule in transaction_rules %} {% for rule in transaction_rules %}
<tr class="transaction_rule"> <tr class="transaction_rule">
<td class="col-auto"> <td class="col-auto">
<div class="btn-group" role="group" aria-label="{% translate 'Actions' %}"> <div class="btn-group" role="group" aria-label="{% translate 'Actions' %}">
<a class="btn btn-secondary btn-sm" <a class="btn btn-secondary btn-sm"
role="button" role="button"
data-bs-toggle="tooltip" data-bs-toggle="tooltip"
data-bs-title="{% translate "View" %}" data-bs-title="{% translate "View" %}"
hx-get="{% url 'transaction_rule_view' transaction_rule_id=rule.id %}" hx-get="{% url 'transaction_rule_view' transaction_rule_id=rule.id %}"
hx-target="#persistent-generic-offcanvas-left"> hx-target="#persistent-generic-offcanvas-left">
<i class="fa-solid fa-eye fa-fw"></i></a> <i class="fa-solid fa-eye fa-fw"></i></a>
<a class="btn btn-secondary btn-sm text-danger" <a class="btn btn-secondary btn-sm text-danger"
role="button" role="button"
data-bs-toggle="tooltip" data-bs-toggle="tooltip"
data-bs-title="{% translate "Delete" %}" data-bs-title="{% translate "Delete" %}"
hx-delete="{% url 'transaction_rule_delete' transaction_rule_id=rule.id %}" hx-delete="{% url 'transaction_rule_delete' transaction_rule_id=rule.id %}"
hx-trigger='confirmed' hx-trigger='confirmed'
data-bypass-on-ctrl="true" data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}" data-title="{% translate "Are you sure?" %}"
data-text="{% translate "You won't be able to revert this!" %}" data-text="{% translate "You won't be able to revert this!" %}"
data-confirm-text="{% translate "Yes, delete it!" %}" data-confirm-text="{% translate "Yes, delete it!" %}"
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw"></i></a> _="install prompt_swal"><i class="fa-solid fa-trash fa-fw"></i></a>
</div> {% if not rule.owner %}
</td> <a class="btn btn-secondary btn-sm text-warning"
<td class="col-auto"> role="button"
<a class="text-decoration-none" data-bs-toggle="tooltip"
role="button" data-bs-title="{% translate "Take ownership" %}"
data-bs-toggle="tooltip" hx-get="{% url 'transaction_rule_take_ownership' transaction_rule_id=rule.id %}">
data-bs-title="{% if rule.active %}{% translate "Deactivate" %}{% else %}{% translate "Activate" %}{% endif %}" <i class="fa-solid fa-crown fa-fw"></i></a>
hx-get="{% url 'transaction_rule_toggle_activity' transaction_rule_id=rule.id %}"> {% endif %}
{% if rule.active %}<i class="fa-solid fa-toggle-on tw-text-green-400"></i>{% else %}<i class="fa-solid fa-toggle-off tw-text-red-400"></i>{% endif %} {% if user == rule.owner %}
</a> <a class="btn btn-secondary btn-sm text-primary"
</td> role="button"
<td class="col"> hx-target="#generic-offcanvas"
<div>{{ rule.name }}</div> hx-swap="innerHTML"
<div class="tw-text-gray-400">{{ rule.description }}</div> data-bs-toggle="tooltip"
</td> data-bs-title="{% translate "Share" %}"
</tr> hx-get="{% url 'transaction_rule_share_settings' pk=rule.id %}">
<i class="fa-solid fa-share fa-fw"></i></a>
{% endif %}
</div>
</td>
<td class="col-auto">
<a class="text-decoration-none"
role="button"
data-bs-toggle="tooltip"
data-bs-title="
{% if rule.active %}{% translate "Deactivate" %}{% else %}{% translate "Activate" %}{% endif %}"
hx-get="{% url 'transaction_rule_toggle_activity' transaction_rule_id=rule.id %}">
{% if rule.active %}<i class="fa-solid fa-toggle-on tw-text-green-400"></i>{% else %}
<i class="fa-solid fa-toggle-off tw-text-red-400"></i>{% endif %}
</a>
</td>
<td class="col">
<div>{{ rule.name }}</div>
<div class="tw-text-gray-400">{{ rule.description }}</div>
</td>
</tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% else %} {% else %}
<c-msg.empty title="{% translate "No rules" %}" remove-padding></c-msg.empty> <c-msg.empty title="{% translate "No rules" %}" remove-padding></c-msg.empty>
{% endif %} {% endif %}
</div> </div>
</div> </div>
+11
View File
@@ -0,0 +1,11 @@
{% extends 'extends/offcanvas.html' %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% translate 'Share settings' %}{% endblock %}
{% block body %}
<form hx-post="{% url "transaction_rule_share_settings" pk=object.id %}" hx-target="#generic-offcanvas" novalidate>
{% crispy form %}
</form>
{% endblock %}
+11
View File
@@ -0,0 +1,11 @@
{% extends 'extends/offcanvas.html' %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% translate 'Share settings' %}{% endblock %}
{% block body %}
<form hx-post="{% url "tag_share_settings" pk=object.id %}" hx-target="#generic-offcanvas" novalidate>
{% crispy form %}
</form>
{% endblock %}
+63 -45
View File
@@ -6,50 +6,68 @@
<div class="show-loading" hx-get="{% url 'tags_table_archived' %}" hx-trigger="updated from:window" <div class="show-loading" hx-get="{% url 'tags_table_archived' %}" hx-trigger="updated from:window"
hx-swap="outerHTML"> hx-swap="outerHTML">
{% endif %} {% endif %}
{% if tags %} {% if tags %}
<div class="table-responsive"> <div class="table-responsive">
<c-config.search></c-config.search> <c-config.search></c-config.search>
<table class="table table-hover"> <table class="table table-hover">
<thead> <thead>
<tr> <tr>
<th scope="col" class="col-auto"></th> <th scope="col" class="col-auto"></th>
<th scope="col" class="col">{% translate 'Name' %}</th> <th scope="col" class="col">{% translate 'Name' %}</th>
</tr>
</thead>
<tbody>
{% for tag in tags %}
<tr class="tag">
<td class="col-auto">
<div class="btn-group" role="group" aria-label="{% translate 'Actions' %}">
<a class="btn btn-secondary btn-sm"
role="button"
hx-swap="innerHTML"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Edit" %}"
hx-get="{% url 'tag_edit' tag_id=tag.id %}"
hx-target="#generic-offcanvas">
<i class="fa-solid fa-pencil fa-fw"></i></a>
<a class="btn btn-secondary btn-sm text-danger"
role="button"
data-bs-toggle="tooltip"
hx-swap="innerHTML"
data-bs-title="{% translate "Delete" %}"
hx-delete="{% url 'tag_delete' tag_id=tag.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>
{% if not tag.owner %}
<a class="btn btn-secondary btn-sm text-warning"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Take ownership" %}"
hx-get="{% url 'tag_take_ownership' tag_id=tag.id %}">
<i class="fa-solid fa-crown fa-fw"></i></a>
{% endif %}
{% if user == tag.owner %}
<a class="btn btn-secondary btn-sm text-primary"
role="button"
hx-target="#generic-offcanvas"
hx-swap="innerHTML"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Share" %}"
hx-get="{% url 'tag_share_settings' pk=tag.id %}">
<i class="fa-solid fa-share fa-fw"></i></a>
{% endif %}
</div>
</td>
<td class="col">{{ tag.name }}</td>
</tr> </tr>
</thead> {% endfor %}
<tbody> </tbody>
{% for tag in tags %} </table>
<tr class="tag"> </div>
<td class="col-auto"> {% else %}
<div class="btn-group" role="group" aria-label="{% translate 'Actions' %}"> <c-msg.empty title="{% translate "No tags" %}" remove-padding></c-msg.empty>
<a class="btn btn-secondary btn-sm" {% endif %}
role="button"
hx-swap="innerHTML"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Edit" %}"
hx-get="{% url 'tag_edit' tag_id=tag.id %}"
hx-target="#generic-offcanvas">
<i class="fa-solid fa-pencil fa-fw"></i></a>
<a class="btn btn-secondary btn-sm text-danger"
role="button"
data-bs-toggle="tooltip"
hx-swap="innerHTML"
data-bs-title="{% translate "Delete" %}"
hx-delete="{% url 'tag_delete' tag_id=tag.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>
</td>
<td class="col">{{ tag.name }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<c-msg.empty title="{% translate "No tags" %}" remove-padding></c-msg.empty>
{% endif %}
</div> </div>