mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-02-25 00:44:52 +01:00
Compare commits
98 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
621799f445 | ||
|
|
124d29e965 | ||
|
|
bf4d23f15e | ||
|
|
020dd74f80 | ||
|
|
c7d70a1748 | ||
|
|
1025b80dda | ||
|
|
1ae245fe01 | ||
|
|
46c5efb8a9 | ||
|
|
abb0993435 | ||
|
|
a9e7692f99 | ||
|
|
531571798a | ||
|
|
7282aa20ee | ||
|
|
13f9950afa | ||
|
|
672cc5ebc7 | ||
|
|
8045e2c73a | ||
|
|
7c042d9299 | ||
|
|
aba47f0eed | ||
|
|
2010ccc92d | ||
|
|
d73d6cbf22 | ||
|
|
e5a9b6e921 | ||
|
|
dbd9774681 | ||
|
|
5a93a907e1 | ||
|
|
e0e159166b | ||
|
|
6c7594ad14 | ||
|
|
d3ea0e43da | ||
|
|
dde75416ca | ||
|
|
c9b346b791 | ||
|
|
9896044a15 | ||
|
|
eb65eb4590 | ||
|
|
017c70e8b2 | ||
|
|
64b0830909 | ||
|
|
25d99cbece | ||
|
|
033f0e1b0d | ||
|
|
35027ee0ae | ||
|
|
91904e959b | ||
|
|
a6a85ae3a2 | ||
|
|
b0f53f45f9 | ||
|
|
0f60f8d486 | ||
|
|
efb207a109 | ||
|
|
95b1481dd5 | ||
|
|
8de340b68b | ||
|
|
ef15b85386 | ||
|
|
45d939237d | ||
|
|
6bf262e514 | ||
|
|
f9d9137336 | ||
|
|
b532521f27 | ||
|
|
1e06e2d34d | ||
|
|
a33fa5e184 | ||
|
|
a2453695d8 | ||
|
|
3e929d0433 | ||
|
|
185fc464a5 | ||
|
|
647c009525 | ||
|
|
ba75492dcc | ||
|
|
8312baaf45 | ||
|
|
4d346dc278 | ||
|
|
70ff7fab38 | ||
|
|
6947c6affd | ||
|
|
dcab83f936 | ||
|
|
b228e4ec26 | ||
|
|
4071a1301f | ||
|
|
5c9db10710 | ||
|
|
19c92e0014 | ||
|
|
6459f2eb46 | ||
|
|
7926e081ef | ||
|
|
ceefe7075f | ||
|
|
ad3230fd83 | ||
|
|
c89b07ed93 | ||
|
|
201ccea842 | ||
|
|
32ada488b4 | ||
|
|
794d11a355 | ||
|
|
67f8f5fe89 | ||
|
|
9ac69fd92a | ||
|
|
069f1b450c | ||
|
|
2f388af928 | ||
|
|
beeb0579ce | ||
|
|
a8666da57b | ||
|
|
835316d0f3 | ||
|
|
f5feeb9617 | ||
|
|
09e380a480 | ||
|
|
3080df9b66 | ||
|
|
ebc41a8049 | ||
|
|
635628e30e | ||
|
|
819a58ac06 | ||
|
|
d433375522 | ||
|
|
c0150f71a8 | ||
|
|
6119698d38 | ||
|
|
f5ae231601 | ||
|
|
972d23abbd | ||
|
|
9a514a8a69 | ||
|
|
7325231548 | ||
|
|
570657371a | ||
|
|
67da60b5b0 | ||
|
|
84c047c5ab | ||
|
|
23f5d09bec | ||
|
|
2a19075e23 | ||
|
|
7f231175b2 | ||
|
|
062e84f864 | ||
|
|
5521eb20bf |
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -20,6 +20,10 @@ on:
|
||||
env:
|
||||
IMAGE_NAME: wygiwyh
|
||||
|
||||
concurrency:
|
||||
group: release
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
72
.github/workflows/translations.yml
vendored
Normal file
72
.github/workflows/translations.yml
vendored
Normal file
@@ -0,0 +1,72 @@
|
||||
name: Django Translation Update
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
# Add manual trigger
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
reason:
|
||||
description: 'Reason for running'
|
||||
required: false
|
||||
default: 'Manual update of translation files'
|
||||
|
||||
# Ensure only one translation job runs at a time
|
||||
concurrency:
|
||||
group: django-translations
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
update-translations:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
# Skip on PRs from forks (which don't have write permissions)
|
||||
# Allow manual runs and pushes to main
|
||||
if: github.event_name == 'workflow_dispatch' || github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.PAT }}
|
||||
ref: ${{ github.head_ref }}
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
|
||||
- name: Install gettext
|
||||
run: sudo apt-get install -y gettext
|
||||
|
||||
- name: Run makemessages
|
||||
run: |
|
||||
cd app
|
||||
python manage.py makemessages -a
|
||||
|
||||
- name: Check for changes
|
||||
id: check_changes
|
||||
run: |
|
||||
if git diff --exit-code --quiet app/locale/; then
|
||||
echo "No translation changes detected"
|
||||
else
|
||||
echo "changes_detected=true" >> $GITHUB_OUTPUT
|
||||
echo "Translation changes detected"
|
||||
fi
|
||||
|
||||
- name: Commit translation files
|
||||
if: steps.check_changes.outputs.changes_detected == 'true'
|
||||
uses: stefanzweifel/git-auto-commit-action@v5
|
||||
with:
|
||||
push_options: --force
|
||||
commit_message: |
|
||||
chore(locale): update translation files
|
||||
|
||||
[skip ci] Automatically generated by Django makemessages workflow
|
||||
file_pattern: "app/locale/**/*.po"
|
||||
@@ -13,6 +13,7 @@
|
||||
<a href="#key-features">Features</a> •
|
||||
<a href="#how-to-use">Usage</a> •
|
||||
<a href="#how-it-works">How</a> •
|
||||
<a href="#help-us-translate-wygiwyh">Translate</a> •
|
||||
<a href="#caveats-and-warnings">Caveats and Warnings</a> •
|
||||
<a href="#built-with">Built with</a>
|
||||
</p>
|
||||
@@ -133,6 +134,14 @@ To create the first user, open the container's console using Unraid's UI, by cli
|
||||
|
||||
Check out our [Wiki](https://github.com/eitchtee/WYGIWYH/wiki) for more information.
|
||||
|
||||
# Help us translate WYGIWYH!
|
||||
<a href="https://translations.herculino.com/engage/wygiwyh/">
|
||||
<img src="https://translations.herculino.com/widget/wygiwyh/open-graph.png" alt="Translation status" />
|
||||
</a>
|
||||
|
||||
> [!NOTE]
|
||||
> Login with your github account
|
||||
|
||||
# Caveats and Warnings
|
||||
|
||||
- I'm not an accountant, some terms and even calculations might be wrong. Make sure to open an issue if you see anything that could be improved.
|
||||
|
||||
@@ -55,6 +55,7 @@ INSTALLED_APPS = [
|
||||
"hijack",
|
||||
"hijack.contrib.admin",
|
||||
"django_filters",
|
||||
"import_export",
|
||||
"apps.users.apps.UsersConfig",
|
||||
"procrastinate.contrib.django",
|
||||
"apps.transactions.apps.TransactionsConfig",
|
||||
@@ -63,6 +64,7 @@ INSTALLED_APPS = [
|
||||
"apps.common.apps.CommonConfig",
|
||||
"apps.net_worth.apps.NetWorthConfig",
|
||||
"apps.import_app.apps.ImportConfig",
|
||||
"apps.export_app.apps.ExportConfig",
|
||||
"apps.api.apps.ApiConfig",
|
||||
"cachalot",
|
||||
"rest_framework",
|
||||
@@ -161,6 +163,7 @@ AUTH_USER_MODEL = "users.User"
|
||||
|
||||
LANGUAGE_CODE = "en"
|
||||
LANGUAGES = (
|
||||
("de", "Deutsch"),
|
||||
("en", "English"),
|
||||
("nl", "Nederlands"),
|
||||
("pt-br", "Português (Brasil)"),
|
||||
|
||||
@@ -49,5 +49,6 @@ urlpatterns = [
|
||||
path("", include("apps.dca.urls")),
|
||||
path("", include("apps.mini_tools.urls")),
|
||||
path("", include("apps.import_app.urls")),
|
||||
path("", include("apps.export_app.urls")),
|
||||
path("", include("apps.insights.urls")),
|
||||
]
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from apps.accounts.models import Account
|
||||
from apps.accounts.models import Account, AccountGroup
|
||||
from apps.common.admin import SharedObjectModelAdmin
|
||||
|
||||
|
||||
admin.site.register(Account)
|
||||
@admin.register(Account)
|
||||
class AccountModelAdmin(SharedObjectModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
@admin.register(AccountGroup)
|
||||
class AccountGroupModelAdmin(SharedObjectModelAdmin):
|
||||
pass
|
||||
|
||||
@@ -77,6 +77,8 @@ class AccountForm(forms.ModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.fields["group"].queryset = AccountGroup.objects.all()
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
self.helper.form_method = "post"
|
||||
@@ -151,5 +153,11 @@ class AccountBalanceForm(forms.Form):
|
||||
decimal_places=self.currency_decimal_places
|
||||
)
|
||||
|
||||
self.fields["category"].queryset = TransactionCategory.objects.filter(
|
||||
active=True
|
||||
)
|
||||
|
||||
self.fields["tags"].queryset = TransactionTag.objects.filter(active=True)
|
||||
|
||||
|
||||
AccountBalanceFormSet = forms.formset_factory(AccountBalanceForm, extra=0)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-04 15:07
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0008_alter_account_name'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='account',
|
||||
name='owner',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_accounts', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='account',
|
||||
name='shared_with',
|
||||
field=models.ManyToManyField(blank=True, related_name='shared_accounts', to=settings.AUTH_USER_MODEL, verbose_name='Shared With'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='accountgroup',
|
||||
name='owner',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_account_groups', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,46 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-05 02:42
|
||||
|
||||
import django.db.models.manager
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0009_account_owner_account_shared_with_accountgroup_owner'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelManagers(
|
||||
name='account',
|
||||
managers=[
|
||||
('all_objects', django.db.models.manager.Manager()),
|
||||
],
|
||||
),
|
||||
migrations.AlterModelManagers(
|
||||
name='accountgroup',
|
||||
managers=[
|
||||
('all_objects', django.db.models.manager.Manager()),
|
||||
],
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='account',
|
||||
name='name',
|
||||
field=models.CharField(max_length=255, verbose_name='Name'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='accountgroup',
|
||||
name='name',
|
||||
field=models.CharField(max_length=255, verbose_name='Name'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='account',
|
||||
unique_together={('owner', 'name')},
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='accountgroup',
|
||||
unique_together={('owner', 'name')},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-05 04:19
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0010_alter_account_managers_alter_accountgroup_managers_and_more'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='account',
|
||||
name='owner',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='owned_accounts', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='accountgroup',
|
||||
name='owner',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='owned_account_groups', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,56 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-05 23:46
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0011_alter_account_owner_alter_accountgroup_owner'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelManagers(
|
||||
name='account',
|
||||
managers=[
|
||||
],
|
||||
),
|
||||
migrations.AlterModelManagers(
|
||||
name='accountgroup',
|
||||
managers=[
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='account',
|
||||
name='visibility',
|
||||
field=models.CharField(choices=[('private', 'Private'), ('shared', 'Shared'), ('public', 'Public')], default='private', max_length=10),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='accountgroup',
|
||||
name='shared_with',
|
||||
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='accountgroup',
|
||||
name='visibility',
|
||||
field=models.CharField(choices=[('private', 'Private'), ('shared', 'Shared'), ('public', 'Public')], default='private', max_length=10),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='account',
|
||||
name='owner',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='account',
|
||||
name='shared_with',
|
||||
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='accountgroup',
|
||||
name='owner',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-06 01:47
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0012_alter_account_managers_alter_accountgroup_managers_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='account',
|
||||
name='visibility',
|
||||
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='accountgroup',
|
||||
name='visibility',
|
||||
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10),
|
||||
),
|
||||
]
|
||||
@@ -1,24 +1,31 @@
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.transactions.models import Transaction
|
||||
from apps.common.models import SharedObject, SharedObjectManager
|
||||
|
||||
|
||||
class AccountGroup(models.Model):
|
||||
name = models.CharField(max_length=255, verbose_name=_("Name"), unique=True)
|
||||
class AccountGroup(SharedObject):
|
||||
name = models.CharField(max_length=255, verbose_name=_("Name"))
|
||||
|
||||
objects = SharedObjectManager()
|
||||
all_objects = models.Manager() # Unfiltered manager
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Account Group")
|
||||
verbose_name_plural = _("Account Groups")
|
||||
db_table = "account_groups"
|
||||
unique_together = (("owner", "name"),)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class Account(models.Model):
|
||||
name = models.CharField(max_length=255, verbose_name=_("Name"), unique=True)
|
||||
class Account(SharedObject):
|
||||
name = models.CharField(max_length=255, verbose_name=_("Name"))
|
||||
group = models.ForeignKey(
|
||||
AccountGroup,
|
||||
on_delete=models.SET_NULL,
|
||||
@@ -55,9 +62,13 @@ class Account(models.Model):
|
||||
help_text=_("Archived accounts don't show up nor count towards your net worth"),
|
||||
)
|
||||
|
||||
objects = SharedObjectManager()
|
||||
all_objects = models.Manager() # Unfiltered manager
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Account")
|
||||
verbose_name_plural = _("Accounts")
|
||||
unique_together = (("owner", "name"),)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@@ -16,11 +16,21 @@ urlpatterns = [
|
||||
views.account_edit,
|
||||
name="account_edit",
|
||||
),
|
||||
path(
|
||||
"account/<int:pk>/share/",
|
||||
views.account_share,
|
||||
name="account_share_settings",
|
||||
),
|
||||
path(
|
||||
"account/<int:pk>/delete/",
|
||||
views.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/list/", views.account_groups_list, name="account_groups_list"),
|
||||
path("account-groups/add/", views.account_group_add, name="account_group_add"),
|
||||
@@ -34,4 +44,14 @@ urlpatterns = [
|
||||
views.account_group_delete,
|
||||
name="account_group_delete",
|
||||
),
|
||||
path(
|
||||
"account-groups/<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.models import AccountGroup
|
||||
from apps.common.decorators.htmx import only_htmx
|
||||
from apps.common.models import SharedObject
|
||||
from apps.common.forms import SharedObjectForm
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -63,6 +65,16 @@ def account_group_add(request, **kwargs):
|
||||
def account_group_edit(request, pk):
|
||||
account_group = get_object_or_404(AccountGroup, id=pk)
|
||||
|
||||
if account_group.owner and account_group.owner != request.user:
|
||||
messages.error(request, _("Only the owner can edit this"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
|
||||
if request.method == "POST":
|
||||
form = AccountGroupForm(request.POST, instance=account_group)
|
||||
if form.is_valid():
|
||||
@@ -91,9 +103,15 @@ def account_group_edit(request, pk):
|
||||
def account_group_delete(request, pk):
|
||||
account_group = get_object_or_404(AccountGroup, id=pk)
|
||||
|
||||
account_group.delete()
|
||||
|
||||
messages.success(request, _("Account Group deleted successfully"))
|
||||
if (
|
||||
account_group.owner != request.user
|
||||
and request.user in account_group.shared_with.all()
|
||||
):
|
||||
account_group.shared_with.remove(request.user)
|
||||
messages.success(request, _("Item no longer shared with you"))
|
||||
else:
|
||||
account_group.delete()
|
||||
messages.success(request, _("Account Group deleted successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
@@ -101,3 +119,62 @@ def account_group_delete(request, pk):
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def account_group_take_ownership(request, pk):
|
||||
account_group = get_object_or_404(AccountGroup, id=pk)
|
||||
|
||||
if not account_group.owner:
|
||||
account_group.owner = request.user
|
||||
account_group.visibility = SharedObject.Visibility.private
|
||||
account_group.save()
|
||||
|
||||
messages.success(request, _("Ownership taken successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def account_share(request, pk):
|
||||
obj = get_object_or_404(AccountGroup, id=pk)
|
||||
|
||||
if obj.owner and obj.owner != request.user:
|
||||
messages.error(request, _("Only the owner can edit this"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
|
||||
if request.method == "POST":
|
||||
form = SharedObjectForm(request.POST, instance=obj, user=request.user)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Configuration saved successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = SharedObjectForm(instance=obj, user=request.user)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"accounts/fragments/share.html",
|
||||
{"form": form, "object": obj},
|
||||
)
|
||||
|
||||
@@ -8,6 +8,8 @@ from django.views.decorators.http import require_http_methods
|
||||
from apps.accounts.forms import AccountForm
|
||||
from apps.accounts.models import Account
|
||||
from apps.common.decorators.htmx import only_htmx
|
||||
from apps.common.models import SharedObject
|
||||
from apps.common.forms import SharedObjectForm
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -62,6 +64,15 @@ def account_add(request, **kwargs):
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def account_edit(request, pk):
|
||||
account = get_object_or_404(Account, id=pk)
|
||||
if account.owner and account.owner != request.user:
|
||||
messages.error(request, _("Only the owner can edit this"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
|
||||
if request.method == "POST":
|
||||
form = AccountForm(request.POST, instance=account)
|
||||
@@ -85,15 +96,77 @@ def account_edit(request, pk):
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def account_share(request, pk):
|
||||
obj = get_object_or_404(Account, id=pk)
|
||||
|
||||
if obj.owner and obj.owner != request.user:
|
||||
messages.error(request, _("Only the owner can edit this"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
|
||||
if request.method == "POST":
|
||||
form = SharedObjectForm(request.POST, instance=obj, user=request.user)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Configuration saved successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = SharedObjectForm(instance=obj, user=request.user)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"accounts/fragments/share.html",
|
||||
{"form": form, "object": obj},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["DELETE"])
|
||||
def account_delete(request, pk):
|
||||
account = get_object_or_404(Account, id=pk)
|
||||
|
||||
account.delete()
|
||||
|
||||
messages.success(request, _("Account deleted successfully"))
|
||||
if account.owner != request.user and request.user in account.shared_with.all():
|
||||
account.shared_with.remove(request.user)
|
||||
messages.success(request, _("Item no longer shared with you"))
|
||||
else:
|
||||
account.delete()
|
||||
messages.success(request, _("Account deleted successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def account_take_ownership(request, pk):
|
||||
account = get_object_or_404(Account, id=pk)
|
||||
|
||||
if not account.owner:
|
||||
account.owner = request.user
|
||||
account.visibility = SharedObject.Visibility.private
|
||||
account.save()
|
||||
|
||||
messages.success(request, _("Ownership taken successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
|
||||
@@ -38,9 +38,9 @@ def account_reconciliation(request):
|
||||
"prefix": account.currency.prefix,
|
||||
"current_balance": get_account_balance(account),
|
||||
}
|
||||
for account in Account.objects.filter(is_archived=False).select_related(
|
||||
"currency", "group"
|
||||
)
|
||||
for account in Account.objects.filter(is_archived=False)
|
||||
.select_related("currency", "group")
|
||||
.order_by("group", "name")
|
||||
]
|
||||
|
||||
if request.method == "POST":
|
||||
|
||||
0
app/apps/api/custom/__init__.py
Normal file
0
app/apps/api/custom/__init__.py
Normal file
6
app/apps/api/custom/pagination.py
Normal file
6
app/apps/api/custom/pagination.py
Normal file
@@ -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 rest_framework import serializers
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.transactions.models import (
|
||||
TransactionCategory,
|
||||
TransactionTag,
|
||||
@@ -29,7 +27,11 @@ class TransactionCategoryField(serializers.Field):
|
||||
_("Category with this ID does not exist.")
|
||||
)
|
||||
elif isinstance(data, str):
|
||||
category, created = TransactionCategory.objects.get_or_create(name=data)
|
||||
try:
|
||||
category = TransactionCategory.objects.get(name=data)
|
||||
except TransactionCategory.DoesNotExist:
|
||||
category = TransactionCategory(name=data)
|
||||
category.save()
|
||||
return category
|
||||
raise serializers.ValidationError(
|
||||
_("Invalid category data. Provide an ID or name.")
|
||||
@@ -65,7 +67,11 @@ class TransactionTagField(serializers.Field):
|
||||
_("Tag with this ID does not exist.")
|
||||
)
|
||||
elif isinstance(item, str):
|
||||
tag, created = TransactionTag.objects.get_or_create(name=item)
|
||||
try:
|
||||
tag = TransactionTag.objects.get(name=item)
|
||||
except TransactionTag.DoesNotExist:
|
||||
tag = TransactionTag(name=item)
|
||||
tag.save()
|
||||
else:
|
||||
raise serializers.ValidationError(
|
||||
_("Invalid tag data. Provide an ID or name.")
|
||||
@@ -74,6 +80,13 @@ class TransactionTagField(serializers.Field):
|
||||
return tags
|
||||
|
||||
|
||||
@extend_schema_field(
|
||||
{
|
||||
"type": "array",
|
||||
"items": {"oneOf": [{"type": "string"}, {"type": "integer"}]},
|
||||
"description": "TransactionEntity ID or name. If the name doesn't exist, a new one will be created",
|
||||
}
|
||||
)
|
||||
class TransactionEntityField(serializers.Field):
|
||||
def to_representation(self, value):
|
||||
return [{"id": entity.id, "name": entity.name} for entity in value.all()]
|
||||
@@ -84,12 +97,16 @@ class TransactionEntityField(serializers.Field):
|
||||
if isinstance(item, int):
|
||||
try:
|
||||
entity = TransactionEntity.objects.get(pk=item)
|
||||
except TransactionTag.DoesNotExist:
|
||||
except TransactionEntity.DoesNotExist:
|
||||
raise serializers.ValidationError(
|
||||
_("Entity with this ID does not exist.")
|
||||
)
|
||||
elif isinstance(item, str):
|
||||
entity, created = TransactionEntity.objects.get_or_create(name=item)
|
||||
try:
|
||||
entity = TransactionEntity.objects.get(name=item)
|
||||
except TransactionEntity.DoesNotExist:
|
||||
entity = TransactionEntity(name=item)
|
||||
entity.save()
|
||||
else:
|
||||
raise serializers.ValidationError(
|
||||
_("Invalid entity data. Provide an ID or name.")
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from drf_spectacular import openapi
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
@@ -48,9 +50,9 @@ class TransactionEntitySerializer(serializers.ModelSerializer):
|
||||
|
||||
|
||||
class InstallmentPlanSerializer(serializers.ModelSerializer):
|
||||
category = TransactionCategoryField(required=False)
|
||||
tags = TransactionTagField(required=False)
|
||||
entities = TransactionEntityField(required=False)
|
||||
category: str | int = TransactionCategoryField(required=False)
|
||||
tags: str | int = TransactionTagField(required=False)
|
||||
entities: str | int = TransactionEntityField(required=False)
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@@ -88,9 +90,9 @@ class InstallmentPlanSerializer(serializers.ModelSerializer):
|
||||
|
||||
|
||||
class RecurringTransactionSerializer(serializers.ModelSerializer):
|
||||
category = TransactionCategoryField(required=False)
|
||||
tags = TransactionTagField(required=False)
|
||||
entities = TransactionEntityField(required=False)
|
||||
category: str | int = TransactionCategoryField(required=False)
|
||||
tags: str | int = TransactionTagField(required=False)
|
||||
entities: str | int = TransactionEntityField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = RecurringTransaction
|
||||
@@ -127,9 +129,9 @@ class RecurringTransactionSerializer(serializers.ModelSerializer):
|
||||
|
||||
|
||||
class TransactionSerializer(serializers.ModelSerializer):
|
||||
category = TransactionCategoryField(required=False)
|
||||
tags = TransactionTagField(required=False)
|
||||
entities = TransactionEntityField(required=False)
|
||||
category: str | int = TransactionCategoryField(required=False)
|
||||
tags: str | int = TransactionTagField(required=False)
|
||||
entities: str | int = TransactionEntityField(required=False)
|
||||
|
||||
exchanged_amount = serializers.SerializerMethodField()
|
||||
|
||||
@@ -192,5 +194,5 @@ class TransactionSerializer(serializers.ModelSerializer):
|
||||
return instance
|
||||
|
||||
@staticmethod
|
||||
def get_exchanged_amount(obj):
|
||||
def get_exchanged_amount(obj) -> Decimal:
|
||||
return obj.exchanged_amount()
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from rest_framework import viewsets
|
||||
|
||||
from apps.api.custom.pagination import CustomPageNumberPagination
|
||||
from apps.accounts.models import AccountGroup, Account
|
||||
from apps.api.serializers import AccountGroupSerializer, AccountSerializer
|
||||
|
||||
@@ -6,12 +8,18 @@ from apps.api.serializers import AccountGroupSerializer, AccountSerializer
|
||||
class AccountGroupViewSet(viewsets.ModelViewSet):
|
||||
queryset = AccountGroup.objects.all()
|
||||
serializer_class = AccountGroupSerializer
|
||||
pagination_class = CustomPageNumberPagination
|
||||
|
||||
def get_queryset(self):
|
||||
return AccountGroup.objects.all().order_by("id")
|
||||
|
||||
|
||||
class AccountViewSet(viewsets.ModelViewSet):
|
||||
queryset = Account.objects.all()
|
||||
serializer_class = AccountSerializer
|
||||
pagination_class = CustomPageNumberPagination
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
return queryset.select_related("group", "currency", "exchange_currency")
|
||||
return Account.objects.all().select_related(
|
||||
"group", "currency", "exchange_currency"
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from rest_framework import viewsets
|
||||
|
||||
from apps.api.custom.pagination import CustomPageNumberPagination
|
||||
from apps.api.serializers import (
|
||||
TransactionSerializer,
|
||||
TransactionCategorySerializer,
|
||||
@@ -22,6 +23,7 @@ from apps.rules.signals import transaction_updated, transaction_created
|
||||
class TransactionViewSet(viewsets.ModelViewSet):
|
||||
queryset = Transaction.objects.all()
|
||||
serializer_class = TransactionSerializer
|
||||
pagination_class = CustomPageNumberPagination
|
||||
|
||||
def perform_create(self, serializer):
|
||||
instance = serializer.save()
|
||||
@@ -35,27 +37,50 @@ class TransactionViewSet(viewsets.ModelViewSet):
|
||||
kwargs["partial"] = True
|
||||
return self.update(request, *args, **kwargs)
|
||||
|
||||
def get_queryset(self):
|
||||
return Transaction.objects.all().order_by("id")
|
||||
|
||||
|
||||
class TransactionCategoryViewSet(viewsets.ModelViewSet):
|
||||
queryset = TransactionCategory.objects.all()
|
||||
serializer_class = TransactionCategorySerializer
|
||||
pagination_class = CustomPageNumberPagination
|
||||
|
||||
def get_queryset(self):
|
||||
return TransactionCategory.objects.all().order_by("id")
|
||||
|
||||
|
||||
class TransactionTagViewSet(viewsets.ModelViewSet):
|
||||
queryset = TransactionTag.objects.all()
|
||||
queryset = TransactionTag.objects.all().order_by("id")
|
||||
serializer_class = TransactionTagSerializer
|
||||
pagination_class = CustomPageNumberPagination
|
||||
|
||||
def get_queryset(self):
|
||||
return TransactionTag.objects.all().order_by("id")
|
||||
|
||||
|
||||
class TransactionEntityViewSet(viewsets.ModelViewSet):
|
||||
queryset = TransactionEntity.objects.all()
|
||||
queryset = TransactionEntity.objects.all().order_by("id")
|
||||
serializer_class = TransactionEntitySerializer
|
||||
pagination_class = CustomPageNumberPagination
|
||||
|
||||
def get_queryset(self):
|
||||
return TransactionEntity.objects.all().order_by("id")
|
||||
|
||||
|
||||
class InstallmentPlanViewSet(viewsets.ModelViewSet):
|
||||
queryset = InstallmentPlan.objects.all()
|
||||
queryset = InstallmentPlan.objects.all().order_by("id")
|
||||
serializer_class = InstallmentPlanSerializer
|
||||
pagination_class = CustomPageNumberPagination
|
||||
|
||||
def get_queryset(self):
|
||||
return InstallmentPlan.objects.all().order_by("id")
|
||||
|
||||
|
||||
class RecurringTransactionViewSet(viewsets.ModelViewSet):
|
||||
queryset = RecurringTransaction.objects.all()
|
||||
queryset = RecurringTransaction.objects.all().order_by("id")
|
||||
serializer_class = RecurringTransactionSerializer
|
||||
pagination_class = CustomPageNumberPagination
|
||||
|
||||
def get_queryset(self):
|
||||
return RecurringTransaction.objects.all().order_by("id")
|
||||
|
||||
7
app/apps/common/admin.py
Normal file
7
app/apps/common/admin.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.contrib import admin
|
||||
|
||||
|
||||
class SharedObjectModelAdmin(admin.ModelAdmin):
|
||||
def get_queryset(self, request):
|
||||
# Use the all_objects manager to show all transactions, including deleted ones
|
||||
return self.model.all_objects.all()
|
||||
@@ -4,6 +4,7 @@ from django.db import transaction
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.common.widgets.tom_select import TomSelect, TomSelectMultiple
|
||||
from apps.common.middleware.thread_local import get_current_user
|
||||
|
||||
|
||||
class DynamicModelChoiceField(forms.ModelChoiceField):
|
||||
@@ -55,19 +56,24 @@ class DynamicModelChoiceField(forms.ModelChoiceField):
|
||||
if self.create_field:
|
||||
try:
|
||||
with transaction.atomic():
|
||||
instance, _ = self.model.objects.update_or_create(
|
||||
**{self.create_field: value}
|
||||
)
|
||||
# First try to get the object
|
||||
lookup = {self.create_field: value}
|
||||
try:
|
||||
instance = self.model.objects.get(**lookup)
|
||||
except self.model.DoesNotExist:
|
||||
# Create a new instance directly
|
||||
instance = self.model(**lookup)
|
||||
instance.save()
|
||||
|
||||
self._created_instance = instance
|
||||
return instance
|
||||
except Exception as e:
|
||||
raise ValidationError(
|
||||
self.error_messages["invalid_choice"], code="invalid_choice"
|
||||
)
|
||||
raise ValidationError(_("Error creating new instance"))
|
||||
else:
|
||||
raise ValidationError(
|
||||
self.error_messages["invalid_choice"], code="invalid_choice"
|
||||
)
|
||||
|
||||
return super().clean(value)
|
||||
|
||||
def bound_data(self, data, initial):
|
||||
@@ -90,8 +96,6 @@ class DynamicModelMultipleChoiceField(forms.ModelMultipleChoiceField):
|
||||
|
||||
def __init__(self, model, **kwargs):
|
||||
"""
|
||||
Initialize the CreateIfNotExistsModelMultipleChoiceField.
|
||||
|
||||
Args:
|
||||
create_field (str): The name of the field to use when creating new instances.
|
||||
*args: Variable length argument list.
|
||||
@@ -123,33 +127,28 @@ class DynamicModelMultipleChoiceField(forms.ModelMultipleChoiceField):
|
||||
"""
|
||||
try:
|
||||
with transaction.atomic():
|
||||
instance, _ = self.model.objects.update_or_create(
|
||||
**{self.create_field: value}
|
||||
)
|
||||
return instance
|
||||
# Check if exists first without using update_or_create
|
||||
lookup = {self.create_field: value}
|
||||
try:
|
||||
# Use base manager to bypass distinct filters
|
||||
instance = self.model.objects.get(**lookup)
|
||||
return instance
|
||||
except self.model.DoesNotExist:
|
||||
# Create a new instance directly
|
||||
instance = self.model(**lookup)
|
||||
instance.save()
|
||||
return instance
|
||||
except Exception as e:
|
||||
print(e)
|
||||
raise ValidationError(_("Error creating new instance"))
|
||||
|
||||
def clean(self, value):
|
||||
"""
|
||||
Clean and validate the field value.
|
||||
|
||||
This method checks if each selected choice exists in the database.
|
||||
If a choice doesn't exist, it creates a new instance of the model.
|
||||
|
||||
Args:
|
||||
value (list): List of selected values.
|
||||
|
||||
Returns:
|
||||
list: A list containing all selected and newly created model instances.
|
||||
|
||||
Raises:
|
||||
ValidationError: If there's an error during the cleaning process.
|
||||
"""
|
||||
if not value:
|
||||
return []
|
||||
|
||||
string_values = set(str(v) for v in value)
|
||||
|
||||
# Get existing objects first
|
||||
existing_objects = list(
|
||||
self.queryset.filter(**{f"{self.create_field}__in": string_values})
|
||||
)
|
||||
@@ -157,13 +156,11 @@ class DynamicModelMultipleChoiceField(forms.ModelMultipleChoiceField):
|
||||
str(getattr(obj, self.create_field)) for obj in existing_objects
|
||||
)
|
||||
|
||||
# Create new objects for missing values
|
||||
new_values = string_values - existing_values
|
||||
new_objects = []
|
||||
|
||||
for new_value in new_values:
|
||||
try:
|
||||
new_objects.append(self._create_new_instance(new_value))
|
||||
except ValidationError as e:
|
||||
raise ValidationError(_("Error creating new instance"))
|
||||
new_objects.append(self._create_new_instance(new_value))
|
||||
|
||||
return existing_objects + new_objects
|
||||
|
||||
95
app/apps/common/forms.py
Normal file
95
app/apps/common/forms.py
Normal file
@@ -0,0 +1,95 @@
|
||||
from crispy_forms.bootstrap import FormActions
|
||||
from django import forms
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Layout, Field, Submit, Div, HTML
|
||||
|
||||
from apps.common.widgets.tom_select import TomSelect, TomSelectMultiple
|
||||
from apps.common.models import SharedObject
|
||||
from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class SharedObjectForm(forms.Form):
|
||||
"""
|
||||
Generic form for editing visibility and sharing settings
|
||||
for models inheriting from SharedObject.
|
||||
"""
|
||||
|
||||
owner = forms.ModelChoiceField(
|
||||
queryset=User.objects.all(),
|
||||
required=False,
|
||||
label=_("Owner"),
|
||||
widget=TomSelect(clear_button=False),
|
||||
help_text=_(
|
||||
"The owner of this object, if empty all users can see, edit and take ownership."
|
||||
),
|
||||
)
|
||||
shared_with_users = forms.ModelMultipleChoiceField(
|
||||
queryset=User.objects.all(),
|
||||
required=False,
|
||||
widget=TomSelectMultiple(clear_button=True),
|
||||
label=_("Shared with users"),
|
||||
help_text=_("Select users to share this object with"),
|
||||
)
|
||||
visibility = forms.ChoiceField(
|
||||
choices=SharedObject.Visibility.choices,
|
||||
required=True,
|
||||
label=_("Visibility"),
|
||||
help_text=_(
|
||||
"Private: Only shown for the owner and shared users. Only editable by the owner."
|
||||
"<br/>"
|
||||
"Public: Shown for all users. Only editable by the owner."
|
||||
),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
fields = ["visibility", "shared_with_users"]
|
||||
widgets = {
|
||||
"visibility": TomSelect(clear_button=False),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
# Get the current user to filter available sharing options
|
||||
self.user = kwargs.pop("user", None)
|
||||
self.instance = kwargs.pop("instance", None)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Pre-populate shared users if instance exists
|
||||
if self.instance:
|
||||
self.fields["shared_with_users"].initial = self.instance.shared_with.all()
|
||||
self.fields["visibility"].initial = self.instance.visibility
|
||||
self.fields["owner"].initial = self.instance.owner
|
||||
|
||||
# Set up crispy form helper
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_method = "post"
|
||||
self.helper.form_tag = False
|
||||
|
||||
self.helper.layout = Layout(
|
||||
Field("owner"),
|
||||
Field("visibility"),
|
||||
HTML("<hr>"),
|
||||
Field("shared_with_users"),
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Save"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
def save(self):
|
||||
instance = self.instance
|
||||
|
||||
instance.visibility = self.cleaned_data["visibility"]
|
||||
instance.owner = self.cleaned_data["owner"]
|
||||
|
||||
instance.save()
|
||||
|
||||
# Clear and set shared_with users
|
||||
instance.shared_with.set(self.cleaned_data.get("shared_with_users", []))
|
||||
|
||||
return instance
|
||||
@@ -56,6 +56,16 @@ def get_current_user():
|
||||
if request:
|
||||
return getattr(request, "user", None)
|
||||
|
||||
return getattr(_thread_locals, "user", None)
|
||||
|
||||
|
||||
def write_current_user(user):
|
||||
_thread_locals.user = user
|
||||
|
||||
|
||||
def delete_current_user():
|
||||
del _thread_locals.user
|
||||
|
||||
|
||||
class ThreadLocalMiddleware(MiddlewareMixin):
|
||||
"""Simple middleware that adds the request object in thread local storage."""
|
||||
|
||||
84
app/apps/common/models.py
Normal file
84
app/apps/common/models.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.common.middleware.thread_local import get_current_user
|
||||
|
||||
|
||||
class SharedObjectManager(models.Manager):
|
||||
def get_queryset(self):
|
||||
"""Return only objects the user can access"""
|
||||
user = get_current_user()
|
||||
base_qs = super().get_queryset()
|
||||
|
||||
if user and user.is_authenticated:
|
||||
return base_qs.filter(
|
||||
Q(visibility="public")
|
||||
| Q(owner=user)
|
||||
| Q(shared_with=user)
|
||||
| Q(visibility="private", owner=None)
|
||||
).distinct()
|
||||
|
||||
return base_qs.filter(visibility="public")
|
||||
|
||||
|
||||
class SharedObject(models.Model):
|
||||
# Access control enum
|
||||
class Visibility(models.TextChoices):
|
||||
private = "private", _("Private")
|
||||
is_paid = "public", _("Public")
|
||||
|
||||
# Core sharing fields
|
||||
owner = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="%(class)s_owned",
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
visibility = models.CharField(
|
||||
max_length=10, choices=Visibility.choices, default=Visibility.private
|
||||
)
|
||||
shared_with = models.ManyToManyField(
|
||||
settings.AUTH_USER_MODEL, related_name="%(class)s_shared", blank=True
|
||||
)
|
||||
|
||||
# Use as abstract base class
|
||||
class Meta:
|
||||
abstract = True
|
||||
indexes = [
|
||||
models.Index(fields=["visibility"]),
|
||||
]
|
||||
|
||||
def is_accessible_by(self, user):
|
||||
"""Check if a user can access this object"""
|
||||
return (
|
||||
self.visibility == "public"
|
||||
or self.owner == user
|
||||
or (self.visibility == "shared" and user in self.shared_with.all())
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.pk and not self.owner:
|
||||
self.owner = get_current_user()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class OwnedObject(models.Model):
|
||||
owner = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="%(class)s_owned",
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
|
||||
# Use as abstract base class
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.pk and not self.owner:
|
||||
self.owner = get_current_user()
|
||||
super().save(*args, **kwargs)
|
||||
@@ -19,6 +19,8 @@ class AirDatePickerInput(widgets.DateInput):
|
||||
format=None,
|
||||
clear_button=True,
|
||||
auto_close=True,
|
||||
read_only=True,
|
||||
toggle_selected=None,
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
@@ -26,6 +28,10 @@ class AirDatePickerInput(widgets.DateInput):
|
||||
super().__init__(attrs=attrs, format=format, *args, **kwargs)
|
||||
self.clear_button = clear_button
|
||||
self.auto_close = auto_close
|
||||
self.read_only = read_only
|
||||
self.toggle_selected = (
|
||||
toggle_selected if isinstance(toggle_selected, bool) else self.clear_button
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_current_language():
|
||||
@@ -47,9 +53,13 @@ class AirDatePickerInput(widgets.DateInput):
|
||||
attrs["data-now-button-txt"] = _("Today")
|
||||
attrs["data-auto-close"] = str(self.auto_close).lower()
|
||||
attrs["data-clear-button"] = str(self.clear_button).lower()
|
||||
attrs["data-toggle-selected"] = str(self.toggle_selected).lower()
|
||||
attrs["data-language"] = self._get_current_language()
|
||||
attrs["data-date-format"] = django_to_airdatepicker_datetime(self._get_format())
|
||||
|
||||
if self.read_only:
|
||||
attrs["readonly"] = True
|
||||
|
||||
return attrs
|
||||
|
||||
def format_value(self, value):
|
||||
@@ -89,6 +99,8 @@ class AirDateTimePickerInput(widgets.DateTimeInput):
|
||||
timepicker=True,
|
||||
clear_button=True,
|
||||
auto_close=True,
|
||||
read_only=True,
|
||||
toggle_selected=None,
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
@@ -97,6 +109,10 @@ class AirDateTimePickerInput(widgets.DateTimeInput):
|
||||
self.timepicker = timepicker
|
||||
self.clear_button = clear_button
|
||||
self.auto_close = auto_close
|
||||
self.read_only = read_only
|
||||
self.toggle_selected = (
|
||||
toggle_selected if isinstance(toggle_selected, bool) else self.clear_button
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_current_language():
|
||||
@@ -123,11 +139,15 @@ class AirDateTimePickerInput(widgets.DateTimeInput):
|
||||
attrs["data-now-button-txt"] = _("Now")
|
||||
attrs["data-timepicker"] = str(self.timepicker).lower()
|
||||
attrs["data-auto-close"] = str(self.auto_close).lower()
|
||||
attrs["data-toggle-selected"] = str(self.toggle_selected).lower()
|
||||
attrs["data-clear-button"] = str(self.clear_button).lower()
|
||||
attrs["data-language"] = self._get_current_language()
|
||||
attrs["data-date-format"] = date_format
|
||||
attrs["data-time-format"] = time_format
|
||||
|
||||
if self.read_only:
|
||||
attrs["readonly"] = True
|
||||
|
||||
return attrs
|
||||
|
||||
def format_value(self, value):
|
||||
|
||||
@@ -6,8 +6,10 @@ from django.utils import timezone
|
||||
|
||||
from apps.currencies.exchange_rates.providers import (
|
||||
SynthFinanceProvider,
|
||||
SynthFinanceStockProvider,
|
||||
CoinGeckoFreeProvider,
|
||||
CoinGeckoProProvider,
|
||||
TransitiveRateProvider,
|
||||
)
|
||||
from apps.currencies.models import ExchangeRateService, ExchangeRate, Currency
|
||||
|
||||
@@ -17,8 +19,10 @@ logger = logging.getLogger(__name__)
|
||||
# Map service types to provider classes
|
||||
PROVIDER_MAPPING = {
|
||||
"synth_finance": SynthFinanceProvider,
|
||||
"synth_finance_stock": SynthFinanceStockProvider,
|
||||
"coingecko_free": CoinGeckoFreeProvider,
|
||||
"coingecko_pro": CoinGeckoProProvider,
|
||||
"transitive": TransitiveRateProvider,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -3,11 +3,11 @@ import time
|
||||
|
||||
import requests
|
||||
from decimal import Decimal
|
||||
from typing import Tuple, List
|
||||
from typing import Tuple, List, Optional, Dict
|
||||
|
||||
from django.db.models import QuerySet
|
||||
|
||||
from apps.currencies.models import Currency
|
||||
from apps.currencies.models import Currency, ExchangeRate
|
||||
from apps.currencies.exchange_rates.base import ExchangeRateProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -150,3 +150,159 @@ class CoinGeckoProProvider(CoinGeckoFreeProvider):
|
||||
super().__init__(api_key)
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({"x-cg-pro-api-key": api_key})
|
||||
|
||||
|
||||
class SynthFinanceStockProvider(ExchangeRateProvider):
|
||||
"""Implementation for Synth Finance API Real-Time Prices endpoint (synthfinance.com)"""
|
||||
|
||||
BASE_URL = "https://api.synthfinance.com/tickers"
|
||||
rates_inverted = True
|
||||
|
||||
def __init__(self, api_key: str = None):
|
||||
super().__init__(api_key)
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update(
|
||||
{"Authorization": f"Bearer {self.api_key}", "accept": "application/json"}
|
||||
)
|
||||
|
||||
def get_rates(
|
||||
self, target_currencies: QuerySet, exchange_currencies: set
|
||||
) -> List[Tuple[Currency, Currency, Decimal]]:
|
||||
results = []
|
||||
|
||||
for currency in target_currencies:
|
||||
if currency.exchange_currency not in exchange_currencies:
|
||||
continue
|
||||
|
||||
try:
|
||||
# Same currency has rate of 1
|
||||
if currency.code == currency.exchange_currency.code:
|
||||
rate = Decimal("1")
|
||||
results.append((currency.exchange_currency, currency, rate))
|
||||
continue
|
||||
|
||||
# Fetch real-time price for this ticker
|
||||
response = self.session.get(
|
||||
f"{self.BASE_URL}/{currency.code}/real-time"
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# Use fair market value as the rate
|
||||
rate = Decimal(data["data"]["fair_market_value"])
|
||||
results.append((currency.exchange_currency, currency, rate))
|
||||
|
||||
# Log API usage
|
||||
credits_used = data["meta"]["credits_used"]
|
||||
credits_remaining = data["meta"]["credits_remaining"]
|
||||
logger.info(
|
||||
f"Synth Finance API call for {currency.code}: {credits_used} credits used, {credits_remaining} remaining"
|
||||
)
|
||||
except requests.RequestException as e:
|
||||
logger.error(
|
||||
f"Error fetching rate from Synth Finance API for ticker {currency.code}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
except KeyError as e:
|
||||
logger.error(
|
||||
f"Unexpected response structure from Synth Finance API for ticker {currency.code}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Unexpected error processing Synth Finance data for ticker {currency.code}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
class TransitiveRateProvider(ExchangeRateProvider):
|
||||
"""Calculates exchange rates through paths of existing rates"""
|
||||
|
||||
rates_inverted = True
|
||||
|
||||
def __init__(self, api_key: str = None):
|
||||
super().__init__(api_key) # API key not needed but maintaining interface
|
||||
|
||||
@classmethod
|
||||
def requires_api_key(cls) -> bool:
|
||||
return False
|
||||
|
||||
def get_rates(
|
||||
self, target_currencies: QuerySet, exchange_currencies: set
|
||||
) -> List[Tuple[Currency, Currency, Decimal]]:
|
||||
results = []
|
||||
|
||||
# Get recent rates for building the graph
|
||||
recent_rates = ExchangeRate.objects.all()
|
||||
|
||||
# Build currency graph
|
||||
currency_graph = self._build_currency_graph(recent_rates)
|
||||
|
||||
for target in target_currencies:
|
||||
if (
|
||||
not target.exchange_currency
|
||||
or target.exchange_currency not in exchange_currencies
|
||||
):
|
||||
continue
|
||||
|
||||
# Find path and calculate rate
|
||||
from_id = target.exchange_currency.id
|
||||
to_id = target.id
|
||||
|
||||
path, rate = self._find_conversion_path(currency_graph, from_id, to_id)
|
||||
|
||||
if path and rate:
|
||||
path_codes = [Currency.objects.get(id=cid).code for cid in path]
|
||||
logger.info(
|
||||
f"Found conversion path: {' -> '.join(path_codes)}, rate: {rate}"
|
||||
)
|
||||
results.append((target.exchange_currency, target, rate))
|
||||
else:
|
||||
logger.debug(
|
||||
f"No conversion path found for {target.exchange_currency.code}->{target.code}"
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def _build_currency_graph(rates) -> Dict[int, Dict[int, Decimal]]:
|
||||
"""Build a graph representation of currency relationships"""
|
||||
graph = {}
|
||||
|
||||
for rate in rates:
|
||||
# Add both directions to make the graph bidirectional
|
||||
if rate.from_currency_id not in graph:
|
||||
graph[rate.from_currency_id] = {}
|
||||
graph[rate.from_currency_id][rate.to_currency_id] = rate.rate
|
||||
|
||||
if rate.to_currency_id not in graph:
|
||||
graph[rate.to_currency_id] = {}
|
||||
graph[rate.to_currency_id][rate.from_currency_id] = Decimal("1") / rate.rate
|
||||
|
||||
return graph
|
||||
|
||||
@staticmethod
|
||||
def _find_conversion_path(
|
||||
graph, from_id, to_id
|
||||
) -> Tuple[Optional[list], Optional[Decimal]]:
|
||||
"""Find the shortest path between currencies using breadth-first search"""
|
||||
if from_id not in graph or to_id not in graph:
|
||||
return None, None
|
||||
|
||||
queue = [(from_id, [from_id], Decimal("1"))]
|
||||
visited = {from_id}
|
||||
|
||||
while queue:
|
||||
current, path, current_rate = queue.pop(0)
|
||||
|
||||
if current == to_id:
|
||||
return path, current_rate
|
||||
|
||||
for neighbor, rate in graph.get(current, {}).items():
|
||||
if neighbor not in visited:
|
||||
visited.add(neighbor)
|
||||
queue.append((neighbor, path + [neighbor], current_rate * rate))
|
||||
|
||||
return None, None
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-02 01:28
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('currencies', '0011_remove_exchangerateservice_fetch_interval_hours_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='exchangerateservice',
|
||||
name='service_type',
|
||||
field=models.CharField(choices=[('synth_finance', 'Synth Finance'), ('synth_finance_stock', 'Synth Finance Stock'), ('coingecko_free', 'CoinGecko (Demo/Free)'), ('coingecko_pro', 'CoinGecko (Pro)')], max_length=255, verbose_name='Service Type'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-02 01:58
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('currencies', '0012_alter_exchangerateservice_service_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='exchangerateservice',
|
||||
name='service_type',
|
||||
field=models.CharField(choices=[('synth_finance', 'Synth Finance'), ('synth_finance_stock', 'Synth Finance Stock'), ('coingecko_free', 'CoinGecko (Demo/Free)'), ('coingecko_pro', 'CoinGecko (Pro)'), ('transitive', 'Transitive (Calculated from Existing Rates)')], max_length=255, verbose_name='Service Type'),
|
||||
),
|
||||
]
|
||||
@@ -92,8 +92,10 @@ class ExchangeRateService(models.Model):
|
||||
|
||||
class ServiceType(models.TextChoices):
|
||||
SYNTH_FINANCE = "synth_finance", "Synth Finance"
|
||||
SYNTH_FINANCE_STOCK = "synth_finance_stock", "Synth Finance Stock"
|
||||
COINGECKO_FREE = "coingecko_free", "CoinGecko (Demo/Free)"
|
||||
COINGECKO_PRO = "coingecko_pro", "CoinGecko (Pro)"
|
||||
TRANSITIVE = "transitive", "Transitive (Calculated from Existing Rates)"
|
||||
|
||||
class IntervalType(models.TextChoices):
|
||||
ON = "on", _("On")
|
||||
@@ -204,11 +206,11 @@ class ExchangeRateService(models.Model):
|
||||
}
|
||||
)
|
||||
hours = int(self.fetch_interval)
|
||||
if hours < 0 or hours > 23:
|
||||
if hours < 1 or hours > 24:
|
||||
raise ValidationError(
|
||||
{
|
||||
"fetch_interval": _(
|
||||
"'Every X hours' interval must be between 0 and 23."
|
||||
"'Every X hours' interval must be between 1 and 24."
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from apps.dca.models import DCAStrategy, DCAEntry
|
||||
from apps.common.admin import SharedObjectModelAdmin
|
||||
|
||||
|
||||
# Register your models here.
|
||||
admin.site.register(DCAStrategy)
|
||||
admin.site.register(DCAEntry)
|
||||
|
||||
|
||||
@admin.register(DCAStrategy)
|
||||
class TransactionEntityModelAdmin(SharedObjectModelAdmin):
|
||||
def get_queryset(self, request):
|
||||
return DCAStrategy.all_objects.all()
|
||||
|
||||
@@ -168,7 +168,7 @@ class DCAEntryForm(forms.ModelForm):
|
||||
Row(
|
||||
Column(
|
||||
"from_account",
|
||||
css_class="form-group col-md-6 mb-0",
|
||||
css_class="form-group",
|
||||
),
|
||||
css_class="form-row",
|
||||
),
|
||||
@@ -190,7 +190,7 @@ class DCAEntryForm(forms.ModelForm):
|
||||
Row(
|
||||
Column(
|
||||
"to_account",
|
||||
css_class="form-group col-md-6 mb-0",
|
||||
css_class="form-group",
|
||||
),
|
||||
css_class="form-row",
|
||||
),
|
||||
@@ -266,6 +266,24 @@ class DCAEntryForm(forms.ModelForm):
|
||||
id=expense_transaction.id
|
||||
)
|
||||
|
||||
self.fields["from_account"].queryset = Account.objects.filter(
|
||||
is_archived=False,
|
||||
)
|
||||
|
||||
self.fields["from_category"].queryset = TransactionCategory.objects.filter(
|
||||
active=True
|
||||
)
|
||||
self.fields["from_tags"].queryset = TransactionTag.objects.filter(active=True)
|
||||
|
||||
self.fields["to_account"].queryset = Account.objects.filter(
|
||||
is_archived=False,
|
||||
)
|
||||
|
||||
self.fields["to_category"].queryset = TransactionCategory.objects.filter(
|
||||
active=True
|
||||
)
|
||||
self.fields["to_tags"].queryset = TransactionTag.objects.filter(active=True)
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
|
||||
|
||||
@@ -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 statistics import mean, stdev
|
||||
|
||||
from django.db import models
|
||||
from django.template.defaultfilters import date
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.common.models import SharedObject, SharedObjectManager
|
||||
from apps.currencies.utils.convert import convert, get_exchange_rate
|
||||
|
||||
|
||||
class DCAStrategy(models.Model):
|
||||
class DCAStrategy(SharedObject):
|
||||
name = models.CharField(max_length=255, verbose_name=_("Name"))
|
||||
target_currency = models.ForeignKey(
|
||||
"currencies.Currency",
|
||||
@@ -28,6 +27,9 @@ class DCAStrategy(models.Model):
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
objects = SharedObjectManager()
|
||||
all_objects = models.Manager() # Unfiltered manager
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("DCA Strategy")
|
||||
verbose_name_plural = _("DCA Strategies")
|
||||
|
||||
@@ -12,6 +12,16 @@ urlpatterns = [
|
||||
views.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(
|
||||
"dca/<int:strategy_id>/",
|
||||
views.strategy_detail_index,
|
||||
|
||||
@@ -11,6 +11,8 @@ from django.views.decorators.http import require_http_methods
|
||||
from apps.common.decorators.htmx import only_htmx
|
||||
from apps.dca.forms import DCAEntryForm, DCAStrategyForm
|
||||
from apps.dca.models import DCAStrategy, DCAEntry
|
||||
from apps.common.models import SharedObject
|
||||
from apps.common.forms import SharedObjectForm
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -57,6 +59,16 @@ def strategy_add(request):
|
||||
def strategy_edit(request, strategy_id):
|
||||
dca_strategy = get_object_or_404(DCAStrategy, id=strategy_id)
|
||||
|
||||
if dca_strategy.owner and dca_strategy.owner != request.user:
|
||||
messages.error(request, _("Only the owner can edit this"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
|
||||
if request.method == "POST":
|
||||
form = DCAStrategyForm(request.POST, instance=dca_strategy)
|
||||
if form.is_valid():
|
||||
@@ -85,9 +97,15 @@ def strategy_edit(request, strategy_id):
|
||||
def strategy_delete(request, strategy_id):
|
||||
dca_strategy = get_object_or_404(DCAStrategy, id=strategy_id)
|
||||
|
||||
dca_strategy.delete()
|
||||
|
||||
messages.success(request, _("DCA strategy deleted successfully"))
|
||||
if (
|
||||
dca_strategy.owner != request.user
|
||||
and request.user in dca_strategy.shared_with.all()
|
||||
):
|
||||
dca_strategy.shared_with.remove(request.user)
|
||||
messages.success(request, _("Item no longer shared with you"))
|
||||
else:
|
||||
dca_strategy.delete()
|
||||
messages.success(request, _("DCA strategy deleted successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
@@ -97,6 +115,65 @@ def strategy_delete(request, strategy_id):
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def strategy_take_ownership(request, strategy_id):
|
||||
dca_strategy = get_object_or_404(DCAStrategy, id=strategy_id)
|
||||
|
||||
if not dca_strategy.owner:
|
||||
dca_strategy.owner = request.user
|
||||
dca_strategy.visibility = SharedObject.Visibility.private
|
||||
dca_strategy.save()
|
||||
|
||||
messages.success(request, _("Ownership taken successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def strategy_share(request, pk):
|
||||
obj = get_object_or_404(DCAStrategy, id=pk)
|
||||
|
||||
if obj.owner and obj.owner != request.user:
|
||||
messages.error(request, _("Only the owner can edit this"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
|
||||
if request.method == "POST":
|
||||
form = SharedObjectForm(request.POST, instance=obj, user=request.user)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Configuration saved successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = SharedObjectForm(instance=obj, user=request.user)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"dca/fragments/strategy/share.html",
|
||||
{"form": form, "object": obj},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def strategy_detail_index(request, strategy_id):
|
||||
strategy = get_object_or_404(DCAStrategy, id=strategy_id)
|
||||
|
||||
0
app/apps/export_app/__init__.py
Normal file
0
app/apps/export_app/__init__.py
Normal file
3
app/apps/export_app/admin.py
Normal file
3
app/apps/export_app/admin.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
6
app/apps/export_app/apps.py
Normal file
6
app/apps/export_app/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ExportConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.export_app"
|
||||
198
app/apps/export_app/forms.py
Normal file
198
app/apps/export_app/forms.py
Normal file
@@ -0,0 +1,198 @@
|
||||
from crispy_forms.bootstrap import FormActions
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Layout, HTML
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||
|
||||
|
||||
class ExportForm(forms.Form):
|
||||
users = forms.BooleanField(
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(),
|
||||
label=_("Users"),
|
||||
initial=True,
|
||||
)
|
||||
accounts = forms.BooleanField(
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(),
|
||||
label=_("Accounts"),
|
||||
initial=True,
|
||||
)
|
||||
currencies = forms.BooleanField(
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(),
|
||||
label=_("Currencies"),
|
||||
initial=True,
|
||||
)
|
||||
transactions = forms.BooleanField(
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(),
|
||||
label=_("Transactions"),
|
||||
initial=True,
|
||||
)
|
||||
categories = forms.BooleanField(
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(),
|
||||
label=_("Categories"),
|
||||
initial=True,
|
||||
)
|
||||
tags = forms.BooleanField(
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(),
|
||||
label=_("Tags"),
|
||||
initial=False,
|
||||
)
|
||||
entities = forms.BooleanField(
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(),
|
||||
label=_("Entities"),
|
||||
initial=False,
|
||||
)
|
||||
recurring_transactions = forms.BooleanField(
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(),
|
||||
label=_("Recurring Transactions"),
|
||||
initial=True,
|
||||
)
|
||||
installment_plans = forms.BooleanField(
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(),
|
||||
label=_("Installment Plans"),
|
||||
initial=True,
|
||||
)
|
||||
exchange_rates = forms.BooleanField(
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(),
|
||||
label=_("Exchange Rates"),
|
||||
initial=False,
|
||||
)
|
||||
exchange_rates_services = forms.BooleanField(
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(),
|
||||
label=_("Automatic Exchange Rates"),
|
||||
initial=False,
|
||||
)
|
||||
rules = forms.BooleanField(
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(),
|
||||
label=_("Rules"),
|
||||
initial=True,
|
||||
)
|
||||
dca = forms.BooleanField(
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(),
|
||||
label=_("DCA"),
|
||||
initial=False,
|
||||
)
|
||||
import_profiles = forms.BooleanField(
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(),
|
||||
label=_("Import Profiles"),
|
||||
initial=True,
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
self.helper.form_method = "post"
|
||||
self.helper.layout = Layout(
|
||||
"users",
|
||||
"accounts",
|
||||
"currencies",
|
||||
"transactions",
|
||||
"categories",
|
||||
"entities",
|
||||
"tags",
|
||||
"installment_plans",
|
||||
"recurring_transactions",
|
||||
"exchange_rates_services",
|
||||
"exchange_rates",
|
||||
"rules",
|
||||
"dca",
|
||||
"import_profiles",
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Export"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class RestoreForm(forms.Form):
|
||||
zip_file = forms.FileField(
|
||||
required=False,
|
||||
help_text=_("Import a ZIP file exported from WYGIWYH"),
|
||||
label=_("ZIP File"),
|
||||
)
|
||||
users = forms.FileField(required=False, label=_("Users"))
|
||||
accounts = forms.FileField(required=False, label=_("Accounts"))
|
||||
currencies = forms.FileField(required=False, label=_("Currencies"))
|
||||
transactions_categories = forms.FileField(required=False, label=_("Categories"))
|
||||
transactions_tags = forms.FileField(required=False, label=_("Tags"))
|
||||
transactions_entities = forms.FileField(required=False, label=_("Entities"))
|
||||
transactions = forms.FileField(required=False, label=_("Transactions"))
|
||||
installment_plans = forms.FileField(required=False, label=_("Installment Plans"))
|
||||
recurring_transactions = forms.FileField(
|
||||
required=False, label=_("Recurring Transactions")
|
||||
)
|
||||
automatic_exchange_rates = forms.FileField(
|
||||
required=False, label=_("Automatic Exchange Rates")
|
||||
)
|
||||
exchange_rates = forms.FileField(required=False, label=_("Exchange Rates"))
|
||||
transaction_rules = forms.FileField(required=False, label=_("Transaction rules"))
|
||||
transaction_rules_actions = forms.FileField(
|
||||
required=False, label=_("Edit transaction action")
|
||||
)
|
||||
transaction_rules_update_or_create = forms.FileField(
|
||||
required=False, label=_("Update or create transaction actions")
|
||||
)
|
||||
dca_strategies = forms.FileField(required=False, label=_("DCA Strategies"))
|
||||
dca_entries = forms.FileField(required=False, label=_("DCA Entries"))
|
||||
import_profiles = forms.FileField(required=False, label=_("Import Profiles"))
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
self.helper.form_method = "post"
|
||||
self.helper.layout = Layout(
|
||||
"zip_file",
|
||||
HTML("<hr />"),
|
||||
"users",
|
||||
"accounts",
|
||||
"currencies",
|
||||
"transactions",
|
||||
"transactions_categories",
|
||||
"transactions_entities",
|
||||
"transactions_tags",
|
||||
"installment_plans",
|
||||
"recurring_transactions",
|
||||
"automatic_exchange_rates",
|
||||
"exchange_rates",
|
||||
"transaction_rules",
|
||||
"transaction_rules_actions",
|
||||
"transaction_rules_update_or_create",
|
||||
"dca_strategies",
|
||||
"dca_entries",
|
||||
"import_profiles",
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Restore"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
if not cleaned_data.get("zip_file") and not any(
|
||||
cleaned_data.get(field) for field in self.fields if field != "zip_file"
|
||||
):
|
||||
raise forms.ValidationError(
|
||||
_("Please upload either a ZIP file or at least one CSV file")
|
||||
)
|
||||
return cleaned_data
|
||||
0
app/apps/export_app/migrations/__init__.py
Normal file
0
app/apps/export_app/migrations/__init__.py
Normal file
3
app/apps/export_app/models.py
Normal file
3
app/apps/export_app/models.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.db import models
|
||||
|
||||
# Create your models here.
|
||||
0
app/apps/export_app/resources/__init__.py
Normal file
0
app/apps/export_app/resources/__init__.py
Normal file
29
app/apps/export_app/resources/accounts.py
Normal file
29
app/apps/export_app/resources/accounts.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from import_export import fields, resources, widgets
|
||||
|
||||
from apps.accounts.models import Account, AccountGroup
|
||||
from apps.export_app.widgets.foreign_key import AutoCreateForeignKeyWidget
|
||||
from apps.currencies.models import Currency
|
||||
|
||||
|
||||
class AccountResource(resources.ModelResource):
|
||||
group = fields.Field(
|
||||
attribute="group",
|
||||
column_name="group",
|
||||
widget=AutoCreateForeignKeyWidget(AccountGroup, "name"),
|
||||
)
|
||||
currency = fields.Field(
|
||||
attribute="currency",
|
||||
column_name="currency",
|
||||
widget=widgets.ForeignKeyWidget(Currency, "name"),
|
||||
)
|
||||
exchange_currency = fields.Field(
|
||||
attribute="exchange_currency",
|
||||
column_name="exchange_currency",
|
||||
widget=widgets.ForeignKeyWidget(Currency, "name"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Account
|
||||
|
||||
def get_queryset(self):
|
||||
return Account.all_objects.all()
|
||||
52
app/apps/export_app/resources/currencies.py
Normal file
52
app/apps/export_app/resources/currencies.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from import_export import fields, resources, widgets
|
||||
|
||||
from apps.accounts.models import Account
|
||||
from apps.currencies.models import Currency, ExchangeRate, ExchangeRateService
|
||||
from apps.export_app.widgets.foreign_key import SkipMissingForeignKeyWidget
|
||||
from apps.export_app.widgets.numbers import UniversalDecimalWidget
|
||||
|
||||
|
||||
class CurrencyResource(resources.ModelResource):
|
||||
exchange_currency = fields.Field(
|
||||
attribute="exchange_currency",
|
||||
column_name="exchange_currency",
|
||||
widget=SkipMissingForeignKeyWidget(Currency, "name"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Currency
|
||||
|
||||
|
||||
class ExchangeRateResource(resources.ModelResource):
|
||||
from_currency = fields.Field(
|
||||
attribute="from_currency",
|
||||
column_name="from_currency",
|
||||
widget=widgets.ForeignKeyWidget(Currency, "name"),
|
||||
)
|
||||
to_currency = fields.Field(
|
||||
attribute="to_currency",
|
||||
column_name="to_currency",
|
||||
widget=widgets.ForeignKeyWidget(Currency, "name"),
|
||||
)
|
||||
rate = fields.Field(
|
||||
attribute="rate", column_name="rate", widget=UniversalDecimalWidget()
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ExchangeRate
|
||||
|
||||
|
||||
class ExchangeRateServiceResource(resources.ModelResource):
|
||||
target_currencies = fields.Field(
|
||||
attribute="target_currencies",
|
||||
column_name="target_currencies",
|
||||
widget=widgets.ManyToManyWidget(Currency, field="name"),
|
||||
)
|
||||
target_accounts = fields.Field(
|
||||
attribute="target_accounts",
|
||||
column_name="target_accounts",
|
||||
widget=widgets.ManyToManyWidget(Account, field="name"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ExchangeRateService
|
||||
38
app/apps/export_app/resources/dca.py
Normal file
38
app/apps/export_app/resources/dca.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from import_export import fields, resources
|
||||
from import_export.widgets import ForeignKeyWidget
|
||||
|
||||
from apps.dca.models import DCAStrategy, DCAEntry
|
||||
from apps.currencies.models import Currency
|
||||
from apps.export_app.widgets.numbers import UniversalDecimalWidget
|
||||
|
||||
|
||||
class DCAStrategyResource(resources.ModelResource):
|
||||
target_currency = fields.Field(
|
||||
attribute="target_currency",
|
||||
column_name="target_currency",
|
||||
widget=ForeignKeyWidget(Currency, "name"),
|
||||
)
|
||||
payment_currency = fields.Field(
|
||||
attribute="payment_currency",
|
||||
column_name="payment_currency",
|
||||
widget=ForeignKeyWidget(Currency, "name"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = DCAStrategy
|
||||
|
||||
|
||||
class DCAEntryResource(resources.ModelResource):
|
||||
amount_paid = fields.Field(
|
||||
attribute="amount_paid",
|
||||
column_name="amount_paid",
|
||||
widget=UniversalDecimalWidget(),
|
||||
)
|
||||
amount_received = fields.Field(
|
||||
attribute="amount_received",
|
||||
column_name="amount_received",
|
||||
widget=UniversalDecimalWidget(),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = DCAEntry
|
||||
8
app/apps/export_app/resources/import_app.py
Normal file
8
app/apps/export_app/resources/import_app.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from import_export import resources
|
||||
|
||||
from apps.import_app.models import ImportProfile
|
||||
|
||||
|
||||
class ImportProfileResource(resources.ModelResource):
|
||||
class Meta:
|
||||
model = ImportProfile
|
||||
25
app/apps/export_app/resources/rules.py
Normal file
25
app/apps/export_app/resources/rules.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from import_export import fields, resources
|
||||
from import_export.widgets import ForeignKeyWidget
|
||||
|
||||
from apps.export_app.widgets.foreign_key import AutoCreateForeignKeyWidget
|
||||
from apps.export_app.widgets.many_to_many import AutoCreateManyToManyWidget
|
||||
from apps.rules.models import (
|
||||
TransactionRule,
|
||||
TransactionRuleAction,
|
||||
UpdateOrCreateTransactionRuleAction,
|
||||
)
|
||||
|
||||
|
||||
class TransactionRuleResource(resources.ModelResource):
|
||||
class Meta:
|
||||
model = TransactionRule
|
||||
|
||||
|
||||
class TransactionRuleActionResource(resources.ModelResource):
|
||||
class Meta:
|
||||
model = TransactionRuleAction
|
||||
|
||||
|
||||
class UpdateOrCreateTransactionRuleResource(resources.ModelResource):
|
||||
class Meta:
|
||||
model = UpdateOrCreateTransactionRuleAction
|
||||
158
app/apps/export_app/resources/transactions.py
Normal file
158
app/apps/export_app/resources/transactions.py
Normal file
@@ -0,0 +1,158 @@
|
||||
from import_export import fields, resources
|
||||
from import_export.widgets import ForeignKeyWidget
|
||||
|
||||
from apps.accounts.models import Account
|
||||
from apps.export_app.widgets.foreign_key import AutoCreateForeignKeyWidget
|
||||
from apps.export_app.widgets.many_to_many import AutoCreateManyToManyWidget
|
||||
from apps.export_app.widgets.string import EmptyStringToNoneField
|
||||
from apps.transactions.models import (
|
||||
Transaction,
|
||||
TransactionCategory,
|
||||
TransactionTag,
|
||||
TransactionEntity,
|
||||
RecurringTransaction,
|
||||
InstallmentPlan,
|
||||
)
|
||||
from apps.export_app.widgets.numbers import UniversalDecimalWidget
|
||||
|
||||
|
||||
class TransactionResource(resources.ModelResource):
|
||||
account = fields.Field(
|
||||
attribute="account",
|
||||
column_name="account",
|
||||
widget=ForeignKeyWidget(Account, "name"),
|
||||
)
|
||||
|
||||
category = fields.Field(
|
||||
attribute="category",
|
||||
column_name="category",
|
||||
widget=AutoCreateForeignKeyWidget(TransactionCategory, "name"),
|
||||
)
|
||||
|
||||
tags = fields.Field(
|
||||
attribute="tags",
|
||||
column_name="tags",
|
||||
widget=AutoCreateManyToManyWidget(TransactionTag, field="name"),
|
||||
)
|
||||
|
||||
entities = fields.Field(
|
||||
attribute="entities",
|
||||
column_name="entities",
|
||||
widget=AutoCreateManyToManyWidget(TransactionEntity, field="name"),
|
||||
)
|
||||
|
||||
internal_id = EmptyStringToNoneField(
|
||||
column_name="internal_id", attribute="internal_id"
|
||||
)
|
||||
|
||||
amount = fields.Field(
|
||||
attribute="amount",
|
||||
column_name="amount",
|
||||
widget=UniversalDecimalWidget(),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Transaction
|
||||
|
||||
def get_queryset(self):
|
||||
return Transaction.userless_all_objects.all()
|
||||
|
||||
|
||||
class TransactionTagResource(resources.ModelResource):
|
||||
class Meta:
|
||||
model = TransactionTag
|
||||
|
||||
def get_queryset(self):
|
||||
return TransactionTag.all_objects.all()
|
||||
|
||||
|
||||
class TransactionEntityResource(resources.ModelResource):
|
||||
class Meta:
|
||||
model = TransactionEntity
|
||||
|
||||
def get_queryset(self):
|
||||
return TransactionEntity.all_objects.all()
|
||||
|
||||
|
||||
class TransactionCategoyResource(resources.ModelResource):
|
||||
class Meta:
|
||||
model = TransactionCategory
|
||||
|
||||
def get_queryset(self):
|
||||
return TransactionCategory.all_objects.all()
|
||||
|
||||
|
||||
class RecurringTransactionResource(resources.ModelResource):
|
||||
account = fields.Field(
|
||||
attribute="account",
|
||||
column_name="account",
|
||||
widget=ForeignKeyWidget(Account, "name"),
|
||||
)
|
||||
|
||||
category = fields.Field(
|
||||
attribute="category",
|
||||
column_name="category",
|
||||
widget=AutoCreateForeignKeyWidget(TransactionCategory, "name"),
|
||||
)
|
||||
|
||||
tags = fields.Field(
|
||||
attribute="tags",
|
||||
column_name="tags",
|
||||
widget=AutoCreateManyToManyWidget(TransactionTag, field="name"),
|
||||
)
|
||||
|
||||
entities = fields.Field(
|
||||
attribute="entities",
|
||||
column_name="entities",
|
||||
widget=AutoCreateManyToManyWidget(TransactionEntity, field="name"),
|
||||
)
|
||||
|
||||
amount = fields.Field(
|
||||
attribute="amount",
|
||||
column_name="amount",
|
||||
widget=UniversalDecimalWidget(),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = RecurringTransaction
|
||||
|
||||
def get_queryset(self):
|
||||
return RecurringTransaction.all_objects.all()
|
||||
|
||||
|
||||
class InstallmentPlanResource(resources.ModelResource):
|
||||
account = fields.Field(
|
||||
attribute="account",
|
||||
column_name="account",
|
||||
widget=ForeignKeyWidget(Account, "name"),
|
||||
)
|
||||
|
||||
category = fields.Field(
|
||||
attribute="category",
|
||||
column_name="category",
|
||||
widget=AutoCreateForeignKeyWidget(TransactionCategory, "name"),
|
||||
)
|
||||
|
||||
tags = fields.Field(
|
||||
attribute="tags",
|
||||
column_name="tags",
|
||||
widget=AutoCreateManyToManyWidget(TransactionTag, field="name"),
|
||||
)
|
||||
|
||||
entities = fields.Field(
|
||||
attribute="entities",
|
||||
column_name="entities",
|
||||
widget=AutoCreateManyToManyWidget(TransactionEntity, field="name"),
|
||||
)
|
||||
|
||||
installment_amount = fields.Field(
|
||||
attribute="installment_amount",
|
||||
column_name="installment_amount",
|
||||
widget=UniversalDecimalWidget(),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = InstallmentPlan
|
||||
|
||||
def get_queryset(self):
|
||||
return InstallmentPlan.all_objects.all()
|
||||
161
app/apps/export_app/resources/users.py
Normal file
161
app/apps/export_app/resources/users.py
Normal file
@@ -0,0 +1,161 @@
|
||||
from import_export import resources, fields
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
from apps.users.models import UserSettings
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class UserResource(resources.ModelResource):
|
||||
# User fields
|
||||
email = fields.Field(attribute="email", column_name="Email")
|
||||
|
||||
# UserSettings fields - for export only
|
||||
hide_amounts = fields.Field(
|
||||
attribute="settings__hide_amounts", column_name="Hide Amounts", readonly=True
|
||||
)
|
||||
mute_sounds = fields.Field(
|
||||
attribute="settings__mute_sounds", column_name="Mute Sounds", readonly=True
|
||||
)
|
||||
date_format = fields.Field(
|
||||
attribute="settings__date_format", column_name="Date Format", readonly=True
|
||||
)
|
||||
datetime_format = fields.Field(
|
||||
attribute="settings__datetime_format",
|
||||
column_name="Datetime Format",
|
||||
readonly=True,
|
||||
)
|
||||
number_format = fields.Field(
|
||||
attribute="settings__number_format", column_name="Number Format", readonly=True
|
||||
)
|
||||
language = fields.Field(
|
||||
attribute="settings__language", column_name="Language", readonly=True
|
||||
)
|
||||
timezone = fields.Field(
|
||||
attribute="settings__timezone", column_name="Timezone", readonly=True
|
||||
)
|
||||
start_page = fields.Field(
|
||||
attribute="settings__start_page", column_name="Start Page", readonly=True
|
||||
)
|
||||
|
||||
# Human-readable fields for choice values
|
||||
start_page_display = fields.Field(column_name="Start Page Display", readonly=True)
|
||||
language_display = fields.Field(column_name="Language Display", readonly=True)
|
||||
timezone_display = fields.Field(column_name="Timezone Display", readonly=True)
|
||||
|
||||
@staticmethod
|
||||
def dehydrate_start_page_display(user):
|
||||
if hasattr(user, "settings"):
|
||||
return dict(UserSettings.StartPage.choices).get(
|
||||
user.settings.start_page, ""
|
||||
)
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def dehydrate_language_display(user):
|
||||
if hasattr(user, "settings"):
|
||||
languages = dict([("auto", "Auto")] + list(settings.LANGUAGES))
|
||||
return languages.get(user.settings.language, user.settings.language)
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def dehydrate_timezone_display(user):
|
||||
if hasattr(user, "settings"):
|
||||
if user.settings.timezone == "auto":
|
||||
return "Auto"
|
||||
return user.settings.timezone
|
||||
return ""
|
||||
|
||||
def after_init_instance(self, instance, new, row, **kwargs):
|
||||
"""
|
||||
Store settings data on the instance to be used after save
|
||||
"""
|
||||
# Process boolean fields properly
|
||||
hide_amounts = row.get("Hide Amounts", "").lower() == "true"
|
||||
mute_sounds = row.get("Mute Sounds", "").lower() == "true"
|
||||
|
||||
# Store settings data on the instance for later use
|
||||
instance._settings_data = {
|
||||
"hide_amounts": hide_amounts,
|
||||
"mute_sounds": mute_sounds,
|
||||
"date_format": row.get("Date Format", "SHORT_DATE_FORMAT"),
|
||||
"datetime_format": row.get("Datetime Format", "SHORT_DATETIME_FORMAT"),
|
||||
"number_format": row.get("Number Format", "AA"),
|
||||
"language": row.get("Language", "auto"),
|
||||
"timezone": row.get("Timezone", "auto"),
|
||||
"start_page": row.get("Start Page", UserSettings.StartPage.MONTHLY),
|
||||
}
|
||||
|
||||
return instance
|
||||
|
||||
def after_save_instance(self, instance, row, **kwargs):
|
||||
"""
|
||||
Create or update UserSettings after User is saved
|
||||
"""
|
||||
if not hasattr(instance, "_settings_data"):
|
||||
return
|
||||
|
||||
settings_data = instance._settings_data
|
||||
|
||||
# Create or update UserSettings
|
||||
try:
|
||||
user_settings = UserSettings.objects.get(user=instance)
|
||||
# Update existing settings
|
||||
for key, value in settings_data.items():
|
||||
setattr(user_settings, key, value)
|
||||
user_settings.save()
|
||||
except UserSettings.DoesNotExist:
|
||||
# Create new settings
|
||||
UserSettings.objects.create(user=instance, **settings_data)
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
Ensure settings are prefetched when exporting users
|
||||
"""
|
||||
return super().get_queryset().select_related("settings")
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
import_id_fields = ["id"]
|
||||
fields = (
|
||||
"id",
|
||||
"email",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"is_active",
|
||||
"date_joined",
|
||||
"password",
|
||||
"hide_amounts",
|
||||
"mute_sounds",
|
||||
"date_format",
|
||||
"datetime_format",
|
||||
"number_format",
|
||||
"language",
|
||||
"language_display",
|
||||
"timezone",
|
||||
"timezone_display",
|
||||
"start_page",
|
||||
"start_page_display",
|
||||
)
|
||||
export_order = (
|
||||
"id",
|
||||
"email",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"is_active",
|
||||
"date_joined",
|
||||
"password",
|
||||
"hide_amounts",
|
||||
"mute_sounds",
|
||||
"date_format",
|
||||
"datetime_format",
|
||||
"number_format",
|
||||
"language",
|
||||
"language_display",
|
||||
"timezone",
|
||||
"timezone_display",
|
||||
"start_page",
|
||||
"start_page_display",
|
||||
)
|
||||
3
app/apps/export_app/tests.py
Normal file
3
app/apps/export_app/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
8
app/apps/export_app/urls.py
Normal file
8
app/apps/export_app/urls.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from django.urls import path
|
||||
import apps.export_app.views as views
|
||||
|
||||
urlpatterns = [
|
||||
path("export/", views.export_index, name="export_index"),
|
||||
path("export/form/", views.export_form, name="export_form"),
|
||||
path("export/restore/", views.import_form, name="restore_form"),
|
||||
]
|
||||
292
app/apps/export_app/views.py
Normal file
292
app/apps/export_app/views.py
Normal file
@@ -0,0 +1,292 @@
|
||||
import logging
|
||||
import zipfile
|
||||
from io import BytesIO
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required, user_passes_test
|
||||
from django.db import transaction
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import render
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from tablib import Dataset
|
||||
|
||||
from apps.common.decorators.htmx import only_htmx
|
||||
from apps.export_app.forms import ExportForm, RestoreForm
|
||||
from apps.export_app.resources.accounts import AccountResource
|
||||
from apps.export_app.resources.currencies import (
|
||||
CurrencyResource,
|
||||
ExchangeRateResource,
|
||||
ExchangeRateServiceResource,
|
||||
)
|
||||
from apps.export_app.resources.dca import (
|
||||
DCAStrategyResource,
|
||||
DCAEntryResource,
|
||||
)
|
||||
from apps.export_app.resources.import_app import (
|
||||
ImportProfileResource,
|
||||
)
|
||||
from apps.export_app.resources.rules import (
|
||||
TransactionRuleResource,
|
||||
TransactionRuleActionResource,
|
||||
UpdateOrCreateTransactionRuleResource,
|
||||
)
|
||||
from apps.export_app.resources.transactions import (
|
||||
TransactionResource,
|
||||
TransactionTagResource,
|
||||
TransactionEntityResource,
|
||||
TransactionCategoyResource,
|
||||
InstallmentPlanResource,
|
||||
RecurringTransactionResource,
|
||||
)
|
||||
from apps.export_app.resources.users import UserResource
|
||||
|
||||
logger = logging.getLogger()
|
||||
|
||||
|
||||
@login_required
|
||||
@user_passes_test(lambda u: u.is_superuser)
|
||||
@require_http_methods(["GET"])
|
||||
def export_index(request):
|
||||
return render(request, "export_app/pages/index.html")
|
||||
|
||||
|
||||
@login_required
|
||||
@user_passes_test(lambda u: u.is_superuser)
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def export_form(request):
|
||||
timestamp = timezone.localtime(timezone.now()).strftime("%Y-%m-%dT%H-%M-%S")
|
||||
|
||||
if request.method == "POST":
|
||||
form = ExportForm(request.POST)
|
||||
if form.is_valid():
|
||||
zip_buffer = BytesIO()
|
||||
|
||||
export_users = form.cleaned_data.get("users", False)
|
||||
export_accounts = form.cleaned_data.get("accounts", False)
|
||||
export_currencies = form.cleaned_data.get("currencies", False)
|
||||
export_transactions = form.cleaned_data.get("transactions", False)
|
||||
export_categories = form.cleaned_data.get("categories", False)
|
||||
export_tags = form.cleaned_data.get("tags", False)
|
||||
export_entities = form.cleaned_data.get("entities", False)
|
||||
export_installment_plans = form.cleaned_data.get("installment_plans", False)
|
||||
export_recurring_transactions = form.cleaned_data.get(
|
||||
"recurring_transactions", False
|
||||
)
|
||||
|
||||
export_exchange_rates_services = form.cleaned_data.get(
|
||||
"exchange_rates_services", False
|
||||
)
|
||||
export_exchange_rates = form.cleaned_data.get("exchange_rates", False)
|
||||
export_rules = form.cleaned_data.get("rules", False)
|
||||
export_dca = form.cleaned_data.get("dca", False)
|
||||
export_import_profiles = form.cleaned_data.get("import_profiles", False)
|
||||
|
||||
exports = []
|
||||
if export_users:
|
||||
exports.append((UserResource().export(), "users"))
|
||||
if export_accounts:
|
||||
exports.append((AccountResource().export(), "accounts"))
|
||||
if export_currencies:
|
||||
exports.append((CurrencyResource().export(), "currencies"))
|
||||
if export_transactions:
|
||||
exports.append((TransactionResource().export(), "transactions"))
|
||||
if export_categories:
|
||||
exports.append(
|
||||
(TransactionCategoyResource().export(), "transactions_categories")
|
||||
)
|
||||
if export_tags:
|
||||
exports.append((TransactionTagResource().export(), "transactions_tags"))
|
||||
if export_entities:
|
||||
exports.append(
|
||||
(TransactionEntityResource().export(), "transactions_entities")
|
||||
)
|
||||
if export_installment_plans:
|
||||
exports.append(
|
||||
(InstallmentPlanResource().export(), "installment_plans")
|
||||
)
|
||||
if export_recurring_transactions:
|
||||
exports.append(
|
||||
(RecurringTransactionResource().export(), "recurring_transactions")
|
||||
)
|
||||
if export_exchange_rates_services:
|
||||
exports.append(
|
||||
(ExchangeRateServiceResource().export(), "automatic_exchange_rates")
|
||||
)
|
||||
if export_exchange_rates:
|
||||
exports.append((ExchangeRateResource().export(), "exchange_rates"))
|
||||
if export_rules:
|
||||
exports.append(
|
||||
(TransactionRuleResource().export(), "transaction_rules")
|
||||
)
|
||||
exports.append(
|
||||
(
|
||||
TransactionRuleActionResource().export(),
|
||||
"transaction_rules_actions",
|
||||
)
|
||||
)
|
||||
exports.append(
|
||||
(
|
||||
UpdateOrCreateTransactionRuleResource().export(),
|
||||
"transaction_rules_update_or_create",
|
||||
)
|
||||
)
|
||||
if export_dca:
|
||||
exports.append((DCAStrategyResource().export(), "dca_strategies"))
|
||||
exports.append(
|
||||
(
|
||||
DCAEntryResource().export(),
|
||||
"dca_entries",
|
||||
)
|
||||
)
|
||||
if export_import_profiles:
|
||||
exports.append((ImportProfileResource().export(), "import_profiles"))
|
||||
|
||||
if len(exports) >= 2:
|
||||
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
|
||||
for dataset, name in exports:
|
||||
zip_file.writestr(f"{name}.csv", dataset.csv)
|
||||
|
||||
response = HttpResponse(
|
||||
zip_buffer.getvalue(),
|
||||
content_type="application/zip",
|
||||
headers={
|
||||
"HX-Trigger": "hide_offcanvas, updated",
|
||||
"Content-Disposition": f'attachment; filename="{timestamp}_WYGIWYH_export.zip"',
|
||||
},
|
||||
)
|
||||
return response
|
||||
elif len(exports) == 1:
|
||||
dataset, name = exports[0]
|
||||
|
||||
response = HttpResponse(
|
||||
dataset.csv,
|
||||
content_type="text/csv",
|
||||
headers={
|
||||
"HX-Trigger": "hide_offcanvas, updated",
|
||||
"Content-Disposition": f'attachment; filename="{timestamp}_WYGIWYH_export_{name}.csv"',
|
||||
},
|
||||
)
|
||||
return response
|
||||
else:
|
||||
return HttpResponse(
|
||||
_("You have to select at least one export"),
|
||||
)
|
||||
|
||||
else:
|
||||
form = ExportForm()
|
||||
|
||||
return render(request, "export_app/fragments/export.html", context={"form": form})
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@user_passes_test(lambda u: u.is_superuser)
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def import_form(request):
|
||||
if request.method == "POST":
|
||||
form = RestoreForm(request.POST, request.FILES)
|
||||
if form.is_valid():
|
||||
try:
|
||||
process_imports(request, form.cleaned_data)
|
||||
messages.success(request, _("Data restored successfully"))
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "hide_offcanvas, updated",
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Error importing", exc_info=e)
|
||||
messages.error(
|
||||
request,
|
||||
_(
|
||||
"There was an error restoring your data. Check the logs for more details."
|
||||
),
|
||||
)
|
||||
else:
|
||||
form = RestoreForm()
|
||||
|
||||
response = render(request, "export_app/fragments/restore.html", {"form": form})
|
||||
response["HX-Trigger"] = "updated"
|
||||
return response
|
||||
|
||||
|
||||
def process_imports(request, cleaned_data):
|
||||
# Define import order to handle dependencies
|
||||
import_order = [
|
||||
("users", UserResource),
|
||||
("currencies", CurrencyResource),
|
||||
(
|
||||
"currencies",
|
||||
CurrencyResource,
|
||||
), # We do a double pass because exchange_currency may not exist when currency is initially created
|
||||
("accounts", AccountResource),
|
||||
("transactions_categories", TransactionCategoyResource),
|
||||
("transactions_tags", TransactionTagResource),
|
||||
("transactions_entities", TransactionEntityResource),
|
||||
("automatic_exchange_rates", ExchangeRateServiceResource),
|
||||
("exchange_rates", ExchangeRateResource),
|
||||
("installment_plans", InstallmentPlanResource),
|
||||
("recurring_transactions", RecurringTransactionResource),
|
||||
("transactions", TransactionResource),
|
||||
("dca_strategies", DCAStrategyResource),
|
||||
("dca_entries", DCAEntryResource),
|
||||
("import_profiles", ImportProfileResource),
|
||||
("transaction_rules", TransactionRuleResource),
|
||||
("transaction_rules_actions", TransactionRuleActionResource),
|
||||
("transaction_rules_update_or_create", UpdateOrCreateTransactionRuleResource),
|
||||
]
|
||||
|
||||
def import_dataset(content, resource_class, field_name):
|
||||
try:
|
||||
# Create a new resource instance
|
||||
resource = resource_class()
|
||||
|
||||
# Create dataset from CSV content
|
||||
dataset = Dataset()
|
||||
dataset.load(content, format="csv")
|
||||
|
||||
# Perform the import
|
||||
result = resource.import_data(
|
||||
dataset,
|
||||
dry_run=False,
|
||||
raise_errors=True,
|
||||
collect_failed_rows=True,
|
||||
use_transactions=False,
|
||||
skip_unchanged=True,
|
||||
)
|
||||
|
||||
if result.has_errors():
|
||||
raise ImportError(f"Failed rows: {result.failed_dataset}")
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error importing {field_name}: {str(e)}")
|
||||
raise ImportError(f"Error importing {field_name}: {str(e)}")
|
||||
|
||||
with transaction.atomic():
|
||||
files = {}
|
||||
|
||||
if zip_file := cleaned_data.get("zip_file"):
|
||||
# Process ZIP file
|
||||
with zipfile.ZipFile(zip_file) as z:
|
||||
for filename in z.namelist():
|
||||
name = filename.replace(".csv", "")
|
||||
with z.open(filename) as f:
|
||||
content = f.read().decode("utf-8")
|
||||
|
||||
files[name] = content
|
||||
|
||||
for field_name, resource_class in import_order:
|
||||
if field_name in files.keys():
|
||||
content = files[field_name]
|
||||
import_dataset(content, resource_class, field_name)
|
||||
else:
|
||||
# Process individual files
|
||||
for field_name, resource_class in import_order:
|
||||
if csv_file := cleaned_data.get(field_name):
|
||||
content = csv_file.read().decode("utf-8")
|
||||
import_dataset(content, resource_class, field_name)
|
||||
0
app/apps/export_app/widgets/__init__.py
Normal file
0
app/apps/export_app/widgets/__init__.py
Normal file
22
app/apps/export_app/widgets/foreign_key.py
Normal file
22
app/apps/export_app/widgets/foreign_key.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from import_export.widgets import ForeignKeyWidget
|
||||
|
||||
|
||||
class AutoCreateForeignKeyWidget(ForeignKeyWidget):
|
||||
def clean(self, value, row=None, *args, **kwargs):
|
||||
if value:
|
||||
try:
|
||||
return super().clean(value, row, **kwargs)
|
||||
except self.model.DoesNotExist:
|
||||
return self.model.objects.create(name=value)
|
||||
return None
|
||||
|
||||
|
||||
class SkipMissingForeignKeyWidget(ForeignKeyWidget):
|
||||
def clean(self, value, row=None, *args, **kwargs):
|
||||
if not value:
|
||||
return None
|
||||
|
||||
try:
|
||||
return super().clean(value, row, *args, **kwargs)
|
||||
except self.model.DoesNotExist:
|
||||
return None
|
||||
21
app/apps/export_app/widgets/many_to_many.py
Normal file
21
app/apps/export_app/widgets/many_to_many.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from import_export.widgets import ManyToManyWidget
|
||||
|
||||
|
||||
class AutoCreateManyToManyWidget(ManyToManyWidget):
|
||||
def clean(self, value, row=None, *args, **kwargs):
|
||||
if not value:
|
||||
return []
|
||||
|
||||
values = value.split(self.separator)
|
||||
cleaned_values = []
|
||||
|
||||
for val in values:
|
||||
val = val.strip()
|
||||
if val:
|
||||
try:
|
||||
obj = self.model.objects.get(**{self.field: val})
|
||||
except self.model.DoesNotExist:
|
||||
obj = self.model.objects.create(name=val)
|
||||
cleaned_values.append(obj)
|
||||
|
||||
return cleaned_values
|
||||
18
app/apps/export_app/widgets/numbers.py
Normal file
18
app/apps/export_app/widgets/numbers.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from import_export.widgets import NumberWidget
|
||||
|
||||
|
||||
class UniversalDecimalWidget(NumberWidget):
|
||||
def clean(self, value, row=None, *args, **kwargs):
|
||||
if self.is_empty(value):
|
||||
return None
|
||||
# Replace comma with dot if present
|
||||
if isinstance(value, str):
|
||||
value = value.replace(",", ".")
|
||||
return Decimal(str(value))
|
||||
|
||||
def render(self, value, obj=None, **kwargs):
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value).replace(",", ".")
|
||||
7
app/apps/export_app/widgets/string.py
Normal file
7
app/apps/export_app/widgets/string.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from import_export import fields
|
||||
|
||||
|
||||
class EmptyStringToNoneField(fields.Field):
|
||||
def clean(self, data, **kwargs):
|
||||
value = super().clean(data)
|
||||
return None if value == "" else value
|
||||
@@ -268,14 +268,17 @@ class ImportService:
|
||||
category = TransactionCategory.objects.get(id=category_name)
|
||||
else: # name
|
||||
if getattr(category_mapping, "create", False):
|
||||
category, _ = TransactionCategory.objects.get_or_create(
|
||||
name=category_name
|
||||
)
|
||||
try:
|
||||
category = TransactionCategory.objects.get(
|
||||
name=category_name
|
||||
)
|
||||
except TransactionCategory.DoesNotExist:
|
||||
category = TransactionCategory(name=category_name)
|
||||
category.save()
|
||||
else:
|
||||
category = TransactionCategory.objects.filter(
|
||||
name=category_name
|
||||
).first()
|
||||
|
||||
if category:
|
||||
data["category"] = category
|
||||
self.import_run.categories.add(category)
|
||||
@@ -325,9 +328,13 @@ class ImportService:
|
||||
tag = TransactionTag.objects.filter(id=tag_name).first()
|
||||
else: # name
|
||||
if getattr(tags_mapping, "create", False):
|
||||
tag, _ = TransactionTag.objects.get_or_create(
|
||||
name=tag_name.strip()
|
||||
)
|
||||
try:
|
||||
tag = TransactionTag.objects.get(
|
||||
name=tag_name.strip()
|
||||
)
|
||||
except TransactionTag.DoesNotExist:
|
||||
tag = TransactionTag(name=tag_name.strip())
|
||||
tag.save()
|
||||
else:
|
||||
tag = TransactionTag.objects.filter(
|
||||
name=tag_name.strip()
|
||||
@@ -361,9 +368,13 @@ class ImportService:
|
||||
).first()
|
||||
else: # name
|
||||
if getattr(entities_mapping, "create", False):
|
||||
entity, _ = TransactionEntity.objects.get_or_create(
|
||||
name=entity_name.strip()
|
||||
)
|
||||
try:
|
||||
entity = TransactionEntity.objects.get(
|
||||
name=entity_name.strip()
|
||||
)
|
||||
except TransactionEntity.DoesNotExist:
|
||||
entity = TransactionEntity(name=entity_name.strip())
|
||||
entity.save()
|
||||
else:
|
||||
entity = TransactionEntity.objects.filter(
|
||||
name=entity_name.strip()
|
||||
@@ -394,7 +405,11 @@ class ImportService:
|
||||
def _create_account(self, data: Dict[str, Any]) -> Account:
|
||||
if "group" in data:
|
||||
group_name = data.pop("group")
|
||||
group, _ = AccountGroup.objects.get_or_create(name=group_name)
|
||||
try:
|
||||
group = AccountGroup.objects.get(name=group_name)
|
||||
except AccountGroup.DoesNotExist:
|
||||
group = AccountGroup(name=group_name)
|
||||
group.save()
|
||||
data["group"] = group
|
||||
|
||||
# Handle currency references
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import logging
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from procrastinate.contrib.django import app
|
||||
|
||||
from apps.common.middleware.thread_local import write_current_user, delete_current_user
|
||||
from apps.import_app.models import ImportRun
|
||||
from apps.import_app.services import ImportServiceV1
|
||||
|
||||
@@ -9,10 +11,15 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@app.task(name="process_import")
|
||||
def process_import(import_run_id: int, file_path: str):
|
||||
def process_import(import_run_id: int, file_path: str, user_id: int):
|
||||
user = get_user_model().objects.get(id=user_id)
|
||||
write_current_user(user)
|
||||
|
||||
try:
|
||||
import_run = ImportRun.objects.get(id=import_run_id)
|
||||
import_service = ImportServiceV1(import_run)
|
||||
import_service.process_file(file_path)
|
||||
delete_current_user()
|
||||
except ImportRun.DoesNotExist:
|
||||
delete_current_user()
|
||||
raise ValueError(f"ImportRun with id {import_run_id} not found")
|
||||
|
||||
@@ -2,7 +2,6 @@ from django.urls import path
|
||||
import apps.import_app.views as views
|
||||
|
||||
urlpatterns = [
|
||||
path("import/", views.import_view, name="import"),
|
||||
path(
|
||||
"import/presets/",
|
||||
views.import_presets_list,
|
||||
|
||||
@@ -15,19 +15,6 @@ from apps.import_app.services import PresetService
|
||||
from apps.import_app.tasks import process_import
|
||||
|
||||
|
||||
def import_view(request):
|
||||
import_profile = ImportProfile.objects.get(id=2)
|
||||
shutil.copyfile(
|
||||
"/usr/src/app/apps/import_app/teste2.csv", "/usr/src/app/temp/teste2.csv"
|
||||
)
|
||||
ir = ImportRun.objects.create(profile=import_profile, file_name="teste.csv")
|
||||
process_import.defer(
|
||||
import_run_id=ir.id,
|
||||
file_path="/usr/src/app/temp/teste2.csv",
|
||||
)
|
||||
return HttpResponse("Hello, world. You're at the polls page.")
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def import_presets_list(request):
|
||||
@@ -189,7 +176,11 @@ def import_run_add(request, profile_id):
|
||||
import_run = ImportRun.objects.create(profile=profile, file_name=filename)
|
||||
|
||||
# Defer the procrastinate task
|
||||
process_import.defer(import_run_id=import_run.id, file_path=file_path)
|
||||
process_import.defer(
|
||||
import_run_id=import_run.id,
|
||||
file_path=file_path,
|
||||
user_id=request.user.id,
|
||||
)
|
||||
|
||||
messages.success(request, _("Import Run queued successfully"))
|
||||
|
||||
|
||||
@@ -8,11 +8,13 @@ from apps.common.widgets.datepicker import (
|
||||
AirYearPickerInput,
|
||||
AirDatePickerInput,
|
||||
)
|
||||
from apps.transactions.models import TransactionCategory
|
||||
from apps.common.widgets.tom_select import TomSelect
|
||||
|
||||
|
||||
class SingleMonthForm(forms.Form):
|
||||
month = forms.DateField(
|
||||
widget=AirMonthYearPickerInput(clear_button=False), label="", required=False
|
||||
widget=AirMonthYearPickerInput(clear_button=False), label="", required=True
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -27,7 +29,7 @@ class SingleMonthForm(forms.Form):
|
||||
|
||||
class SingleYearForm(forms.Form):
|
||||
year = forms.DateField(
|
||||
widget=AirYearPickerInput(clear_button=False), label="", required=False
|
||||
widget=AirYearPickerInput(clear_button=False), label="", required=True
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -42,10 +44,10 @@ class SingleYearForm(forms.Form):
|
||||
|
||||
class MonthRangeForm(forms.Form):
|
||||
month_from = forms.DateField(
|
||||
widget=AirMonthYearPickerInput(clear_button=False), label="", required=False
|
||||
widget=AirMonthYearPickerInput(clear_button=False), label="", required=True
|
||||
)
|
||||
month_to = forms.DateField(
|
||||
widget=AirMonthYearPickerInput(clear_button=False), label="", required=False
|
||||
widget=AirMonthYearPickerInput(clear_button=False), label="", required=True
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -65,10 +67,10 @@ class MonthRangeForm(forms.Form):
|
||||
|
||||
class YearRangeForm(forms.Form):
|
||||
year_from = forms.DateField(
|
||||
widget=AirYearPickerInput(clear_button=False), label="", required=False
|
||||
widget=AirYearPickerInput(clear_button=False), label="", required=True
|
||||
)
|
||||
year_to = forms.DateField(
|
||||
widget=AirYearPickerInput(clear_button=False), label="", required=False
|
||||
widget=AirYearPickerInput(clear_button=False), label="", required=True
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -88,10 +90,10 @@ class YearRangeForm(forms.Form):
|
||||
|
||||
class DateRangeForm(forms.Form):
|
||||
date_from = forms.DateField(
|
||||
widget=AirDatePickerInput(clear_button=False), label="", required=False
|
||||
widget=AirDatePickerInput(clear_button=False), label="", required=True
|
||||
)
|
||||
date_to = forms.DateField(
|
||||
widget=AirDatePickerInput(clear_button=False), label="", required=False
|
||||
widget=AirDatePickerInput(clear_button=False), label="", required=True
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -108,3 +110,22 @@ class DateRangeForm(forms.Form):
|
||||
css_class="mb-0",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class CategoryForm(forms.Form):
|
||||
category = forms.ModelChoiceField(
|
||||
required=False,
|
||||
label=_("Category"),
|
||||
empty_label=_("Uncategorized"),
|
||||
queryset=TransactionCategory.objects.filter(active=True),
|
||||
widget=TomSelect(clear_button=True),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
self.helper.disable_csrf = True
|
||||
|
||||
self.helper.layout = Layout("category")
|
||||
|
||||
@@ -14,4 +14,39 @@ urlpatterns = [
|
||||
views.sankey_by_currency,
|
||||
name="insights_sankey_by_currency",
|
||||
),
|
||||
path(
|
||||
"insights/category-explorer/",
|
||||
views.category_explorer_index,
|
||||
name="category_explorer_index",
|
||||
),
|
||||
path(
|
||||
"insights/category-explorer/account/",
|
||||
views.category_sum_by_account,
|
||||
name="category_sum_by_account",
|
||||
),
|
||||
path(
|
||||
"insights/category-explorer/currency/",
|
||||
views.category_sum_by_currency,
|
||||
name="category_sum_by_currency",
|
||||
),
|
||||
path(
|
||||
"insights/category-overview/",
|
||||
views.category_overview,
|
||||
name="category_overview",
|
||||
),
|
||||
path(
|
||||
"insights/late-transactions/",
|
||||
views.late_transactions,
|
||||
name="insights_late_transactions",
|
||||
),
|
||||
path(
|
||||
"insights/latest-transactions/",
|
||||
views.latest_transactions,
|
||||
name="insights_latest_transactions",
|
||||
),
|
||||
path(
|
||||
"insights/emergency-fund/",
|
||||
views.emergency_fund,
|
||||
name="insights_emergency_fund",
|
||||
),
|
||||
]
|
||||
|
||||
161
app/apps/insights/utils/category_explorer.py
Normal file
161
app/apps/insights/utils/category_explorer.py
Normal file
@@ -0,0 +1,161 @@
|
||||
from django.db.models import Sum, Case, When, F, DecimalField, Value
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
def get_category_sums_by_account(queryset, category=None):
|
||||
"""
|
||||
Returns income/expense sums per account for a specific category.
|
||||
"""
|
||||
sums = (
|
||||
queryset.filter(category=category)
|
||||
.values("account__name")
|
||||
.annotate(
|
||||
current_income=Coalesce(
|
||||
Sum(
|
||||
Case(
|
||||
When(type="IN", is_paid=True, then="amount"),
|
||||
default=Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
)
|
||||
),
|
||||
Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
),
|
||||
current_expense=Coalesce(
|
||||
Sum(
|
||||
Case(
|
||||
When(type="EX", is_paid=True, then=-F("amount")),
|
||||
default=Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
)
|
||||
),
|
||||
Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
),
|
||||
projected_income=Coalesce(
|
||||
Sum(
|
||||
Case(
|
||||
When(type="IN", is_paid=False, then="amount"),
|
||||
default=Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
)
|
||||
),
|
||||
Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
),
|
||||
projected_expense=Coalesce(
|
||||
Sum(
|
||||
Case(
|
||||
When(type="EX", is_paid=False, then=-F("amount")),
|
||||
default=Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
)
|
||||
),
|
||||
Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
),
|
||||
)
|
||||
.order_by("account__name")
|
||||
)
|
||||
|
||||
return {
|
||||
"labels": [item["account__name"] for item in sums],
|
||||
"datasets": [
|
||||
{
|
||||
"label": _("Current Income"),
|
||||
"data": [float(item["current_income"]) for item in sums],
|
||||
},
|
||||
{
|
||||
"label": _("Current Expenses"),
|
||||
"data": [float(item["current_expense"]) for item in sums],
|
||||
},
|
||||
{
|
||||
"label": _("Projected Income"),
|
||||
"data": [float(item["projected_income"]) for item in sums],
|
||||
},
|
||||
{
|
||||
"label": _("Projected Expenses"),
|
||||
"data": [float(item["projected_expense"]) for item in sums],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def get_category_sums_by_currency(queryset, category=None):
|
||||
"""
|
||||
Returns income/expense sums per currency for a specific category.
|
||||
"""
|
||||
sums = (
|
||||
queryset.filter(category=category)
|
||||
.values("account__currency__name")
|
||||
.annotate(
|
||||
current_income=Coalesce(
|
||||
Sum(
|
||||
Case(
|
||||
When(type="IN", is_paid=True, then="amount"),
|
||||
default=Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
)
|
||||
),
|
||||
Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
),
|
||||
current_expense=Coalesce(
|
||||
Sum(
|
||||
Case(
|
||||
When(type="EX", is_paid=True, then=-F("amount")),
|
||||
default=Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
)
|
||||
),
|
||||
Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
),
|
||||
projected_income=Coalesce(
|
||||
Sum(
|
||||
Case(
|
||||
When(type="IN", is_paid=False, then="amount"),
|
||||
default=Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
)
|
||||
),
|
||||
Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
),
|
||||
projected_expense=Coalesce(
|
||||
Sum(
|
||||
Case(
|
||||
When(type="EX", is_paid=False, then=-F("amount")),
|
||||
default=Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
)
|
||||
),
|
||||
Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
),
|
||||
)
|
||||
.order_by("account__currency__name")
|
||||
)
|
||||
|
||||
return {
|
||||
"labels": [item["account__currency__name"] for item in sums],
|
||||
"datasets": [
|
||||
{
|
||||
"label": _("Current Income"),
|
||||
"data": [float(item["current_income"]) for item in sums],
|
||||
},
|
||||
{
|
||||
"label": _("Current Expenses"),
|
||||
"data": [float(item["current_expense"]) for item in sums],
|
||||
},
|
||||
{
|
||||
"label": _("Projected Income"),
|
||||
"data": [float(item["projected_income"]) for item in sums],
|
||||
},
|
||||
{
|
||||
"label": _("Projected Expenses"),
|
||||
"data": [float(item["projected_expense"]) for item in sums],
|
||||
},
|
||||
],
|
||||
}
|
||||
165
app/apps/insights/utils/category_overview.py
Normal file
165
app/apps/insights/utils/category_overview.py
Normal file
@@ -0,0 +1,165 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import models
|
||||
from django.db.models import Sum, Case, When, Value, DecimalField
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
from apps.transactions.models import Transaction
|
||||
from apps.currencies.models import Currency
|
||||
from apps.currencies.utils.convert import convert
|
||||
|
||||
|
||||
def get_categories_totals(transactions_queryset, ignore_empty=False):
|
||||
# Get metrics for each category and currency in a single query
|
||||
category_currency_metrics = (
|
||||
transactions_queryset.values(
|
||||
"category",
|
||||
"category__name",
|
||||
"account__currency",
|
||||
"account__currency__code",
|
||||
"account__currency__name",
|
||||
"account__currency__decimal_places",
|
||||
"account__currency__prefix",
|
||||
"account__currency__suffix",
|
||||
"account__currency__exchange_currency",
|
||||
)
|
||||
.annotate(
|
||||
expense_current=Coalesce(
|
||||
Sum(
|
||||
Case(
|
||||
When(
|
||||
type=Transaction.Type.EXPENSE, is_paid=True, then="amount"
|
||||
),
|
||||
default=Value(0),
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
),
|
||||
Decimal("0"),
|
||||
),
|
||||
expense_projected=Coalesce(
|
||||
Sum(
|
||||
Case(
|
||||
When(
|
||||
type=Transaction.Type.EXPENSE, is_paid=False, then="amount"
|
||||
),
|
||||
default=Value(0),
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
),
|
||||
Decimal("0"),
|
||||
),
|
||||
income_current=Coalesce(
|
||||
Sum(
|
||||
Case(
|
||||
When(type=Transaction.Type.INCOME, is_paid=True, then="amount"),
|
||||
default=Value(0),
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
),
|
||||
Decimal("0"),
|
||||
),
|
||||
income_projected=Coalesce(
|
||||
Sum(
|
||||
Case(
|
||||
When(
|
||||
type=Transaction.Type.INCOME, is_paid=False, then="amount"
|
||||
),
|
||||
default=Value(0),
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
),
|
||||
Decimal("0"),
|
||||
),
|
||||
)
|
||||
.order_by("category__name")
|
||||
)
|
||||
|
||||
# Process the results to structure by category
|
||||
result = {}
|
||||
|
||||
for metric in category_currency_metrics:
|
||||
# Skip empty categories if ignore_empty is True
|
||||
if ignore_empty and all(
|
||||
metric[field] == Decimal("0")
|
||||
for field in [
|
||||
"expense_current",
|
||||
"expense_projected",
|
||||
"income_current",
|
||||
"income_projected",
|
||||
]
|
||||
):
|
||||
continue
|
||||
|
||||
# Calculate derived totals
|
||||
total_current = metric["income_current"] - metric["expense_current"]
|
||||
total_projected = metric["income_projected"] - metric["expense_projected"]
|
||||
total_income = metric["income_current"] + metric["income_projected"]
|
||||
total_expense = metric["expense_current"] + metric["expense_projected"]
|
||||
total_final = total_current + total_projected
|
||||
|
||||
category_id = metric["category"]
|
||||
currency_id = metric["account__currency"]
|
||||
|
||||
if category_id not in result:
|
||||
result[category_id] = {"name": metric["category__name"], "currencies": {}}
|
||||
|
||||
# Add currency data
|
||||
currency_data = {
|
||||
"currency": {
|
||||
"code": metric["account__currency__code"],
|
||||
"name": metric["account__currency__name"],
|
||||
"decimal_places": metric["account__currency__decimal_places"],
|
||||
"prefix": metric["account__currency__prefix"],
|
||||
"suffix": metric["account__currency__suffix"],
|
||||
},
|
||||
"expense_current": metric["expense_current"],
|
||||
"expense_projected": metric["expense_projected"],
|
||||
"total_expense": total_expense,
|
||||
"income_current": metric["income_current"],
|
||||
"income_projected": metric["income_projected"],
|
||||
"total_income": total_income,
|
||||
"total_current": total_current,
|
||||
"total_projected": total_projected,
|
||||
"total_final": total_final,
|
||||
}
|
||||
|
||||
# Add exchanged values if exchange_currency exists
|
||||
if metric["account__currency__exchange_currency"]:
|
||||
from_currency = Currency.objects.get(id=currency_id)
|
||||
exchange_currency = Currency.objects.get(
|
||||
id=metric["account__currency__exchange_currency"]
|
||||
)
|
||||
|
||||
exchanged = {}
|
||||
for field in [
|
||||
"expense_current",
|
||||
"expense_projected",
|
||||
"income_current",
|
||||
"income_projected",
|
||||
"total_income",
|
||||
"total_expense",
|
||||
"total_current",
|
||||
"total_projected",
|
||||
"total_final",
|
||||
]:
|
||||
amount, prefix, suffix, decimal_places = convert(
|
||||
amount=currency_data[field],
|
||||
from_currency=from_currency,
|
||||
to_currency=exchange_currency,
|
||||
)
|
||||
if amount is not None:
|
||||
exchanged[field] = amount
|
||||
if "currency" not in exchanged:
|
||||
exchanged["currency"] = {
|
||||
"prefix": prefix,
|
||||
"suffix": suffix,
|
||||
"decimal_places": decimal_places,
|
||||
"code": exchange_currency.code,
|
||||
"name": exchange_currency.name,
|
||||
}
|
||||
if exchanged:
|
||||
currency_data["exchanged"] = exchanged
|
||||
|
||||
result[category_id]["currencies"][currency_id] = currency_data
|
||||
|
||||
return result
|
||||
@@ -52,13 +52,29 @@ def generate_sankey_data_by_account(transactions_queryset):
|
||||
total_volume_by_currency.get(currency, Decimal("0")) + amount
|
||||
)
|
||||
|
||||
unique_accounts = {
|
||||
account_id: idx
|
||||
for idx, account_id in enumerate(
|
||||
transactions_queryset.values_list("account", flat=True).distinct()
|
||||
)
|
||||
}
|
||||
|
||||
def get_node_priority(node_id: str) -> int:
|
||||
"""Get priority based on the account ID embedded in the node ID."""
|
||||
account_id = int(node_id.split("_")[-1])
|
||||
return unique_accounts[account_id]
|
||||
|
||||
def get_node_id(node_type: str, name: str, account_id: int) -> str:
|
||||
"""Generate unique node ID."""
|
||||
return f"{node_type}_{name}_{account_id}".lower().replace(" ", "_")
|
||||
|
||||
def add_node(node_id: str, display_name: str) -> None:
|
||||
"""Add node with both ID and display name."""
|
||||
nodes[node_id] = {"id": node_id, "name": display_name}
|
||||
"""Add node with ID, display name and priority."""
|
||||
nodes[node_id] = {
|
||||
"id": node_id,
|
||||
"name": display_name,
|
||||
"priority": get_node_priority(node_id),
|
||||
}
|
||||
|
||||
def add_flow(
|
||||
from_node_id: str, to_node_id: str, amount: Decimal, currency, is_income: bool
|
||||
@@ -167,13 +183,29 @@ def generate_sankey_data_by_currency(transactions_queryset):
|
||||
total_volume_by_currency.get(currency, Decimal("0")) + amount
|
||||
)
|
||||
|
||||
unique_currencies = {
|
||||
currency_id: idx
|
||||
for idx, currency_id in enumerate(
|
||||
transactions_queryset.values_list("account__currency", flat=True).distinct()
|
||||
)
|
||||
}
|
||||
|
||||
def get_node_priority(node_id: str) -> int:
|
||||
"""Get priority based on the currency ID embedded in the node ID."""
|
||||
currency_id = int(node_id.split("_")[-1])
|
||||
return unique_currencies[currency_id]
|
||||
|
||||
def get_node_id(node_type: str, name: str, currency_id: int) -> str:
|
||||
"""Generate unique node ID including currency information."""
|
||||
return f"{node_type}_{name}_{currency_id}".lower().replace(" ", "_")
|
||||
|
||||
def add_node(node_id: str, display_name: str) -> None:
|
||||
"""Add node with both ID and display name."""
|
||||
nodes[node_id] = {"id": node_id, "name": display_name}
|
||||
"""Add node with ID, display name and priority."""
|
||||
nodes[node_id] = {
|
||||
"id": node_id,
|
||||
"name": display_name,
|
||||
"priority": get_node_priority(node_id),
|
||||
}
|
||||
|
||||
def add_flow(
|
||||
from_node_id: str, to_node_id: str, amount: Decimal, currency, is_income: bool
|
||||
|
||||
@@ -1,24 +1,35 @@
|
||||
import decimal
|
||||
import json
|
||||
from collections import defaultdict
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db.models import Sum, Avg, F
|
||||
from django.shortcuts import render
|
||||
from django.utils import timezone
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from apps.transactions.models import Transaction
|
||||
from apps.insights.utils.sankey import (
|
||||
generate_sankey_data_by_account,
|
||||
generate_sankey_data_by_currency,
|
||||
)
|
||||
from apps.common.decorators.htmx import only_htmx
|
||||
from apps.insights.forms import (
|
||||
SingleMonthForm,
|
||||
SingleYearForm,
|
||||
MonthRangeForm,
|
||||
YearRangeForm,
|
||||
DateRangeForm,
|
||||
CategoryForm,
|
||||
)
|
||||
from apps.insights.utils.category_explorer import (
|
||||
get_category_sums_by_account,
|
||||
get_category_sums_by_currency,
|
||||
)
|
||||
from apps.insights.utils.sankey import (
|
||||
generate_sankey_data_by_account,
|
||||
generate_sankey_data_by_currency,
|
||||
)
|
||||
from apps.common.decorators.htmx import only_htmx
|
||||
from apps.insights.utils.transactions import get_transactions
|
||||
from apps.transactions.models import TransactionCategory, Transaction
|
||||
from apps.insights.utils.category_overview import get_categories_totals
|
||||
from apps.transactions.utils.calculations import calculate_currency_totals
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -61,7 +72,7 @@ def index(request):
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
@require_http_methods(["GET"])
|
||||
def sankey_by_account(request):
|
||||
# Get filtered transactions
|
||||
|
||||
@@ -79,7 +90,7 @@ def sankey_by_account(request):
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
@require_http_methods(["GET"])
|
||||
def sankey_by_currency(request):
|
||||
# Get filtered transactions
|
||||
transactions = get_transactions(request)
|
||||
@@ -92,3 +103,164 @@ def sankey_by_currency(request):
|
||||
"insights/fragments/sankey.html",
|
||||
{"sankey_data": sankey_data, "type": "currency"},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def category_explorer_index(request):
|
||||
category_form = CategoryForm()
|
||||
|
||||
return render(
|
||||
request,
|
||||
"insights/fragments/category_explorer/index.html",
|
||||
{"category_form": category_form},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def category_sum_by_account(request):
|
||||
# Get filtered transactions
|
||||
transactions = get_transactions(request, include_silent=True)
|
||||
|
||||
category = request.GET.get("category")
|
||||
|
||||
if category:
|
||||
category = TransactionCategory.objects.get(id=category)
|
||||
|
||||
# Generate data
|
||||
account_data = get_category_sums_by_account(transactions, category)
|
||||
else:
|
||||
account_data = get_category_sums_by_account(transactions, category=None)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"insights/fragments/category_explorer/charts/account.html",
|
||||
{"account_data": account_data},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def category_sum_by_currency(request):
|
||||
# Get filtered transactions
|
||||
transactions = get_transactions(request, include_silent=True)
|
||||
|
||||
category = request.GET.get("category")
|
||||
|
||||
if category:
|
||||
category = TransactionCategory.objects.get(id=category)
|
||||
|
||||
# Generate data
|
||||
currency_data = get_category_sums_by_currency(transactions, category)
|
||||
else:
|
||||
currency_data = get_category_sums_by_currency(transactions, category=None)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"insights/fragments/category_explorer/charts/currency.html",
|
||||
{"currency_data": currency_data},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def category_overview(request):
|
||||
# Get filtered transactions
|
||||
transactions = get_transactions(request, include_silent=True)
|
||||
|
||||
total_table = get_categories_totals(
|
||||
transactions_queryset=transactions, ignore_empty=False
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"insights/fragments/category_overview/index.html",
|
||||
{"total_table": total_table},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def latest_transactions(request):
|
||||
limit = timezone.now() - relativedelta(days=3)
|
||||
transactions = Transaction.objects.filter(created_at__gte=limit).order_by("-id")[
|
||||
:30
|
||||
]
|
||||
|
||||
return render(
|
||||
request,
|
||||
"insights/fragments/latest_transactions.html",
|
||||
{"transactions": transactions},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def late_transactions(request):
|
||||
now = timezone.localdate(timezone.now())
|
||||
transactions = Transaction.objects.filter(is_paid=False, date__lt=now)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"insights/fragments/late_transactions.html",
|
||||
{"transactions": transactions},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def emergency_fund(request):
|
||||
transactions_currency_queryset = Transaction.objects.filter(
|
||||
is_paid=True, account__is_archived=False, account__is_asset=False
|
||||
).order_by(
|
||||
"account__currency__name",
|
||||
)
|
||||
currency_net_worth = calculate_currency_totals(
|
||||
transactions_queryset=transactions_currency_queryset, ignore_empty=False
|
||||
)
|
||||
|
||||
end_date = (timezone.now() - relativedelta(months=1)).replace(day=1)
|
||||
start_date = (end_date - relativedelta(months=12)).replace(day=1)
|
||||
|
||||
# Step 1: Calculate total expense for each month and currency
|
||||
monthly_expenses = (
|
||||
Transaction.objects.filter(
|
||||
type=Transaction.Type.EXPENSE,
|
||||
is_paid=True,
|
||||
account__is_asset=False,
|
||||
reference_date__gte=start_date,
|
||||
reference_date__lte=end_date,
|
||||
category__mute=False,
|
||||
)
|
||||
.values("reference_date", "account__currency")
|
||||
.annotate(monthly_total=Sum("amount"))
|
||||
)
|
||||
|
||||
# Step 2: Calculate averages by currency using Python
|
||||
currency_totals = defaultdict(list)
|
||||
for expense in monthly_expenses:
|
||||
currency_id = expense["account__currency"]
|
||||
currency_totals[currency_id].append(expense["monthly_total"])
|
||||
|
||||
for currency_id, totals in currency_totals.items():
|
||||
avg = currency_net_worth[currency_id]["average"] = sum(totals) / len(totals)
|
||||
if currency_net_worth[currency_id]["total_current"] < 0:
|
||||
currency_net_worth[currency_id]["months"] = 0
|
||||
else:
|
||||
currency_net_worth[currency_id]["months"] = int(
|
||||
currency_net_worth[currency_id]["total_current"] / avg
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"insights/fragments/emergency_fund.html",
|
||||
{"data": currency_net_worth},
|
||||
)
|
||||
|
||||
0
app/apps/mini_tools/utils/__init__.py
Normal file
0
app/apps/mini_tools/utils/__init__.py
Normal file
85
app/apps/mini_tools/utils/exchange_rate_map.py
Normal file
85
app/apps/mini_tools/utils/exchange_rate_map.py
Normal file
@@ -0,0 +1,85 @@
|
||||
from typing import Dict
|
||||
|
||||
from django.db.models import Func, F, Value
|
||||
from django.db.models.functions import Extract
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.currencies.models import ExchangeRate
|
||||
|
||||
|
||||
def get_currency_exchange_map(date=None) -> Dict[str, dict]:
|
||||
"""
|
||||
Creates a nested dictionary of exchange rates and currency information.
|
||||
|
||||
Returns:
|
||||
{
|
||||
'BTC': {
|
||||
'decimal_places': 8,
|
||||
'prefix': '₿',
|
||||
'suffix': '',
|
||||
'rates': {'USD': Decimal('34000.00'), 'EUR': Decimal('31000.00')}
|
||||
},
|
||||
'USD': {
|
||||
'decimal_places': 2,
|
||||
'prefix': '$',
|
||||
'suffix': '',
|
||||
'rates': {'BTC': Decimal('0.0000294'), 'EUR': Decimal('0.91')}
|
||||
},
|
||||
...
|
||||
}
|
||||
"""
|
||||
if date is None:
|
||||
date = timezone.localtime(timezone.now())
|
||||
|
||||
# Get all exchange rates for the closest date
|
||||
exchange_rates = (
|
||||
ExchangeRate.objects.select_related(
|
||||
"from_currency", "to_currency"
|
||||
) # Optimize currency queries
|
||||
.annotate(
|
||||
date_diff=Func(Extract(F("date") - Value(date), "epoch"), function="ABS"),
|
||||
effective_rate=F("rate"),
|
||||
)
|
||||
.order_by("from_currency", "to_currency", "date_diff")
|
||||
.distinct("from_currency", "to_currency")
|
||||
)
|
||||
|
||||
# Initialize the result dictionary
|
||||
rate_map = {}
|
||||
|
||||
# Build the exchange rate mapping with currency info
|
||||
for rate in exchange_rates:
|
||||
# Add from_currency info if not exists
|
||||
if rate.from_currency.name not in rate_map:
|
||||
rate_map[rate.from_currency.name] = {
|
||||
"decimal_places": rate.from_currency.decimal_places,
|
||||
"prefix": rate.from_currency.prefix,
|
||||
"suffix": rate.from_currency.suffix,
|
||||
"rates": {},
|
||||
}
|
||||
|
||||
# Add to_currency info if not exists
|
||||
if rate.to_currency.name not in rate_map:
|
||||
rate_map[rate.to_currency.name] = {
|
||||
"decimal_places": rate.to_currency.decimal_places,
|
||||
"prefix": rate.to_currency.prefix,
|
||||
"suffix": rate.to_currency.suffix,
|
||||
"rates": {},
|
||||
}
|
||||
|
||||
# Add direct rate
|
||||
rate_map[rate.from_currency.name]["rates"][rate.to_currency.name] = {
|
||||
"rate": rate.rate,
|
||||
"decimal_places": rate.to_currency.decimal_places,
|
||||
"prefix": rate.to_currency.prefix,
|
||||
"suffix": rate.to_currency.suffix,
|
||||
}
|
||||
# Add inverse rate
|
||||
rate_map[rate.to_currency.name]["rates"][rate.from_currency.name] = {
|
||||
"rate": 1 / rate.rate,
|
||||
"decimal_places": rate.from_currency.decimal_places,
|
||||
"prefix": rate.from_currency.prefix,
|
||||
"suffix": rate.from_currency.suffix,
|
||||
}
|
||||
|
||||
return rate_map
|
||||
@@ -5,6 +5,7 @@ from apps.common.widgets.decimal import convert_to_decimal
|
||||
from apps.currencies.models import Currency
|
||||
from apps.currencies.utils.convert import convert
|
||||
from apps.mini_tools.forms import CurrencyConverterForm
|
||||
from apps.mini_tools.utils.exchange_rate_map import get_currency_exchange_map
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -14,11 +15,13 @@ def unit_price_calculator(request):
|
||||
|
||||
@login_required
|
||||
def currency_converter(request):
|
||||
rate_map = get_currency_exchange_map()
|
||||
|
||||
form = CurrencyConverterForm()
|
||||
return render(
|
||||
request,
|
||||
"mini_tools/currency_converter/currency_converter.html",
|
||||
context={"form": form},
|
||||
context={"form": form, "rate_map": rate_map},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -77,24 +77,20 @@ def transactions_list(request, month: int, year: int):
|
||||
request.session["monthly_transactions_order"] = order
|
||||
|
||||
f = TransactionsFilter(request.GET)
|
||||
transactions_filtered = (
|
||||
f.qs.filter()
|
||||
.filter(
|
||||
reference_date__year=year,
|
||||
reference_date__month=month,
|
||||
)
|
||||
.prefetch_related(
|
||||
"account",
|
||||
"account__group",
|
||||
"category",
|
||||
"tags",
|
||||
"account__exchange_currency",
|
||||
"account__currency",
|
||||
"installment_plan",
|
||||
"entities",
|
||||
"dca_expense_entries",
|
||||
"dca_income_entries",
|
||||
)
|
||||
transactions_filtered = f.qs.filter(
|
||||
reference_date__year=year,
|
||||
reference_date__month=month,
|
||||
).prefetch_related(
|
||||
"account",
|
||||
"account__group",
|
||||
"category",
|
||||
"tags",
|
||||
"account__exchange_currency",
|
||||
"account__currency",
|
||||
"installment_plan",
|
||||
"entities",
|
||||
"dca_expense_entries",
|
||||
"dca_income_entries",
|
||||
)
|
||||
|
||||
transactions_filtered = default_order(transactions_filtered, order=order)
|
||||
|
||||
@@ -2,20 +2,13 @@ from collections import OrderedDict, defaultdict
|
||||
from decimal import Decimal
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.db.models import (
|
||||
OuterRef,
|
||||
Subquery,
|
||||
)
|
||||
from django.db.models import Sum, Min, Max, Case, When, F, Value, DecimalField
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.db.models.functions import TruncMonth
|
||||
from django.template.defaultfilters import date as date_filter
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.accounts.models import Account
|
||||
from apps.currencies.models import Currency
|
||||
from apps.currencies.utils.convert import convert
|
||||
from apps.transactions.models import Transaction
|
||||
|
||||
|
||||
@@ -104,7 +97,9 @@ def calculate_historical_currency_net_worth(is_paid=True):
|
||||
def calculate_historical_account_balance(is_paid=True):
|
||||
transactions_params = {**{k: v for k, v in [("is_paid", True)] if is_paid}}
|
||||
# Get all accounts
|
||||
accounts = Account.objects.filter(is_archived=False)
|
||||
accounts = Account.objects.filter(
|
||||
is_archived=False,
|
||||
)
|
||||
|
||||
# Get the date range
|
||||
date_range = Transaction.objects.filter(**transactions_params).aggregate(
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import json
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.shortcuts import render
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from apps.net_worth.utils.calculate_net_worth import (
|
||||
calculate_historical_currency_net_worth,
|
||||
@@ -14,6 +16,8 @@ from apps.transactions.utils.calculations import (
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def net_worth_current(request):
|
||||
transactions_currency_queryset = Transaction.objects.filter(
|
||||
is_paid=True, account__is_archived=False
|
||||
@@ -113,6 +117,8 @@ def net_worth_current(request):
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def net_worth_projected(request):
|
||||
transactions_currency_queryset = Transaction.objects.filter(
|
||||
account__is_archived=False
|
||||
|
||||
@@ -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.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.common.models import SharedObject, SharedObjectManager
|
||||
|
||||
class TransactionRule(models.Model):
|
||||
|
||||
class TransactionRule(SharedObject):
|
||||
active = models.BooleanField(default=True)
|
||||
on_update = models.BooleanField(default=False)
|
||||
on_create = models.BooleanField(default=True)
|
||||
@@ -11,6 +13,9 @@ class TransactionRule(models.Model):
|
||||
description = models.TextField(blank=True, null=True, verbose_name=_("Description"))
|
||||
trigger = models.TextField(verbose_name=_("Trigger"))
|
||||
|
||||
objects = SharedObjectManager()
|
||||
all_objects = models.Manager() # Unfiltered manager
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Transaction rule")
|
||||
verbose_name_plural = _("Transaction rules")
|
||||
@@ -297,10 +302,8 @@ class UpdateOrCreateTransactionRuleAction(models.Model):
|
||||
search_query = Q()
|
||||
|
||||
def add_to_query(field_name, value, operator):
|
||||
if isinstance(value, (int, str)):
|
||||
lookup = f"{field_name}__{operator}"
|
||||
return Q(**{lookup: value})
|
||||
return Q()
|
||||
lookup = f"{field_name}__{operator}"
|
||||
return Q(**{lookup: value})
|
||||
|
||||
if self.search_account:
|
||||
value = simple.eval(self.search_account)
|
||||
|
||||
@@ -6,6 +6,7 @@ from apps.transactions.models import (
|
||||
transaction_updated,
|
||||
)
|
||||
from apps.rules.tasks import check_for_transaction_rules
|
||||
from apps.common.middleware.thread_local import get_current_user
|
||||
|
||||
|
||||
@receiver(transaction_created)
|
||||
@@ -20,6 +21,7 @@ def transaction_changed_receiver(sender: Transaction, signal, **kwargs):
|
||||
|
||||
check_for_transaction_rules.defer(
|
||||
instance_id=sender.id,
|
||||
user_id=get_current_user().id,
|
||||
signal=(
|
||||
"transaction_created"
|
||||
if signal is transaction_created
|
||||
|
||||
@@ -4,6 +4,7 @@ from datetime import datetime, date
|
||||
|
||||
from cachalot.api import cachalot_disabled
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.contrib.auth import get_user_model
|
||||
from procrastinate.contrib.django import app
|
||||
from simpleeval import EvalWithCompoundTypes
|
||||
|
||||
@@ -18,6 +19,7 @@ from apps.transactions.models import (
|
||||
TransactionTag,
|
||||
TransactionEntity,
|
||||
)
|
||||
from apps.common.middleware.thread_local import write_current_user, delete_current_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -25,8 +27,12 @@ logger = logging.getLogger(__name__)
|
||||
@app.task(name="check_for_transaction_rules")
|
||||
def check_for_transaction_rules(
|
||||
instance_id: int,
|
||||
user_id: int,
|
||||
signal,
|
||||
):
|
||||
user = get_user_model().objects.get(id=user_id)
|
||||
write_current_user(user)
|
||||
|
||||
try:
|
||||
with cachalot_disabled():
|
||||
instance = Transaction.objects.get(id=instance_id)
|
||||
@@ -91,8 +97,11 @@ def check_for_transaction_rules(
|
||||
"Error while executing 'check_for_transaction_rules' task",
|
||||
exc_info=True,
|
||||
)
|
||||
delete_current_user()
|
||||
raise e
|
||||
|
||||
delete_current_user()
|
||||
|
||||
|
||||
def _get_names(instance):
|
||||
return {
|
||||
@@ -131,14 +140,16 @@ def _process_update_or_create_transaction_action(action, simple_eval):
|
||||
|
||||
# Build search query using the helper method
|
||||
search_query = action.build_search_query(simple_eval)
|
||||
logger.info("Searching transactions using: %s", search_query)
|
||||
|
||||
# Find latest matching transaction or create new
|
||||
if search_query:
|
||||
transaction = (
|
||||
Transaction.objects.filter(search_query).order_by("-date", "-id").first()
|
||||
)
|
||||
transactions = Transaction.objects.filter(search_query).order_by("-date", "-id")
|
||||
transaction = transactions.first()
|
||||
logger.info("Found at least one matching transaction, using latest")
|
||||
else:
|
||||
transaction = None
|
||||
logger.info("No matching transaction found, creating a new transaction")
|
||||
|
||||
if not transaction:
|
||||
transaction = Transaction()
|
||||
|
||||
@@ -37,6 +37,16 @@ urlpatterns = [
|
||||
views.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(
|
||||
"rules/transaction/<int:transaction_rule_id>/transaction-action/add/",
|
||||
views.transaction_rule_action_add,
|
||||
|
||||
@@ -16,6 +16,8 @@ from apps.rules.models import (
|
||||
TransactionRuleAction,
|
||||
UpdateOrCreateTransactionRuleAction,
|
||||
)
|
||||
from apps.common.models import SharedObject
|
||||
from apps.common.forms import SharedObjectForm
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -93,6 +95,16 @@ def transaction_rule_add(request, **kwargs):
|
||||
def transaction_rule_edit(request, transaction_rule_id):
|
||||
transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id)
|
||||
|
||||
if transaction_rule.owner and transaction_rule.owner != request.user:
|
||||
messages.error(request, _("Only the owner can edit this"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
|
||||
if request.method == "POST":
|
||||
form = TransactionRuleForm(request.POST, instance=transaction_rule)
|
||||
if form.is_valid():
|
||||
@@ -134,9 +146,15 @@ def transaction_rule_view(request, transaction_rule_id):
|
||||
def transaction_rule_delete(request, transaction_rule_id):
|
||||
transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id)
|
||||
|
||||
transaction_rule.delete()
|
||||
|
||||
messages.success(request, _("Rule deleted successfully"))
|
||||
if (
|
||||
transaction_rule.owner != request.user
|
||||
and request.user in transaction_rule.shared_with.all()
|
||||
):
|
||||
transaction_rule.shared_with.remove(request.user)
|
||||
messages.success(request, _("Item no longer shared with you"))
|
||||
else:
|
||||
transaction_rule.delete()
|
||||
messages.success(request, _("Rule deleted successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
@@ -146,6 +164,65 @@ def transaction_rule_delete(request, transaction_rule_id):
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def transaction_rule_take_ownership(request, transaction_rule_id):
|
||||
transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id)
|
||||
|
||||
if not transaction_rule.owner:
|
||||
transaction_rule.owner = request.user
|
||||
transaction_rule.visibility = SharedObject.Visibility.private
|
||||
transaction_rule.save()
|
||||
|
||||
messages.success(request, _("Ownership taken successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def transaction_rule_share(request, pk):
|
||||
obj = get_object_or_404(TransactionRule, id=pk)
|
||||
|
||||
if obj.owner and obj.owner != request.user:
|
||||
messages.error(request, _("Only the owner can edit this"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
|
||||
if request.method == "POST":
|
||||
form = SharedObjectForm(request.POST, instance=obj, user=request.user)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Configuration saved successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = SharedObjectForm(instance=obj, user=request.user)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"rules/fragments/share.html",
|
||||
{"form": form, "object": obj},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
|
||||
@@ -8,13 +8,14 @@ from apps.transactions.models import (
|
||||
RecurringTransaction,
|
||||
TransactionEntity,
|
||||
)
|
||||
from apps.common.admin import SharedObjectModelAdmin
|
||||
|
||||
|
||||
@admin.register(Transaction)
|
||||
class TransactionModelAdmin(admin.ModelAdmin):
|
||||
def get_queryset(self, request):
|
||||
# Use the all_objects manager to show all transactions, including deleted ones
|
||||
return self.model.all_objects.all()
|
||||
return self.model.userless_all_objects.all()
|
||||
|
||||
list_filter = ["deleted", "type", "is_paid", "date", "account"]
|
||||
|
||||
@@ -48,19 +49,29 @@ class TransactionInline(admin.TabularInline):
|
||||
|
||||
|
||||
@admin.register(InstallmentPlan)
|
||||
class InstallmentPlanAdmin(admin.ModelAdmin):
|
||||
class InstallmentPlanAdmin(SharedObjectModelAdmin):
|
||||
inlines = [
|
||||
TransactionInline,
|
||||
]
|
||||
|
||||
|
||||
@admin.register(RecurringTransaction)
|
||||
class RecurringTransactionAdmin(admin.ModelAdmin):
|
||||
class RecurringTransactionAdmin(SharedObjectModelAdmin):
|
||||
inlines = [
|
||||
TransactionInline,
|
||||
]
|
||||
|
||||
|
||||
admin.site.register(TransactionCategory)
|
||||
admin.site.register(TransactionTag)
|
||||
admin.site.register(TransactionEntity)
|
||||
@admin.register(TransactionCategory)
|
||||
class TransactionCategoryModelAdmin(SharedObjectModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
@admin.register(TransactionTag)
|
||||
class TransactionTagModelAdmin(SharedObjectModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
@admin.register(TransactionEntity)
|
||||
class TransactionEntityModelAdmin(SharedObjectModelAdmin):
|
||||
pass
|
||||
|
||||
@@ -184,3 +184,8 @@ class TransactionsFilter(django_filters.FilterSet):
|
||||
self.form.fields["from_amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||
self.form.fields["date_start"].widget = AirDatePickerInput()
|
||||
self.form.fields["date_end"].widget = AirDatePickerInput()
|
||||
|
||||
self.form.fields["account"].queryset = Account.objects.all()
|
||||
self.form.fields["category"].queryset = TransactionCategory.objects.all()
|
||||
self.form.fields["tags"].queryset = TransactionTag.objects.all()
|
||||
self.form.fields["entities"].queryset = TransactionEntity.objects.all()
|
||||
|
||||
@@ -29,6 +29,7 @@ from apps.transactions.models import (
|
||||
RecurringTransaction,
|
||||
TransactionEntity,
|
||||
)
|
||||
from apps.common.middleware.thread_local import get_current_user
|
||||
|
||||
|
||||
class TransactionForm(forms.ModelForm):
|
||||
@@ -63,7 +64,9 @@ class TransactionForm(forms.ModelForm):
|
||||
date = forms.DateField(label=_("Date"))
|
||||
|
||||
reference_date = forms.DateField(
|
||||
widget=AirMonthYearPickerInput(), label=_("Reference Date"), required=False
|
||||
widget=AirMonthYearPickerInput(),
|
||||
label=_("Reference Date"),
|
||||
required=False,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -92,20 +95,30 @@ class TransactionForm(forms.ModelForm):
|
||||
# if editing a transaction display non-archived items and it's own item even if it's archived
|
||||
if self.instance.id:
|
||||
self.fields["account"].queryset = Account.objects.filter(
|
||||
Q(is_archived=False) | Q(transactions=self.instance.id)
|
||||
).distinct()
|
||||
Q(is_archived=False) | Q(transactions=self.instance.id),
|
||||
)
|
||||
|
||||
self.fields["category"].queryset = TransactionCategory.objects.filter(
|
||||
Q(active=True) | Q(transaction=self.instance.id)
|
||||
).distinct()
|
||||
)
|
||||
|
||||
self.fields["tags"].queryset = TransactionTag.objects.filter(
|
||||
Q(active=True) | Q(transaction=self.instance.id)
|
||||
).distinct()
|
||||
)
|
||||
|
||||
self.fields["entities"].queryset = TransactionEntity.objects.filter(
|
||||
Q(active=True) | Q(transactions=self.instance.id)
|
||||
).distinct()
|
||||
)
|
||||
else:
|
||||
self.fields["account"].queryset = Account.objects.filter(
|
||||
is_archived=False,
|
||||
)
|
||||
|
||||
self.fields["category"].queryset = TransactionCategory.objects.filter(
|
||||
active=True
|
||||
)
|
||||
self.fields["tags"].queryset = TransactionTag.objects.filter(active=True)
|
||||
self.fields["entities"].queryset = TransactionEntity.objects.all()
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
@@ -176,7 +189,6 @@ class TransactionForm(forms.ModelForm):
|
||||
),
|
||||
)
|
||||
|
||||
self.fields["reference_date"].required = False
|
||||
self.fields["date"].widget = AirDatePickerInput(clear_button=False)
|
||||
|
||||
if self.instance and self.instance.pk:
|
||||
@@ -404,6 +416,24 @@ class TransferForm(forms.Form):
|
||||
self.fields["to_amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||
self.fields["date"].widget = AirDatePickerInput(clear_button=False)
|
||||
|
||||
self.fields["from_account"].queryset = Account.objects.filter(
|
||||
is_archived=False,
|
||||
)
|
||||
|
||||
self.fields["from_category"].queryset = TransactionCategory.objects.filter(
|
||||
active=True
|
||||
)
|
||||
self.fields["from_tags"].queryset = TransactionTag.objects.filter(active=True)
|
||||
|
||||
self.fields["to_account"].queryset = Account.objects.filter(
|
||||
is_archived=False,
|
||||
)
|
||||
|
||||
self.fields["to_category"].queryset = TransactionCategory.objects.filter(
|
||||
active=True
|
||||
)
|
||||
self.fields["to_tags"].queryset = TransactionTag.objects.filter(active=True)
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
from_account = cleaned_data.get("from_account")
|
||||
@@ -535,6 +565,18 @@ class InstallmentPlanForm(forms.ModelForm):
|
||||
self.fields["entities"].queryset = TransactionEntity.objects.filter(
|
||||
Q(active=True) | Q(installmentplan=self.instance.id)
|
||||
).distinct()
|
||||
else:
|
||||
self.fields["account"].queryset = Account.objects.filter(is_archived=False)
|
||||
|
||||
self.fields["category"].queryset = TransactionCategory.objects.filter(
|
||||
active=True
|
||||
)
|
||||
|
||||
self.fields["tags"].queryset = TransactionTag.objects.filter(active=True)
|
||||
|
||||
self.fields["entities"].queryset = TransactionEntity.objects.filter(
|
||||
active=True
|
||||
)
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
@@ -780,6 +822,18 @@ class RecurringTransactionForm(forms.ModelForm):
|
||||
self.fields["entities"].queryset = TransactionEntity.objects.filter(
|
||||
Q(active=True) | Q(recurringtransaction=self.instance.id)
|
||||
).distinct()
|
||||
else:
|
||||
self.fields["account"].queryset = Account.objects.filter(is_archived=False)
|
||||
|
||||
self.fields["category"].queryset = TransactionCategory.objects.filter(
|
||||
active=True
|
||||
)
|
||||
|
||||
self.fields["tags"].queryset = TransactionTag.objects.filter(active=True)
|
||||
|
||||
self.fields["entities"].queryset = TransactionEntity.objects.filter(
|
||||
active=True
|
||||
)
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_method = "post"
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-05 04:19
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.db.models.manager
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('transactions', '0033_transaction_internal_id'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelManagers(
|
||||
name='installmentplan',
|
||||
managers=[
|
||||
('all_objects', django.db.models.manager.Manager()),
|
||||
],
|
||||
),
|
||||
migrations.AlterModelManagers(
|
||||
name='recurringtransaction',
|
||||
managers=[
|
||||
('all_objects', django.db.models.manager.Manager()),
|
||||
],
|
||||
),
|
||||
migrations.AlterModelManagers(
|
||||
name='transactioncategory',
|
||||
managers=[
|
||||
('all_objects', django.db.models.manager.Manager()),
|
||||
],
|
||||
),
|
||||
migrations.AlterModelManagers(
|
||||
name='transactionentity',
|
||||
managers=[
|
||||
('all_objects', django.db.models.manager.Manager()),
|
||||
],
|
||||
),
|
||||
migrations.AlterModelManagers(
|
||||
name='transactiontag',
|
||||
managers=[
|
||||
('all_objects', django.db.models.manager.Manager()),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='transactioncategory',
|
||||
name='owner',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='owned_transaction_categories', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='transactionentity',
|
||||
name='owner',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='owned_transaction_entities', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='transactiontag',
|
||||
name='owner',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='owned_transaction_tags', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,37 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-05 04:51
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('transactions', '0034_alter_installmentplan_managers_and_more'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='transactioncategory',
|
||||
name='name',
|
||||
field=models.CharField(max_length=255, verbose_name='Name'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='transactiontag',
|
||||
name='name',
|
||||
field=models.CharField(max_length=255, verbose_name='Name'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='transactioncategory',
|
||||
unique_together={('owner', 'name')},
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='transactionentity',
|
||||
unique_together={('owner', 'name')},
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='transactiontag',
|
||||
unique_together={('owner', 'name')},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,76 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-06 00:10
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('transactions', '0035_alter_transactioncategory_name_and_more'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelManagers(
|
||||
name='transactioncategory',
|
||||
managers=[
|
||||
],
|
||||
),
|
||||
migrations.AlterModelManagers(
|
||||
name='transactionentity',
|
||||
managers=[
|
||||
],
|
||||
),
|
||||
migrations.AlterModelManagers(
|
||||
name='transactiontag',
|
||||
managers=[
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='transactioncategory',
|
||||
name='shared_with',
|
||||
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='transactioncategory',
|
||||
name='visibility',
|
||||
field=models.CharField(choices=[('private', 'Private'), ('shared', 'Shared'), ('public', 'Public')], default='private', max_length=10),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='transactionentity',
|
||||
name='shared_with',
|
||||
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='transactionentity',
|
||||
name='visibility',
|
||||
field=models.CharField(choices=[('private', 'Private'), ('shared', 'Shared'), ('public', 'Public')], default='private', max_length=10),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='transactiontag',
|
||||
name='shared_with',
|
||||
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='transactiontag',
|
||||
name='visibility',
|
||||
field=models.CharField(choices=[('private', 'Private'), ('shared', 'Shared'), ('public', 'Public')], default='private', max_length=10),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='transactioncategory',
|
||||
name='owner',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='transactionentity',
|
||||
name='owner',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='transactiontag',
|
||||
name='owner',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-06 01:47
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('transactions', '0036_alter_transactioncategory_managers_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='transactioncategory',
|
||||
name='visibility',
|
||||
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='transactionentity',
|
||||
name='visibility',
|
||||
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='transactiontag',
|
||||
name='visibility',
|
||||
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10),
|
||||
),
|
||||
]
|
||||
21
app/apps/transactions/migrations/0038_transaction_owner.py
Normal file
21
app/apps/transactions/migrations/0038_transaction_owner.py
Normal file
@@ -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.currencies.utils.convert import convert
|
||||
from apps.transactions.validators import validate_decimal_places, validate_non_negative
|
||||
from apps.common.middleware.thread_local import get_current_user
|
||||
from apps.common.models import SharedObject, SharedObjectManager, OwnedObject
|
||||
|
||||
logger = logging.getLogger()
|
||||
|
||||
@@ -93,10 +95,40 @@ class SoftDeleteQuerySet(models.QuerySet):
|
||||
class SoftDeleteManager(models.Manager):
|
||||
def get_queryset(self):
|
||||
qs = SoftDeleteQuerySet(self.model, using=self._db)
|
||||
return qs.filter(deleted=False)
|
||||
user = get_current_user()
|
||||
if user and not user.is_anonymous:
|
||||
return qs.filter(
|
||||
Q(account__visibility="public")
|
||||
| Q(account__owner=user)
|
||||
| Q(account__shared_with=user)
|
||||
| Q(account__visibility="private", account__owner=None),
|
||||
deleted=False,
|
||||
).distinct()
|
||||
else:
|
||||
return qs.filter(
|
||||
deleted=False,
|
||||
)
|
||||
|
||||
|
||||
class AllObjectsManager(models.Manager):
|
||||
def get_queryset(self):
|
||||
user = get_current_user()
|
||||
if user and not user.is_anonymous:
|
||||
return (
|
||||
SoftDeleteQuerySet(self.model, using=self._db)
|
||||
.filter(
|
||||
Q(account__visibility="public")
|
||||
| Q(account__owner=user)
|
||||
| Q(account__shared_with=user)
|
||||
| Q(account__visibility="private", account__owner=None),
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
else:
|
||||
return SoftDeleteQuerySet(self.model, using=self._db)
|
||||
|
||||
|
||||
class UserlessAllObjectsManager(models.Manager):
|
||||
def get_queryset(self):
|
||||
return SoftDeleteQuerySet(self.model, using=self._db)
|
||||
|
||||
@@ -104,11 +136,45 @@ class AllObjectsManager(models.Manager):
|
||||
class DeletedObjectsManager(models.Manager):
|
||||
def get_queryset(self):
|
||||
qs = SoftDeleteQuerySet(self.model, using=self._db)
|
||||
return qs.filter(deleted=True)
|
||||
user = get_current_user()
|
||||
if user and not user.is_anonymous:
|
||||
return qs.filter(
|
||||
Q(account__visibility="public")
|
||||
| Q(account__owner=user)
|
||||
| Q(account__shared_with=user)
|
||||
| Q(account__visibility="private", account__owner=None),
|
||||
deleted=True,
|
||||
).distinct()
|
||||
else:
|
||||
return qs.filter(
|
||||
deleted=True,
|
||||
)
|
||||
|
||||
|
||||
class TransactionCategory(models.Model):
|
||||
name = models.CharField(max_length=255, verbose_name=_("Name"), unique=True)
|
||||
class UserlessDeletedObjectsManager(models.Manager):
|
||||
def get_queryset(self):
|
||||
qs = SoftDeleteQuerySet(self.model, using=self._db)
|
||||
return qs.filter(
|
||||
deleted=True,
|
||||
)
|
||||
|
||||
|
||||
class GenericAccountOwnerManager(models.Manager):
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
user = get_current_user()
|
||||
if user and not user.is_anonymous:
|
||||
return queryset.filter(
|
||||
Q(account__visibility="public")
|
||||
| Q(account__owner=user)
|
||||
| Q(account__shared_with=user)
|
||||
| Q(account__visibility="private", account__owner=None),
|
||||
).distinct()
|
||||
return queryset.none()
|
||||
|
||||
|
||||
class TransactionCategory(SharedObject):
|
||||
name = models.CharField(max_length=255, verbose_name=_("Name"))
|
||||
mute = models.BooleanField(default=False, verbose_name=_("Mute"))
|
||||
active = models.BooleanField(
|
||||
default=True,
|
||||
@@ -118,17 +184,21 @@ class TransactionCategory(models.Model):
|
||||
),
|
||||
)
|
||||
|
||||
objects = SharedObjectManager()
|
||||
all_objects = models.Manager() # Unfiltered manager
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Transaction Category")
|
||||
verbose_name_plural = _("Transaction Categories")
|
||||
db_table = "t_categories"
|
||||
unique_together = (("owner", "name"),)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class TransactionTag(models.Model):
|
||||
name = models.CharField(max_length=255, verbose_name=_("Name"), unique=True)
|
||||
class TransactionTag(SharedObject):
|
||||
name = models.CharField(max_length=255, verbose_name=_("Name"))
|
||||
active = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name=_("Active"),
|
||||
@@ -137,16 +207,20 @@ class TransactionTag(models.Model):
|
||||
),
|
||||
)
|
||||
|
||||
objects = SharedObjectManager()
|
||||
all_objects = models.Manager() # Unfiltered manager
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Transaction Tags")
|
||||
verbose_name_plural = _("Transaction Tags")
|
||||
db_table = "tags"
|
||||
unique_together = (("owner", "name"),)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class TransactionEntity(models.Model):
|
||||
class TransactionEntity(SharedObject):
|
||||
name = models.CharField(max_length=255, verbose_name=_("Name"))
|
||||
active = models.BooleanField(
|
||||
default=True,
|
||||
@@ -156,16 +230,20 @@ class TransactionEntity(models.Model):
|
||||
),
|
||||
)
|
||||
|
||||
objects = SharedObjectManager()
|
||||
all_objects = models.Manager() # Unfiltered manager
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Entity")
|
||||
verbose_name_plural = _("Entities")
|
||||
db_table = "entities"
|
||||
unique_together = (("owner", "name"),)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class Transaction(models.Model):
|
||||
class Transaction(OwnedObject):
|
||||
class Type(models.TextChoices):
|
||||
INCOME = "IN", _("Income")
|
||||
EXPENSE = "EX", _("Expense")
|
||||
@@ -249,7 +327,11 @@ class Transaction(models.Model):
|
||||
|
||||
objects = SoftDeleteManager.from_queryset(SoftDeleteQuerySet)()
|
||||
all_objects = AllObjectsManager.from_queryset(SoftDeleteQuerySet)()
|
||||
userless_all_objects = UserlessAllObjectsManager.from_queryset(SoftDeleteQuerySet)()
|
||||
deleted_objects = DeletedObjectsManager.from_queryset(SoftDeleteQuerySet)()
|
||||
userless_deleted_objects = UserlessDeletedObjectsManager.from_queryset(
|
||||
SoftDeleteQuerySet
|
||||
)()
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Transaction")
|
||||
@@ -386,6 +468,9 @@ class InstallmentPlan(models.Model):
|
||||
|
||||
notes = models.TextField(blank=True, verbose_name=_("Notes"))
|
||||
|
||||
all_objects = models.Manager() # Unfiltered manager
|
||||
objects = GenericAccountOwnerManager() # Default filtered manager
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Installment Plan")
|
||||
verbose_name_plural = _("Installment Plans")
|
||||
@@ -440,7 +525,7 @@ class InstallmentPlan(models.Model):
|
||||
|
||||
transaction_date = self.start_date + delta
|
||||
transaction_reference_date = (self.reference_date + delta).replace(day=1)
|
||||
new_transaction = Transaction.objects.create(
|
||||
new_transaction = Transaction.all_objects.create(
|
||||
account=self.account,
|
||||
type=self.type,
|
||||
date=transaction_date,
|
||||
@@ -500,7 +585,7 @@ class InstallmentPlan(models.Model):
|
||||
existing_transaction.entities.set(self.entities.all())
|
||||
else:
|
||||
# If the transaction doesn't exist, create a new one
|
||||
new_transaction = Transaction.objects.create(
|
||||
new_transaction = Transaction.all_objects.create(
|
||||
account=self.account,
|
||||
type=self.type,
|
||||
date=transaction_date,
|
||||
@@ -587,6 +672,9 @@ class RecurringTransaction(models.Model):
|
||||
verbose_name=_("Last Generated Reference Date"), null=True, blank=True
|
||||
)
|
||||
|
||||
all_objects = models.Manager() # Unfiltered manager
|
||||
objects = GenericAccountOwnerManager() # Default filtered manager
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Recurring Transaction")
|
||||
verbose_name_plural = _("Recurring Transactions")
|
||||
@@ -624,7 +712,7 @@ class RecurringTransaction(models.Model):
|
||||
)
|
||||
|
||||
def create_transaction(self, date, reference_date):
|
||||
created_transaction = Transaction.objects.create(
|
||||
created_transaction = Transaction.all_objects.create(
|
||||
account=self.account,
|
||||
type=self.type,
|
||||
date=date,
|
||||
|
||||
@@ -34,7 +34,7 @@ def cleanup_deleted_transactions(timestamp=None):
|
||||
|
||||
if not settings.ENABLE_SOFT_DELETE:
|
||||
# Hard delete all soft-deleted transactions
|
||||
deleted_count, _ = Transaction.deleted_objects.all().hard_delete()
|
||||
deleted_count, _ = Transaction.userless_deleted_objects.all().hard_delete()
|
||||
return (
|
||||
f"Hard deleted {deleted_count} transactions (soft deletion disabled)."
|
||||
)
|
||||
@@ -47,7 +47,9 @@ def cleanup_deleted_transactions(timestamp=None):
|
||||
invalidate()
|
||||
|
||||
# Hard delete soft-deleted transactions older than the cutoff date
|
||||
old_transactions = Transaction.deleted_objects.filter(deleted_at__lt=cutoff_date)
|
||||
old_transactions = Transaction.userless_deleted_objects.filter(
|
||||
deleted_at__lt=cutoff_date
|
||||
)
|
||||
deleted_count, _ = old_transactions.hard_delete()
|
||||
|
||||
return f"Hard deleted {deleted_count} objects older than {settings.KEEP_DELETED_TRANSACTIONS_FOR} days."
|
||||
|
||||
@@ -131,6 +131,16 @@ urlpatterns = [
|
||||
views.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/list/", views.entities_list, name="entities_list"),
|
||||
path(
|
||||
@@ -154,6 +164,16 @@ urlpatterns = [
|
||||
views.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/list/", views.categories_list, name="categories_list"),
|
||||
path(
|
||||
@@ -177,6 +197,16 @@ urlpatterns = [
|
||||
views.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(
|
||||
"installment-plans/",
|
||||
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.transactions.forms import TransactionCategoryForm
|
||||
from apps.transactions.models import TransactionCategory
|
||||
from apps.common.models import SharedObject
|
||||
from apps.common.forms import SharedObjectForm
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -85,6 +87,16 @@ def category_add(request, **kwargs):
|
||||
def category_edit(request, category_id):
|
||||
category = get_object_or_404(TransactionCategory, id=category_id)
|
||||
|
||||
if category.owner and category.owner != request.user:
|
||||
messages.error(request, _("Only the owner can edit this"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
|
||||
if request.method == "POST":
|
||||
form = TransactionCategoryForm(request.POST, instance=category)
|
||||
if form.is_valid():
|
||||
@@ -107,15 +119,77 @@ def category_edit(request, category_id):
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def category_share(request, pk):
|
||||
obj = get_object_or_404(TransactionCategory, id=pk)
|
||||
|
||||
if obj.owner and obj.owner != request.user:
|
||||
messages.error(request, _("Only the owner can edit this"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
|
||||
if request.method == "POST":
|
||||
form = SharedObjectForm(request.POST, instance=obj, user=request.user)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Configuration saved successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = SharedObjectForm(instance=obj, user=request.user)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"categories/fragments/share.html",
|
||||
{"form": form, "object": obj},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["DELETE"])
|
||||
def category_delete(request, category_id):
|
||||
category = get_object_or_404(TransactionCategory, id=category_id)
|
||||
|
||||
category.delete()
|
||||
|
||||
messages.success(request, _("Category deleted successfully"))
|
||||
if category.owner != request.user and request.user in category.shared_with.all():
|
||||
category.shared_with.remove(request.user)
|
||||
messages.success(request, _("Item no longer shared with you"))
|
||||
else:
|
||||
category.delete()
|
||||
messages.success(request, _("Category deleted successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def category_take_ownership(request, category_id):
|
||||
category = get_object_or_404(TransactionCategory, id=category_id)
|
||||
|
||||
if not category.owner:
|
||||
category.owner = request.user
|
||||
category.visibility = SharedObject.Visibility.private
|
||||
category.save()
|
||||
|
||||
messages.success(request, _("Ownership taken successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
|
||||
@@ -8,6 +8,8 @@ from django.views.decorators.http import require_http_methods
|
||||
from apps.common.decorators.htmx import only_htmx
|
||||
from apps.transactions.forms import TransactionEntityForm
|
||||
from apps.transactions.models import TransactionEntity
|
||||
from apps.common.models import SharedObject
|
||||
from apps.common.forms import SharedObjectForm
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -85,6 +87,16 @@ def entity_add(request, **kwargs):
|
||||
def entity_edit(request, entity_id):
|
||||
entity = get_object_or_404(TransactionEntity, id=entity_id)
|
||||
|
||||
if entity.owner and entity.owner != request.user:
|
||||
messages.error(request, _("Only the owner can edit this"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
|
||||
if request.method == "POST":
|
||||
form = TransactionEntityForm(request.POST, instance=entity)
|
||||
if form.is_valid():
|
||||
@@ -113,9 +125,12 @@ def entity_edit(request, entity_id):
|
||||
def entity_delete(request, entity_id):
|
||||
entity = get_object_or_404(TransactionEntity, id=entity_id)
|
||||
|
||||
entity.delete()
|
||||
|
||||
messages.success(request, _("Entity deleted successfully"))
|
||||
if entity.owner != request.user and request.user in entity.shared_with.all():
|
||||
entity.shared_with.remove(request.user)
|
||||
messages.success(request, _("Item no longer shared with you"))
|
||||
else:
|
||||
entity.delete()
|
||||
messages.success(request, _("Entity deleted successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
@@ -123,3 +138,62 @@ def entity_delete(request, entity_id):
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def entity_take_ownership(request, entity_id):
|
||||
entity = get_object_or_404(TransactionEntity, id=entity_id)
|
||||
|
||||
if not entity.owner:
|
||||
entity.owner = request.user
|
||||
entity.visibility = SharedObject.Visibility.private
|
||||
entity.save()
|
||||
|
||||
messages.success(request, _("Ownership taken successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def entity_share(request, pk):
|
||||
obj = get_object_or_404(TransactionEntity, id=pk)
|
||||
|
||||
if obj.owner and obj.owner != request.user:
|
||||
messages.error(request, _("Only the owner can edit this"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
|
||||
if request.method == "POST":
|
||||
form = SharedObjectForm(request.POST, instance=obj, user=request.user)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Configuration saved successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = SharedObjectForm(instance=obj, user=request.user)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"entities/fragments/share.html",
|
||||
{"form": form, "object": obj},
|
||||
)
|
||||
|
||||
@@ -8,6 +8,8 @@ from django.views.decorators.http import require_http_methods
|
||||
from apps.common.decorators.htmx import only_htmx
|
||||
from apps.transactions.forms import TransactionTagForm
|
||||
from apps.transactions.models import TransactionTag
|
||||
from apps.common.models import SharedObject
|
||||
from apps.common.forms import SharedObjectForm
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -85,6 +87,16 @@ def tag_add(request, **kwargs):
|
||||
def tag_edit(request, tag_id):
|
||||
tag = get_object_or_404(TransactionTag, id=tag_id)
|
||||
|
||||
if tag.owner and tag.owner != request.user:
|
||||
messages.error(request, _("Only the owner can edit this"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
|
||||
if request.method == "POST":
|
||||
form = TransactionTagForm(request.POST, instance=tag)
|
||||
if form.is_valid():
|
||||
@@ -113,9 +125,12 @@ def tag_edit(request, tag_id):
|
||||
def tag_delete(request, tag_id):
|
||||
tag = get_object_or_404(TransactionTag, id=tag_id)
|
||||
|
||||
tag.delete()
|
||||
|
||||
messages.success(request, _("Tag deleted successfully"))
|
||||
if tag.owner != request.user and request.user in tag.shared_with.all():
|
||||
tag.shared_with.remove(request.user)
|
||||
messages.success(request, _("Item no longer shared with you"))
|
||||
else:
|
||||
tag.delete()
|
||||
messages.success(request, _("Tag deleted successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
@@ -123,3 +138,62 @@ def tag_delete(request, tag_id):
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def tag_take_ownership(request, tag_id):
|
||||
tag = get_object_or_404(TransactionTag, id=tag_id)
|
||||
|
||||
if not tag.owner:
|
||||
tag.owner = request.user
|
||||
tag.visibility = SharedObject.Visibility.private
|
||||
tag.save()
|
||||
|
||||
messages.success(request, _("Ownership taken successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def tag_share(request, pk):
|
||||
obj = get_object_or_404(TransactionTag, id=pk)
|
||||
|
||||
if obj.owner and obj.owner != request.user:
|
||||
messages.error(request, _("Only the owner can edit this"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
|
||||
if request.method == "POST":
|
||||
form = SharedObjectForm(request.POST, instance=obj, user=request.user)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Configuration saved successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = SharedObjectForm(instance=obj, user=request.user)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"tags/fragments/share.html",
|
||||
{"form": form, "object": obj},
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user