From b05c3e67607b0b66ae3451710f01ed53fc8f6a3f Mon Sep 17 00:00:00 2001 From: "Edgar P. Burkhart" Date: Sat, 22 Apr 2023 11:16:42 +0200 Subject: [PATCH] Split backend in applications --- nummi/account/__init__.py | 0 nummi/account/apps.py | 6 + nummi/account/forms.py | 13 + nummi/account/migrations/0001_initial.py | 65 ++++ nummi/account/migrations/__init__.py | 0 nummi/account/models.py | 49 +++ nummi/account/urls.py | 33 ++ nummi/account/views.py | 68 ++++ nummi/category/__init__.py | 0 nummi/category/apps.py | 6 + nummi/category/forms.py | 13 + nummi/category/migrations/0001_initial.py | 64 ++++ nummi/category/migrations/__init__.py | 0 nummi/category/models.py | 33 ++ nummi/category/urls.py | 21 + nummi/category/views.py | 50 +++ nummi/main/forms.py | 101 ----- nummi/main/migrations/0001_v1.py | 6 +- nummi/main/migrations/0002_segmentation.py | 32 ++ nummi/main/models.py | 287 +------------- nummi/main/urls.py | 88 ----- nummi/main/views.py | 384 +------------------ nummi/media/utils.py | 10 + nummi/nummi/settings.py | 8 +- nummi/search/__init__.py | 0 nummi/search/apps.py | 6 + nummi/search/forms.py | 7 + nummi/search/migrations/__init__.py | 0 nummi/search/urls.py | 8 + nummi/search/views.py | 44 +++ nummi/statement/__init__.py | 0 nummi/statement/apps.py | 6 + nummi/statement/forms.py | 41 ++ nummi/statement/migrations/0001_initial.py | 127 ++++++ nummi/statement/migrations/__init__.py | 0 nummi/statement/models.py | 90 +++++ nummi/statement/urls.py | 24 ++ nummi/statement/views.py | 97 +++++ nummi/transaction/__init__.py | 0 nummi/transaction/apps.py | 6 + nummi/transaction/forms.py | 44 +++ nummi/transaction/migrations/0001_initial.py | 175 +++++++++ nummi/transaction/migrations/__init__.py | 0 nummi/transaction/models.py | 128 +++++++ nummi/transaction/urls.py | 34 ++ nummi/{main => transaction}/utils.py | 11 - nummi/transaction/views.py | 144 +++++++ 47 files changed, 1463 insertions(+), 866 deletions(-) create mode 100644 nummi/account/__init__.py create mode 100644 nummi/account/apps.py create mode 100644 nummi/account/forms.py create mode 100644 nummi/account/migrations/0001_initial.py create mode 100644 nummi/account/migrations/__init__.py create mode 100644 nummi/account/models.py create mode 100644 nummi/account/urls.py create mode 100644 nummi/account/views.py create mode 100644 nummi/category/__init__.py create mode 100644 nummi/category/apps.py create mode 100644 nummi/category/forms.py create mode 100644 nummi/category/migrations/0001_initial.py create mode 100644 nummi/category/migrations/__init__.py create mode 100644 nummi/category/models.py create mode 100644 nummi/category/urls.py create mode 100644 nummi/category/views.py create mode 100644 nummi/main/migrations/0002_segmentation.py create mode 100644 nummi/media/utils.py create mode 100644 nummi/search/__init__.py create mode 100644 nummi/search/apps.py create mode 100644 nummi/search/forms.py create mode 100644 nummi/search/migrations/__init__.py create mode 100644 nummi/search/urls.py create mode 100644 nummi/search/views.py create mode 100644 nummi/statement/__init__.py create mode 100644 nummi/statement/apps.py create mode 100644 nummi/statement/forms.py create mode 100644 nummi/statement/migrations/0001_initial.py create mode 100644 nummi/statement/migrations/__init__.py create mode 100644 nummi/statement/models.py create mode 100644 nummi/statement/urls.py create mode 100644 nummi/statement/views.py create mode 100644 nummi/transaction/__init__.py create mode 100644 nummi/transaction/apps.py create mode 100644 nummi/transaction/forms.py create mode 100644 nummi/transaction/migrations/0001_initial.py create mode 100644 nummi/transaction/migrations/__init__.py create mode 100644 nummi/transaction/models.py create mode 100644 nummi/transaction/urls.py rename nummi/{main => transaction}/utils.py (87%) create mode 100644 nummi/transaction/views.py diff --git a/nummi/account/__init__.py b/nummi/account/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nummi/account/apps.py b/nummi/account/apps.py new file mode 100644 index 0000000..2c684a9 --- /dev/null +++ b/nummi/account/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "account" diff --git a/nummi/account/forms.py b/nummi/account/forms.py new file mode 100644 index 0000000..3225ab0 --- /dev/null +++ b/nummi/account/forms.py @@ -0,0 +1,13 @@ +from main.forms import NummiForm + +from .models import Account + + +class AccountForm(NummiForm): + class Meta: + model = Account + fields = [ + "name", + "icon", + "default", + ] diff --git a/nummi/account/migrations/0001_initial.py b/nummi/account/migrations/0001_initial.py new file mode 100644 index 0000000..bb9c8c8 --- /dev/null +++ b/nummi/account/migrations/0001_initial.py @@ -0,0 +1,65 @@ +# Generated by Django 4.1.4 on 2023-04-22 09:01 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("main", "0002_segmentation"), + ] + + state_operations = [ + migrations.CreateModel( + name="Account", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "name", + models.CharField( + default="Account", max_length=64, verbose_name="Name" + ), + ), + ( + "icon", + models.SlugField( + default="bank", max_length=24, verbose_name="Icon" + ), + ), + ("default", models.BooleanField(default=False, verbose_name="Default")), + ( + "user", + models.ForeignKey( + editable=False, + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + verbose_name="User", + ), + ), + ], + options={ + "verbose_name": "Account", + "verbose_name_plural": "Accounts", + "ordering": ["-default", "name"], + "db_table": "account_account", + }, + ), + ] + + operations = [ + migrations.SeparateDatabaseAndState(state_operations=state_operations) + ] diff --git a/nummi/account/migrations/__init__.py b/nummi/account/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nummi/account/models.py b/nummi/account/models.py new file mode 100644 index 0000000..594f5e9 --- /dev/null +++ b/nummi/account/models.py @@ -0,0 +1,49 @@ +from uuid import uuid4 + +from django.db import models +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ +from main.models import UserModel + + +class Account(UserModel): + id = models.UUIDField(primary_key=True, default=uuid4, editable=False) + name = models.CharField(max_length=64, default=_("Account"), verbose_name=_("Name")) + icon = models.SlugField( + max_length=24, + default="bank", + verbose_name=_("Icon"), + ) + default = models.BooleanField(default=False, verbose_name=_("Default")) + + def save(self, *args, **kwargs): + if self.default: + for ac in Account.objects.filter(user=self.user, default=True): + ac.default = False + ac.save() + super().save(*args, **kwargs) + + def __str__(self): + return str(self.name) + + def get_absolute_url(self): + return reverse("account", kwargs={"pk": self.pk}) + + def get_delete_url(self): + return reverse("del_account", kwargs={"pk": self.pk}) + + class Meta: + ordering = ["-default", "name"] + verbose_name = _("Account") + verbose_name_plural = _("Accounts") + + +class AccountModel(UserModel): + account = models.ForeignKey( + Account, + on_delete=models.CASCADE, + verbose_name=_("Account"), + ) + + class Meta: + abstract = True diff --git a/nummi/account/urls.py b/nummi/account/urls.py new file mode 100644 index 0000000..b5158ea --- /dev/null +++ b/nummi/account/urls.py @@ -0,0 +1,33 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path("account", views.AccountCreateView.as_view(), name="new_account"), + path("account/", views.AccountUpdateView.as_view(), name="account"), + path( + "account//transactions", + views.AccountTListView.as_view(), + name="account_transactions", + ), + path( + "account//statements", + views.AccountSListView.as_view(), + name="account_statements", + ), + path( + "account//statement", + views.StatementCreateView.as_view(), + name="new_statement", + ), + path( + "account//delete", + views.AccountDeleteView.as_view(), + name="del_account", + ), + path( + "account//history//", + views.TransactionMonthView.as_view(), + name="transaction_month", + ), +] diff --git a/nummi/account/views.py b/nummi/account/views.py new file mode 100644 index 0000000..576d153 --- /dev/null +++ b/nummi/account/views.py @@ -0,0 +1,68 @@ +from django.urls import reverse_lazy +from main.views import NummiCreateView, NummiDeleteView, NummiUpdateView +from snapshot.views import SnapshotListView +from transaction.utils import history +from transaction.views import TransactionListView + +from .forms import AccountForm +from .models import Account + + +class AccountCreateView(NummiCreateView): + model = Account + form_class = AccountForm + template_name = "main/form/account.html" + + +class AccountUpdateView(NummiUpdateView): + model = Account + form_class = AccountForm + template_name = "main/form/account.html" + + def get_context_data(self, **kwargs): + _max = 8 + data = super().get_context_data(**kwargs) + account = data["form"].instance + + _transactions = account.transaction_set.all() + if _transactions.count() > _max: + data["transactions_url"] = reverse_lazy( + "account_transactions", args=(account.pk,) + ) + _snapshots = account.snapshot_set.all() + if _snapshots.count() > _max: + data["snapshots_url"] = reverse_lazy( + "account_snapshots", args=(account.pk,) + ) + + return data | { + "transactions": _transactions[:8], + "new_snapshot_url": reverse_lazy( + "new_snapshot", kwargs={"account": account.pk} + ), + "snapshots": _snapshots[:8], + "history": history(account.transaction_set), + } + + +class AccountDeleteView(NummiDeleteView): + model = Account + + +class AccountMixin: + def get_queryset(self): + return super().get_queryset().filter(account=self.kwargs.get("pk")) + + def get_context_data(self, **kwargs): + return super().get_context_data(**kwargs) | { + "object": Account.objects.get(pk=self.kwargs.get("pk")), + "account": True, + } + + +class AccountTListView(AccountMixin, TransactionListView): + pass + + +class AccountSListView(AccountMixin, SnapshotListView): + pass diff --git a/nummi/category/__init__.py b/nummi/category/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nummi/category/apps.py b/nummi/category/apps.py new file mode 100644 index 0000000..e953ee6 --- /dev/null +++ b/nummi/category/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CategoryConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "category" diff --git a/nummi/category/forms.py b/nummi/category/forms.py new file mode 100644 index 0000000..b0bbf27 --- /dev/null +++ b/nummi/category/forms.py @@ -0,0 +1,13 @@ +from main.forms import NummiForm + +from .models import Category + + +class CategoryForm(NummiForm): + class Meta: + model = Category + fields = [ + "name", + "icon", + "budget", + ] diff --git a/nummi/category/migrations/0001_initial.py b/nummi/category/migrations/0001_initial.py new file mode 100644 index 0000000..e4a71d2 --- /dev/null +++ b/nummi/category/migrations/0001_initial.py @@ -0,0 +1,64 @@ +# Generated by Django 4.1.4 on 2023-04-22 09:01 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + state_operations = [ + migrations.CreateModel( + name="Category", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "name", + models.CharField( + default="Category", max_length=64, verbose_name="Name" + ), + ), + ( + "icon", + models.SlugField( + default="folder", max_length=24, verbose_name="Icon" + ), + ), + ("budget", models.BooleanField(default=True, verbose_name="Budget")), + ( + "user", + models.ForeignKey( + editable=False, + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + verbose_name="User", + ), + ), + ], + options={ + "verbose_name": "Category", + "verbose_name_plural": "Categories", + "ordering": ["name"], + "db_table": "category_category", + }, + ), + ] + + operations = [ + migrations.SeparateDatabaseAndState(state_operations=state_operations) + ] diff --git a/nummi/category/migrations/__init__.py b/nummi/category/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nummi/category/models.py b/nummi/category/models.py new file mode 100644 index 0000000..845600b --- /dev/null +++ b/nummi/category/models.py @@ -0,0 +1,33 @@ +from uuid import uuid4 + +from django.db import models +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ +from main.models import UserModel + + +class Category(UserModel): + id = models.UUIDField(primary_key=True, default=uuid4, editable=False) + name = models.CharField( + max_length=64, default=_("Category"), verbose_name=_("Name") + ) + icon = models.SlugField( + max_length=24, + default="folder", + verbose_name=_("Icon"), + ) + budget = models.BooleanField(default=True, verbose_name=_("Budget")) + + def __str__(self): + return str(self.name) + + def get_absolute_url(self): + return reverse("category", kwargs={"pk": self.pk}) + + def get_delete_url(self): + return reverse("del_category", kwargs={"pk": self.pk}) + + class Meta: + ordering = ["name"] + verbose_name = _("Category") + verbose_name_plural = _("Categories") diff --git a/nummi/category/urls.py b/nummi/category/urls.py new file mode 100644 index 0000000..113aac9 --- /dev/null +++ b/nummi/category/urls.py @@ -0,0 +1,21 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path("category", views.CategoryCreateView.as_view(), name="new_category"), + path("category/", views.CategoryUpdateView.as_view(), name="category"), + path( + "category//transactions", + views.CategoryTListView.as_view(), + name="category_transactions", + ), + path( + "category//delete", views.CategoryDeleteView.as_view(), name="del_category" + ), + path( + "category//history//", + views.TransactionMonthView.as_view(), + name="transaction_month", + ), +] diff --git a/nummi/category/views.py b/nummi/category/views.py new file mode 100644 index 0000000..e84ac96 --- /dev/null +++ b/nummi/category/views.py @@ -0,0 +1,50 @@ +from django.urls import reverse_lazy +from main.views import NummiCreateView, NummiDeleteView, NummiUpdateView +from transaction.utils import history +from transaction.views import TransactionListView + +from .forms import CategoryForm +from .models import Category + + +class CategoryCreateView(NummiCreateView): + model = Category + form_class = CategoryForm + template_name = "main/form/category.html" + + +class CategoryUpdateView(NummiUpdateView): + model = Category + form_class = CategoryForm + template_name = "main/form/category.html" + + def get_context_data(self, **kwargs): + data = super().get_context_data(**kwargs) + category = data["form"].instance + + return data | { + "transactions": category.transaction_set.all()[:8], + "transactions_url": reverse_lazy( + "category_transactions", args=(category.pk,) + ), + "history": history(category.transaction_set), + } + + +class CategoryDeleteView(NummiDeleteView): + model = Category + + +class CategoryMixin: + def get_queryset(self): + return super().get_queryset().filter(category=self.kwargs.get("pk")) + + def get_context_data(self, **kwargs): + return super().get_context_data(**kwargs) | { + "object": Category.objects.get(pk=self.kwargs.get("pk")), + "category": True, + } + + +class CategoryTListView(CategoryMixin, TransactionListView): + pass diff --git a/nummi/main/forms.py b/nummi/main/forms.py index 2fc2f8a..449720f 100644 --- a/nummi/main/forms.py +++ b/nummi/main/forms.py @@ -1,7 +1,4 @@ from django import forms -from django.utils.translation import gettext_lazy as _ - -from .models import Account, Category, Invoice, Snapshot, Transaction class NummiFileInput(forms.ClearableFileInput): @@ -13,101 +10,3 @@ class NummiForm(forms.ModelForm): def __init__(self, *args, user, **kwargs): super().__init__(*args, **kwargs) - - -class AccountForm(NummiForm): - class Meta: - model = Account - fields = [ - "name", - "icon", - "default", - ] - - -class CategoryForm(NummiForm): - class Meta: - model = Category - fields = [ - "name", - "icon", - "budget", - ] - - -class TransactionForm(NummiForm): - class Meta: - model = Transaction - fields = [ - "snapshot", - "name", - "value", - "date", - "real_date", - "category", - "trader", - "payment", - "description", - ] - - def __init__(self, *args, **kwargs): - _user = kwargs.get("user") - _disable_snapshot = kwargs.pop("disable_snapshot", False) - super().__init__(*args, **kwargs) - self.fields["category"].queryset = Category.objects.filter(user=_user) - self.fields["snapshot"].queryset = Snapshot.objects.filter(user=_user) - if _disable_snapshot: - self.fields["snapshot"].disabled = True - - -class InvoiceForm(NummiForm): - prefix = "invoice" - - class Meta: - model = Invoice - fields = [ - "name", - "file", - ] - widgets = { - "file": NummiFileInput, - } - - -class SnapshotForm(NummiForm): - class Meta: - model = Snapshot - fields = ["account", "start_date", "date", "start_value", "value", "file"] - widgets = { - "file": NummiFileInput, - } - - def __init__(self, *args, **kwargs): - _user = kwargs.get("user") - _disable_account = kwargs.pop("disable_account", False) - super().__init__(*args, **kwargs) - self.fields["account"].queryset = Account.objects.filter(user=_user) - self.fields["transactions"] = forms.MultipleChoiceField( - choices=( - ((_transaction.id), _transaction) - for _transaction in Transaction.objects.filter(user=_user) - ), - label=_("Add transactions"), - required=False, - ) - if _disable_account: - self.fields["account"].disabled = True - - def save(self, *args, **kwargs): - instance = super().save(*args, **kwargs) - new_transactions = Transaction.objects.filter( - id__in=self.cleaned_data["transactions"] - ) - - instance.transaction_set.add(*new_transactions, bulk=False) - return instance - - -class SearchForm(forms.Form): - template_name = "main/form/search.html" - search = forms.CharField(label=_("Search"), max_length=128) diff --git a/nummi/main/migrations/0001_v1.py b/nummi/main/migrations/0001_v1.py index 2eb2ac7..98b9585 100644 --- a/nummi/main/migrations/0001_v1.py +++ b/nummi/main/migrations/0001_v1.py @@ -6,7 +6,7 @@ import uuid import django.contrib.postgres.operations import django.core.validators import django.db.models.deletion -import main.utils +import media.utils from django.conf import settings from django.db import migrations, models @@ -136,7 +136,7 @@ class Migration(migrations.Migration): blank=True, default="", max_length=256, - upload_to=main.utils.get_path, + upload_to=media.utils.get_path, validators=[ django.core.validators.FileExtensionValidator(["pdf"]) ], @@ -315,7 +315,7 @@ class Migration(migrations.Migration): "file", models.FileField( max_length=128, - upload_to=main.utils.get_path, + upload_to=media.utils.get_path, validators=[ django.core.validators.FileExtensionValidator(["pdf"]) ], diff --git a/nummi/main/migrations/0002_segmentation.py b/nummi/main/migrations/0002_segmentation.py new file mode 100644 index 0000000..ba9052b --- /dev/null +++ b/nummi/main/migrations/0002_segmentation.py @@ -0,0 +1,32 @@ +# Generated by Django 4.1.4 on 2023-04-22 09:01 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0001_v1"), + ] + + database_operations = [ + migrations.AlterModelTable("Account", "account_account"), + migrations.AlterModelTable("Category", "category_category"), + migrations.AlterModelTable("Snapshot", "statement_statement"), + migrations.AlterModelTable("Transaction", "transaction_transaction"), + migrations.AlterModelTable("Invoice", "transaction_invoice"), + ] + + state_operations = [ + migrations.DeleteModel("Account"), + migrations.DeleteModel("Category"), + migrations.DeleteModel("Snapshot"), + migrations.DeleteModel("Transaction"), + migrations.DeleteModel("Invoice"), + ] + + operations = [ + migrations.SeparateDatabaseAndState( + database_operations=database_operations, + state_operations=state_operations, + ), + ] diff --git a/nummi/main/models.py b/nummi/main/models.py index 9203718..5ae4f2c 100644 --- a/nummi/main/models.py +++ b/nummi/main/models.py @@ -1,15 +1,7 @@ -import datetime -import pathlib -import uuid - from django.conf import settings -from django.core.validators import FileExtensionValidator -from django.db import models, transaction -from django.urls import reverse +from django.db import models from django.utils.translation import gettext_lazy as _ -from .utils import get_path - class UserModel(models.Model): user = models.ForeignKey( @@ -21,280 +13,3 @@ class UserModel(models.Model): class Meta: abstract = True - - -class Account(UserModel): - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - name = models.CharField(max_length=64, default=_("Account"), verbose_name=_("Name")) - icon = models.SlugField( - max_length=24, - default="bank", - verbose_name=_("Icon"), - ) - default = models.BooleanField(default=False, verbose_name=_("Default")) - - def save(self, *args, **kwargs): - if self.default: - for ac in Account.objects.filter(user=self.user, default=True): - ac.default = False - ac.save() - super().save(*args, **kwargs) - - def __str__(self): - return str(self.name) - - def get_absolute_url(self): - return reverse("account", kwargs={"pk": self.pk}) - - def get_delete_url(self): - return reverse("del_account", kwargs={"pk": self.pk}) - - @property - def transactions(self): - return Transaction.objects.filter(account=self) - - @property - def snapshots(self): - return Snapshot.objects.filter(account=self) - - class Meta: - ordering = ["-default", "name"] - verbose_name = _("Account") - verbose_name_plural = _("Accounts") - - -class AccountModel(UserModel): - account = models.ForeignKey( - Account, - on_delete=models.CASCADE, - verbose_name=_("Account"), - ) - - class Meta: - abstract = True - - -class Category(UserModel): - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - name = models.CharField( - max_length=64, default=_("Category"), verbose_name=_("Name") - ) - icon = models.SlugField( - max_length=24, - default="folder", - verbose_name=_("Icon"), - ) - budget = models.BooleanField(default=True, verbose_name=_("Budget")) - - def __str__(self): - return str(self.name) - - def get_absolute_url(self): - return reverse("category", kwargs={"pk": self.pk}) - - def get_delete_url(self): - return reverse("del_category", kwargs={"pk": self.pk}) - - @property - def transactions(self): - return Transaction.objects.filter(category=self) - - class Meta: - ordering = ["name"] - verbose_name = _("Category") - verbose_name_plural = _("Categories") - - -class Snapshot(AccountModel): - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - date = models.DateField(default=datetime.date.today, verbose_name=_("End date")) - start_date = models.DateField( - default=datetime.date.today, verbose_name=_("Start date") - ) - value = models.DecimalField( - max_digits=12, decimal_places=2, default=0, verbose_name=_("End value") - ) - start_value = models.DecimalField( - max_digits=12, decimal_places=2, default=0, verbose_name=_("Start value") - ) - diff = models.DecimalField( - max_digits=12, - decimal_places=2, - default=0, - verbose_name=_("Difference"), - editable=False, - ) - sum = models.DecimalField( - max_digits=12, - decimal_places=2, - default=0, - verbose_name=_("Transaction difference"), - editable=False, - ) - file = models.FileField( - upload_to=get_path, - validators=[FileExtensionValidator(["pdf"])], - verbose_name=_("File"), - max_length=256, - blank=True, - default="", - ) - - def __str__(self): - desc = _("%(date)s statement") % {"date": self.date} - if hasattr(self, "account"): - return f"{desc} – {self.account}" - return desc - - def save(self, *args, **kwargs): - if Snapshot.objects.filter(id=self.id).exists(): - _prever = Snapshot.objects.get(id=self.id) - if _prever.file and _prever.file != self.file: - pathlib.Path(_prever.file.path).unlink(missing_ok=True) - - with transaction.atomic(): - for trans in self.transaction_set.all(): - trans.save() - - self.diff = self.value - self.start_value - self.sum = ( - self.transaction_set.aggregate(sum=models.Sum("value")).get("sum", 0) or 0 - ) - super().save(*args, **kwargs) - - def update_sum(self): - self.sum = ( - self.transaction_set.aggregate(sum=models.Sum("value")).get("sum", 0) or 0 - ) - super().save() - - def delete(self, *args, **kwargs): - if self.file: - self.file.delete() - super().delete(*args, **kwargs) - - def get_absolute_url(self): - return reverse("snapshot", kwargs={"pk": self.pk}) - - def get_delete_url(self): - return reverse("del_snapshot", kwargs={"pk": self.pk}) - - class Meta: - ordering = ["-date", "account"] - verbose_name = _("Statement") - verbose_name_plural = _("Statements") - - -class Transaction(UserModel): - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - name = models.CharField( - max_length=256, default=_("Transaction"), verbose_name=_("Name") - ) - description = models.TextField(null=True, blank=True, verbose_name=_("Description")) - value = models.DecimalField( - max_digits=12, decimal_places=2, default=0, verbose_name=_("Value") - ) - date = models.DateField(default=datetime.date.today, verbose_name=_("Date")) - real_date = models.DateField(blank=True, null=True, verbose_name=_("Real date")) - trader = models.CharField( - max_length=128, blank=True, null=True, verbose_name=_("Trader") - ) - payment = models.CharField( - max_length=128, blank=True, null=True, verbose_name=_("Payment") - ) - category = models.ForeignKey( - Category, - on_delete=models.SET_NULL, - blank=True, - null=True, - verbose_name=_("Category"), - ) - snapshot = models.ForeignKey( - Snapshot, - on_delete=models.CASCADE, - verbose_name=_("Statement"), - ) - account = models.ForeignKey( - Account, - on_delete=models.CASCADE, - verbose_name=_("Account"), - editable=False, - ) - - def save(self, *args, **kwargs): - if Transaction.objects.filter(pk=self.pk): - prev_self = Transaction.objects.get(pk=self.pk) - else: - prev_self = None - self.account = self.snapshot.account - super().save(*args, **kwargs) - if prev_self is not None: - prev_self.snapshot.update_sum() - self.snapshot.update_sum() - - def __str__(self): - return f"{self.name}" - - def get_absolute_url(self): - return reverse("transaction", kwargs={"pk": self.pk}) - - def get_delete_url(self): - return reverse("del_transaction", kwargs={"pk": self.pk}) - - @property - def invoices(self): - return Invoice.objects.filter(transaction=self) - - @property - def has_invoice(self): - return self.invoices.count() > 0 - - class Meta: - ordering = ["-date", "snapshot"] - verbose_name = _("Transaction") - verbose_name_plural = _("Transactions") - - -class Invoice(UserModel): - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - name = models.CharField( - max_length=256, default=_("Invoice"), verbose_name=_("Name") - ) - file = models.FileField( - upload_to=get_path, - validators=[FileExtensionValidator(["pdf"])], - verbose_name=_("File"), - max_length=128, - ) - transaction = models.ForeignKey( - Transaction, on_delete=models.CASCADE, editable=False - ) - - def save(self, *args, **kwargs): - if Invoice.objects.filter(id=self.id).exists(): - _prever = Invoice.objects.get(id=self.id) - if _prever.file and _prever.file != self.file: - pathlib.Path(_prever.file.path).unlink(missing_ok=True) - super().save(*args, **kwargs) - - def __str__(self): - return str(self.name) - - def delete(self, *args, **kwargs): - self.file.delete() - super().delete(*args, **kwargs) - - def get_absolute_url(self): - return reverse( - "invoice", kwargs={"transaction_pk": self.transaction.pk, "pk": self.pk} - ) - - def get_delete_url(self): - return reverse( - "del_invoice", kwargs={"transaction_pk": self.transaction.pk, "pk": self.pk} - ) - - class Meta: - verbose_name = _("Invoice") - verbose_name_plural = _("Invoices") - ordering = ["transaction", "name"] diff --git a/nummi/main/urls.py b/nummi/main/urls.py index cb48dca..3b911ad 100644 --- a/nummi/main/urls.py +++ b/nummi/main/urls.py @@ -6,92 +6,4 @@ urlpatterns = [ path("", views.IndexView.as_view(), name="index"), path("login", views.LoginView.as_view(), name="login"), path("logout", views.LogoutView.as_view(), name="logout"), - path("transactions", views.TransactionListView.as_view(), name="transactions"), - path("snapshots", views.SnapshotListView.as_view(), name="snapshots"), - path("account", views.AccountCreateView.as_view(), name="new_account"), - path("transaction", views.TransactionCreateView.as_view(), name="new_transaction"), - path( - "transaction//invoice", - views.InvoiceCreateView.as_view(), - name="new_invoice", - ), - path("category", views.CategoryCreateView.as_view(), name="new_category"), - path("snapshot", views.SnapshotCreateView.as_view(), name="new_snapshot"), - path("account/", views.AccountUpdateView.as_view(), name="account"), - path( - "account//transactions", - views.AccountTListView.as_view(), - name="account_transactions", - ), - path( - "account//snapshots", - views.AccountSListView.as_view(), - name="account_snapshots", - ), - path( - "account//snapshot", - views.SnapshotCreateView.as_view(), - name="new_snapshot", - ), - path("transaction/", views.TransactionUpdateView.as_view(), name="transaction"), - path( - "transaction//invoice/", - views.InvoiceUpdateView.as_view(), - name="invoice", - ), - path("category/", views.CategoryUpdateView.as_view(), name="category"), - path( - "category//transactions", - views.CategoryTListView.as_view(), - name="category_transactions", - ), - path("snapshot/", views.SnapshotUpdateView.as_view(), name="snapshot"), - path( - "snapshot//transactions", - views.SnapshotTListView.as_view(), - name="snapshot_transactions", - ), - path( - "snapshot//transaction", - views.TransactionCreateView.as_view(), - name="new_transaction", - ), - path( - "account//delete", - views.AccountDeleteView.as_view(), - name="del_account", - ), - path( - "transaction//delete", - views.TransactionDeleteView.as_view(), - name="del_transaction", - ), - path( - "transaction//invoice//delete", - views.InvoiceDeleteView.as_view(), - name="del_invoice", - ), - path( - "category//delete", views.CategoryDeleteView.as_view(), name="del_category" - ), - path( - "snapshot//delete", views.SnapshotDeleteView.as_view(), name="del_snapshot" - ), - path("search", views.SearchFormView.as_view(), name="search"), - path("search/", views.SearchView.as_view(), name="search"), - path( - "history//", - views.TransactionMonthView.as_view(), - name="transaction_month", - ), - path( - "account//history//", - views.TransactionMonthView.as_view(), - name="transaction_month", - ), - path( - "category//history//", - views.TransactionMonthView.as_view(), - name="transaction_month", - ), ] diff --git a/nummi/main/views.py b/nummi/main/views.py index efadff2..7ede354 100644 --- a/nummi/main/views.py +++ b/nummi/main/views.py @@ -1,34 +1,18 @@ +from account.models import Account +from category.models import Category from django.contrib.auth import views as auth_views from django.contrib.auth.mixins import LoginRequiredMixin -from django.contrib.postgres.search import ( - SearchQuery, - SearchRank, - SearchVector, - TrigramSimilarity, -) -from django.db import models -from django.shortcuts import get_object_or_404, redirect from django.urls import reverse_lazy from django.views.generic import ( CreateView, DeleteView, - FormView, ListView, TemplateView, UpdateView, ) -from django.views.generic.dates import MonthArchiveView - -from .forms import ( - AccountForm, - CategoryForm, - InvoiceForm, - SearchForm, - SnapshotForm, - TransactionForm, -) -from .models import Account, Category, Invoice, Snapshot, Transaction -from .utils import history +from statement.models import Statement +from transaction.models import Transaction +from transaction.utils import history class IndexView(LoginRequiredMixin, TemplateView): @@ -37,19 +21,19 @@ class IndexView(LoginRequiredMixin, TemplateView): def get_context_data(self, **kwargs): _max = 8 _transactions = Transaction.objects.filter(user=self.request.user) - _snapshots = Snapshot.objects.filter(user=self.request.user) + _statements = Statement.objects.filter(user=self.request.user) res = { "accounts": Account.objects.filter(user=self.request.user), "transactions": _transactions[:_max], "categories": Category.objects.filter(user=self.request.user), - "snapshots": _snapshots[:_max], + "statements": _statements[:_max], "history": history(_transactions.exclude(category__budget=False)), } if _transactions.count() > _max: res["transactions_url"] = reverse_lazy("transactions") - if _snapshots.count() > _max: - res["snapshots_url"] = reverse_lazy("snapshots") + if _statements.count() > _max: + res["statements_url"] = reverse_lazy("statements") return super().get_context_data(**kwargs) | res @@ -92,355 +76,5 @@ class LogoutView(auth_views.LogoutView): next_page = "login" -class AccountCreateView(NummiCreateView): - model = Account - form_class = AccountForm - template_name = "main/form/account.html" - - -class TransactionCreateView(NummiCreateView): - model = Transaction - form_class = TransactionForm - template_name = "main/form/transaction.html" - - def get_initial(self): - _queryset = Snapshot.objects.filter(user=self.request.user) - if "snapshot" in self.kwargs: - self.snapshot = get_object_or_404(_queryset, pk=self.kwargs["snapshot"]) - else: - self.snapshot = _queryset.first() - return {"snapshot": self.snapshot} - - def get_form_kwargs(self): - if "snapshot" in self.kwargs: - return super().get_form_kwargs() | {"disable_snapshot": True} - return super().get_form_kwargs() - - def get_context_data(self, **kwargs): - if "snapshot" in self.kwargs: - return super().get_context_data(**kwargs) | {"snapshot": self.snapshot} - return super().get_context_data(**kwargs) - - -class InvoiceCreateView(NummiCreateView): - model = Invoice - form_class = InvoiceForm - template_name = "main/form/invoice.html" - - def form_valid(self, form): - form.instance.transaction = get_object_or_404( - Transaction.objects.filter(user=self.request.user), - pk=self.kwargs["transaction_pk"], - ) - return super().form_valid(form) - - def get_success_url(self): - return reverse_lazy("transaction", kwargs={"pk": self.object.transaction.pk}) - - -class CategoryCreateView(NummiCreateView): - model = Category - form_class = CategoryForm - template_name = "main/form/category.html" - - -class SnapshotCreateView(NummiCreateView): - model = Snapshot - form_class = SnapshotForm - template_name = "main/form/snapshot.html" - - def get_initial(self): - _queryset = Account.objects.filter(user=self.request.user) - if "account" in self.kwargs: - self.account = get_object_or_404(_queryset, pk=self.kwargs["account"]) - else: - self.account = _queryset.first() - return {"account": self.account} - - def get_form_kwargs(self): - if "account" in self.kwargs: - return super().get_form_kwargs() | {"disable_account": True} - return super().get_form_kwargs() - - def get_context_data(self, **kwargs): - if "account" in self.kwargs: - return super().get_context_data(**kwargs) | {"account": self.account} - return super().get_context_data(**kwargs) - - -class AccountUpdateView(NummiUpdateView): - model = Account - form_class = AccountForm - template_name = "main/form/account.html" - - def get_context_data(self, **kwargs): - _max = 8 - data = super().get_context_data(**kwargs) - account = data["form"].instance - - _transactions = account.transaction_set.all() - if _transactions.count() > _max: - data["transactions_url"] = reverse_lazy( - "account_transactions", args=(account.pk,) - ) - _snapshots = account.snapshot_set.all() - if _snapshots.count() > _max: - data["snapshots_url"] = reverse_lazy( - "account_snapshots", args=(account.pk,) - ) - - return data | { - "transactions": _transactions[:8], - "new_snapshot_url": reverse_lazy( - "new_snapshot", kwargs={"account": account.pk} - ), - "snapshots": _snapshots[:8], - "history": history(account.transaction_set), - } - - -class TransactionUpdateView(NummiUpdateView): - model = Transaction - form_class = TransactionForm - template_name = "main/form/transaction.html" - - -class InvoiceUpdateView(NummiUpdateView): - model = Invoice - form_class = InvoiceForm - template_name = "main/form/invoice.html" - - def get_success_url(self): - return reverse_lazy("transaction", kwargs={"pk": self.object.transaction.pk}) - - def get_queryset(self): - return ( - super() - .get_queryset() - .filter( - transaction=get_object_or_404( - Transaction, pk=self.kwargs["transaction_pk"] - ) - ) - ) - - -class CategoryUpdateView(NummiUpdateView): - model = Category - form_class = CategoryForm - template_name = "main/form/category.html" - - def get_context_data(self, **kwargs): - data = super().get_context_data(**kwargs) - category = data["form"].instance - - return data | { - "transactions": category.transaction_set.all()[:8], - "transactions_url": reverse_lazy( - "category_transactions", args=(category.pk,) - ), - "history": history(category.transaction_set), - } - - -class SnapshotUpdateView(NummiUpdateView): - model = Snapshot - form_class = SnapshotForm - template_name = "main/form/snapshot.html" - - def get_context_data(self, **kwargs): - data = super().get_context_data(**kwargs) - snapshot = data["form"].instance - - _transactions = snapshot.transaction_set.all() - if _transactions: - _categories = ( - _transactions.values("category", "category__name", "category__icon") - .annotate( - sum=models.Sum("value"), - sum_m=models.Sum("value", filter=models.Q(value__lt=0)), - sum_p=models.Sum("value", filter=models.Q(value__gt=0)), - ) - .order_by("-sum") - ) - data["categories"] = { - "data": _categories, - "max": max( - _categories.aggregate( - max=models.Max("sum_p", default=0), - min=models.Min("sum_m", default=0), - ).values(), - ), - } - - return data | { - "account": snapshot.account, - "new_transaction_url": reverse_lazy( - "new_transaction", kwargs={"snapshot": snapshot.pk} - ), - "transactions": _transactions, - } - - -class AccountDeleteView(NummiDeleteView): - model = Account - - -class TransactionDeleteView(NummiDeleteView): - model = Transaction - - -class InvoiceDeleteView(NummiDeleteView): - model = Invoice - - def get_success_url(self): - return reverse_lazy("transaction", kwargs={"pk": self.object.transaction.pk}) - - def get_queryset(self): - return ( - super() - .get_queryset() - .filter( - transaction=get_object_or_404( - Transaction, pk=self.kwargs["transaction_pk"] - ) - ) - ) - - -class CategoryDeleteView(NummiDeleteView): - model = Category - - -class SnapshotDeleteView(NummiDeleteView): - model = Snapshot - - class NummiListView(UserMixin, ListView): paginate_by = 96 - - -class TransactionListView(NummiListView): - model = Transaction - template_name = "main/list/transaction.html" - context_object_name = "transactions" - - -class SnapshotListView(NummiListView): - model = Snapshot - template_name = "main/list/snapshot.html" - context_object_name = "snapshots" - - -class AccountMixin: - def get_queryset(self): - return super().get_queryset().filter(account=self.kwargs.get("pk")) - - def get_context_data(self, **kwargs): - return super().get_context_data(**kwargs) | { - "object": Account.objects.get(pk=self.kwargs.get("pk")), - "account": True, - } - - -class SnapshotMixin: - def get_queryset(self): - return super().get_queryset().filter(snapshot=self.kwargs.get("pk")) - - def get_context_data(self, **kwargs): - return super().get_context_data(**kwargs) | { - "object": Snapshot.objects.get(pk=self.kwargs.get("pk")), - "snapshot": True, - } - - -class CategoryMixin: - def get_queryset(self): - return super().get_queryset().filter(category=self.kwargs.get("pk")) - - def get_context_data(self, **kwargs): - return super().get_context_data(**kwargs) | { - "object": Category.objects.get(pk=self.kwargs.get("pk")), - "category": True, - } - - -class AccountTListView(AccountMixin, TransactionListView): - pass - - -class AccountSListView(AccountMixin, SnapshotListView): - pass - - -class SnapshotTListView(SnapshotMixin, TransactionListView): - pass - - -class CategoryTListView(CategoryMixin, TransactionListView): - pass - - -class SearchFormView(LoginRequiredMixin, FormView): - template_name = "main/search.html" - form_class = SearchForm - - def form_valid(self, form): - return redirect("search", search=form.cleaned_data.get("search")) - - -class SearchView(TransactionListView): - def get_queryset(self): - self.search = self.kwargs["search"] - return ( - super() - .get_queryset() - .annotate( - rank=SearchRank( - SearchVector("name", weight="A") - + SearchVector("description", weight="B") - + SearchVector("trader", weight="B"), - SearchQuery(self.search, search_type="websearch"), - ), - similarity=TrigramSimilarity("name", self.search), - ) - .filter(models.Q(rank__gte=0.1) | models.Q(similarity__gte=0.3)) - .order_by("-rank", "-date") - ) - - def get_context_data(self, **kwargs): - return super().get_context_data(**kwargs) | {"search": self.kwargs["search"]} - - -class TransactionMonthView(UserMixin, MonthArchiveView): - template_name = "main/month/transaction.html" - model = Transaction - date_field = "date" - context_object_name = "transactions" - month_format = "%m" - - account = None - category = None - - def get_queryset(self): - if "account" in self.kwargs: - self.account = get_object_or_404( - Account.objects.filter(user=self.request.user), - pk=self.kwargs["account"], - ) - return super().get_queryset().filter(account=self.account) - if "category" in self.kwargs: - self.category = get_object_or_404( - Category.objects.filter(user=self.request.user), - pk=self.kwargs["category"], - ) - return super().get_queryset().filter(category=self.category) - - return super().get_queryset() - - def get_context_data(self, **kwargs): - if "account" in self.kwargs: - return super().get_context_data(**kwargs) | {"account": self.account} - if "category" in self.kwargs: - return super().get_context_data(**kwargs) | {"category": self.category} - return super().get_context_data(**kwargs) diff --git a/nummi/media/utils.py b/nummi/media/utils.py new file mode 100644 index 0000000..533f017 --- /dev/null +++ b/nummi/media/utils.py @@ -0,0 +1,10 @@ +import pathlib + + +def get_path(instance, filename): + return pathlib.Path( + "user", + str(instance.user.username), + instance._meta.model_name, + str(instance.pk), + ).with_suffix(".pdf") diff --git a/nummi/nummi/settings.py b/nummi/nummi/settings.py index 3acd55f..a2cc1e9 100644 --- a/nummi/nummi/settings.py +++ b/nummi/nummi/settings.py @@ -44,8 +44,12 @@ CSRF_TRUSTED_ORIGINS = CONFIG.get("trusted_origins", ["http://localhost"]) # Application definition INSTALLED_APPS = [ - "main.apps.MainConfig", - "media.apps.MediaConfig", + "main", + "media", + "account", + "category", + "statement", + "transaction", "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", diff --git a/nummi/search/__init__.py b/nummi/search/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nummi/search/apps.py b/nummi/search/apps.py new file mode 100644 index 0000000..1c3a606 --- /dev/null +++ b/nummi/search/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class SearchConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "search" diff --git a/nummi/search/forms.py b/nummi/search/forms.py new file mode 100644 index 0000000..70d6e60 --- /dev/null +++ b/nummi/search/forms.py @@ -0,0 +1,7 @@ +from django import forms +from django.utils.translation import gettext_lazy as _ + + +class SearchForm(forms.Form): + template_name = "main/form/search.html" + search = forms.CharField(label=_("Search"), max_length=128) diff --git a/nummi/search/migrations/__init__.py b/nummi/search/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nummi/search/urls.py b/nummi/search/urls.py new file mode 100644 index 0000000..fb383ab --- /dev/null +++ b/nummi/search/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path("search", views.SearchFormView.as_view(), name="search"), + path("search/", views.SearchView.as_view(), name="search"), +] diff --git a/nummi/search/views.py b/nummi/search/views.py new file mode 100644 index 0000000..c41965c --- /dev/null +++ b/nummi/search/views.py @@ -0,0 +1,44 @@ +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.postgres.search import ( + SearchQuery, + SearchRank, + SearchVector, + TrigramSimilarity, +) +from django.db import models +from django.shortcuts import redirect +from django.views.generic.edit import FormView +from transaction.views import TransactionListView + +from .forms import SearchForm + + +class SearchFormView(LoginRequiredMixin, FormView): + template_name = "main/search.html" + form_class = SearchForm + + def form_valid(self, form): + return redirect("search", search=form.cleaned_data.get("search")) + + +class SearchView(TransactionListView): + def get_queryset(self): + self.search = self.kwargs["search"] + return ( + super() + .get_queryset() + .annotate( + rank=SearchRank( + SearchVector("name", weight="A") + + SearchVector("description", weight="B") + + SearchVector("trader", weight="B"), + SearchQuery(self.search, search_type="websearch"), + ), + similarity=TrigramSimilarity("name", self.search), + ) + .filter(models.Q(rank__gte=0.1) | models.Q(similarity__gte=0.3)) + .order_by("-rank", "-date") + ) + + def get_context_data(self, **kwargs): + return super().get_context_data(**kwargs) | {"search": self.kwargs["search"]} diff --git a/nummi/statement/__init__.py b/nummi/statement/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nummi/statement/apps.py b/nummi/statement/apps.py new file mode 100644 index 0000000..b94c1a5 --- /dev/null +++ b/nummi/statement/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class StatementConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "statement" diff --git a/nummi/statement/forms.py b/nummi/statement/forms.py new file mode 100644 index 0000000..328d921 --- /dev/null +++ b/nummi/statement/forms.py @@ -0,0 +1,41 @@ +from account.models import Account +from django import forms +from django.utils.translation import gettext_lazy as _ +from main.forms import NummiFileInput, NummiForm +from transaction.models import Transaction + +from .models import Statement + + +class StatementForm(NummiForm): + class Meta: + model = Statement + fields = ["account", "start_date", "date", "start_value", "value", "file"] + widgets = { + "file": NummiFileInput, + } + + def __init__(self, *args, **kwargs): + _user = kwargs.get("user") + _disable_account = kwargs.pop("disable_account", False) + super().__init__(*args, **kwargs) + self.fields["account"].queryset = Account.objects.filter(user=_user) + self.fields["transactions"] = forms.MultipleChoiceField( + choices=( + ((_transaction.id), _transaction) + for _transaction in Transaction.objects.filter(user=_user) + ), + label=_("Add transactions"), + required=False, + ) + if _disable_account: + self.fields["account"].disabled = True + + def save(self, *args, **kwargs): + instance = super().save(*args, **kwargs) + new_transactions = Transaction.objects.filter( + id__in=self.cleaned_data["transactions"] + ) + + instance.transaction_set.add(*new_transactions, bulk=False) + return instance diff --git a/nummi/statement/migrations/0001_initial.py b/nummi/statement/migrations/0001_initial.py new file mode 100644 index 0000000..b2356d8 --- /dev/null +++ b/nummi/statement/migrations/0001_initial.py @@ -0,0 +1,127 @@ +# Generated by Django 4.1.4 on 2023-04-22 09:01 + +import datetime +import uuid + +import django.core.validators +import django.db.models.deletion +import media.utils +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("main", "0002_segmentation"), + ("account", "0001_initial"), + ] + + state_operations = [ + migrations.CreateModel( + name="Statement", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "date", + models.DateField( + default=datetime.date.today, verbose_name="End date" + ), + ), + ( + "start_date", + models.DateField( + default=datetime.date.today, verbose_name="Start date" + ), + ), + ( + "value", + models.DecimalField( + decimal_places=2, + default=0, + max_digits=12, + verbose_name="End value", + ), + ), + ( + "start_value", + models.DecimalField( + decimal_places=2, + default=0, + max_digits=12, + verbose_name="Start value", + ), + ), + ( + "diff", + models.DecimalField( + decimal_places=2, + default=0, + editable=False, + max_digits=12, + verbose_name="Difference", + ), + ), + ( + "sum", + models.DecimalField( + decimal_places=2, + default=0, + editable=False, + max_digits=12, + verbose_name="Transaction difference", + ), + ), + ( + "file", + models.FileField( + blank=True, + default="", + max_length=256, + upload_to=media.utils.get_path, + validators=[ + django.core.validators.FileExtensionValidator(["pdf"]) + ], + verbose_name="File", + ), + ), + ( + "account", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="account.account", + verbose_name="Account", + ), + ), + ( + "user", + models.ForeignKey( + editable=False, + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + verbose_name="User", + ), + ), + ], + options={ + "verbose_name": "Statement", + "verbose_name_plural": "Statements", + "ordering": ["-date", "account"], + "db_table": "statement_statement", + }, + ), + ] + + operations = [ + migrations.SeparateDatabaseAndState(state_operations=state_operations) + ] diff --git a/nummi/statement/migrations/__init__.py b/nummi/statement/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nummi/statement/models.py b/nummi/statement/models.py new file mode 100644 index 0000000..8e91bcb --- /dev/null +++ b/nummi/statement/models.py @@ -0,0 +1,90 @@ +import datetime +from pathlib import Path +from uuid import uuid4 + +from account.models import AccountModel +from django.core.validators import FileExtensionValidator +from django.db import models, transaction +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ +from media.utils import get_path + + +class Statement(AccountModel): + id = models.UUIDField(primary_key=True, default=uuid4, editable=False) + date = models.DateField(default=datetime.date.today, verbose_name=_("End date")) + start_date = models.DateField( + default=datetime.date.today, verbose_name=_("Start date") + ) + value = models.DecimalField( + max_digits=12, decimal_places=2, default=0, verbose_name=_("End value") + ) + start_value = models.DecimalField( + max_digits=12, decimal_places=2, default=0, verbose_name=_("Start value") + ) + diff = models.DecimalField( + max_digits=12, + decimal_places=2, + default=0, + verbose_name=_("Difference"), + editable=False, + ) + sum = models.DecimalField( + max_digits=12, + decimal_places=2, + default=0, + verbose_name=_("Transaction difference"), + editable=False, + ) + file = models.FileField( + upload_to=get_path, + validators=[FileExtensionValidator(["pdf"])], + verbose_name=_("File"), + max_length=256, + blank=True, + default="", + ) + + def __str__(self): + desc = _("%(date)s statement") % {"date": self.date} + if hasattr(self, "account"): + return f"{desc} – {self.account}" + return desc + + def save(self, *args, **kwargs): + if Statement.objects.filter(id=self.id).exists(): + _prever = Statement.objects.get(id=self.id) + if _prever.file and _prever.file != self.file: + Path(_prever.file.path).unlink(missing_ok=True) + + with transaction.atomic(): + for trans in self.transaction_set.all(): + trans.save() + + self.diff = self.value - self.start_value + self.sum = ( + self.transaction_set.aggregate(sum=models.Sum("value")).get("sum", 0) or 0 + ) + super().save(*args, **kwargs) + + def update_sum(self): + self.sum = ( + self.transaction_set.aggregate(sum=models.Sum("value")).get("sum", 0) or 0 + ) + super().save() + + def delete(self, *args, **kwargs): + if self.file: + self.file.delete() + super().delete(*args, **kwargs) + + def get_absolute_url(self): + return reverse("statement", kwargs={"pk": self.pk}) + + def get_delete_url(self): + return reverse("del_statement", kwargs={"pk": self.pk}) + + class Meta: + ordering = ["-date", "account"] + verbose_name = _("Statement") + verbose_name_plural = _("Statements") diff --git a/nummi/statement/urls.py b/nummi/statement/urls.py new file mode 100644 index 0000000..65ac4ec --- /dev/null +++ b/nummi/statement/urls.py @@ -0,0 +1,24 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path("statements", views.SnapshotListView.as_view(), name="statements"), + path("statement", views.StatementCreateView.as_view(), name="new_statement"), + path("statement/", views.StatementUpdateView.as_view(), name="statement"), + path( + "statement//transactions", + views.StatementTListView.as_view(), + name="statement_transactions", + ), + path( + "statement//transaction", + views.TransactionCreateView.as_view(), + name="new_transaction", + ), + path( + "statement//delete", + views.StatementDeleteView.as_view(), + name="del_statement", + ), +] diff --git a/nummi/statement/views.py b/nummi/statement/views.py new file mode 100644 index 0000000..68cdc43 --- /dev/null +++ b/nummi/statement/views.py @@ -0,0 +1,97 @@ +from account.models import Account +from django.db import models +from django.shortcuts import get_object_or_404 +from django.urls import reverse_lazy +from main.views import NummiCreateView, NummiDeleteView, NummiListView, NummiUpdateView +from transaction.views import TransactionListView + +from .forms import StatementForm +from .models import Statement + + +class StatementCreateView(NummiCreateView): + model = Statement + form_class = StatementForm + template_name = "main/form/statement.html" + + def get_initial(self): + _queryset = Account.objects.filter(user=self.request.user) + if "account" in self.kwargs: + self.account = get_object_or_404(_queryset, pk=self.kwargs["account"]) + else: + self.account = _queryset.first() + return {"account": self.account} + + def get_form_kwargs(self): + if "account" in self.kwargs: + return super().get_form_kwargs() | {"disable_account": True} + return super().get_form_kwargs() + + def get_context_data(self, **kwargs): + if "account" in self.kwargs: + return super().get_context_data(**kwargs) | {"account": self.account} + return super().get_context_data(**kwargs) + + +class StatementUpdateView(NummiUpdateView): + model = Statement + form_class = StatementForm + template_name = "main/form/statement.html" + + def get_context_data(self, **kwargs): + data = super().get_context_data(**kwargs) + statement = data["form"].instance + + _transactions = statement.transaction_set.all() + if _transactions: + _categories = ( + _transactions.values("category", "category__name", "category__icon") + .annotate( + sum=models.Sum("value"), + sum_m=models.Sum("value", filter=models.Q(value__lt=0)), + sum_p=models.Sum("value", filter=models.Q(value__gt=0)), + ) + .order_by("-sum") + ) + data["categories"] = { + "data": _categories, + "max": max( + _categories.aggregate( + max=models.Max("sum_p", default=0), + min=models.Min("sum_m", default=0), + ).values(), + ), + } + + return data | { + "account": statement.account, + "new_transaction_url": reverse_lazy( + "new_transaction", kwargs={"statement": statement.pk} + ), + "transactions": _transactions, + } + + +class StatementDeleteView(NummiDeleteView): + model = Statement + + +class StatementListView(NummiListView): + model = Statement + template_name = "main/list/statement.html" + context_object_name = "statements" + + +class StatementMixin: + def get_queryset(self): + return super().get_queryset().filter(statement=self.kwargs.get("pk")) + + def get_context_data(self, **kwargs): + return super().get_context_data(**kwargs) | { + "object": Statement.objects.get(pk=self.kwargs.get("pk")), + "statement": True, + } + + +class StatementTListView(StatementMixin, TransactionListView): + pass diff --git a/nummi/transaction/__init__.py b/nummi/transaction/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nummi/transaction/apps.py b/nummi/transaction/apps.py new file mode 100644 index 0000000..55b277b --- /dev/null +++ b/nummi/transaction/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class TransactionConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "transaction" diff --git a/nummi/transaction/forms.py b/nummi/transaction/forms.py new file mode 100644 index 0000000..f492e21 --- /dev/null +++ b/nummi/transaction/forms.py @@ -0,0 +1,44 @@ +from category.models import Category +from main.forms import NummiFileInput, NummiForm +from Statement.models import Statement + +from .models import Invoice, Transaction + + +class TransactionForm(NummiForm): + class Meta: + model = Transaction + fields = [ + "statement", + "name", + "value", + "date", + "real_date", + "category", + "trader", + "payment", + "description", + ] + + def __init__(self, *args, **kwargs): + _user = kwargs.get("user") + _disable_statement = kwargs.pop("disable_statement", False) + super().__init__(*args, **kwargs) + self.fields["category"].queryset = Category.objects.filter(user=_user) + self.fields["statement"].queryset = Statement.objects.filter(user=_user) + if _disable_statement: + self.fields["statement"].disabled = True + + +class InvoiceForm(NummiForm): + prefix = "invoice" + + class Meta: + model = Invoice + fields = [ + "name", + "file", + ] + widgets = { + "file": NummiFileInput, + } diff --git a/nummi/transaction/migrations/0001_initial.py b/nummi/transaction/migrations/0001_initial.py new file mode 100644 index 0000000..60448ad --- /dev/null +++ b/nummi/transaction/migrations/0001_initial.py @@ -0,0 +1,175 @@ +# Generated by Django 4.1.4 on 2023-04-22 09:01 + +import datetime +import uuid + +import django.core.validators +import django.db.models.deletion +import media.utils +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("main", "0002_segmentation"), + ("category", "0001_initial"), + ("account", "0001_initial"), + ("statement", "0001_initial"), + ] + + state_operations = [ + migrations.CreateModel( + name="Transaction", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "name", + models.CharField( + default="Transaction", max_length=256, verbose_name="Name" + ), + ), + ( + "description", + models.TextField(blank=True, null=True, verbose_name="Description"), + ), + ( + "value", + models.DecimalField( + decimal_places=2, default=0, max_digits=12, verbose_name="Value" + ), + ), + ( + "date", + models.DateField(default=datetime.date.today, verbose_name="Date"), + ), + ( + "real_date", + models.DateField(blank=True, null=True, verbose_name="Real date"), + ), + ( + "trader", + models.CharField( + blank=True, max_length=128, null=True, verbose_name="Trader" + ), + ), + ( + "payment", + models.CharField( + blank=True, max_length=128, null=True, verbose_name="Payment" + ), + ), + ( + "account", + models.ForeignKey( + editable=False, + on_delete=django.db.models.deletion.CASCADE, + to="account.account", + verbose_name="Account", + ), + ), + ( + "category", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="category.category", + verbose_name="Category", + ), + ), + ( + "statement", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="statement.statement", + verbose_name="Statement", + ), + ), + ( + "user", + models.ForeignKey( + editable=False, + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + verbose_name="User", + ), + ), + ], + options={ + "verbose_name": "Transaction", + "verbose_name_plural": "Transactions", + "ordering": ["-date", "statement"], + "db_table": "transaction_transaction", + }, + ), + migrations.CreateModel( + name="Invoice", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "name", + models.CharField( + default="Invoice", max_length=256, verbose_name="Name" + ), + ), + ( + "file", + models.FileField( + max_length=128, + upload_to=media.utils.get_path, + validators=[ + django.core.validators.FileExtensionValidator(["pdf"]) + ], + verbose_name="File", + ), + ), + ( + "transaction", + models.ForeignKey( + editable=False, + on_delete=django.db.models.deletion.CASCADE, + to="transaction.transaction", + ), + ), + ( + "user", + models.ForeignKey( + editable=False, + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + verbose_name="User", + ), + ), + ], + options={ + "verbose_name": "Invoice", + "verbose_name_plural": "Invoices", + "ordering": ["transaction", "name"], + "db_table": "transaction_invoice", + }, + ), + ] + + operations = [ + migrations.SeparateDatabaseAndState(state_operations=state_operations) + ] diff --git a/nummi/transaction/migrations/__init__.py b/nummi/transaction/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nummi/transaction/models.py b/nummi/transaction/models.py new file mode 100644 index 0000000..3238a76 --- /dev/null +++ b/nummi/transaction/models.py @@ -0,0 +1,128 @@ +import datetime +from pathlib import Path +from uuid import uuid4 + +from account.models import Account +from category.models import Category +from django.core.validators import FileExtensionValidator +from django.db import models +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ +from main.models import UserModel +from media.utils import get_path +from statement.models import Statement + + +class Transaction(UserModel): + id = models.UUIDField(primary_key=True, default=uuid4, editable=False) + name = models.CharField( + max_length=256, default=_("Transaction"), verbose_name=_("Name") + ) + description = models.TextField(null=True, blank=True, verbose_name=_("Description")) + value = models.DecimalField( + max_digits=12, decimal_places=2, default=0, verbose_name=_("Value") + ) + date = models.DateField(default=datetime.date.today, verbose_name=_("Date")) + real_date = models.DateField(blank=True, null=True, verbose_name=_("Real date")) + trader = models.CharField( + max_length=128, blank=True, null=True, verbose_name=_("Trader") + ) + payment = models.CharField( + max_length=128, blank=True, null=True, verbose_name=_("Payment") + ) + category = models.ForeignKey( + Category, + on_delete=models.SET_NULL, + blank=True, + null=True, + verbose_name=_("Category"), + ) + statement = models.ForeignKey( + Statement, + on_delete=models.CASCADE, + verbose_name=_("Statement"), + ) + account = models.ForeignKey( + Account, + on_delete=models.CASCADE, + verbose_name=_("Account"), + editable=False, + ) + + def save(self, *args, **kwargs): + if Transaction.objects.filter(pk=self.pk): + prev_self = Transaction.objects.get(pk=self.pk) + else: + prev_self = None + self.account = self.statement.account + super().save(*args, **kwargs) + if prev_self is not None: + prev_self.statement.update_sum() + self.statement.update_sum() + + def __str__(self): + return f"{self.name}" + + def get_absolute_url(self): + return reverse("transaction", kwargs={"pk": self.pk}) + + def get_delete_url(self): + return reverse("del_transaction", kwargs={"pk": self.pk}) + + @property + def invoices(self): + return Invoice.objects.filter(transaction=self) + + @property + def has_invoice(self): + return self.invoices.count() > 0 + + class Meta: + ordering = ["-date", "statement"] + verbose_name = _("Transaction") + verbose_name_plural = _("Transactions") + + +class Invoice(UserModel): + id = models.UUIDField(primary_key=True, default=uuid4, editable=False) + name = models.CharField( + max_length=256, default=_("Invoice"), verbose_name=_("Name") + ) + file = models.FileField( + upload_to=get_path, + validators=[FileExtensionValidator(["pdf"])], + verbose_name=_("File"), + max_length=128, + ) + transaction = models.ForeignKey( + Transaction, on_delete=models.CASCADE, editable=False + ) + + def save(self, *args, **kwargs): + if Invoice.objects.filter(id=self.id).exists(): + _prever = Invoice.objects.get(id=self.id) + if _prever.file and _prever.file != self.file: + Path(_prever.file.path).unlink(missing_ok=True) + super().save(*args, **kwargs) + + def __str__(self): + return str(self.name) + + def delete(self, *args, **kwargs): + self.file.delete() + super().delete(*args, **kwargs) + + def get_absolute_url(self): + return reverse( + "invoice", kwargs={"transaction_pk": self.transaction.pk, "pk": self.pk} + ) + + def get_delete_url(self): + return reverse( + "del_invoice", kwargs={"transaction_pk": self.transaction.pk, "pk": self.pk} + ) + + class Meta: + verbose_name = _("Invoice") + verbose_name_plural = _("Invoices") + ordering = ["transaction", "name"] diff --git a/nummi/transaction/urls.py b/nummi/transaction/urls.py new file mode 100644 index 0000000..31866e9 --- /dev/null +++ b/nummi/transaction/urls.py @@ -0,0 +1,34 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path("transactions", views.TransactionListView.as_view(), name="transactions"), + path("transaction", views.TransactionCreateView.as_view(), name="new_transaction"), + path( + "transaction//invoice", + views.InvoiceCreateView.as_view(), + name="new_invoice", + ), + path("transaction/", views.TransactionUpdateView.as_view(), name="transaction"), + path( + "transaction//invoice/", + views.InvoiceUpdateView.as_view(), + name="invoice", + ), + path( + "transaction//delete", + views.TransactionDeleteView.as_view(), + name="del_transaction", + ), + path( + "transaction//invoice//delete", + views.InvoiceDeleteView.as_view(), + name="del_invoice", + ), + path( + "history//", + views.TransactionMonthView.as_view(), + name="transaction_month", + ), +] diff --git a/nummi/main/utils.py b/nummi/transaction/utils.py similarity index 87% rename from nummi/main/utils.py rename to nummi/transaction/utils.py index 467dde3..ef0a353 100644 --- a/nummi/main/utils.py +++ b/nummi/transaction/utils.py @@ -1,19 +1,8 @@ -import pathlib - from django.db import models from django.db.models import Func, Max, Min, Q, Sum, Value from django.db.models.functions import Now, TruncMonth -def get_path(instance, filename): - return pathlib.Path( - "user", - str(instance.user.username), - instance._meta.model_name, - str(instance.pk), - ).with_suffix(".pdf") - - class GenerateMonth(Func): function = "generate_series" template = "%(function)s(%(expressions)s, '1 month')::date" diff --git a/nummi/transaction/views.py b/nummi/transaction/views.py new file mode 100644 index 0000000..42bf7e1 --- /dev/null +++ b/nummi/transaction/views.py @@ -0,0 +1,144 @@ +from account.models import Account +from category.models import Category +from django.shortcuts import get_object_or_404 +from django.urls import reverse_lazy +from django.views.generic.dates import MonthArchiveView +from main.views import ( + NummiCreateView, + NummiDeleteView, + NummiListView, + NummiUpdateView, + UserMixin, +) +from statement.models import Statement + +from .forms import InvoiceForm, TransactionForm +from .models import Invoice, Transaction + + +class TransactionCreateView(NummiCreateView): + model = Transaction + form_class = TransactionForm + template_name = "main/form/transaction.html" + + def get_initial(self): + _queryset = Statement.objects.filter(user=self.request.user) + if "statement" in self.kwargs: + self.statement = get_object_or_404(_queryset, pk=self.kwargs["statement"]) + else: + self.statement = _queryset.first() + return {"statement": self.statement} + + def get_form_kwargs(self): + if "statement" in self.kwargs: + return super().get_form_kwargs() | {"disable_statement": True} + return super().get_form_kwargs() + + def get_context_data(self, **kwargs): + if "statement" in self.kwargs: + return super().get_context_data(**kwargs) | {"statement": self.statement} + return super().get_context_data(**kwargs) + + +class InvoiceCreateView(NummiCreateView): + model = Invoice + form_class = InvoiceForm + template_name = "main/form/invoice.html" + + def form_valid(self, form): + form.instance.transaction = get_object_or_404( + Transaction.objects.filter(user=self.request.user), + pk=self.kwargs["transaction_pk"], + ) + return super().form_valid(form) + + def get_success_url(self): + return reverse_lazy("transaction", kwargs={"pk": self.object.transaction.pk}) + + +class TransactionUpdateView(NummiUpdateView): + model = Transaction + form_class = TransactionForm + template_name = "main/form/transaction.html" + + +class InvoiceUpdateView(NummiUpdateView): + model = Invoice + form_class = InvoiceForm + template_name = "main/form/invoice.html" + + def get_success_url(self): + return reverse_lazy("transaction", kwargs={"pk": self.object.transaction.pk}) + + def get_queryset(self): + return ( + super() + .get_queryset() + .filter( + transaction=get_object_or_404( + Transaction, pk=self.kwargs["transaction_pk"] + ) + ) + ) + + +class TransactionDeleteView(NummiDeleteView): + model = Transaction + + +class InvoiceDeleteView(NummiDeleteView): + model = Invoice + + def get_success_url(self): + return reverse_lazy("transaction", kwargs={"pk": self.object.transaction.pk}) + + def get_queryset(self): + return ( + super() + .get_queryset() + .filter( + transaction=get_object_or_404( + Transaction, pk=self.kwargs["transaction_pk"] + ) + ) + ) + + +class TransactionListView(NummiListView): + model = Transaction + template_name = "main/list/transaction.html" + context_object_name = "transactions" + + +class TransactionMonthView(UserMixin, MonthArchiveView): + template_name = "main/month/transaction.html" + model = Transaction + date_field = "date" + context_object_name = "transactions" + month_format = "%m" + + account = None + category = None + + def get_queryset(self): + if "account" in self.kwargs: + self.account = get_object_or_404( + Account.objects.filter(user=self.request.user), + pk=self.kwargs["account"], + ) + return super().get_queryset().filter(account=self.account) + if "category" in self.kwargs: + self.category = get_object_or_404( + Category.objects.filter(user=self.request.user), + pk=self.kwargs["category"], + ) + return super().get_queryset().filter(category=self.category) + + return super().get_queryset() + + def get_context_data(self, **kwargs): + if "account" in self.kwargs: + return super().get_context_data(**kwargs) | {"account": self.account} + if "category" in self.kwargs: + return super().get_context_data(**kwargs) | {"category": self.category} + return super().get_context_data(**kwargs)