Compare commits

...

8 commits

71 changed files with 1643 additions and 948 deletions

2
.gitignore vendored
View file

@ -4,4 +4,4 @@ __pycache__
/nummi-git/
/pkg/
/src/
/nummi/media
/media

View file

6
nummi/account/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class AccountConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "account"

13
nummi/account/forms.py Normal file
View file

@ -0,0 +1,13 @@
from main.forms import NummiForm
from .models import Account
class AccountForm(NummiForm):
class Meta:
model = Account
fields = [
"name",
"icon",
"default",
]

View file

@ -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)
]

View file

@ -0,0 +1,16 @@
# Generated by Django 4.1.4 on 2023-04-22 09:28
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("account", "0001_initial"),
]
operations = [
migrations.AlterModelTable(
name="account",
table=None,
),
]

View file

49
nummi/account/models.py Normal file
View file

@ -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", args=(self.pk,))
def get_delete_url(self):
return reverse("del_account", args=(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

View file

@ -11,7 +11,7 @@
{% block tables %}
{% if not form.instance|adding %}
<h3>{% translate "Statements" %}</h3>
{% include "main/table/snapshot.html" %}
{% include "main/table/statement.html" %}
{% endif %}
{% if transactions %}
<h3>{% translate "Transactions" %}</h3>

35
nummi/account/urls.py Normal file
View file

@ -0,0 +1,35 @@
from django.urls import path
from statement.views import StatementCreateView
from transaction.views import TransactionMonthView
from . import views
urlpatterns = [
path("", 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",
StatementCreateView.as_view(),
name="new_statement",
),
path(
"<account>/delete",
views.AccountDeleteView.as_view(),
name="del_account",
),
path(
"<account>/history/<int:year>/<int:month>",
TransactionMonthView.as_view(),
name="transaction_month",
),
]

70
nummi/account/views.py Normal file
View file

@ -0,0 +1,70 @@
from django.shortcuts import get_object_or_404
from django.urls import reverse_lazy
from main.views import NummiCreateView, NummiDeleteView, NummiUpdateView
from statement.views import StatementListView
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
class AccountUpdateView(NummiUpdateView):
model = Account
form_class = AccountForm
pk_url_kwarg = "account"
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,)
)
_statements = account.statement_set.all()
if _statements.count() > _max:
data["statements_url"] = reverse_lazy(
"account_statements", args=(account.pk,)
)
return data | {
"transactions": _transactions[:8],
"new_statement_url": reverse_lazy(
"new_statement", kwargs={"account": account.pk}
),
"statements": _statements[:8],
"history": history(account.transaction_set),
}
class AccountDeleteView(NummiDeleteView):
model = Account
pk_url_kwarg = "account"
class AccountMixin:
def get_queryset(self):
self.account = get_object_or_404(
Account.objects.filter(user=self.request.user),
pk=self.kwargs.get("account"),
)
return super().get_queryset().filter(account=self.account)
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {"account": self.account}
class AccountTListView(AccountMixin, TransactionListView):
pass
class AccountSListView(AccountMixin, StatementListView):
pass

View file

6
nummi/category/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class CategoryConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "category"

13
nummi/category/forms.py Normal file
View file

@ -0,0 +1,13 @@
from main.forms import NummiForm
from .models import Category
class CategoryForm(NummiForm):
class Meta:
model = Category
fields = [
"name",
"icon",
"budget",
]

View file

@ -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)
]

View file

@ -0,0 +1,16 @@
# Generated by Django 4.1.4 on 2023-04-22 09:28
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("category", "0001_initial"),
]
operations = [
migrations.AlterModelTable(
name="category",
table=None,
),
]

View file

33
nummi/category/models.py Normal file
View file

@ -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", args=(self.pk,))
def get_delete_url(self):
return reverse("del_category", args=(self.pk,))
class Meta:
ordering = ["name"]
verbose_name = _("Category")
verbose_name_plural = _("Categories")

View file

@ -9,9 +9,11 @@
{% endblock %}
{% block h2 %}{{ form.instance.icon|remix }}{{ form.instance }}{% endblock %}
{% block tables %}
{% if form.instance.transactions %}
{% if transactions %}
<h3>{% translate "Transactions" %}</h3>
{% include "main/table/transaction.html" %}
{% endif %}
{% if history.data %}
<h3>{% translate "History" %}</h3>
{% include "main/plot/history.html" %}
{% endif %}

