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