diff --git a/app/apps/accounts/admin.py b/app/apps/accounts/admin.py index d8ac008..4c33d20 100644 --- a/app/apps/accounts/admin.py +++ b/app/apps/accounts/admin.py @@ -1,6 +1,14 @@ 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 diff --git a/app/apps/accounts/forms.py b/app/apps/accounts/forms.py index 748c8d0..54bc3f2 100644 --- a/app/apps/accounts/forms.py +++ b/app/apps/accounts/forms.py @@ -77,6 +77,8 @@ class AccountForm(forms.ModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + self.fields["group"].queryset = AccountGroup.objects.all() + self.helper = FormHelper() self.helper.form_tag = False self.helper.form_method = "post" @@ -151,5 +153,11 @@ class AccountBalanceForm(forms.Form): 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) diff --git a/app/apps/accounts/migrations/0009_account_owner_account_shared_with_accountgroup_owner.py b/app/apps/accounts/migrations/0009_account_owner_account_shared_with_accountgroup_owner.py new file mode 100644 index 0000000..2cf4965 --- /dev/null +++ b/app/apps/accounts/migrations/0009_account_owner_account_shared_with_accountgroup_owner.py @@ -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'), + ), + ] diff --git a/app/apps/accounts/migrations/0010_alter_account_managers_alter_accountgroup_managers_and_more.py b/app/apps/accounts/migrations/0010_alter_account_managers_alter_accountgroup_managers_and_more.py new file mode 100644 index 0000000..3ff859f --- /dev/null +++ b/app/apps/accounts/migrations/0010_alter_account_managers_alter_accountgroup_managers_and_more.py @@ -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')}, + ), + ] diff --git a/app/apps/accounts/migrations/0011_alter_account_owner_alter_accountgroup_owner.py b/app/apps/accounts/migrations/0011_alter_account_owner_alter_accountgroup_owner.py new file mode 100644 index 0000000..61dd137 --- /dev/null +++ b/app/apps/accounts/migrations/0011_alter_account_owner_alter_accountgroup_owner.py @@ -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'), + ), + ] diff --git a/app/apps/accounts/migrations/0012_alter_account_managers_alter_accountgroup_managers_and_more.py b/app/apps/accounts/migrations/0012_alter_account_managers_alter_accountgroup_managers_and_more.py new file mode 100644 index 0000000..23c34e8 --- /dev/null +++ b/app/apps/accounts/migrations/0012_alter_account_managers_alter_accountgroup_managers_and_more.py @@ -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), + ), + ] diff --git a/app/apps/accounts/migrations/0013_alter_account_visibility_and_more.py b/app/apps/accounts/migrations/0013_alter_account_visibility_and_more.py new file mode 100644 index 0000000..16a0105 --- /dev/null +++ b/app/apps/accounts/migrations/0013_alter_account_visibility_and_more.py @@ -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), + ), + ] diff --git a/app/apps/accounts/models.py b/app/apps/accounts/models.py index eed0cd5..ae3bc7c 100644 --- a/app/apps/accounts/models.py +++ b/app/apps/accounts/models.py @@ -1,24 +1,31 @@ +from django.conf import settings from django.core.exceptions import ValidationError from django.db import models +from django.db.models import Q from django.utils.translation import gettext_lazy as _ from apps.transactions.models import Transaction +from apps.common.models import SharedObject, SharedObjectManager -class AccountGroup(models.Model): - name = models.CharField(max_length=255, verbose_name=_("Name"), unique=True) +class AccountGroup(SharedObject): + name = models.CharField(max_length=255, verbose_name=_("Name")) + + objects = SharedObjectManager() + all_objects = models.Manager() # Unfiltered manager class Meta: verbose_name = _("Account Group") verbose_name_plural = _("Account Groups") db_table = "account_groups" + unique_together = (("owner", "name"),) def __str__(self): return self.name -class Account(models.Model): - name = models.CharField(max_length=255, verbose_name=_("Name"), unique=True) +class Account(SharedObject): + name = models.CharField(max_length=255, verbose_name=_("Name")) group = models.ForeignKey( AccountGroup, 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"), ) + objects = SharedObjectManager() + all_objects = models.Manager() # Unfiltered manager + class Meta: verbose_name = _("Account") verbose_name_plural = _("Accounts") + unique_together = (("owner", "name"),) def __str__(self): return self.name diff --git a/app/apps/accounts/urls.py b/app/apps/accounts/urls.py index ae3d15a..6452d92 100644 --- a/app/apps/accounts/urls.py +++ b/app/apps/accounts/urls.py @@ -16,11 +16,21 @@ urlpatterns = [ views.account_edit, name="account_edit", ), + path( + "account//share/", + views.account_share, + name="account_share_settings", + ), path( "account//delete/", views.account_delete, name="account_delete", ), + path( + "account//take-ownership/", + views.account_take_ownership, + name="account_take_ownership", + ), 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/add/", views.account_group_add, name="account_group_add"), @@ -34,4 +44,14 @@ urlpatterns = [ views.account_group_delete, name="account_group_delete", ), + path( + "account-groups//take-ownership/", + views.account_group_take_ownership, + name="account_group_take_ownership", + ), + path( + "account-groups//share/", + views.account_share, + name="account_group_share_settings", + ), ] diff --git a/app/apps/accounts/views/account_groups.py b/app/apps/accounts/views/account_groups.py index 2c0f70c..872a7ea 100644 --- a/app/apps/accounts/views/account_groups.py +++ b/app/apps/accounts/views/account_groups.py @@ -8,6 +8,8 @@ from django.views.decorators.http import require_http_methods from apps.accounts.forms import AccountGroupForm from apps.accounts.models import AccountGroup from apps.common.decorators.htmx import only_htmx +from apps.common.models import SharedObject +from apps.common.forms import SharedObjectForm @login_required @@ -63,6 +65,16 @@ def account_group_add(request, **kwargs): def account_group_edit(request, 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": form = AccountGroupForm(request.POST, instance=account_group) if form.is_valid(): @@ -91,9 +103,15 @@ def account_group_edit(request, pk): def account_group_delete(request, pk): account_group = get_object_or_404(AccountGroup, id=pk) - account_group.delete() - - messages.success(request, _("Account Group deleted successfully")) + if ( + account_group.owner != request.user + 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( status=204, @@ -101,3 +119,62 @@ def account_group_delete(request, pk): "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}, + ) diff --git a/app/apps/accounts/views/accounts.py b/app/apps/accounts/views/accounts.py index a6bffa2..53b70ae 100644 --- a/app/apps/accounts/views/accounts.py +++ b/app/apps/accounts/views/accounts.py @@ -8,6 +8,8 @@ from django.views.decorators.http import require_http_methods from apps.accounts.forms import AccountForm from apps.accounts.models import Account from apps.common.decorators.htmx import only_htmx +from apps.common.models import SharedObject +from apps.common.forms import SharedObjectForm @login_required @@ -62,6 +64,15 @@ def account_add(request, **kwargs): @require_http_methods(["GET", "POST"]) def account_edit(request, 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": 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 @login_required @require_http_methods(["DELETE"]) def account_delete(request, pk): account = get_object_or_404(Account, id=pk) - account.delete() - - messages.success(request, _("Account deleted successfully")) + if account.owner != request.user and request.user in account.shared_with.all(): + account.shared_with.remove(request.user) + 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( status=204, diff --git a/app/apps/accounts/views/balance.py b/app/apps/accounts/views/balance.py index e86cead..5292f95 100644 --- a/app/apps/accounts/views/balance.py +++ b/app/apps/accounts/views/balance.py @@ -38,9 +38,9 @@ def account_reconciliation(request): "prefix": account.currency.prefix, "current_balance": get_account_balance(account), } - for account in Account.objects.filter(is_archived=False).select_related( - "currency", "group" - ) + for account in Account.objects.filter(is_archived=False) + .select_related("currency", "group") + .order_by("group", "name") ] if request.method == "POST": diff --git a/app/apps/api/custom/__init__.py b/app/apps/api/custom/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/apps/api/custom/pagination.py b/app/apps/api/custom/pagination.py new file mode 100644 index 0000000..871aec7 --- /dev/null +++ b/app/apps/api/custom/pagination.py @@ -0,0 +1,6 @@ +from rest_framework.pagination import PageNumberPagination + + +class CustomPageNumberPagination(PageNumberPagination): + page_size = 100 + page_size_query_param = "page_size" diff --git a/app/apps/api/fields/transactions.py b/app/apps/api/fields/transactions.py index eceaebe..8723c76 100644 --- a/app/apps/api/fields/transactions.py +++ b/app/apps/api/fields/transactions.py @@ -1,8 +1,6 @@ -from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema_field from rest_framework import serializers from django.utils.translation import gettext_lazy as _ - from apps.transactions.models import ( TransactionCategory, TransactionTag, @@ -29,7 +27,11 @@ class TransactionCategoryField(serializers.Field): _("Category with this ID does not exist.") ) 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 raise serializers.ValidationError( _("Invalid category data. Provide an ID or name.") @@ -65,7 +67,11 @@ class TransactionTagField(serializers.Field): _("Tag with this ID does not exist.") ) 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: raise serializers.ValidationError( _("Invalid tag data. Provide an ID or name.") @@ -74,6 +80,13 @@ class TransactionTagField(serializers.Field): 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): def to_representation(self, value): return [{"id": entity.id, "name": entity.name} for entity in value.all()] @@ -84,12 +97,16 @@ class TransactionEntityField(serializers.Field): if isinstance(item, int): try: entity = TransactionEntity.objects.get(pk=item) - except TransactionTag.DoesNotExist: + except TransactionEntity.DoesNotExist: raise serializers.ValidationError( _("Entity with this ID does not exist.") ) 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: raise serializers.ValidationError( _("Invalid entity data. Provide an ID or name.") diff --git a/app/apps/api/serializers/transactions.py b/app/apps/api/serializers/transactions.py index 50f3d1e..2d7d09d 100644 --- a/app/apps/api/serializers/transactions.py +++ b/app/apps/api/serializers/transactions.py @@ -1,3 +1,5 @@ +from decimal import Decimal + from django.utils.translation import gettext_lazy as _ from drf_spectacular import openapi from drf_spectacular.types import OpenApiTypes @@ -48,9 +50,9 @@ class TransactionEntitySerializer(serializers.ModelSerializer): class InstallmentPlanSerializer(serializers.ModelSerializer): - category = TransactionCategoryField(required=False) - tags = TransactionTagField(required=False) - entities = TransactionEntityField(required=False) + category: str | int = TransactionCategoryField(required=False) + tags: str | int = TransactionTagField(required=False) + entities: str | int = TransactionEntityField(required=False) permission_classes = [IsAuthenticated] @@ -88,9 +90,9 @@ class InstallmentPlanSerializer(serializers.ModelSerializer): class RecurringTransactionSerializer(serializers.ModelSerializer): - category = TransactionCategoryField(required=False) - tags = TransactionTagField(required=False) - entities = TransactionEntityField(required=False) + category: str | int = TransactionCategoryField(required=False) + tags: str | int = TransactionTagField(required=False) + entities: str | int = TransactionEntityField(required=False) class Meta: model = RecurringTransaction @@ -127,9 +129,9 @@ class RecurringTransactionSerializer(serializers.ModelSerializer): class TransactionSerializer(serializers.ModelSerializer): - category = TransactionCategoryField(required=False) - tags = TransactionTagField(required=False) - entities = TransactionEntityField(required=False) + category: str | int = TransactionCategoryField(required=False) + tags: str | int = TransactionTagField(required=False) + entities: str | int = TransactionEntityField(required=False) exchanged_amount = serializers.SerializerMethodField() @@ -192,5 +194,5 @@ class TransactionSerializer(serializers.ModelSerializer): return instance @staticmethod - def get_exchanged_amount(obj): + def get_exchanged_amount(obj) -> Decimal: return obj.exchanged_amount() diff --git a/app/apps/api/views/accounts.py b/app/apps/api/views/accounts.py index de5b29c..aa1fe00 100644 --- a/app/apps/api/views/accounts.py +++ b/app/apps/api/views/accounts.py @@ -1,4 +1,6 @@ from rest_framework import viewsets + +from apps.api.custom.pagination import CustomPageNumberPagination from apps.accounts.models import AccountGroup, Account from apps.api.serializers import AccountGroupSerializer, AccountSerializer @@ -6,12 +8,18 @@ from apps.api.serializers import AccountGroupSerializer, AccountSerializer class AccountGroupViewSet(viewsets.ModelViewSet): queryset = AccountGroup.objects.all() serializer_class = AccountGroupSerializer + pagination_class = CustomPageNumberPagination + + def get_queryset(self): + return AccountGroup.objects.all().order_by("id") class AccountViewSet(viewsets.ModelViewSet): queryset = Account.objects.all() serializer_class = AccountSerializer + pagination_class = CustomPageNumberPagination def get_queryset(self): - queryset = super().get_queryset() - return queryset.select_related("group", "currency", "exchange_currency") + return Account.objects.all().select_related( + "group", "currency", "exchange_currency" + ) diff --git a/app/apps/api/views/transactions.py b/app/apps/api/views/transactions.py index 000ae5e..20b2a82 100644 --- a/app/apps/api/views/transactions.py +++ b/app/apps/api/views/transactions.py @@ -1,5 +1,6 @@ from rest_framework import viewsets +from apps.api.custom.pagination import CustomPageNumberPagination from apps.api.serializers import ( TransactionSerializer, TransactionCategorySerializer, @@ -22,6 +23,7 @@ from apps.rules.signals import transaction_updated, transaction_created class TransactionViewSet(viewsets.ModelViewSet): queryset = Transaction.objects.all() serializer_class = TransactionSerializer + pagination_class = CustomPageNumberPagination def perform_create(self, serializer): instance = serializer.save() @@ -35,27 +37,50 @@ class TransactionViewSet(viewsets.ModelViewSet): kwargs["partial"] = True return self.update(request, *args, **kwargs) + def get_queryset(self): + return Transaction.objects.all().order_by("id") + class TransactionCategoryViewSet(viewsets.ModelViewSet): queryset = TransactionCategory.objects.all() serializer_class = TransactionCategorySerializer + pagination_class = CustomPageNumberPagination + + def get_queryset(self): + return TransactionCategory.objects.all().order_by("id") class TransactionTagViewSet(viewsets.ModelViewSet): - queryset = TransactionTag.objects.all() + queryset = TransactionTag.objects.all().order_by("id") serializer_class = TransactionTagSerializer + pagination_class = CustomPageNumberPagination + + def get_queryset(self): + return TransactionTag.objects.all().order_by("id") class TransactionEntityViewSet(viewsets.ModelViewSet): - queryset = TransactionEntity.objects.all() + queryset = TransactionEntity.objects.all().order_by("id") serializer_class = TransactionEntitySerializer + pagination_class = CustomPageNumberPagination + + def get_queryset(self): + return TransactionEntity.objects.all().order_by("id") class InstallmentPlanViewSet(viewsets.ModelViewSet): - queryset = InstallmentPlan.objects.all() + queryset = InstallmentPlan.objects.all().order_by("id") serializer_class = InstallmentPlanSerializer + pagination_class = CustomPageNumberPagination + + def get_queryset(self): + return InstallmentPlan.objects.all().order_by("id") class RecurringTransactionViewSet(viewsets.ModelViewSet): - queryset = RecurringTransaction.objects.all() + queryset = RecurringTransaction.objects.all().order_by("id") serializer_class = RecurringTransactionSerializer + pagination_class = CustomPageNumberPagination + + def get_queryset(self): + return RecurringTransaction.objects.all().order_by("id") diff --git a/app/apps/common/admin.py b/app/apps/common/admin.py new file mode 100644 index 0000000..a5fd486 --- /dev/null +++ b/app/apps/common/admin.py @@ -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() diff --git a/app/apps/common/fields/forms/dynamic_select.py b/app/apps/common/fields/forms/dynamic_select.py index e7ce943..ae61e9f 100644 --- a/app/apps/common/fields/forms/dynamic_select.py +++ b/app/apps/common/fields/forms/dynamic_select.py @@ -4,6 +4,7 @@ from django.db import transaction from django.utils.translation import gettext_lazy as _ from apps.common.widgets.tom_select import TomSelect, TomSelectMultiple +from apps.common.middleware.thread_local import get_current_user class DynamicModelChoiceField(forms.ModelChoiceField): @@ -55,19 +56,24 @@ class DynamicModelChoiceField(forms.ModelChoiceField): if self.create_field: try: with transaction.atomic(): - instance, _ = self.model.objects.update_or_create( - **{self.create_field: value} - ) + # First try to get the object + 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 return instance except Exception as e: - raise ValidationError( - self.error_messages["invalid_choice"], code="invalid_choice" - ) + raise ValidationError(_("Error creating new instance")) else: raise ValidationError( self.error_messages["invalid_choice"], code="invalid_choice" ) + return super().clean(value) def bound_data(self, data, initial): @@ -90,8 +96,6 @@ class DynamicModelMultipleChoiceField(forms.ModelMultipleChoiceField): def __init__(self, model, **kwargs): """ - Initialize the CreateIfNotExistsModelMultipleChoiceField. - Args: create_field (str): The name of the field to use when creating new instances. *args: Variable length argument list. @@ -123,33 +127,28 @@ class DynamicModelMultipleChoiceField(forms.ModelMultipleChoiceField): """ try: with transaction.atomic(): - instance, _ = self.model.objects.update_or_create( - **{self.create_field: value} - ) - return instance + # Check if exists first without using update_or_create + lookup = {self.create_field: value} + try: + # 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: + print(e) raise ValidationError(_("Error creating new instance")) 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: return [] string_values = set(str(v) for v in value) + + # Get existing objects first existing_objects = list( 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 ) + # Create new objects for missing values new_values = string_values - existing_values new_objects = [] for new_value in new_values: - try: - new_objects.append(self._create_new_instance(new_value)) - except ValidationError as e: - raise ValidationError(_("Error creating new instance")) + new_objects.append(self._create_new_instance(new_value)) return existing_objects + new_objects diff --git a/app/apps/common/forms.py b/app/apps/common/forms.py new file mode 100644 index 0000000..dd81c28 --- /dev/null +++ b/app/apps/common/forms.py @@ -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." + "
" + "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("
"), + 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 diff --git a/app/apps/common/middleware/thread_local.py b/app/apps/common/middleware/thread_local.py index 9eaf55f..3f723b0 100644 --- a/app/apps/common/middleware/thread_local.py +++ b/app/apps/common/middleware/thread_local.py @@ -56,6 +56,16 @@ def get_current_user(): if request: 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): """Simple middleware that adds the request object in thread local storage.""" diff --git a/app/apps/common/models.py b/app/apps/common/models.py new file mode 100644 index 0000000..7d33dc5 --- /dev/null +++ b/app/apps/common/models.py @@ -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) diff --git a/app/apps/dca/admin.py b/app/apps/dca/admin.py index 7a29716..f550c0b 100644 --- a/app/apps/dca/admin.py +++ b/app/apps/dca/admin.py @@ -1,7 +1,13 @@ from django.contrib import admin 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.register(DCAStrategy) +class TransactionEntityModelAdmin(SharedObjectModelAdmin): + def get_queryset(self, request): + return DCAStrategy.all_objects.all() diff --git a/app/apps/dca/forms.py b/app/apps/dca/forms.py index a0a25c3..db045aa 100644 --- a/app/apps/dca/forms.py +++ b/app/apps/dca/forms.py @@ -168,7 +168,7 @@ class DCAEntryForm(forms.ModelForm): Row( Column( "from_account", - css_class="form-group col-md-6 mb-0", + css_class="form-group", ), css_class="form-row", ), @@ -190,7 +190,7 @@ class DCAEntryForm(forms.ModelForm): Row( Column( "to_account", - css_class="form-group col-md-6 mb-0", + css_class="form-group", ), css_class="form-row", ), @@ -266,6 +266,24 @@ class DCAEntryForm(forms.ModelForm): 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): cleaned_data = super().clean() diff --git a/app/apps/dca/migrations/0003_dcastrategy_owner_dcastrategy_shared_with_and_more.py b/app/apps/dca/migrations/0003_dcastrategy_owner_dcastrategy_shared_with_and_more.py new file mode 100644 index 0000000..68eb348 --- /dev/null +++ b/app/apps/dca/migrations/0003_dcastrategy_owner_dcastrategy_shared_with_and_more.py @@ -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), + ), + ] diff --git a/app/apps/dca/models.py b/app/apps/dca/models.py index 4625790..ec88f78 100644 --- a/app/apps/dca/models.py +++ b/app/apps/dca/models.py @@ -1,16 +1,15 @@ -from datetime import timedelta from decimal import Decimal -from statistics import mean, stdev from django.db import models from django.template.defaultfilters import date from django.utils import timezone 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 -class DCAStrategy(models.Model): +class DCAStrategy(SharedObject): name = models.CharField(max_length=255, verbose_name=_("Name")) target_currency = models.ForeignKey( "currencies.Currency", @@ -28,6 +27,9 @@ class DCAStrategy(models.Model): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) + objects = SharedObjectManager() + all_objects = models.Manager() # Unfiltered manager + class Meta: verbose_name = _("DCA Strategy") verbose_name_plural = _("DCA Strategies") diff --git a/app/apps/dca/urls.py b/app/apps/dca/urls.py index 14b7131..2424bd6 100644 --- a/app/apps/dca/urls.py +++ b/app/apps/dca/urls.py @@ -12,6 +12,16 @@ urlpatterns = [ views.strategy_delete, name="dca_strategy_delete", ), + path( + "dca//take-ownership/", + views.strategy_take_ownership, + name="dca_strategy_take_ownership", + ), + path( + "dca//share/", + views.strategy_share, + name="dca_strategy_share_settings", + ), path( "dca//", views.strategy_detail_index, diff --git a/app/apps/dca/views.py b/app/apps/dca/views.py index c1bb56b..d2892b1 100644 --- a/app/apps/dca/views.py +++ b/app/apps/dca/views.py @@ -11,6 +11,8 @@ from django.views.decorators.http import require_http_methods from apps.common.decorators.htmx import only_htmx from apps.dca.forms import DCAEntryForm, DCAStrategyForm from apps.dca.models import DCAStrategy, DCAEntry +from apps.common.models import SharedObject +from apps.common.forms import SharedObjectForm @login_required @@ -57,6 +59,16 @@ def strategy_add(request): def strategy_edit(request, 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": form = DCAStrategyForm(request.POST, instance=dca_strategy) if form.is_valid(): @@ -85,9 +97,15 @@ def strategy_edit(request, strategy_id): def strategy_delete(request, strategy_id): dca_strategy = get_object_or_404(DCAStrategy, id=strategy_id) - dca_strategy.delete() - - messages.success(request, _("DCA strategy deleted successfully")) + if ( + dca_strategy.owner != request.user + 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( 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 def strategy_detail_index(request, strategy_id): strategy = get_object_or_404(DCAStrategy, id=strategy_id) diff --git a/app/apps/export_app/forms.py b/app/apps/export_app/forms.py index 5ff7fe5..3100c2b 100644 --- a/app/apps/export_app/forms.py +++ b/app/apps/export_app/forms.py @@ -8,6 +8,12 @@ from apps.common.widgets.crispy.submit import NoClassSubmit class ExportForm(forms.Form): + users = forms.BooleanField( + required=False, + widget=forms.CheckboxInput(), + label=_("Users"), + initial=True, + ) accounts = forms.BooleanField( required=False, widget=forms.CheckboxInput(), @@ -94,6 +100,7 @@ class ExportForm(forms.Form): self.helper.form_tag = False self.helper.form_method = "post" self.helper.layout = Layout( + "users", "accounts", "currencies", "transactions", @@ -121,6 +128,7 @@ class RestoreForm(forms.Form): help_text=_("Import a ZIP file exported from WYGIWYH"), label=_("ZIP File"), ) + users = forms.FileField(required=False, label=_("Users")) accounts = forms.FileField(required=False, label=_("Accounts")) currencies = forms.FileField(required=False, label=_("Currencies")) transactions_categories = forms.FileField(required=False, label=_("Categories")) @@ -155,6 +163,7 @@ class RestoreForm(forms.Form): self.helper.layout = Layout( "zip_file", HTML("
"), + "users", "accounts", "currencies", "transactions", diff --git a/app/apps/export_app/resources/accounts.py b/app/apps/export_app/resources/accounts.py index a1b8eb3..7c993ba 100644 --- a/app/apps/export_app/resources/accounts.py +++ b/app/apps/export_app/resources/accounts.py @@ -24,3 +24,6 @@ class AccountResource(resources.ModelResource): class Meta: model = Account + + def get_queryset(self): + return Account.all_objects.all() diff --git a/app/apps/export_app/resources/transactions.py b/app/apps/export_app/resources/transactions.py index 9668bce..daa02b9 100644 --- a/app/apps/export_app/resources/transactions.py +++ b/app/apps/export_app/resources/transactions.py @@ -55,23 +55,32 @@ class TransactionResource(resources.ModelResource): model = Transaction def get_queryset(self): - return Transaction.all_objects.all() + return Transaction.userless_all_objects.all() class TransactionTagResource(resources.ModelResource): class Meta: model = TransactionTag + def get_queryset(self): + return TransactionTag.all_objects.all() + class TransactionEntityResource(resources.ModelResource): class Meta: model = TransactionEntity + def get_queryset(self): + return TransactionEntity.all_objects.all() + class TransactionCategoyResource(resources.ModelResource): class Meta: model = TransactionCategory + def get_queryset(self): + return TransactionCategory.all_objects.all() + class RecurringTransactionResource(resources.ModelResource): account = fields.Field( @@ -107,6 +116,9 @@ class RecurringTransactionResource(resources.ModelResource): class Meta: model = RecurringTransaction + def get_queryset(self): + return RecurringTransaction.all_objects.all() + class InstallmentPlanResource(resources.ModelResource): account = fields.Field( @@ -141,3 +153,6 @@ class InstallmentPlanResource(resources.ModelResource): class Meta: model = InstallmentPlan + + def get_queryset(self): + return InstallmentPlan.all_objects.all() diff --git a/app/apps/export_app/resources/users.py b/app/apps/export_app/resources/users.py new file mode 100644 index 0000000..1ecb257 --- /dev/null +++ b/app/apps/export_app/resources/users.py @@ -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", + ) diff --git a/app/apps/export_app/views.py b/app/apps/export_app/views.py index ce9917f..94dd7bc 100644 --- a/app/apps/export_app/views.py +++ b/app/apps/export_app/views.py @@ -1,9 +1,9 @@ import logging import zipfile -from io import BytesIO, TextIOWrapper +from io import BytesIO 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.http import HttpResponse 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 tablib import Dataset +from apps.common.decorators.htmx import only_htmx from apps.export_app.forms import ExportForm, RestoreForm 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 ( CurrencyResource, ExchangeRateResource, ExchangeRateServiceResource, ) -from apps.export_app.resources.rules import ( - TransactionRuleResource, - TransactionRuleActionResource, - UpdateOrCreateTransactionRuleResource, -) from apps.export_app.resources.dca import ( DCAStrategyResource, DCAEntryResource, @@ -39,18 +27,33 @@ from apps.export_app.resources.dca import ( from apps.export_app.resources.import_app import ( 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() @login_required +@user_passes_test(lambda u: u.is_superuser) @require_http_methods(["GET"]) def export_index(request): return render(request, "export_app/pages/index.html") @login_required +@user_passes_test(lambda u: u.is_superuser) @require_http_methods(["GET", "POST"]) def export_form(request): timestamp = timezone.localtime(timezone.now()).strftime("%Y-%m-%dT%H-%M-%S") @@ -60,6 +63,7 @@ def export_form(request): if form.is_valid(): zip_buffer = BytesIO() + export_users = form.cleaned_data.get("users", False) export_accounts = form.cleaned_data.get("accounts", False) export_currencies = form.cleaned_data.get("currencies", 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) exports = [] + if export_users: + exports.append((UserResource().export(), "users")) if export_accounts: exports.append((AccountResource().export(), "accounts")) if export_currencies: @@ -176,6 +182,7 @@ def export_form(request): @only_htmx @login_required +@user_passes_test(lambda u: u.is_superuser) @require_http_methods(["GET", "POST"]) def import_form(request): if request.method == "POST": @@ -209,6 +216,7 @@ def import_form(request): def process_imports(request, cleaned_data): # Define import order to handle dependencies import_order = [ + ("users", UserResource), ("currencies", CurrencyResource), ( "currencies", diff --git a/app/apps/import_app/services/v1.py b/app/apps/import_app/services/v1.py index 8cff32f..40f9ba0 100644 --- a/app/apps/import_app/services/v1.py +++ b/app/apps/import_app/services/v1.py @@ -268,14 +268,17 @@ class ImportService: category = TransactionCategory.objects.get(id=category_name) else: # name if getattr(category_mapping, "create", False): - category, _ = TransactionCategory.objects.get_or_create( - name=category_name - ) + try: + category = TransactionCategory.objects.get( + name=category_name + ) + except TransactionCategory.DoesNotExist: + category = TransactionCategory(name=category_name) + category.save() else: category = TransactionCategory.objects.filter( name=category_name ).first() - if category: data["category"] = category self.import_run.categories.add(category) @@ -325,9 +328,13 @@ class ImportService: tag = TransactionTag.objects.filter(id=tag_name).first() else: # name if getattr(tags_mapping, "create", False): - tag, _ = TransactionTag.objects.get_or_create( - name=tag_name.strip() - ) + try: + tag = TransactionTag.objects.get( + name=tag_name.strip() + ) + except TransactionTag.DoesNotExist: + tag = TransactionTag(name=tag_name.strip()) + tag.save() else: tag = TransactionTag.objects.filter( name=tag_name.strip() @@ -361,9 +368,13 @@ class ImportService: ).first() else: # name if getattr(entities_mapping, "create", False): - entity, _ = TransactionEntity.objects.get_or_create( - name=entity_name.strip() - ) + try: + entity = TransactionEntity.objects.get( + name=entity_name.strip() + ) + except TransactionEntity.DoesNotExist: + entity = TransactionEntity(name=entity_name.strip()) + entity.save() else: entity = TransactionEntity.objects.filter( name=entity_name.strip() @@ -394,7 +405,11 @@ class ImportService: def _create_account(self, data: Dict[str, Any]) -> Account: if "group" in data: 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 # Handle currency references diff --git a/app/apps/import_app/tasks.py b/app/apps/import_app/tasks.py index 58026bb..5946c6e 100644 --- a/app/apps/import_app/tasks.py +++ b/app/apps/import_app/tasks.py @@ -1,7 +1,9 @@ import logging +from django.contrib.auth import get_user_model 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.services import ImportServiceV1 @@ -9,10 +11,15 @@ logger = logging.getLogger(__name__) @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: import_run = ImportRun.objects.get(id=import_run_id) import_service = ImportServiceV1(import_run) import_service.process_file(file_path) + delete_current_user() except ImportRun.DoesNotExist: + delete_current_user() raise ValueError(f"ImportRun with id {import_run_id} not found") diff --git a/app/apps/import_app/urls.py b/app/apps/import_app/urls.py index eae9851..549e0c8 100644 --- a/app/apps/import_app/urls.py +++ b/app/apps/import_app/urls.py @@ -2,7 +2,6 @@ from django.urls import path import apps.import_app.views as views urlpatterns = [ - path("import/", views.import_view, name="import"), path( "import/presets/", views.import_presets_list, diff --git a/app/apps/import_app/views.py b/app/apps/import_app/views.py index 434a75b..a9f45ad 100644 --- a/app/apps/import_app/views.py +++ b/app/apps/import_app/views.py @@ -15,19 +15,6 @@ from apps.import_app.services import PresetService 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 @require_http_methods(["GET"]) 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) # 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")) diff --git a/app/apps/monthly_overview/views.py b/app/apps/monthly_overview/views.py index 4ae4165..d363067 100644 --- a/app/apps/monthly_overview/views.py +++ b/app/apps/monthly_overview/views.py @@ -77,24 +77,20 @@ def transactions_list(request, month: int, year: int): request.session["monthly_transactions_order"] = order f = TransactionsFilter(request.GET) - transactions_filtered = ( - f.qs.filter() - .filter( - reference_date__year=year, - reference_date__month=month, - ) - .prefetch_related( - "account", - "account__group", - "category", - "tags", - "account__exchange_currency", - "account__currency", - "installment_plan", - "entities", - "dca_expense_entries", - "dca_income_entries", - ) + transactions_filtered = f.qs.filter( + reference_date__year=year, + reference_date__month=month, + ).prefetch_related( + "account", + "account__group", + "category", + "tags", + "account__exchange_currency", + "account__currency", + "installment_plan", + "entities", + "dca_expense_entries", + "dca_income_entries", ) transactions_filtered = default_order(transactions_filtered, order=order) diff --git a/app/apps/net_worth/utils/calculate_net_worth.py b/app/apps/net_worth/utils/calculate_net_worth.py index a0ba24b..efdbb30 100644 --- a/app/apps/net_worth/utils/calculate_net_worth.py +++ b/app/apps/net_worth/utils/calculate_net_worth.py @@ -2,20 +2,13 @@ from collections import OrderedDict, defaultdict from decimal import Decimal 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.functions import Coalesce from django.db.models.functions import TruncMonth from django.template.defaultfilters import date as date_filter from django.utils import timezone -from django.utils.translation import gettext_lazy as _ from apps.accounts.models import Account from apps.currencies.models import Currency -from apps.currencies.utils.convert import convert 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): transactions_params = {**{k: v for k, v in [("is_paid", True)] if is_paid}} # Get all accounts - accounts = Account.objects.filter(is_archived=False) + accounts = Account.objects.filter( + is_archived=False, + ) # Get the date range date_range = Transaction.objects.filter(**transactions_params).aggregate( diff --git a/app/apps/net_worth/views.py b/app/apps/net_worth/views.py index 96b5526..b7bd05b 100644 --- a/app/apps/net_worth/views.py +++ b/app/apps/net_worth/views.py @@ -1,7 +1,9 @@ import json +from django.contrib.auth.decorators import login_required from django.core.serializers.json import DjangoJSONEncoder from django.shortcuts import render +from django.views.decorators.http import require_http_methods from apps.net_worth.utils.calculate_net_worth import ( 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): transactions_currency_queryset = Transaction.objects.filter( 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): transactions_currency_queryset = Transaction.objects.filter( account__is_archived=False diff --git a/app/apps/rules/migrations/0012_transactionrule_owner_transactionrule_shared_with_and_more.py b/app/apps/rules/migrations/0012_transactionrule_owner_transactionrule_shared_with_and_more.py new file mode 100644 index 0000000..35ac07a --- /dev/null +++ b/app/apps/rules/migrations/0012_transactionrule_owner_transactionrule_shared_with_and_more.py @@ -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), + ), + ] diff --git a/app/apps/rules/models.py b/app/apps/rules/models.py index 9d462fe..ad094a0 100644 --- a/app/apps/rules/models.py +++ b/app/apps/rules/models.py @@ -2,8 +2,10 @@ from django.db import models from django.db.models import Q 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) on_update = models.BooleanField(default=False) on_create = models.BooleanField(default=True) @@ -11,6 +13,9 @@ class TransactionRule(models.Model): description = models.TextField(blank=True, null=True, verbose_name=_("Description")) trigger = models.TextField(verbose_name=_("Trigger")) + objects = SharedObjectManager() + all_objects = models.Manager() # Unfiltered manager + class Meta: verbose_name = _("Transaction rule") verbose_name_plural = _("Transaction rules") diff --git a/app/apps/rules/signals.py b/app/apps/rules/signals.py index 1004ee4..e0589c3 100644 --- a/app/apps/rules/signals.py +++ b/app/apps/rules/signals.py @@ -6,6 +6,7 @@ from apps.transactions.models import ( transaction_updated, ) from apps.rules.tasks import check_for_transaction_rules +from apps.common.middleware.thread_local import get_current_user @receiver(transaction_created) @@ -20,6 +21,7 @@ def transaction_changed_receiver(sender: Transaction, signal, **kwargs): check_for_transaction_rules.defer( instance_id=sender.id, + user_id=get_current_user().id, signal=( "transaction_created" if signal is transaction_created diff --git a/app/apps/rules/tasks.py b/app/apps/rules/tasks.py index 2d3227c..1119cc6 100644 --- a/app/apps/rules/tasks.py +++ b/app/apps/rules/tasks.py @@ -4,6 +4,7 @@ from datetime import datetime, date from cachalot.api import cachalot_disabled from dateutil.relativedelta import relativedelta +from django.contrib.auth import get_user_model from procrastinate.contrib.django import app from simpleeval import EvalWithCompoundTypes @@ -18,6 +19,7 @@ from apps.transactions.models import ( TransactionTag, TransactionEntity, ) +from apps.common.middleware.thread_local import write_current_user, delete_current_user logger = logging.getLogger(__name__) @@ -25,8 +27,12 @@ logger = logging.getLogger(__name__) @app.task(name="check_for_transaction_rules") def check_for_transaction_rules( instance_id: int, + user_id: int, signal, ): + user = get_user_model().objects.get(id=user_id) + write_current_user(user) + try: with cachalot_disabled(): instance = Transaction.objects.get(id=instance_id) @@ -91,8 +97,11 @@ def check_for_transaction_rules( "Error while executing 'check_for_transaction_rules' task", exc_info=True, ) + delete_current_user() raise e + delete_current_user() + def _get_names(instance): return { diff --git a/app/apps/rules/urls.py b/app/apps/rules/urls.py index b0f9661..2107aa4 100644 --- a/app/apps/rules/urls.py +++ b/app/apps/rules/urls.py @@ -37,6 +37,16 @@ urlpatterns = [ views.transaction_rule_delete, name="transaction_rule_delete", ), + path( + "rules/transaction//take-ownership/", + views.transaction_rule_take_ownership, + name="transaction_rule_take_ownership", + ), + path( + "rules/transaction//share/", + views.transaction_rule_share, + name="transaction_rule_share_settings", + ), path( "rules/transaction//transaction-action/add/", views.transaction_rule_action_add, diff --git a/app/apps/rules/views.py b/app/apps/rules/views.py index 0bd1892..79e8588 100644 --- a/app/apps/rules/views.py +++ b/app/apps/rules/views.py @@ -16,6 +16,8 @@ from apps.rules.models import ( TransactionRuleAction, UpdateOrCreateTransactionRuleAction, ) +from apps.common.models import SharedObject +from apps.common.forms import SharedObjectForm @login_required @@ -93,6 +95,16 @@ def transaction_rule_add(request, **kwargs): def transaction_rule_edit(request, 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": form = TransactionRuleForm(request.POST, instance=transaction_rule) if form.is_valid(): @@ -134,9 +146,15 @@ def transaction_rule_view(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.delete() - - messages.success(request, _("Rule deleted successfully")) + if ( + transaction_rule.owner != request.user + 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( 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 @login_required @require_http_methods(["GET", "POST"]) diff --git a/app/apps/transactions/admin.py b/app/apps/transactions/admin.py index 8f37317..11c8a3d 100644 --- a/app/apps/transactions/admin.py +++ b/app/apps/transactions/admin.py @@ -8,13 +8,14 @@ from apps.transactions.models import ( RecurringTransaction, TransactionEntity, ) +from apps.common.admin import SharedObjectModelAdmin @admin.register(Transaction) class TransactionModelAdmin(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() + return self.model.userless_all_objects.all() list_filter = ["deleted", "type", "is_paid", "date", "account"] @@ -48,19 +49,29 @@ class TransactionInline(admin.TabularInline): @admin.register(InstallmentPlan) -class InstallmentPlanAdmin(admin.ModelAdmin): +class InstallmentPlanAdmin(SharedObjectModelAdmin): inlines = [ TransactionInline, ] @admin.register(RecurringTransaction) -class RecurringTransactionAdmin(admin.ModelAdmin): +class RecurringTransactionAdmin(SharedObjectModelAdmin): inlines = [ TransactionInline, ] -admin.site.register(TransactionCategory) -admin.site.register(TransactionTag) -admin.site.register(TransactionEntity) +@admin.register(TransactionCategory) +class TransactionCategoryModelAdmin(SharedObjectModelAdmin): + pass + + +@admin.register(TransactionTag) +class TransactionTagModelAdmin(SharedObjectModelAdmin): + pass + + +@admin.register(TransactionEntity) +class TransactionEntityModelAdmin(SharedObjectModelAdmin): + pass diff --git a/app/apps/transactions/filters.py b/app/apps/transactions/filters.py index ccbb58f..aeae7bf 100644 --- a/app/apps/transactions/filters.py +++ b/app/apps/transactions/filters.py @@ -184,3 +184,8 @@ class TransactionsFilter(django_filters.FilterSet): self.form.fields["from_amount"].widget = ArbitraryDecimalDisplayNumberInput() self.form.fields["date_start"].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() diff --git a/app/apps/transactions/forms.py b/app/apps/transactions/forms.py index 7bdb574..130d470 100644 --- a/app/apps/transactions/forms.py +++ b/app/apps/transactions/forms.py @@ -29,6 +29,7 @@ from apps.transactions.models import ( RecurringTransaction, TransactionEntity, ) +from apps.common.middleware.thread_local import get_current_user 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 self.instance.id: self.fields["account"].queryset = Account.objects.filter( - Q(is_archived=False) | Q(transactions=self.instance.id) - ).distinct() + Q(is_archived=False) | Q(transactions=self.instance.id), + ) self.fields["category"].queryset = TransactionCategory.objects.filter( Q(active=True) | Q(transaction=self.instance.id) - ).distinct() + ) self.fields["tags"].queryset = TransactionTag.objects.filter( Q(active=True) | Q(transaction=self.instance.id) - ).distinct() + ) self.fields["entities"].queryset = TransactionEntity.objects.filter( 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.form_tag = False @@ -405,6 +416,24 @@ class TransferForm(forms.Form): self.fields["to_amount"].widget = ArbitraryDecimalDisplayNumberInput() 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): cleaned_data = super().clean() from_account = cleaned_data.get("from_account") @@ -536,6 +565,18 @@ class InstallmentPlanForm(forms.ModelForm): self.fields["entities"].queryset = TransactionEntity.objects.filter( Q(active=True) | Q(installmentplan=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.filter( + active=True + ) self.helper = FormHelper() self.helper.form_tag = False @@ -781,6 +822,18 @@ class RecurringTransactionForm(forms.ModelForm): self.fields["entities"].queryset = TransactionEntity.objects.filter( Q(active=True) | Q(recurringtransaction=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.filter( + active=True + ) self.helper = FormHelper() self.helper.form_method = "post" diff --git a/app/apps/transactions/migrations/0034_alter_installmentplan_managers_and_more.py b/app/apps/transactions/migrations/0034_alter_installmentplan_managers_and_more.py new file mode 100644 index 0000000..5aa3211 --- /dev/null +++ b/app/apps/transactions/migrations/0034_alter_installmentplan_managers_and_more.py @@ -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'), + ), + ] diff --git a/app/apps/transactions/migrations/0035_alter_transactioncategory_name_and_more.py b/app/apps/transactions/migrations/0035_alter_transactioncategory_name_and_more.py new file mode 100644 index 0000000..6b323a3 --- /dev/null +++ b/app/apps/transactions/migrations/0035_alter_transactioncategory_name_and_more.py @@ -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')}, + ), + ] diff --git a/app/apps/transactions/migrations/0036_alter_transactioncategory_managers_and_more.py b/app/apps/transactions/migrations/0036_alter_transactioncategory_managers_and_more.py new file mode 100644 index 0000000..952e720 --- /dev/null +++ b/app/apps/transactions/migrations/0036_alter_transactioncategory_managers_and_more.py @@ -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), + ), + ] diff --git a/app/apps/transactions/migrations/0037_alter_transactioncategory_visibility_and_more.py b/app/apps/transactions/migrations/0037_alter_transactioncategory_visibility_and_more.py new file mode 100644 index 0000000..6e30520 --- /dev/null +++ b/app/apps/transactions/migrations/0037_alter_transactioncategory_visibility_and_more.py @@ -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), + ), + ] diff --git a/app/apps/transactions/migrations/0038_transaction_owner.py b/app/apps/transactions/migrations/0038_transaction_owner.py new file mode 100644 index 0000000..166829d --- /dev/null +++ b/app/apps/transactions/migrations/0038_transaction_owner.py @@ -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), + ), + ] diff --git a/app/apps/transactions/migrations/0039_alter_transaction_internal_id_and_more.py b/app/apps/transactions/migrations/0039_alter_transaction_internal_id_and_more.py new file mode 100644 index 0000000..ee81a92 --- /dev/null +++ b/app/apps/transactions/migrations/0039_alter_transaction_internal_id_and_more.py @@ -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')}, + ), + ] diff --git a/app/apps/transactions/migrations/0040_alter_transaction_unique_together_and_more.py b/app/apps/transactions/migrations/0040_alter_transaction_unique_together_and_more.py new file mode 100644 index 0000000..34804bc --- /dev/null +++ b/app/apps/transactions/migrations/0040_alter_transaction_unique_together_and_more.py @@ -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'), + ), + ] diff --git a/app/apps/transactions/models.py b/app/apps/transactions/models.py index 2141fb8..5435056 100644 --- a/app/apps/transactions/models.py +++ b/app/apps/transactions/models.py @@ -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.currencies.utils.convert import convert from apps.transactions.validators import validate_decimal_places, validate_non_negative +from apps.common.middleware.thread_local import get_current_user +from apps.common.models import SharedObject, SharedObjectManager, OwnedObject logger = logging.getLogger() @@ -93,10 +95,40 @@ class SoftDeleteQuerySet(models.QuerySet): class SoftDeleteManager(models.Manager): def get_queryset(self): 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): + 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): return SoftDeleteQuerySet(self.model, using=self._db) @@ -104,11 +136,45 @@ class AllObjectsManager(models.Manager): class DeletedObjectsManager(models.Manager): def get_queryset(self): 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): - name = models.CharField(max_length=255, verbose_name=_("Name"), unique=True) +class UserlessDeletedObjectsManager(models.Manager): + 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")) active = models.BooleanField( default=True, @@ -118,17 +184,21 @@ class TransactionCategory(models.Model): ), ) + objects = SharedObjectManager() + all_objects = models.Manager() # Unfiltered manager + class Meta: verbose_name = _("Transaction Category") verbose_name_plural = _("Transaction Categories") db_table = "t_categories" + unique_together = (("owner", "name"),) def __str__(self): return self.name -class TransactionTag(models.Model): - name = models.CharField(max_length=255, verbose_name=_("Name"), unique=True) +class TransactionTag(SharedObject): + name = models.CharField(max_length=255, verbose_name=_("Name")) active = models.BooleanField( default=True, verbose_name=_("Active"), @@ -137,16 +207,20 @@ class TransactionTag(models.Model): ), ) + objects = SharedObjectManager() + all_objects = models.Manager() # Unfiltered manager + class Meta: verbose_name = _("Transaction Tags") verbose_name_plural = _("Transaction Tags") db_table = "tags" + unique_together = (("owner", "name"),) def __str__(self): return self.name -class TransactionEntity(models.Model): +class TransactionEntity(SharedObject): name = models.CharField(max_length=255, verbose_name=_("Name")) active = models.BooleanField( default=True, @@ -156,16 +230,20 @@ class TransactionEntity(models.Model): ), ) + objects = SharedObjectManager() + all_objects = models.Manager() # Unfiltered manager + class Meta: verbose_name = _("Entity") verbose_name_plural = _("Entities") db_table = "entities" + unique_together = (("owner", "name"),) def __str__(self): return self.name -class Transaction(models.Model): +class Transaction(OwnedObject): class Type(models.TextChoices): INCOME = "IN", _("Income") EXPENSE = "EX", _("Expense") @@ -249,7 +327,11 @@ class Transaction(models.Model): objects = SoftDeleteManager.from_queryset(SoftDeleteQuerySet)() all_objects = AllObjectsManager.from_queryset(SoftDeleteQuerySet)() + userless_all_objects = UserlessAllObjectsManager.from_queryset(SoftDeleteQuerySet)() deleted_objects = DeletedObjectsManager.from_queryset(SoftDeleteQuerySet)() + userless_deleted_objects = UserlessDeletedObjectsManager.from_queryset( + SoftDeleteQuerySet + )() class Meta: verbose_name = _("Transaction") @@ -386,6 +468,9 @@ class InstallmentPlan(models.Model): notes = models.TextField(blank=True, verbose_name=_("Notes")) + all_objects = models.Manager() # Unfiltered manager + objects = GenericAccountOwnerManager() # Default filtered manager + class Meta: verbose_name = _("Installment Plan") verbose_name_plural = _("Installment Plans") @@ -440,7 +525,7 @@ class InstallmentPlan(models.Model): transaction_date = self.start_date + delta transaction_reference_date = (self.reference_date + delta).replace(day=1) - new_transaction = Transaction.objects.create( + new_transaction = Transaction.all_objects.create( account=self.account, type=self.type, date=transaction_date, @@ -500,7 +585,7 @@ class InstallmentPlan(models.Model): existing_transaction.entities.set(self.entities.all()) else: # If the transaction doesn't exist, create a new one - new_transaction = Transaction.objects.create( + new_transaction = Transaction.all_objects.create( account=self.account, type=self.type, date=transaction_date, @@ -587,6 +672,9 @@ class RecurringTransaction(models.Model): verbose_name=_("Last Generated Reference Date"), null=True, blank=True ) + all_objects = models.Manager() # Unfiltered manager + objects = GenericAccountOwnerManager() # Default filtered manager + class Meta: verbose_name = _("Recurring Transaction") verbose_name_plural = _("Recurring Transactions") @@ -624,7 +712,7 @@ class RecurringTransaction(models.Model): ) def create_transaction(self, date, reference_date): - created_transaction = Transaction.objects.create( + created_transaction = Transaction.all_objects.create( account=self.account, type=self.type, date=date, diff --git a/app/apps/transactions/tasks.py b/app/apps/transactions/tasks.py index c154729..c526c67 100644 --- a/app/apps/transactions/tasks.py +++ b/app/apps/transactions/tasks.py @@ -34,7 +34,7 @@ def cleanup_deleted_transactions(timestamp=None): if not settings.ENABLE_SOFT_DELETE: # Hard delete all soft-deleted transactions - deleted_count, _ = Transaction.deleted_objects.all().hard_delete() + deleted_count, _ = Transaction.userless_deleted_objects.all().hard_delete() return ( f"Hard deleted {deleted_count} transactions (soft deletion disabled)." ) @@ -47,7 +47,9 @@ def cleanup_deleted_transactions(timestamp=None): invalidate() # 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() return f"Hard deleted {deleted_count} objects older than {settings.KEEP_DELETED_TRANSACTIONS_FOR} days." diff --git a/app/apps/transactions/urls.py b/app/apps/transactions/urls.py index bcd2712..d1a8fac 100644 --- a/app/apps/transactions/urls.py +++ b/app/apps/transactions/urls.py @@ -131,6 +131,16 @@ urlpatterns = [ views.tag_delete, name="tag_delete", ), + path( + "tags//take-ownership/", + views.tag_take_ownership, + name="tag_take_ownership", + ), + path( + "tags//share/", + views.tag_share, + name="tag_share_settings", + ), path("entities/", views.entities_index, name="entities_index"), path("entities/list/", views.entities_list, name="entities_list"), path( @@ -154,6 +164,16 @@ urlpatterns = [ views.entity_delete, name="entity_delete", ), + path( + "entities//take-ownership/", + views.entity_take_ownership, + name="entity_take_ownership", + ), + path( + "entities//share/", + views.entity_share, + name="entity_share_settings", + ), path("categories/", views.categories_index, name="categories_index"), path("categories/list/", views.categories_list, name="categories_list"), path( @@ -177,6 +197,16 @@ urlpatterns = [ views.category_delete, name="category_delete", ), + path( + "categories//share/", + views.category_share, + name="category_share_settings", + ), + path( + "categories//take-ownership/", + views.category_take_ownership, + name="category_take_ownership", + ), path( "installment-plans/", views.installment_plans_index, diff --git a/app/apps/transactions/views/categories.py b/app/apps/transactions/views/categories.py index a2779c8..5293be3 100644 --- a/app/apps/transactions/views/categories.py +++ b/app/apps/transactions/views/categories.py @@ -8,6 +8,8 @@ from django.views.decorators.http import require_http_methods from apps.common.decorators.htmx import only_htmx from apps.transactions.forms import TransactionCategoryForm from apps.transactions.models import TransactionCategory +from apps.common.models import SharedObject +from apps.common.forms import SharedObjectForm @login_required @@ -85,6 +87,16 @@ def category_add(request, **kwargs): def category_edit(request, 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": form = TransactionCategoryForm(request.POST, instance=category) 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 @login_required @require_http_methods(["DELETE"]) def category_delete(request, category_id): category = get_object_or_404(TransactionCategory, id=category_id) - category.delete() - - messages.success(request, _("Category deleted successfully")) + if category.owner != request.user and request.user in category.shared_with.all(): + category.shared_with.remove(request.user) + 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( status=204, diff --git a/app/apps/transactions/views/entities.py b/app/apps/transactions/views/entities.py index 1104941..cd46e4b 100644 --- a/app/apps/transactions/views/entities.py +++ b/app/apps/transactions/views/entities.py @@ -8,6 +8,8 @@ from django.views.decorators.http import require_http_methods from apps.common.decorators.htmx import only_htmx from apps.transactions.forms import TransactionEntityForm from apps.transactions.models import TransactionEntity +from apps.common.models import SharedObject +from apps.common.forms import SharedObjectForm @login_required @@ -85,6 +87,16 @@ def entity_add(request, **kwargs): def entity_edit(request, 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": form = TransactionEntityForm(request.POST, instance=entity) if form.is_valid(): @@ -113,9 +125,12 @@ def entity_edit(request, entity_id): def entity_delete(request, entity_id): entity = get_object_or_404(TransactionEntity, id=entity_id) - entity.delete() - - messages.success(request, _("Entity deleted successfully")) + if entity.owner != request.user and request.user in entity.shared_with.all(): + entity.shared_with.remove(request.user) + messages.success(request, _("Item no longer shared with you")) + else: + entity.delete() + messages.success(request, _("Entity deleted successfully")) return HttpResponse( status=204, @@ -123,3 +138,62 @@ def entity_delete(request, entity_id): "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}, + ) diff --git a/app/apps/transactions/views/tags.py b/app/apps/transactions/views/tags.py index 0157641..a79babc 100644 --- a/app/apps/transactions/views/tags.py +++ b/app/apps/transactions/views/tags.py @@ -8,6 +8,8 @@ from django.views.decorators.http import require_http_methods from apps.common.decorators.htmx import only_htmx from apps.transactions.forms import TransactionTagForm from apps.transactions.models import TransactionTag +from apps.common.models import SharedObject +from apps.common.forms import SharedObjectForm @login_required @@ -85,6 +87,16 @@ def tag_add(request, **kwargs): def tag_edit(request, 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": form = TransactionTagForm(request.POST, instance=tag) if form.is_valid(): @@ -113,9 +125,12 @@ def tag_edit(request, tag_id): def tag_delete(request, tag_id): tag = get_object_or_404(TransactionTag, id=tag_id) - tag.delete() - - messages.success(request, _("Tag deleted successfully")) + if tag.owner != request.user and request.user in tag.shared_with.all(): + tag.shared_with.remove(request.user) + messages.success(request, _("Item no longer shared with you")) + else: + tag.delete() + messages.success(request, _("Tag deleted successfully")) return HttpResponse( status=204, @@ -123,3 +138,62 @@ def tag_delete(request, tag_id): "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}, + ) diff --git a/app/apps/users/models.py b/app/apps/users/models.py index 16e1e96..8034bae 100644 --- a/app/apps/users/models.py +++ b/app/apps/users/models.py @@ -18,7 +18,7 @@ class User(AbstractUser): REQUIRED_FIELDS = [] def __str__(self): - return f"{self.first_name} {self.last_name} ({self.email})" + return self.email class UserSettings(models.Model): diff --git a/app/templates/account_groups/fragments/list.html b/app/templates/account_groups/fragments/list.html index 5eea86d..9bf1b31 100644 --- a/app/templates/account_groups/fragments/list.html +++ b/app/templates/account_groups/fragments/list.html @@ -2,13 +2,13 @@
{% spaceless %} -
{% translate 'Account Groups' %} + {% endspaceless %} @@ -17,46 +17,64 @@
{% if account_groups %} - - - + +
+ - - + + {% for account_group in account_groups %} - - - - + + + + {% endfor %} - -
{% translate 'Name' %}
+ + {% else %} - + {% endif %}
diff --git a/app/templates/account_groups/fragments/share.html b/app/templates/account_groups/fragments/share.html new file mode 100644 index 0000000..b8f1bc8 --- /dev/null +++ b/app/templates/account_groups/fragments/share.html @@ -0,0 +1,11 @@ +{% extends 'extends/offcanvas.html' %} +{% load i18n %} +{% load crispy_forms_tags %} + +{% block title %}{% translate 'Share settings' %}{% endblock %} + +{% block body %} +
+ {% crispy form %} +
+{% endblock %} diff --git a/app/templates/accounts/fragments/list.html b/app/templates/accounts/fragments/list.html index d23bc55..daaffe4 100644 --- a/app/templates/accounts/fragments/list.html +++ b/app/templates/accounts/fragments/list.html @@ -53,6 +53,24 @@ data-text="{% translate "You won't be able to revert this!" %}" data-confirm-text="{% translate "Yes, delete it!" %}" _="install prompt_swal"> + {% if not account.owner %} + + + {% endif %} + {% if user == account.owner %} + + + {% endif %}
{{ account.name }} diff --git a/app/templates/accounts/fragments/share.html b/app/templates/accounts/fragments/share.html new file mode 100644 index 0000000..6e18189 --- /dev/null +++ b/app/templates/accounts/fragments/share.html @@ -0,0 +1,11 @@ +{% extends 'extends/offcanvas.html' %} +{% load i18n %} +{% load crispy_forms_tags %} + +{% block title %}{% translate 'Share settings' %}{% endblock %} + +{% block body %} +
+ {% crispy form %} +
+{% endblock %} diff --git a/app/templates/categories/fragments/share.html b/app/templates/categories/fragments/share.html new file mode 100644 index 0000000..711b23f --- /dev/null +++ b/app/templates/categories/fragments/share.html @@ -0,0 +1,11 @@ +{% extends 'extends/offcanvas.html' %} +{% load i18n %} +{% load crispy_forms_tags %} + +{% block title %}{% translate 'Share settings' %}{% endblock %} + +{% block body %} +
+ {% crispy form %} +
+{% endblock %} diff --git a/app/templates/categories/fragments/table.html b/app/templates/categories/fragments/table.html index 675fabd..0cc340d 100644 --- a/app/templates/categories/fragments/table.html +++ b/app/templates/categories/fragments/table.html @@ -42,6 +42,24 @@ data-text="{% translate "You won't be able to revert this!" %}" data-confirm-text="{% translate "Yes, delete it!" %}" _="install prompt_swal"> + {% if not category.owner %} + + + {% endif %} + {% if user == category.owner %} + + + {% endif %}
{{ category.name }} diff --git a/app/templates/dca/fragments/strategy/list.html b/app/templates/dca/fragments/strategy/list.html index e1f1891..9a03471 100644 --- a/app/templates/dca/fragments/strategy/list.html +++ b/app/templates/dca/fragments/strategy/list.html @@ -17,40 +17,59 @@
{% for strategy in strategies %}
-
-
- {{ strategy.payment_currency.name }} x {{ strategy.target_currency.name }} -
- -
-
{{ strategy.name }}
-
{{ strategy.notes }}
-
-
- +
+
+ {{ strategy.payment_currency.name }} x {{ strategy.target_currency.name }}
+ +
+
{{ strategy.name }}
+
{{ strategy.notes }}
+
+
+ +
{% endfor %}
diff --git a/app/templates/dca/fragments/strategy/share.html b/app/templates/dca/fragments/strategy/share.html new file mode 100644 index 0000000..368531f --- /dev/null +++ b/app/templates/dca/fragments/strategy/share.html @@ -0,0 +1,11 @@ +{% extends 'extends/offcanvas.html' %} +{% load i18n %} +{% load crispy_forms_tags %} + +{% block title %}{% translate 'Share settings' %}{% endblock %} + +{% block body %} +
+ {% crispy form %} +
+{% endblock %} diff --git a/app/templates/entities/fragments/share.html b/app/templates/entities/fragments/share.html new file mode 100644 index 0000000..b7973af --- /dev/null +++ b/app/templates/entities/fragments/share.html @@ -0,0 +1,11 @@ +{% extends 'extends/offcanvas.html' %} +{% load i18n %} +{% load crispy_forms_tags %} + +{% block title %}{% translate 'Share settings' %}{% endblock %} + +{% block body %} +
+ {% crispy form %} +
+{% endblock %} diff --git a/app/templates/entities/fragments/table.html b/app/templates/entities/fragments/table.html index ea43d30..e5f3259 100644 --- a/app/templates/entities/fragments/table.html +++ b/app/templates/entities/fragments/table.html @@ -6,50 +6,68 @@
{% endif %} - {% if entities %} -
- - - - - - +{% if entities %} +
+ +
{% translate 'Name' %}
+ + + + + + + + {% for entity in entities %} + + + - - - {% for entity in entities %} - - - - - {% endfor %} - -
{% translate 'Name' %}
+
+ + + + {% if not entity.owner %} + + + {% endif %} + {% if user == entity.owner %} + + + {% endif %} +
+
{{ entity.name }}
-
- - - -
-
{{ entity.name }}
-
- {% else %} - - {% endif %} + {% endfor %} + + +
+{% else %} + +{% endif %}
diff --git a/app/templates/includes/navbar.html b/app/templates/includes/navbar.html index 1724b55..013f8ae 100644 --- a/app/templates/includes/navbar.html +++ b/app/templates/includes/navbar.html @@ -132,8 +132,10 @@ href="{% url 'rules_index' %}">{% translate 'Rules' %}
  • {% translate 'Import' %} beta
  • + {% if user.is_superuser %}
  • {% translate 'Export and Restore' %}
  • + {% endif %}
  • {% translate 'Automatic Exchange Rates' %}
  • diff --git a/app/templates/rules/fragments/list.html b/app/templates/rules/fragments/list.html index 29577bf..5f74f1a 100644 --- a/app/templates/rules/fragments/list.html +++ b/app/templates/rules/fragments/list.html @@ -2,13 +2,13 @@
    {% spaceless %} -
    {% translate 'Rules' %} + {% endspaceless %} @@ -17,59 +17,79 @@
    {% if transaction_rules %} - - - + +
    + - - + + {% for rule in transaction_rules %} - - - - - + + + + + {% endfor %} - -
    {% translate 'Name' %}
    -
    - - - -
    -
    - - {% if rule.active %}{% else %}{% endif %} - - -
    {{ rule.name }}
    -
    {{ rule.description }}
    -
    +
    + + + + {% if not rule.owner %} + + + {% endif %} + {% if user == rule.owner %} + + + {% endif %} +
    +
    + + {% if rule.active %}{% else %} + {% endif %} + + +
    {{ rule.name }}
    +
    {{ rule.description }}
    +
    + + {% else %} - + {% endif %}
    diff --git a/app/templates/rules/fragments/share.html b/app/templates/rules/fragments/share.html new file mode 100644 index 0000000..83422fb --- /dev/null +++ b/app/templates/rules/fragments/share.html @@ -0,0 +1,11 @@ +{% extends 'extends/offcanvas.html' %} +{% load i18n %} +{% load crispy_forms_tags %} + +{% block title %}{% translate 'Share settings' %}{% endblock %} + +{% block body %} +
    + {% crispy form %} +
    +{% endblock %} diff --git a/app/templates/tags/fragments/share.html b/app/templates/tags/fragments/share.html new file mode 100644 index 0000000..ff4eb5a --- /dev/null +++ b/app/templates/tags/fragments/share.html @@ -0,0 +1,11 @@ +{% extends 'extends/offcanvas.html' %} +{% load i18n %} +{% load crispy_forms_tags %} + +{% block title %}{% translate 'Share settings' %}{% endblock %} + +{% block body %} +
    + {% crispy form %} +
    +{% endblock %} diff --git a/app/templates/tags/fragments/table.html b/app/templates/tags/fragments/table.html index 9a104c1..b787aeb 100644 --- a/app/templates/tags/fragments/table.html +++ b/app/templates/tags/fragments/table.html @@ -6,50 +6,68 @@
    {% endif %} - {% if tags %} -
    - - - - - - +{% if tags %} +
    + +
    {% translate 'Name' %}
    + + + + + + + + {% for tag in tags %} + + + - - - {% for tag in tags %} - - - - - {% endfor %} - -
    {% translate 'Name' %}
    +
    + + + + {% if not tag.owner %} + + + {% endif %} + {% if user == tag.owner %} + + + {% endif %} +
    +
    {{ tag.name }}
    -
    - - - -
    -
    {{ tag.name }}
    -
    - {% else %} - - {% endif %} + {% endfor %} + + +
    +{% else %} + +{% endif %}