20
nummi/category/urls.py Normal file
View file

@ -0,0 +1,20 @@
from django.urls import path
from transaction.views import TransactionMonthView
from . import views
urlpatterns = [
path("", 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/<int:year>/<int:month>",
TransactionMonthView.as_view(),
name="transaction_month",
),
]

52
nummi/category/views.py Normal file
View file

@ -0,0 +1,52 @@
from django.shortcuts import get_object_or_404
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
class CategoryUpdateView(NummiUpdateView):
model = Category
form_class = CategoryForm
pk_url_kwarg = "category"
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
pk_url_kwarg = "category"
class CategoryMixin:
def get_queryset(self):
self.category = get_object_or_404(
Category.objects.filter(user=self.request.user),
pk=self.kwargs.get("category"),
)
return super().get_queryset().filter(category=self.category)
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {"category": self.category}
class CategoryTListView(CategoryMixin, TransactionListView):
pass

View file

@ -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)

View file

@ -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"])
],

View file

@ -0,0 +1,33 @@
# 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.RenameField("Transaction", "snapshot", "statement"),
migrations.SeparateDatabaseAndState(
database_operations=database_operations,
state_operations=state_operations,
),
]

View file

@ -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"]

View file

@ -33,9 +33,9 @@
accesskey="h">{% translate "Home" %}</a>
</li>
<li>
<a href="{% url "snapshots" %}"
class="{% if request.resolver_match.url_name == "snapshots" %}cur{% endif %}">
{% translate "Snapshots" %}
<a href="{% url "statements" %}"
class="{% if request.resolver_match.url_name == "statements" %}cur{% endif %}">
{% translate "Statements" %}
</a>
</li>
<li>
@ -50,9 +50,9 @@
accesskey="a">{% translate "Create account" %}</a>
</li>
<li>
<a href="{% url "new_snapshot" %}"
class="{% if request.resolver_match.url_name == "new_snapshot" %}cur{% endif %}"
accesskey="s">{% translate "Create snapshot" %}</a>
<a href="{% url "new_statement" %}"
class="{% if request.resolver_match.url_name == "new_statement" %}cur{% endif %}"
accesskey="s">{% translate "Create statement" %}</a>
</li>
<li>
<a href="{% url "new_category" %}"

View file

@ -36,9 +36,9 @@
</p>
{% endspaceless %}
{% endif %}
{% if snapshots %}
<h2>{% translate "Snapshots" %}</h2>
{% include "main/table/snapshot.html" %}
{% if statements %}
<h2>{% translate "Statements" %}</h2>
{% include "main/table/statement.html" %}
{% endif %}
{% if history.data %}
<h2>{% translate "History" %}</h2>

View file

@ -1,27 +0,0 @@
{% extends "main/base.html" %}
{% load static %}
{% load main_extras %}
{% load i18n %}
{% block link %}
{{ block.super }}
<link rel="stylesheet"
href="{% static 'main/css/table.css' %}"
type="text/css" />
<link rel="stylesheet"
href="{% static 'main/css/plot.css' %}"
type="text/css" />
{% endblock %}
{% block body %}
<h2>{% translate "Transactions" %} {{ month|date:"F Y"|capfirst }}</h2>
{% if account %}
<p>
<a href="{% url "account" account.pk %}">{{ account.icon|remix }}{{ account }}</a>
</p>
{% endif %}
{% if category %}
<p>
<a href="{% url "category" category.pk %}">{{ category.icon|remix }}{{ category }}</a>
</p>
{% endif %}
{% include "main/table/transaction.html" %}
{% endblock %}

View file

@ -1,12 +1,12 @@
{% load main_extras %}
{% load i18n %}
{% if new_snapshot_url %}
{% if new_statement_url %}
<p>
<a href="{{ new_snapshot_url }}">{% translate "Create statement" %}</a>
<a href="{{ new_statement_url }}">{% translate "Create statement" %}</a>
</p>
{% endif %}
<div id="snapshots" class="table">
<table class="full-width {% if snapshots_url %}more{% endif %}">
<div id="statements" class="table">
<table class="full-width {% if statements_url %}more{% endif %}">
<colgroup>
<col class="icon" span="2">
<col class="date">
@ -28,7 +28,7 @@
<th>{% translate "Transactions" %}</th>
</thead>
<tbody>
{% for snap in snapshots %}
{% for snap in statements %}
<tr>
{% if snap.sum == snap.diff %}
<td class="c green">{{ "check"|remix }}</td>
@ -39,12 +39,12 @@
{% if snap.file %}<a href="{{ snap.file.url }}">{{ "attachment"|remix }}</a>{% endif %}
</td>
<th class="date" scope="row">
<a href="{% url "snapshot" pk=snap.id %}">{{ snap.date|date:"Y-m-d" }}</a>
<a href="{% url "statement" snap.id %}">{{ snap.date|date:"Y-m-d" }}</a>
</th>
{% if not account %}
<td class="r">{{ snap.account.icon|remix }}</td>
<td>
<a href="{% url "account" pk=snap.account.id %}">{{ snap.account }}</a>
<a href="{% url "account" snap.account.id %}">{{ snap.account }}</a>
</td>
{% endif %}
<td class="value">{{ snap.value|value }}</td>
@ -55,8 +55,8 @@
</tbody>
</table>
</div>
{% if snapshots_url %}
{% if statements_url %}
<p>
<a href="{{ snapshots_url }}">{% translate "View all statements" %}</a>
<a href="{{ statements_url }}">{% translate "View all statements" %}</a>
</p>
{% endif %}

View file

@ -43,7 +43,7 @@
</td>
<td class="date">{{ trans.date|date:"Y-m-d" }}</td>
<th scope="row" class="l">
<a href="{% url "transaction" pk=trans.id %}">{{ trans.name }}</a>
<a href="{% url "transaction" trans.id %}">{{ trans.name }}</a>
</th>
<td class="value">{{ trans.value|pmvalue }}</td>
<td>{{ trans.trader|default_if_none:"" }}</td>
@ -51,7 +51,7 @@
{% if trans.category %}
<td class="r">{{ trans.category.icon|remix }}</td>
<td>
<a href="{% url "category" pk=trans.category.id %}">{{ trans.category }}</a>
<a href="{% url "category" trans.category.id %}">{{ trans.category }}</a>
</td>
{% else %}
<td colspan="2"></td>
@ -60,7 +60,7 @@
{% if not account %}
<td class="r">{{ trans.account.icon|remix }}</td>
<td>
<a href="{% url "account" pk=trans.account.id %}">{{ trans.account }}</a>
<a href="{% url "account" trans.account.id %}">{{ trans.account }}</a>
</td>
{% endif %}
</tr>

View file

@ -1,98 +1,14 @@
from django.urls import path
from django.urls import include, path
from . import views
urlpatterns = [
path("", views.IndexView.as_view(), name="index"),
path("media/user/<username>/<path:path>", views.MediaView.as_view(), name="media"),
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/<transaction_pk>/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/<pk>", views.AccountUpdateView.as_view(), name="account"),
path(
"account/<pk>/transactions",
views.AccountTListView.as_view(),
name="account_transactions",
),
path(
"account/<pk>/snapshots",
views.AccountSListView.as_view(),
name="account_snapshots",
),
path(
"account/<account>/snapshot",
views.SnapshotCreateView.as_view(),
name="new_snapshot",
),
path("transaction/<pk>", views.TransactionUpdateView.as_view(), name="transaction"),
path(
"transaction/<transaction_pk>/invoice/<pk>",
views.InvoiceUpdateView.as_view(),
name="invoice",
),
path("category/<pk>", views.CategoryUpdateView.as_view(), name="category"),
path(
"category/<pk>/transactions",
views.CategoryTListView.as_view(),
name="category_transactions",
),
path("snapshot/<pk>", views.SnapshotUpdateView.as_view(), name="snapshot"),
path(
"snapshot/<pk>/transactions",
views.SnapshotTListView.as_view(),
name="snapshot_transactions",
),
path(
"snapshot/<snapshot>/transaction",
views.TransactionCreateView.as_view(),
name="new_transaction",
),
path(
"account/<pk>/delete",
views.AccountDeleteView.as_view(),
name="del_account",
),
path(
"transaction/<pk>/delete",
views.TransactionDeleteView.as_view(),
name="del_transaction",
),
path(
"transaction/<transaction_pk>/invoice/<pk>/delete",
views.InvoiceDeleteView.as_view(),
name="del_invoice",
),
path(
"category/<pk>/delete", views.CategoryDeleteView.as_view(), name="del_category"
),
path(
"snapshot/<pk>/delete", views.SnapshotDeleteView.as_view(), name="del_snapshot"
),
path("search", views.SearchFormView.as_view(), name="search"),
path("search/<search>", views.SearchView.as_view(), name="search"),
path(
"history/<int:year>/<int:month>",
views.TransactionMonthView.as_view(),
name="transaction_month",
),
path(
"account/<account>/history/<int:year>/<int:month>",
views.TransactionMonthView.as_view(),
name="transaction_month",
),
path(
"category/<category>/history/<int:year>/<int:month>",
views.TransactionMonthView.as_view(),
name="transaction_month",
),
path("account/", include("account.urls")),
path("category/", include("category.urls")),
path("statement/", include("statement.urls")),
path("transaction/", include("transaction.urls")),
path("search/", include("search.urls")),
]

View file

@ -1,39 +1,18 @@
from django.conf import settings
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.core.exceptions import PermissionDenied
from django.db import models
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse_lazy
from django.views import View
from django.views.generic import (
CreateView,
DeleteView,
FormView,
ListView,
TemplateView,
UpdateView,
)
from django.views.generic.dates import MonthArchiveView
from django.views.static import serve
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):
@ -42,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
@ -87,6 +66,11 @@ class NummiDeleteView(UserMixin, DeleteView):
template_name = "main/confirm_delete.html"
success_url = reverse_lazy("index")
def get_form_kwargs(self):
_res = super().get_form_kwargs()
_res.pop("user")
return _res
class LoginView(auth_views.LoginView):
template_name = "main/login.html"
@ -97,371 +81,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 MediaView(LoginRequiredMixin, View):
def get(self, request, *args, **kwargs):
_username = kwargs.get("username")
_path = kwargs.get("path")
if request.user.get_username() != _username:
raise PermissionDenied
if settings.DEBUG:
return serve(request, f"user/{_username}/{_path}", settings.MEDIA_ROOT)
_res = HttpResponse()
_res["Content-Type"] = ""
_res["X-Accel-Redirect"] = f"/internal/media/user/{_username}/{_path}"
return _res
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)

0
nummi/media/__init__.py Normal file
View file

6
nummi/media/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class MediaConfig(AppConfig):
name = "media"
verbose_name = "Media"

7
nummi/media/urls.py Normal file
View file

@ -0,0 +1,7 @@
from django.urls import path
from . import views
urlpatterns = [
path("user/<username>/<path:path>", views.MediaView.as_view(), name="media"),
]

10
nummi/media/utils.py Normal file
View file

@ -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")

22
nummi/media/views.py Normal file
View file

@ -0,0 +1,22 @@
from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import PermissionDenied
from django.http import HttpResponse
from django.views import View
from django.views.static import serve
class MediaView(LoginRequiredMixin, View):
def get(self, request, *args, **kwargs):
_username = kwargs.get("username")
_path = kwargs.get("path")
if request.user.get_username() != _username:
raise PermissionDenied
if settings.DEBUG:
return serve(request, f"user/{_username}/{_path}", settings.MEDIA_ROOT)
_res = HttpResponse()
_res["Content-Type"] = ""
_res["X-Accel-Redirect"] = f"/internal/media/user/{_username}/{_path}"
return _res

View file

@ -44,7 +44,13 @@ CSRF_TRUSTED_ORIGINS = CONFIG.get("trusted_origins", ["http://localhost"])
# Application definition
INSTALLED_APPS = [
"main.apps.MainConfig",
"main",
"media",
"account",
"category",
"statement",
"transaction",
"search",
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",

View file

@ -19,6 +19,7 @@ from django.urls import include, path
urlpatterns = i18n_patterns(
path("", include("main.urls")),
path("media/", include("media.urls")),
path("admin/", admin.site.urls),
prefix_default_language=False,
)

0
nummi/search/__init__.py Normal file
View file

6
nummi/search/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class SearchConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "search"

7
nummi/search/forms.py Normal file
View file

@ -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)

View file

8
nummi/search/urls.py Normal file
View file

@ -0,0 +1,8 @@
from django.urls import path
from . import views
urlpatterns = [
path("search", views.SearchFormView.as_view(), name="search"),
path("search/<search>", views.SearchView.as_view(), name="search"),
]

44
nummi/search/views.py Normal file
View file

@ -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 = "search/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"]}

View file

6
nummi/statement/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class StatementConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "statement"

41
nummi/statement/forms.py Normal file
View file

@ -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

View file

@ -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)
]

View file

@ -0,0 +1,16 @@
# Generated by Django 4.1.4 on 2023-04-22 09:28
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("statement", "0001_initial"),
]
operations = [
migrations.AlterModelTable(
name="statement",
table=None,
),
]

View file

90
nummi/statement/models.py Normal file
View file

@ -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", args=(self.pk,))
def get_delete_url(self):
return reverse("del_statement", args=(self.pk,))
class Meta:
ordering = ["-date", "account"]
verbose_name = _("Statement")
verbose_name_plural = _("Statements")

View file

@ -4,7 +4,8 @@
{% load i18n %}
{% block title %}
{% translate "Statements" %}
{% if object %} {{ object }}{% endif %}
{% if account %} {{ account }}{% endif %}
{% if category %} {{ category }}{% endif %}
Nummi
{% endblock %}
{% block link %}
@ -18,12 +19,21 @@
{% endblock %}
{% block body %}
<h2>{% translate "Statements" %}</h2>
{% if object %}<a href="{{ object.get_absolute_url }}">{{ object }}</a>{% endif %}
{% if snapshots %}
{% if account %}
<p>
<a href="{{ account.get_absolute_url }}">{{ account.icon|remix }}{{ account }}</a>
</p>
{% endif %}
{% if category %}
<p>
<a href="{{ category.get_absolute_url }}">{{ category.icon|remix }}{{ category }}</a>
</p>
{% endif %}
{% if statements %}
{% include "main/list/pagination.html" %}
{% include "main/table/snapshot.html" %}
{% include "main/table/statement.html" %}
{% include "main/list/pagination.html" %}
{% else %}
<p>{% translate "No snapshots to show" %}</p>
<p>{% translate "No statements to show" %}</p>
{% endif %}
{% endblock %}

25
nummi/statement/urls.py Normal file
View file

@ -0,0 +1,25 @@
from django.urls import path
from transaction.views import TransactionCreateView
from . import views
urlpatterns = [
path("list", views.StatementListView.as_view(), name="statements"),
path("new", views.StatementCreateView.as_view(), name="new_statement"),
path("<statement>", views.StatementUpdateView.as_view(), name="statement"),
path(
"<statement>/transaction/list",
views.StatementTListView.as_view(),
name="statement_transactions",
),
path(
"<statement>/transaction/new",
TransactionCreateView.as_view(),
name="new_transaction",
),
path(
"<statement>/delete",
views.StatementDeleteView.as_view(),
name="del_statement",
),
]

97
nummi/statement/views.py Normal file
View file

@ -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
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
pk_url_kwarg = "statement"
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
pk_url_kwarg = "statement"
class StatementListView(NummiListView):
model = Statement
context_object_name = "statements"
class StatementMixin:
def get_queryset(self):
self.statement = get_object_or_404(
Statement.objects.filter(user=self.request.user),
pk=self.kwargs.get("statement"),
)
return super().get_queryset().filter(statement=self.statement)
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {"statement": self.statement}
class StatementTListView(StatementMixin, TransactionListView):
pass

View file

View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class TransactionConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "transaction"

View file

@ -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,
}

View file

@ -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)
]

View file

@ -0,0 +1,20 @@
# Generated by Django 4.1.4 on 2023-04-22 09:28
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("transaction", "0001_initial"),
]
operations = [
migrations.AlterModelTable(
name="invoice",
table=None,
),
migrations.AlterModelTable(
name="transaction",
table=None,
),
]

View file

124
nummi/transaction/models.py Normal file
View file

@ -0,0 +1,124 @@
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", args=(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", args=(self.transaction.pk, self.pk))
def get_delete_url(self):
return reverse("del_invoice", args=(self.transaction.pk, self.pk))
class Meta:
verbose_name = _("Invoice")
verbose_name_plural = _("Invoices")
ordering = ["transaction", "name"]

View file

@ -0,0 +1,2 @@
{% extends "transaction/transaction_list.html" %}
{% block h2 %}{{ month|date:"F Y"|capfirst }}{% endblock %}

View file

@ -4,7 +4,8 @@
{% load i18n %}
{% block title %}
{% translate "Transactions" %}
{% if object %} {{ object }}{% endif %}
{% if account %} {{ account }}{% endif %}
{% if category %} {{ category }}{% endif %}
{% if search %}
{% translate "Search" %}
{% endif %}
@ -20,10 +21,25 @@
type="text/css" />
{% endblock %}
{% block body %}
<h2>{% translate "Transactions" %}</h2>
{% if object %}<a href="{{ object.get_absolute_url }}">{{ object }}</a>{% endif %}
<h2>
{% block h2 %}
{% translate "Transactions" %}
{% endblock %}
</h2>
{% if account %}
<p>
<a href="{{ account.get_absolute_url }}">{{ account.icon|remix }}{{ account }}</a>
</p>
{% endif %}
{% if category %}
<p>
<a href="{{ category.get_absolute_url }}">{{ category.icon|remix }}{{ category }}</a>
</p>
{% endif %}
{% if search %}
<a href="{% url "search" %}">{% translate "Search" %}</a>
<p>
<a href="{% url "search" %}">{% translate "Search" %}</a>
</p>
{% endif %}
{% if transactions %}
{% include "main/list/pagination.html" %}

34
nummi/transaction/urls.py Normal file
View file

@ -0,0 +1,34 @@
from django.urls import path
from . import views
urlpatterns = [
path("list", views.TransactionListView.as_view(), name="transactions"),
path(
"history/<int:year>/<int:month>",
views.TransactionMonthView.as_view(),
name="transaction_month",
),
path("new", views.TransactionCreateView.as_view(), name="new_transaction"),
path("<transaction>", views.TransactionUpdateView.as_view(), name="transaction"),
path(
"<transaction>/delete",
views.TransactionDeleteView.as_view(),
name="del_transaction",
),
path(
"<transaction>/invoice/new",
views.InvoiceCreateView.as_view(),
name="new_invoice",
),
path(
"<transaction>/invoice/<invoice>",
views.InvoiceUpdateView.as_view(),
name="invoice",
),
path(
"<transaction>/invoice/<invoice>/delete",
views.InvoiceDeleteView.as_view(),
name="del_invoice",
),
]

View file

@ -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"

142
nummi/transaction/views.py Normal file
View file

@ -0,0 +1,142 @@
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
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
def form_valid(self, form):
form.instance.transaction = get_object_or_404(
Transaction.objects.filter(user=self.request.user),
pk=self.kwargs["transaction"],
)
return super().form_valid(form)
def get_success_url(self):
return reverse_lazy("transaction", args=(self.object.transaction.pk,))
class TransactionUpdateView(NummiUpdateView):
model = Transaction
form_class = TransactionForm
pk_url_kwarg = "transaction"
class InvoiceUpdateView(NummiUpdateView):
model = Invoice
form_class = InvoiceForm
pk_url_kwarg = "invoice"
def get_success_url(self):
return reverse_lazy("transaction", args=(self.object.transaction.pk,))
def get_queryset(self):
return (
super()
.get_queryset()
.filter(
transaction=get_object_or_404(
Transaction, pk=self.kwargs["transaction"]
)
)
)
class TransactionDeleteView(NummiDeleteView):
model = Transaction
pk_url_kwarg = "transaction"
class InvoiceDeleteView(NummiDeleteView):
model = Invoice
pk_url_kwarg = "invoice"
def get_success_url(self):
return reverse_lazy("transaction", args=(self.object.transaction.pk,))
def get_queryset(self):
return (
super()
.get_queryset()
.filter(
transaction=get_object_or_404(
Transaction, pk=self.kwargs["transaction"]
)
)
)
class TransactionListView(NummiListView):
model = Transaction
context_object_name = "transactions"
class TransactionMonthView(UserMixin, MonthArchiveView):
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)