Compare commits

...

60 commits

Author SHA1 Message Date
fc68368f3f
Fix entrypoint script path and update permissions for execution
All checks were successful
Build and push Docker image / build (push) Successful in 1m36s
2025-05-08 21:07:28 +02:00
100773c958
Remove version tag from Nummi image in Docker Compose configuration
All checks were successful
Build and push Docker image / build (push) Successful in 1m35s
2025-05-08 21:03:28 +02:00
530e6eaf47
Refactor static file settings to use configuration for STATIC_ROOT 2025-05-08 21:03:19 +02:00
0e0da7f658
Update Docker build workflow to include latest tag in image push 2025-05-08 20:59:08 +02:00
8a86ff9a4b
Update build workflow to trigger on version tags instead of main branch
All checks were successful
Build and push Docker image / build (push) Successful in 2m0s
2025-05-08 20:57:26 +02:00
b12f151b8d
Add Docker support with Dockerfile and docker-compose; implement entrypoint script for application startup 2025-05-08 20:56:55 +02:00
2c0e7b7699
Move to UV and Docker
All checks were successful
Build and push Docker image / build (push) Successful in 1m39s
2025-05-06 08:55:04 +02:00
08732b6ad4
Fix add transaction to empty statement bug 2025-02-06 18:01:52 +01:00
6563260b34
Add metadata and tags fields to Statement model; enhance search results to include statements
Close #44
2025-01-05 18:59:24 +01:00
4974c30397
Enhance invoice display with improved CSS styling and update invoice links
Fix #47
2025-01-05 18:51:59 +01:00
a277b37526
Refactor invoice handling to remove unused formset and improve metadata extraction from PDF files
Fix #46
2025-01-05 18:42:58 +01:00
7194039706
Fix history_plot function with regard to category and account checks
Fix #31
2025-01-05 17:12:29 +01:00
c153000d3d
Add invoice model with metadata and tags; update search and templates for invoice handling
Progress #44
2025-01-05 16:01:26 +01:00
608da4be55
Refactor category_plot tag to remove budget parameter
Fix #45
2025-01-05 15:15:35 +01:00
d246843be0
Enhance category plot template with improved URL handling for transactions based on year, month, statement, and account 2025-01-05 11:47:40 +01:00
e5ae5caf2a
Add history URL routing and templates for transaction history views 2025-01-05 11:38:28 +01:00
71f7a82f60
Refactor transaction archive templates
Fix #42
2025-01-05 09:43:29 +01:00
d5292911c2
Refactor models to inherit from NummiModel and implement custom query sets for enhanced search functionality 2025-01-04 21:57:21 +01:00
d44407d9ab
Refactor transaction URL handling and enhance filter form functionality
Close #43
2025-01-04 21:16:10 +01:00
02c53c7dab
Fix template variable references in transaction detail view 2025-01-04 20:28:32 +01:00
e42410863d
Update French translations 2025-01-04 18:57:13 +01:00
a238401f13
Add AccountSelect widget and update account field in forms
- Introduced AccountSelect widget for improved account selection in forms.
- Updated AccountForm, StatementForm, and TransactionFiltersForm to use AccountSelect.
- Modified account model options to include 'archived' in ordering.
- Added new migration for account model options change.
2025-01-04 18:45:36 +01:00
ccbf433ec0
Add filter icon 2025-01-04 18:33:04 +01:00
b848bf8d65
Implement filters and sorting for transactions
Close #5
2025-01-04 18:28:37 +01:00
7851e8afbb
Improve form buttons styling
Close #34
2025-01-04 14:34:23 +01:00
6a17a3e333
Add pre-commit hook to prevent commits to main 2025-01-04 14:21:59 +01:00
e1f29f90c6
Move transaction link
Close #35
Progress #38
2025-01-04 14:17:48 +01:00
805c7d3dc0
Enable file drag and drop on transaction details page
Close #37
2025-01-04 12:37:09 +01:00
cfb2ceb2c3
Fix #39 2025-01-03 22:46:53 +01:00
cb1882ef8a
Default statement transaction date
Fix #40
2025-01-03 21:53:01 +01:00
d2d101fe34
Resize block on textarea
Fix #41
2025-01-03 21:49:47 +01:00
ec5c0e2fef
Fix update_sum left in transaction models 2025-01-03 19:16:03 +01:00
0c5ba6899b
Fix no autocomplete on statement transaction creation
Fix #36
2025-01-03 19:04:40 +01:00
ff519ea7d1
Auto-fill transaction form
Close #27
2025-01-03 18:50:50 +01:00
0cb4a681f1
Refactor datalist input usage 2025-01-03 16:52:02 +01:00
f203d1db46
Fix transaction table (fix #33) 2025-01-03 16:46:36 +01:00
38ab298094
Improve transaction form
Default to no statement (fix #28)
Add link to statement on statement transaction creation
Fix textarea height
2025-01-03 16:25:49 +01:00
7478404d8a
Improve statement details page
Fix #29
2025-01-03 15:55:58 +01:00
d8961c8198
Improve forms
Form errors displayed correctly (fix #30)
Fix #26
Close #20
2025-01-03 15:21:06 +01:00
5b49836bad
Fix history plot
Fix #31
2025-01-03 14:55:16 +01:00
c14e075cad
Add VS Code debug config 2025-01-03 14:54:47 +01:00
a6fc7f5a92
Move statement sum and diff to properties instead of model fields
Fix #32
2025-01-03 14:25:40 +01:00
26f97dd179
Use only budgeted categories in history plot 2025-01-03 13:05:46 +01:00
2cba5c6d83
Hide archived accounts in select for new statements 2025-01-03 12:46:19 +01:00
c6c67b9f93
Fix invoices not being shown on transactions 2025-01-03 12:33:33 +01:00
1f22ac7042
JS bugfix 2025-01-02 17:36:45 +01:00
57b315c842
Fix bug with js for archived accounts 2025-01-02 17:33:25 +01:00
224f55a8b1
Add icon field, closes #24 2025-01-02 17:30:59 +01:00
b4654ec445
Add automatic icons to category select (#24) 2025-01-02 16:40:27 +01:00
348ac494e9
Use templatetag for transaction_table 2025-01-02 15:19:12 +01:00
229033aac0
Use templatetag for history_plot 2025-01-02 15:03:10 +01:00
b1fddd0dd6
Use templatetag for statement_table 2025-01-02 14:41:53 +01:00
786d7c2130
Use templatetags for statement_table 2025-01-02 14:19:06 +01:00
090f1a3a5c
Fix #21 2025-01-02 14:05:08 +01:00
bac3b59358
Fix #22 2025-01-02 14:02:01 +01:00
cb939df81e
Fix #23, Fix #25 2025-01-02 13:58:28 +01:00
e8050fadb9
Fix statement list, gaps 2025-01-02 10:16:05 +01:00
57d5330d75
Create account list view 2025-01-02 10:07:01 +01:00
8575f43475
Fix transaction table with no account 2025-01-02 09:37:47 +01:00
79f87779dd
Fix display on index page 2025-01-01 19:05:34 +01:00
98 changed files with 2315 additions and 8313 deletions

View file

@ -0,0 +1,29 @@
name: Build and push Docker image
on:
push:
tags:
- 'v*'
jobs:
build:
runs-on: ubuntu-latest
steps:
-
name: Login to Docker Hub
uses: docker/login-action@v3
with:
registry: code.edgarpierre.fr
username: ${{ vars.DOCKER_PUSH_USERNAME }}
password: ${{ secrets.DOCKER_PUSH_PASSWORD }}
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
-
name: Build and push
uses: docker/build-push-action@v6
with:
push: true
tags: |
code.edgarpierre.fr/${{ github.repository }}:${{ github.ref_name }}
code.edgarpierre.fr/${{ github.repository }}:latest

View file

@ -1,4 +1,10 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: check-added-large-files
- id: no-commit-to-branch
args: ["--branch", "main"]
- repo: https://github.com/PyCQA/isort
rev: 5.12.0
hooks:

1
.python-version Normal file
View file

@ -0,0 +1 @@
3.12

22
.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,22 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Python Debugger: Django",
"type": "debugpy",
"request": "launch",
"program": "${workspaceFolder}\\nummi\\manage.py",
"args": [
"runserver"
],
"env": {
"NUMMI_CONFIG": "${workspaceFolder}\\env\\config.toml"
},
"django": true
}
]
}

17
Dockerfile Normal file
View file

@ -0,0 +1,17 @@
FROM ghcr.io/astral-sh/uv:debian-slim
ADD . /app
WORKDIR /app
RUN useradd -m -r nummi && \
chown -R nummi /app
USER nummi
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV NUMMI_CONFIG=/nummi/config.toml
RUN uv sync --locked
CMD ["/app/entrypoint.sh"]

23
compose.yaml Normal file
View file

@ -0,0 +1,23 @@
services:
nummi:
image: code.edgarpierre.fr/edpibu/nummi
container_name: nummi
restart: unless-stopped
ports:
- 33001:8000
volumes:
- /docker/nummi/config:/nummi
- /docker/nummi/static:/app/static
- /docker/nummi/media:/app/media
depends_on:
- postgres
postgres:
image: postgres:17-alpine
container_name: nummi_postgres
restart: unless-stopped
environment:
POSTGRES_USER: nummi
POSTGRES_PASSWORD:
volumes:
- /docker/nummi/postgres:/var/lib/postgresql/data

5
entrypoint.sh Executable file
View file

@ -0,0 +1,5 @@
#!/usr/bin/env sh
cd /app/nummi
uv run manage.py collectstatic --noinput
uv run manage.py migrate --noinput
uv run gunicorn --bind :8000 --workers 2 nummi.wsgi:application

View file

@ -1,4 +1,5 @@
from main.forms import NummiForm
from django.forms.widgets import Select
from main.forms import IconInput, NummiForm
from .models import Account
@ -12,3 +13,10 @@ class AccountForm(NummiForm):
"default",
"archived",
]
widgets = {
"icon": IconInput(),
}
class AccountSelect(Select):
template_name = "account/forms/widgets/account.html"

View file

@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-01-04 16:18+0100\n"
"POT-Creation-Date: 2025-01-04 18:51+0100\n"
"PO-Revision-Date: 2023-04-22 15:17+0200\n"
"Last-Translator: Edgar P. Burkhart <traduction@edgarpierre.fr>\n"
"Language-Team: \n"
@ -17,46 +17,59 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: Poedit 3.2.2\n"
#: .\account\models.py:11 .\account\models.py:37 .\account\models.py:45
#: .\account\models.py:12 .\account\models.py:45 .\account\models.py:53
#: .\account\templates\account\account_list.html:9
msgid "Account"
msgstr "Compte"
#: .\account\models.py:11
#: .\account\models.py:12
msgid "Name"
msgstr "Nom"
#: .\account\models.py:15
#: .\account\models.py:16
msgid "Icon"
msgstr "Icône"
#: .\account\models.py:17
#: .\account\models.py:18
msgid "Default"
msgstr "Défaut"
#: .\account\models.py:38
#: .\account\models.py:19
msgid "Archived"
msgstr "Archivé"
#: .\account\models.py:46 .\account\templates\account\account_list.html:12
msgid "Accounts"
msgstr "Comptes"
#: .\account\templates\account\account_detail.html:13
#: .\account\templates\account\account_detail.html:15
msgid "Edit account"
msgstr "Modifier le compte"
#: .\account\templates\account\account_detail.html:16
#: .\account\templates\account\account_detail.html:18
msgid "Statements"
msgstr "Relevés"
#: .\account\templates\account\account_detail.html:20
msgid "Transactions"
msgstr "Transactions"
#: .\account\templates\account\account_detail.html:25
#: .\account\templates\account\account_detail.html:24
msgid "History"
msgstr "Historique"
#: .\account\templates\account\account_form.html:5
#: .\account\templates\account\account_table.html:35
msgid "Create account"
msgstr "Créer un compte"
#: .\account\templates\account\account_form.html:8
msgid "New account"
msgstr "Nouveau compte"
#: .\account\templates\account\account_table.html:16
msgid "All accounts"
msgstr "Tous les comptes"
#: .\account\templates\account\account_table.html:26
msgid "Show archived"
msgstr "Afficher archivés"
#~ msgid "Transactions"
#~ msgstr "Transactions"

View file

@ -0,0 +1,20 @@
# Generated by Django 4.2.7 on 2025-01-04 17:44
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("account", "0003_account_archived"),
]
operations = [
migrations.AlterModelOptions(
name="account",
options={
"ordering": ["-default", "archived", "name"],
"verbose_name": "Account",
"verbose_name_plural": "Accounts",
},
),
]

View file

@ -1,12 +1,13 @@
from uuid import uuid4
from django.apps import apps
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from main.models import UserModel
from main.models import NummiModel
class Account(UserModel):
class Account(NummiModel):
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
name = models.CharField(max_length=64, default=_("Account"), verbose_name=_("Name"))
icon = models.SlugField(
@ -33,13 +34,19 @@ class Account(UserModel):
def get_delete_url(self):
return reverse("del_account", args=(self.pk,))
@property
def transactions(self):
return apps.get_model("transaction", "Transaction").objects.filter(
statement__account=self
)
class Meta:
ordering = ["-default", "name"]
ordering = ["-default", "archived", "name"]
verbose_name = _("Account")
verbose_name_plural = _("Accounts")
class AccountModel(UserModel):
class AccountModel(NummiModel):
account = models.ForeignKey(
Account,
on_delete=models.CASCADE,

View file

@ -1,25 +1,27 @@
{% extends "main/base.html" %}
{% load main_extras %}
{% load main_extras history_extras statement_extras %}
{% load i18n %}
{% block title %}{{ object }} {{ block.super }}{% endblock %}
{% block title %}
{{ account }} {{ block.super }}
{% endblock title %}
{% block link %}
{{ block.super }}
{% css "main/css/table.css" %}
{% css "main/css/plot.css" %}
{% endblock %}
{% endblock link %}
{% block body %}
<h2>{{ object.icon|remix }}{{ object }}</h2>
<h2>{{ account.icon|remix }}{{ account }}</h2>
<p>
<a href="{% url "edit_account" object.pk %}">{{ "edit"|remix }}{% translate "Edit account" %}</a>
<a href="{% url "edit_account" account.pk %}">{{ "edit"|remix }}{% translate "Edit account" %}</a>
</p>
<section>
<h3>{% translate "Statements" %}</h3>
{% include "statement/statement_table.html" %}
{% url "new_statement" account=account.pk as ns_url %}
{% url "account_statements" account=account.pk as s_url %}
{% statement_table account.statement_set.all statements_url=s_url new_statement_url=ns_url n_max=6 %}
</section>
{% if history %}
<section>
<h3>{% translate "History" %}</h3>
{% include "history/plot.html" %}
{% history_plot account.transactions account=account %}
</section>
{% endif %}
{% endblock %}
{% endblock body %}

View file

@ -0,0 +1,16 @@
{% extends "main/list.html" %}
{% load i18n main_extras account_extras %}
{% block link %}
{{ block.super }}
{% css "main/css/table.css" %}
{% css "main/css/plot.css" %}
{% endblock link %}
{% block name %}
{% translate "Account" %}
{% endblock name %}
{% block h2 %}
{% translate "Accounts" %}
{% endblock h2 %}
{% block table %}
<div class="split">{% account_table accounts %}</div>
{% endblock table %}

View file

@ -0,0 +1,39 @@
{% load i18n main_extras %}
<dl class="accounts">
{% for acc in accounts %}
<div class="account {% if not search and acc.archived %}archived{% endif %}">
<dt>
<a href="{{ acc.get_absolute_url }}">{{ acc.icon|remix }}{{ acc }}</a>
</dt>
<dd class="value">
{% if acc.statement_set.first %}{{ acc.statement_set.first.value|value }}{% endif %}
</dd>
</div>
{% endfor %}
{% if index %}
<div class="more account">
<dt>
<a href="{% url "accounts" %}">{{ "gallery-view"|remixnl }}{% translate "All accounts" %}</a>
</dt>
<dd class="value">
{{ accounts|balance|value }}
</dd>
</div>
{% elif not search %}
<div class="more account">
<dt>
<label class="wi" for="show-archived-accounts">
{{ "archive"|remix }}{% translate "Show archived" %}
</label>
</dt>
<dd>
<input type="checkbox" class="show-archived" id="show-archived-accounts" />
</dd>
</div>
<div class="new account">
<dt>
<a href="{% url "new_account" %}">{{ "add-box"|remix }}{% translate "Create account" %}</a>
</dt>
</div>
{% endif %}
</dl>

View file

@ -0,0 +1,5 @@
{% load main_extras %}
<span class="ico-input account-select">
{{ "bank"|remix }}
{% include "django/forms/widgets/select.html" %}
</span>

View file

View file

@ -0,0 +1,10 @@
from django import template
register = template.Library()
@register.inclusion_tag("account/account_table.html")
def account_table(accounts, **kwargs):
return kwargs | {
"accounts": accounts,
}

View file

@ -1,10 +1,10 @@
from django.urls import path
from statement.views import StatementCreateView
from transaction.views import TransactionMonthView, TransactionYearView
from . import views
urlpatterns = [
path("list", views.AccountListView.as_view(), name="accounts"),
path("new", views.AccountCreateView.as_view(), name="new_account"),
path("<account>", views.AccountDetailView.as_view(), name="account"),
path("<account>/edit", views.AccountUpdateView.as_view(), name="edit_account"),
@ -28,14 +28,4 @@ urlpatterns = [
views.AccountDeleteView.as_view(),
name="del_account",
),
path(
"<account>/history/<int:year>",
TransactionYearView.as_view(),
name="account_transaction_year",
),
path(
"<account>/history/<int:year>/<int:month>",
TransactionMonthView.as_view(),
name="account_transaction_month",
),
]

View file

@ -1,10 +1,9 @@
from django.shortcuts import get_object_or_404
from django.urls import reverse_lazy
from history.utils import history
from main.views import (
NummiCreateView,
NummiDeleteView,
NummiDetailView,
NummiListView,
NummiUpdateView,
)
from statement.views import StatementListView
@ -33,25 +32,7 @@ class AccountDeleteView(NummiDeleteView):
class AccountDetailView(NummiDetailView):
model = Account
pk_url_kwarg = "account"
def get_context_data(self, **kwargs):
_max = 6
data = super().get_context_data(**kwargs)
account = data.get("object")
_statements = account.statement_set.all()
if _statements.count() > _max:
data["statements_url"] = reverse_lazy(
"account_statements", args=(account.pk,)
)
return data | {
"new_statement_url": reverse_lazy(
"new_statement", kwargs={"account": account.pk}
),
"statements": _statements[:_max],
"history": history(account.transaction_set),
}
context_object_name = "account"
class AccountMixin:
@ -66,6 +47,11 @@ class AccountMixin:
return super().get_context_data(**kwargs) | {"account": self.account}
class AccountListView(NummiListView):
model = Account
context_object_name = "accounts"
class AccountTListView(AccountMixin, TransactionListView):
pass

View file

@ -1,4 +1,5 @@
from main.forms import NummiForm
from django.forms.widgets import Select
from main.forms import IconInput, NummiForm
from .models import Category
@ -11,3 +12,10 @@ class CategoryForm(NummiForm):
"icon",
"budget",
]
widgets = {
"icon": IconInput,
}
class CategorySelect(Select):
template_name = "category/forms/widgets/category.html"

View file

@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-01-04 16:18+0100\n"
"POT-Creation-Date: 2025-01-04 18:51+0100\n"
"PO-Revision-Date: 2023-04-22 15:18+0200\n"
"Last-Translator: Edgar P. Burkhart <traduction@edgarpierre.fr>\n"
"Language-Team: \n"
@ -18,7 +18,7 @@ msgstr ""
"X-Generator: Poedit 3.2.2\n"
#: .\category\models.py:12 .\category\models.py:32
#: .\category\templates\category\category_plot.html:14
#: .\category\templates\category\category_plot.html:13
msgid "Category"
msgstr "Catégorie"
@ -38,15 +38,19 @@ msgstr "Budget"
msgid "Categories"
msgstr "Catégories"
#: .\category\templates\category\category_detail.html:12
#: .\category\templates\category\category_detail.html:14
msgid "Edit category"
msgstr "Modifier la catégorie"
#: .\category\templates\category\category_detail.html:15
#: .\category\templates\category\category_detail.html:17
msgid "Transactions"
msgstr "Transactions"
#: .\category\templates\category\category_detail.html:20
msgid "View all transactions"
msgstr "Voir toutes les transactions"
#: .\category\templates\category\category_detail.html:25
msgid "History"
msgstr "Historique"
@ -58,18 +62,22 @@ msgstr "Créer une catégorie"
msgid "New category"
msgstr "Nouvelle catégorie"
#: .\category\templates\category\category_plot.html:15
#: .\category\templates\category\category_plot.html:14
msgid "Expenses"
msgstr "Dépenses"
#: .\category\templates\category\category_plot.html:16
#: .\category\templates\category\category_plot.html:15
msgid "Income"
msgstr "Revenus"
#: .\category\templates\category\category_plot.html:62
#: .\category\templates\category\category_plot.html:58
msgid "No transaction"
msgstr "Aucune transaction"
#: .\category\templates\category\category_plot.html:70
#: .\category\templates\category\category_plot.html:66
msgid "Total"
msgstr "Total"
#: .\category\templates\category\category_plot.html:89
msgid "Expected total"
msgstr "Total attendu"

View file

@ -3,10 +3,10 @@ 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
from main.models import NummiModel
class Category(UserModel):
class Category(NummiModel):
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
name = models.CharField(
max_length=64, default=_("Category"), verbose_name=_("Name")

View file

@ -1,24 +1,28 @@
{% extends "main/base.html" %}
{% load main_extras i18n %}
{% block title %}{{ object }} {{ block.super }}{% endblock %}
{% load i18n main_extras history_extras transaction_extras %}
{% block title %}
{{ category }} {{ block.super }}
{% endblock title %}
{% block link %}
{{ block.super }}
{% css "main/css/table.css" %}
{% css "main/css/plot.css" %}
{% endblock %}
{% endblock link %}
{% block body %}
<h2>{{ object.icon|remix }}{{ object }}</h2>
<h2>{{ category.icon|remix }}{{ category }}</h2>
<p>
<a href="{% url "edit_category" object.pk %}">{{ "edit"|remix }}{% translate "Edit category" %}</a>
<a href="{% url "edit_category" category.pk %}">{{ "edit"|remix }}{% translate "Edit category" %}</a>
</p>
<section>
<h3>{% translate "Transactions" %}</h3>
{% include "transaction/transaction_table.html" %}
{% url_get "transactions" category=category.id as t_url %}
<p>
<a class="big-link" href="{{ t_url }}">{{ "list-check"|remixnl }}{% translate "View all transactions" %}</a>
</p>
{% transaction_table category.transaction_set.all n_max=8 transactions_url=t_url %}
</section>
{% if history %}
<section>
<h3>{% translate "History" %}</h3>
{% include "history/plot.html" %}
{% history_plot category.transaction_set category=category %}
</section>
{% endif %}
{% endblock %}
{% endblock body %}

View file

@ -1,4 +1,4 @@
{% load main_extras statement_extras %}
{% load main_extras statement_extras history_extras %}
{% load i18n %}
<div class="plot">
<table class="full-width">
@ -21,10 +21,14 @@
<tr>
<th scope="row" class="l wi">
{% if cat.category %}
{% if month %}
<a href="{% url "category_transaction_month" cat.category month.year month.month %}">{{ cat.category__icon|remix }}{{ cat.category__name }}</a>
{% elif year %}
<a href="{% url "category_transaction_year" cat.category year.year %}">{{ cat.category__icon|remix }}{{ cat.category__name }}</a>
{% if year %}
<a href="{% history_url year=year category=cat.category account=account.id %}">{{ cat.category__icon|remix }}{{ cat.category__name }}</a>
{% elif month %}
<a href="{% url_get "transactions" start_date=month end_date=month|end_of_month account=account.id category=cat.category %}">{{ cat.category__icon|remix }}{{ cat.category__name }}</a>
{% elif statement %}
<a href="{% url_get "transactions" account=statement.account.id statement=statement.id category=cat.category %}">{{ cat.category_.icon|remix }}{{ cat.category__name }}</a>
{% elif account %}
<a href="{% url_get "transactions" account=account.id category=cat.category %}">{{ cat.category__icon|remix }}{{ cat.category__name }}</a>
{% else %}
{{ cat.category__icon|remix }}{{ cat.category__name }}
{% endif %}

View file

@ -0,0 +1,5 @@
{% load main_extras %}
<span class="ico-input category-select">
{{ "folder"|remix }}
{% include "django/forms/widgets/select.html" %}
</span>

View file

@ -5,9 +5,11 @@ from django.db.models.functions import Greatest
register = template.Library()
@register.inclusion_tag("category/category_plot.html")
def category_plot(transactions, budget=True, **kwargs):
if budget:
@register.inclusion_tag("category/category_plot.html", takes_context=True)
def category_plot(context, transactions, **kwargs):
kwargs.setdefault("account", context.get("account"))
if not kwargs.get("account"):
transactions = transactions.exclude(category__budget=False)
categories = (
transactions.values("category", "category__name", "category__icon")

View file

@ -1,5 +1,4 @@
from django.urls import path
from transaction.views import TransactionMonthView, TransactionYearView
from . import views
@ -13,14 +12,4 @@ urlpatterns = [
name="category_transactions",
),
path("<category>/delete", views.CategoryDeleteView.as_view(), name="del_category"),
path(
"<category>/history/<int:year>",
TransactionYearView.as_view(),
name="category_transaction_year",
),
path(
"<category>/history/<int:year>/<int:month>",
TransactionMonthView.as_view(),
name="category_transaction_month",
),
]

View file

@ -1,6 +1,4 @@
from django.shortcuts import get_object_or_404
from django.urls import reverse_lazy
from history.utils import history
from main.views import (
NummiCreateView,
NummiDeleteView,
@ -27,21 +25,7 @@ class CategoryUpdateView(NummiUpdateView):
class CategoryDetailView(NummiDetailView):
model = Category
pk_url_kwarg = "category"
def get_context_data(self, **kwargs):
_max = 8
data = super().get_context_data(**kwargs)
category = data["object"]
data["transactions"] = category.transaction_set.all()[:_max]
if len(data["transactions"]) == _max:
data["transactions_url"] = reverse_lazy(
"category_transactions", args=(category.pk,)
)
return data | {
"history": history(category.transaction_set),
}
context_object_name = "category"
class CategoryDeleteView(NummiDeleteView):

View file

@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-01-03 15:51+0100\n"
"POT-Creation-Date: 2025-01-04 18:51+0100\n"
"PO-Revision-Date: 2023-04-22 15:18+0200\n"
"Last-Translator: Edgar P. Burkhart <traduction@edgarpierre.fr>\n"
"Language-Team: \n"
@ -17,18 +17,22 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: Poedit 3.2.2\n"
#: .\history\templates\history\plot.html:17
#: .\history\templates\history\plot.html:10
msgid "Year"
msgstr "Année"
#: .\history\templates\history\plot.html:13
msgid "Total"
msgstr "Total"
#: .\history\templates\history\plot.html:56
msgid "Month"
msgstr "Mois"
#: .\history\templates\history\plot.html:18
#: .\history\templates\history\plot.html:57
msgid "Expenses"
msgstr "Dépenses"
#: .\history\templates\history\plot.html:19
#: .\history\templates\history\plot.html:58
msgid "Income"
msgstr "Revenus"
#: .\history\templates\history\plot.html:55
msgid "Year"
msgstr "Année"

View file

@ -0,0 +1,25 @@
{% load history_extras %}
{% if month %}
<p class="pagination">
<a href="{% history_url year=month %}">{{ month.year }}</a>
</p>
{% endif %}
<p class="pagination n3">
{% if month %}
{% if previous_month %}
<a href="{% history_url month=previous_month %}">{{ previous_month|date:"F Y"|capfirst }}</a>
{% endif %}
<a class="cur" href="{% history_url month=month %}">{{ month|date:"F Y"|capfirst }}</a>
{% if next_month %}
<a href="{% history_url month=next_month %}">{{ next_month|date:"F Y"|capfirst }}</a>
{% endif %}
{% elif year %}
{% if previous_year %}
<a href="{% history_url year=previous_year %}">{{ previous_year|date:"Y" }}</a>
{% endif %}
<a class="cur" href="{% history_url year=year %}">{{ year|date:"Y" }}</a>
{% if next_year %}
<a href="{% history_url year=next_year %}">{{ next_year|date:"Y" }}</a>
{% endif %}
{% endif %}
</p>

View file

@ -18,7 +18,9 @@
{% for y, y_data in years_list reversed %}
<tr>
{% if not year %}
<th class="date" scope="row">{% year_url y %}</th>
<th class="date" scope="row">
<a href="{% history_url year=y account=account.id category=category.id %}">{{ y }}</a>
</th>
{% endif %}
{% for m in y_data %}
{% if forloop.parentloop.last and forloop.first %}
@ -65,11 +67,13 @@
<tr {% if not date.month.month|divisibleby:"2" %}class="even"{% endif %}>
<td class="icon">{% up_down_icon date.sum %}</td>
<th class="date" scope="row">
<a href="{% history_url year=date.month.year month=date.month.month account=account.id category=category.id %}">
{% if year %}
{% month_url date.month fmt="F" %}
{{ date.month|date:"F"|capfirst }}
{% else %}
{% month_url date.month %}
{{ date.month|date:"Y-m" }}
{% endif %}
</a>
</th>
<td class="value">{{ date.sum_m|pmrvalue }}</td>
<td class="bar m">{% plot_bar date.sum date.sum_m history.max.pm %}</td>

View file

@ -0,0 +1,51 @@
{% extends "main/base.html" %}
{% load i18n static main_extras transaction_extras category history_extras %}
{% block link %}
{{ block.super }}
{% css "main/css/plot.css" %}
{% css "main/css/table.css" %}
{% endblock link %}
{% block body %}
<h2>
{% block h2 %}
{% endblock h2 %}
</h2>
{% history_pagination %}
{% if account or category %}
<p class="back">
<a class="big-link"
href="{% history_url year=year month=month clear=True %}">{{ "arrow-go-back"|remix }}{% translate "Back" %}</a>
{% if account %}
<a class="big-link" href="{% url "account" account.id %}">{{ account.icon|remix }}{{ account }}</a>
{% endif %}
{% if category %}
<a class="big-link" href="{% url "category" category.id %}">{{ category.icon|remix }}{{ category }}</a>
{% endif %}
</p>
{% endif %}
{% if history %}
<section>
<h3>{% translate "History" %}</h3>
{% include "history/plot.html" %}
</section>
{% endif %}
{% if not category %}
<section>
<h3>{% translate "Categories" %}</h3>
{% category_plot transactions month=month year=year %}
</section>
{% endif %}
<section>
<h3>{% translate "Transactions" %}</h3>
{% if month %}
{% url_get "transactions" start_date=month end_date=month|end_of_month category=category.id account=account.id as t_url %}
{% elif year %}
{% url_get "transactions" start_date=year end_date=year|end_of_year category=category.id account=account.id as t_url %}
{% endif %}
<p>
<a class="big-link" href="{{ t_url }}">{{ "list-check"|remixnl }}{% translate "View all transactions" %}</a>
</p>
{% transaction_table transactions n_max=8 transactions_url=t_url %}
</section>
{% history_pagination %}
{% endblock body %}

View file

@ -0,0 +1,8 @@
{% extends "history/transaction_archive.html" %}
{% load i18n static main_extras transaction_extras category %}
{% block title %}
{{ month|date:"F Y"|capfirst }} {{ block.super }}
{% endblock title %}
{% block h2 %}
{{ month|date:"F Y"|capfirst }}
{% endblock h2 %}

View file

@ -0,0 +1,8 @@
{% extends "history/transaction_archive.html" %}
{% load i18n static main_extras transaction_extras category %}
{% block title %}
{{ year|date:"Y" }} {{ block.super }}
{% endblock title %}
{% block h2 %}
{{ year|date:"Y" }}
{% endblock h2 %}

View file

@ -1,17 +1,34 @@
import datetime
import math
from urllib import parse
from django import template
from django.urls import reverse
from django.utils.safestring import mark_safe
from history.utils import history
from main.templatetags.main_extras import pmrvalue, remix
register = template.Library()
@register.inclusion_tag("history/plot.html", takes_context=True)
def history_plot(context, transactions, **kwargs):
kwargs.setdefault("account", context.get("account"))
kwargs.setdefault("category", context.get("category"))
if kwargs.get("category") or kwargs.get("account"):
kwargs["history"] = history(transactions.all())
else:
kwargs["history"] = history(transactions.exclude(category__budget=False))
return kwargs
@register.simple_tag
def calendar_opacity(v, vmax):
if v is None:
return "0%"
return f"{math.sin(min(1, math.fabs(v/vmax))*math.pi/2):.0%}"
return f"{math.sin(min(1, math.fabs(v/vmax))*math.pi/2): .0%}"
@register.simple_tag
@ -48,11 +65,11 @@ def plot_bar(s, sum_pm, s_max):
if s_max:
if sum_pm:
_w = abs(sum_pm / s_max)
_res += f"""<div style="width: {_w:.1%}"></div>"""
_res += f"""<div style="width: {_w: .1%}"></div>"""
if sum_pm is not None and s * sum_pm > 0:
_w = abs(s / s_max)
_res += (
f"""<div class="tot" style="width: {_w:.1%}">"""
f"""<div class="tot" style="width: {_w: .1%}">"""
f"""<span>{pmrvalue(s)}</span></div>"""
)
else:
@ -64,7 +81,7 @@ def plot_bar(s, sum_pm, s_max):
@register.simple_tag
def calendar_head():
months = range(1, 13)
th = (f"""<th>{month:02d}</th>""" for month in months)
th = (f"""<th>{month: 02d}</th>""" for month in months)
return mark_safe("".join(th))
@ -72,3 +89,31 @@ def calendar_head():
@register.filter
def sum_year(y_data):
return sum(y["sum"] or 0 for y in y_data)
@register.inclusion_tag("history/pagination.html", takes_context=True)
def history_pagination(context):
return context
@register.simple_tag(takes_context=True)
def history_url(context, month=None, year=None, clear=False, **kwargs):
if not clear:
kwargs.setdefault("account", getattr(context.get("account"), "id", None))
kwargs.setdefault("category", getattr(context.get("category"), "id", None))
if month:
if isinstance(month, datetime.date):
year = month.year
month = month.month
url = reverse("history:month", kwargs={"year": year, "month": month})
elif year:
if isinstance(year, datetime.date):
year = year.year
url = reverse("history:year", kwargs={"year": year})
kwargs = {k: v for k, v in kwargs.items() if v}
if kwargs:
return f"{url}?{parse.urlencode(kwargs)}"
return url

13
nummi/history/urls.py Normal file
View file

@ -0,0 +1,13 @@
from django.urls import path
from . import views
app_name = "history"
urlpatterns = [
path(
"month/<int:year>/<int:month>",
views.TransactionMonthView.as_view(),
name="month",
),
path("year/<int:year>", views.TransactionYearView.as_view(), name="year"),
]

54
nummi/history/views.py Normal file
View file

@ -0,0 +1,54 @@
from django.shortcuts import get_object_or_404
from django.views.generic.dates import MonthArchiveView, YearArchiveView
from history.utils import history
from main.views import UserMixin
from transaction.models import Transaction
class ACFilterMixin:
def get_queryset(self):
queryset = super().get_queryset()
if account := self.request.GET.get("account"):
queryset = queryset.filter(statement__account=account)
if category := self.request.GET.get("category"):
queryset = queryset.filter(category=category)
return queryset
def get_context_data(self, **kwargs):
context_data = super().get_context_data(**kwargs)
if account := self.request.GET.get("account"):
context_data["account"] = get_object_or_404(
self.request.user.account_set, pk=account
)
if category := self.request.GET.get("category"):
context_data["category"] = get_object_or_404(
self.request.user.category_set, pk=category
)
return context_data
class TransactionMonthView(UserMixin, ACFilterMixin, MonthArchiveView):
model = Transaction
date_field = "date"
context_object_name = "transactions"
month_format = "%m"
template_name = "history/transaction_month.html"
class TransactionYearView(UserMixin, ACFilterMixin, YearArchiveView):
model = Transaction
date_field = "date"
context_object_name = "transactions"
make_object_list = True
template_name = "history/transaction_year.html"
def get_context_data(self, **kwargs):
context_data = super().get_context_data(**kwargs)
h_data = context_data.get("transactions")
if not (context_data.get("account") or context_data.get("category")):
h_data = h_data.exclude(category__budget=False)
context_data["history"] = history(h_data)
return context_data

View file

@ -1,4 +1,7 @@
from django import forms
from django.forms.widgets import TextInput
from .utils import get_icons
class NummiFileInput(forms.ClearableFileInput):
@ -9,7 +12,8 @@ class NummiForm(forms.ModelForm):
template_name = "main/form/form_base.html"
meta_fieldsets = []
def __init__(self, *args, user, **kwargs):
def __init__(self, *args, **kwargs):
kwargs.pop("user", None)
super().__init__(*args, **kwargs)
@property
@ -19,3 +23,29 @@ class NummiForm(forms.ModelForm):
yield ((self[f] for f in fieldset) for fieldset in group)
else:
yield ([f] for f in self)
class DatalistInput(TextInput):
template_name = "main/forms/widgets/datalist.html"
def __init__(self, *args, options=[]):
self.options = options
super().__init__(*args)
def get_context(self, *args):
context = super().get_context(*args)
name = context["widget"]["name"]
context["widget"]["attrs"]["list"] = f"{name}-list"
context["widget"]["attrs"]["autocomplete"] = "off"
context["widget"]["options"] = self.options
return context
class IconInput(DatalistInput):
template_name = "main/forms/widgets/icon.html"
icon_list = get_icons()
def get_context(self, *args):
context = super().get_context(*args)
context["widget"]["options"] = self.icon_list
return context

View file

@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-01-04 16:18+0100\n"
"POT-Creation-Date: 2025-01-04 18:51+0100\n"
"PO-Revision-Date: 2023-04-23 08:03+0200\n"
"Last-Translator: Edgar P. Burkhart <traduction@edgarpierre.fr>\n"
"Language-Team: \n"
@ -25,110 +25,105 @@ msgstr "Utilisateur"
msgid "Skip to main content"
msgstr "Aller au contenu principal"
#: .\main\templates\main\base.html:34
#: .\main\templates\main\base.html:35
msgid "Home"
msgstr "Accueil"
#: .\main\templates\main\base.html:39
#: .\main\templates\main\base.html:42 .\main\templates\main\index.html:17
msgid "Statements"
msgstr "Relevés"
#: .\main\templates\main\base.html:45
#: .\main\templates\main\base.html:49
msgid "Transactions"
msgstr "Transactions"
#: .\main\templates\main\base.html:51 .\main\templates\main\list.html:10
#: .\main\templates\main\list.html:34
#: .\main\templates\main\base.html:57 .\main\templates\main\list.html:10
#: .\main\templates\main\list.html:37
msgid "Search"
msgstr "Rechercher"
#: .\main\templates\main\base.html:54
msgid "Log out"
msgstr "Se déconnecter"
#: .\main\templates\main\base.html:59 .\main\templates\main\form\login.html:6
#: .\main\templates\main\login.html:11
msgid "Log in"
msgstr "Se connecter"
#: .\main\templates\main\base.html:65
#: .\main\templates\main\base.html:62
#, python-format
msgid "Logged in as <strong>%(user)s</strong>"
msgstr "Connecté en tant que <strong>%(user)s</strong>"
#: .\main\templates\main\base.html:66
msgid "Log out"
msgstr "Se déconnecter"
#: .\main\templates\main\base.html:74 .\main\templates\main\form\login.html:5
#: .\main\templates\main\login.html:11
msgid "Log in"
msgstr "Se connecter"
#: .\main\templates\main\confirm_delete.html:15
#, python-format
msgid "Are you sure you want do delete <strong>%(object)s</strong> ?"
msgstr "Êtes-vous sûr de vouloir supprimer <strong>%(object)s</strong> ?"
#: .\main\templates\main\confirm_delete.html:19
#: .\main\templates\main\confirm_delete.html:20
msgid "Cancel"
msgstr "Annuler"
#: .\main\templates\main\confirm_delete.html:20
#: .\main\templates\main\confirm_delete.html:21
msgid "Confirm"
msgstr "Confirmer"
#: .\main\templates\main\form\fileinput.html:8
#: .\main\templates\main\form\fileinput.html:6
msgid "File"
msgstr "Fichier"
#: .\main\templates\main\form\form_base.html:30
msgid "Delete"
msgstr "Supprimer"
#: .\main\templates\main\form\form_base.html:32
msgid "Reset"
msgstr "Réinitialiser"
#: .\main\templates\main\form\form_base.html:34
#: .\main\templates\main\form\form_base.html:46
msgid "Create"
msgstr "Créer"
#: .\main\templates\main\form\form_base.html:36
#: .\main\templates\main\form\form_base.html:48
msgid "Save"
msgstr "Enregistrer"
#: .\main\templates\main\form\form_base.html:50
msgid "Reset"
msgstr "Réinitialiser"
#: .\main\templates\main\form\form_base.html:52
msgid "Delete"
msgstr "Supprimer"
#: .\main\templates\main\index.html:13
msgid "Accounts"
msgstr "Comptes"
#: .\main\templates\main\index.html:17
msgid "Account"
msgstr "Compte"
#: .\main\templates\main\index.html:18
msgid "Balance"
msgstr "Solde"
#: .\main\templates\main\index.html:19 .\main\templates\main\index.html:31
msgid "Edit"
msgstr "Modifier"
#: .\main\templates\main\index.html:36
msgid "No account"
msgstr "Aucun compte"
#: .\main\templates\main\index.html:43
msgid "Create account"
msgstr "Créer un compte"
#: .\main\templates\main\index.html:50
#: .\main\templates\main\index.html:23
msgid "Categories"
msgstr "Catégories"
#: .\main\templates\main\index.html:56
#: .\main\templates\main\index.html:29
msgid "Create category"
msgstr "Créer une catégorie"
#: .\main\templates\main\index.html:63
#: .\main\templates\main\index.html:34
msgid "History"
msgstr "Historique"
#: .\main\views.py:69
#: .\main\views.py:54
msgid "was created successfully"
msgstr "a été créé avec succès"
#~ msgid "Account"
#~ msgstr "Compte"
#~ msgid "Balance"
#~ msgstr "Solde"
#~ msgid "Edit"
#~ msgstr "Modifier"
#~ msgid "No account"
#~ msgstr "Aucun compte"
#~ msgid "Create account"
#~ msgstr "Créer un compte"
#~ msgid "Create statement"
#~ msgstr "Créer un relevé"

View file

@ -1,15 +1,46 @@
from django.conf import settings
from django.contrib.postgres.search import (
SearchQuery,
SearchRank,
SearchVector,
TrigramSimilarity,
)
from django.db import models
from django.utils.translation import gettext_lazy as _
class UserModel(models.Model):
class NummiQuerySet(models.QuerySet):
main_field = "name"
fields = dict()
def search(self, search):
return (
self.annotate(
rank=SearchRank(
sum(
(
SearchVector(field, weight=weight)
for field, weight in self.fields.items()
),
start=SearchVector(self.main_field, weight="A"),
),
SearchQuery(search, search_type="websearch"),
),
similarity=TrigramSimilarity(self.main_field, search),
)
.filter(models.Q(rank__gte=0.1) | models.Q(similarity__gte=0.3))
.order_by("-rank")
)
class NummiModel(models.Model):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
verbose_name=_("User"),
editable=False,
)
objects = NummiQuerySet.as_manager()
class Meta:
abstract = True

View file

@ -1,41 +1,105 @@
form ul.errorlist {
color: var(--red);
font-weight: 550;
list-style-type: "! ";
margin: 0;
.drop-zone {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
grid-template-columns: 1fr;
grid-template-rows: 1fr;
align-items: center;
text-align: center;
color: transparent;
display: grid;
transition-property: backdrop-filter;
transition-duration: 750ms;
z-index: -1;
> span {
font-weight: 650;
font-size: 2rem;
transition-property: color;
transition-duration: inherit;
}
main.highlight > & {
z-index: 100;
backdrop-filter: blur(0.1rem);
> span {
color: var(--green);
}
}
}
form {
display: grid;
grid-gap: var(--gap);
gap: 0.5rem;
grid-template-columns: repeat(auto-fill, 32rem);
@media (width < 1024px) {
grid-template-columns: 1fr;
}
&.hidden {
display: none;
}
.column {
display: grid;
grid-gap: var(--gap);
gap: 0.5rem;
border: 1px solid var(--gray);
padding: var(--gap);
block-size: min-content;
}
.fieldset {
display: grid;
grid-auto-columns: 1fr;
grid-auto-flow: column;
grid-gap: inherit;
gap: inherit;
padding: 0;
margin: 0;
border: none;
}
ul.errorlist {
color: var(--red);
font-weight: 550;
list-style: none;
padding: 0;
margin: 0;
}
.field {
display: grid;
grid-auto-rows: min-content;
overflow: hidden;
align-items: center;
column-gap: 0.5rem;
ul.errorlist {
font-size: 0.8rem;
}
&:has(> textarea) {
grid-template-rows: min-content 1fr;
textarea {
resize: block;
}
}
&:has(> input[type="checkbox"]) {
grid-template-columns: min-content 1fr;
> label {
font-size: inherit;
grid-row: 1;
grid-column: 2;
padding: 0.5rem;
line-height: initial;
}
> input {
grid-row: 1;
grid-column: 1;
margin: 0.5rem;
}
&:has(> :focus) {
background: var(--bg-1);
}
}
> label {
@ -44,15 +108,33 @@ form {
z-index: 10;
}
> a {
padding: 0.5rem;
}
input,
select,
textarea {
font: inherit;
line-height: initial;
border: none;
border: 1px solid transparent;
border-bottom: 1px solid var(--gray);
background: none;
z-index: 1;
padding: 0.5rem;
&:has(~ ul.errorlist) {
border-color: var(--red);
}
&.autocompleted:not(.mod) {
border-bottom-color: var(--green);
}
&:not([type="checkbox"]) {
width: 100%;
margin: 0;
}
&[name*="value"] {
text-align: right;
@ -64,7 +146,7 @@ form {
&:focus {
outline: none;
background: var(--bg-01);
background: var(--bg-1);
}
}
@ -76,6 +158,9 @@ form {
grid-template-columns: 1fr;
grid-auto-columns: max-content;
grid-auto-flow: column;
a {
padding: 0.5rem;
}
}
input[type="file"] {
@ -84,37 +169,49 @@ form {
}
}
}
> .ico-input {
display: grid;
grid-template-columns: min-content 1fr;
column-gap: 0.5rem;
align-items: center;
span[class|="ri"] {
padding: 0.5rem;
}
&:has(> :focus) {
background: var(--bg-1);
}
}
}
.buttons {
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
grid-auto-rows: 1fr;
grid-gap: var(--gap);
align-items: center;
input {
line-height: 2rem;
input,
a {
font: inherit;
line-height: 1.5;
border-radius: var(--radius);
padding: 0 var(--gap);
cursor: pointer;
padding: 0 var(--gap);
border: var(--gray) 1px solid;
margin-right: 0.5rem;
margin-bottom: 0.5rem;
color: inherit;
&:hover {
text-decoration: underline;
}
&[type="submit"] {
border: 0.1rem solid var(--green);
}
input[type="submit"] {
background: var(--green-1);
border-color: var(--green);
}
&[type="reset"] {
border: 0.1rem solid var(--red);
background: var(--red-1);
}
input[type="reset"] {
background: var(--bg-1);
}
a.del {
color: var(--red);
border-color: var(--red);
border-style: dashed;
}
}
}

View file

@ -1,5 +1,5 @@
@import "https://rsms.me/inter/inter.css";
@import "https://cdn.jsdelivr.net/npm/remixicon@4.0.0/fonts/remixicon.css";
@import "https://cdn.jsdelivr.net/npm/remixicon@4.5.0/fonts/remixicon.css";
*,
*::before,
@ -24,7 +24,7 @@
--bg-inv: var(--theme-1);
--text-inv: #ffffffde;
--bg-01: #f0f0f0;
--bg-1: #f0f0f0;
--text-green: #296629;
--text-link: var(--text-green);
@ -81,7 +81,11 @@ a {
main,
nav,
footer {
padding: 2rem 1rem;
@media (width > 720px) {
padding: 2rem;
}
background: var(--bg);
}
main {
position: relative;
@ -94,8 +98,7 @@ main {
.split {
display: grid;
column-gap: var(--gap);
row-gap: var(--gap);
gap: var(--gap);
grid-template-columns: 100%;
@media (width > 720px) {
grid-template-columns: minmax(20rem, max-content) 1fr;
@ -115,7 +118,7 @@ nav {
top: 0;
overflow-y: auto;
background: var(--bg-01);
background: var(--bg-1);
line-height: 2rem;
h1 img {
@ -237,7 +240,7 @@ footer {
color: var(--bg);
}
&.white {
background: var(--bg-01);
background: var(--bg-1);
}
border-radius: var(--radius);
height: 1.5em;
@ -280,7 +283,7 @@ ul.messages {
list-style-type: none;
margin: 0;
margin-bottom: var(--gap);
background: var(--bg-01);
background: var(--bg-1);
padding: 0;
li {
@ -336,13 +339,9 @@ ul.messages {
}
}
.accounts {
display: grid;
grid-row-gap: 0.5rem;
grid-auto-rows: min-content;
dl {
dl.accounts {
margin: 0;
dt,
dd {
margin: 0;
@ -365,21 +364,21 @@ ul.messages {
&:not(.show-archive) .account.archived {
display: none;
}
}
}
ul.statements,
ul.invoices {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr));
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
grid-auto-rows: 1fr;
grid-gap: var(--gap);
gap: 0.5rem;
list-style: none;
padding: 0;
margin: 0;
li {
display: grid;
grid-row-gap: var(--gap);
gap: 0.5rem;
padding: var(--gap);
border: var(--gray) 1px solid;
text-align: right;
@ -388,6 +387,9 @@ ul.invoices {
> * {
&.title {
font-weight: 650;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
@ -424,14 +426,19 @@ ul.statements {
}
}
ul.invoices {
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
}
.statement-details {
overflow-x: auto;
display: grid;
grid-template-columns: repeat(4, min-content);
grid-gap: var(--gap);
@media (width > 720px) {
grid-template-columns: repeat(3, min-content);
}
gap: var(--gap);
align-items: center;
> span:nth-child(2) {
> span.evolution {
display: grid;
grid-auto-rows: min-content;
> span[class^="ri-"] {
@ -441,12 +448,49 @@ ul.statements {
text-align: right;
}
}
> span:first-child,
> span:nth-child(3) {
> span.start,
> span.end,
> span.file,
> span.incons {
display: grid;
border: var(--gray) 1px solid;
padding: var(--gap);
&.file,
&.incons {
grid-column: 1 / -1;
}
&.incons {
border-color: var(--red);
grid-template-columns: min-content 1fr;
grid-auto-flow: column;
align-items: center;
gap: 0.5rem;
> .ds {
display: grid;
grid-auto-rows: min-content;
}
.value {
text-align: right;
}
.diff {
font-weight: 650;
}
.sum {
text-decoration: line-through;
}
.links {
grid-column: 1 / -1;
grid-row: 2;
display: grid;
gap: inherit;
a {
color: var(--red);
}
}
}
> .date {
text-align: right;
}
@ -485,11 +529,12 @@ ul.statements {
}
}
.category {
.category,
.big-link {
padding: 0 var(--gap);
border: var(--gray) 1px solid;
margin-right: var(--gap);
margin-bottom: var(--gap);
margin-right: 0.5rem;
margin-bottom: 0.5rem;
&.add {
border-style: dashed;
@ -500,3 +545,28 @@ ul.statements {
.value {
font-feature-settings: var(--num);
}
details {
border: var(--gray) 1px solid;
margin-bottom: var(--gap);
summary {
font-weight: 650;
cursor: pointer;
padding: var(--gap);
&::marker {
content: "\ed27\2002";
font-family: remixicon;
font-weight: initial;
}
}
&[open] summary {
background: var(--bg-1);
}
form {
padding: var(--gap);
}
}

View file

@ -1,5 +1,4 @@
.table,
form {
.table {
overflow-x: auto;
width: 100%;
}
@ -17,7 +16,7 @@ table {
width: 1ch;
}
thead tr:not(.new) {
background: var(--bg-01);
background: var(--bg-1);
}
tr {
border: 1px solid var(--gray);
@ -45,9 +44,7 @@ table {
font-weight: 300;
}
}
tfoot tr:not(.new) {
background: var(--bg-01);
}
.l {
text-align: left;
}

File diff suppressed because one or more lines are too long

View file

@ -18,4 +18,164 @@ for (let form of forms) {
form.addEventListener("reset", (event) => {
window.removeEventListener("beforeunload", beforeUnloadHandler);
});
let categorySelect = form.querySelector(".category-select");
if (categorySelect) {
let input = categorySelect.querySelector("select");
let icon = categorySelect.querySelector("span");
let icons = JSON.parse(input.dataset.icons);
function setIcon(event) {
icon.className = `ri-${icons[input.value] || "folder"}-line`;
}
setIcon();
input.addEventListener("input", setIcon);
form.addEventListener("reset", (event) => {
setTimeout(setIcon, 0);
});
}
let accountSelect = form.querySelector(".account-select");
if (accountSelect) {
let input = accountSelect.querySelector("select");
let icon = accountSelect.querySelector("span");
let icons = JSON.parse(input.dataset.icons);
function setIcon(event) {
icon.className = `ri-${icons[input.value] || "bank"}-line`;
}
setIcon();
input.addEventListener("input", setIcon);
form.addEventListener("reset", (event) => {
setTimeout(setIcon, 0);
});
}
let iconSelect = form.querySelector(".icon-select");
if (iconSelect) {
let input = iconSelect.querySelector("input");
let icon = iconSelect.querySelector("span");
let icons = Array.from(iconSelect.querySelector("datalist").options).map(
(opt) => opt.value,
);
function setIcon(event) {
icon.className = `ri-${
icons.includes(input.value) ? input.value : "square"
}-line`;
}
setIcon();
input.addEventListener("input", setIcon);
form.addEventListener("reset", (event) => {
setTimeout(setIcon, 0);
});
}
let ac_json = form.querySelector("#autocomplete");
if (ac_json) {
let autocomplete_data = JSON.parse(ac_json.textContent);
let fields = form.querySelectorAll("[name]");
function clearMod(event) {
event.target.classList.add("mod");
event.target.removeEventListener("input", clearMod);
}
function setMod() {
for (let f of fields) {
f.addEventListener("input", clearMod);
}
}
setMod();
form.addEventListener("reset", (event) => {
for (let f of fields) {
f.classList.remove("mod", "autocompleted");
}
setMod();
});
let old_values = [];
let field = form.querySelector(`[name="${autocomplete_data.field}"]`);
field.addEventListener("change", (event) => {
if (field.value in autocomplete_data.data) {
old_values = [];
for (let comp of autocomplete_data.data[field.value]) {
let f = form.querySelector(`[name="${comp.field}"]`);
if (!f.classList.contains("mod")) {
old_values.push({ field: f, value: f.value });
f.value = comp.value;
f.dispatchEvent(new Event("input"));
f.animate([{ borderColor: "var(--green)" }, {}], 750);
f.classList.remove("mod");
f.classList.add("autocompleted");
f.addEventListener("input", clearMod);
function ccAutocomplete(event) {
document.removeEventListener("keyup", cancelAutocomplete);
for (comp of old_values) {
comp.field.removeEventListener("input", ccAutocomplete);
}
}
f.addEventListener("input", ccAutocomplete);
}
}
function cancelAutocomplete(event) {
if (event.code == "Escape") {
for (comp of old_values) {
let f = comp.field;
f.value = comp.value;
f.dispatchEvent(new Event("input"));
f.animate([{ borderColor: "var(--red)" }, {}], 750);
f.classList.remove("mod");
f.classList.remove("autocompleted");
f.addEventListener("input", clearMod);
}
document.removeEventListener("keyup", cancelAutocomplete);
}
}
document.addEventListener("keyup", cancelAutocomplete);
form.addEventListener("reset", (event) => {
document.removeEventListener("keyup", cancelAutocomplete);
});
}
});
}
}
let accounts = document.querySelector("dl.accounts");
if (accounts) {
let toggle = accounts.querySelector("input#show-archived-accounts");
if (toggle) {
accounts.classList.toggle("show-archive", toggle.checked);
toggle.addEventListener("change", (event) => {
accounts.classList.toggle("show-archive", toggle.checked);
});
}
}
const filterForm = document.querySelector("form.filter");
if (filterForm) {
const accountSelect = filterForm.querySelector("[name='account']");
const statementSelect = filterForm.querySelector("[name='statement']");
if (!statementSelect.disabled) {
accountSelect.addEventListener("input", (event) => {
statementSelect.value = "";
statementSelect.disabled = true;
});
filterForm.addEventListener("reset", (event) => {
statementSelect.disabled = false;
});
}
let disableStatement = false;
filterForm.addEventListener("submit", (event) => {
for (element of filterForm.elements) {
if (
element.value == "" ||
(disableStatement && element.name == "statement")
) {
if (element.name == "account") {
disableStatement = true;
}
element.disabled = true;
}
}
});
}

View file

@ -1,10 +0,0 @@
let accounts = document.querySelector(".accounts");
let toggle = accounts.querySelector("input#show-archived-accounts");
let dl = accounts.querySelector("dl");
dl.classList.toggle("show-archive", toggle.checked);
if (toggle) {
toggle.addEventListener("change", (event) => {
dl.classList.toggle("show-archive", toggle.checked);
});
}

File diff suppressed because it is too large Load diff

View file

@ -5,7 +5,7 @@
<div class="current">
<a href="{{ widget.value.url }}" target="_blank">{{ "file"|remix }}{% translate "File" %} [{{ widget.value|extension }}]</a>
{% if not widget.required %}
<span class="checkbox">
<span class="field checkbox">
<label for="{{ widget.checkbox_id }}">{{ widget.clear_checkbox_label }}</label>
<input type="checkbox"
name="{{ widget.checkbox_name }}"
@ -19,34 +19,3 @@
name="{{ widget.name }}"
{% include "django/forms/widgets/attrs.html" %}>
</div>
{% comment %}
<table class="file-input">
{% if widget.is_initial %}
<tr>
<th>{{ widget.initial_text }}</th>
<td>
<a href="{{ widget.value.url }}">{% translate "File" %} [{{ widget.value|extension }}]</a>
</td>
</tr>
{% if not widget.required %}
<tr>
<th>
<label for="{{ widget.checkbox_id }}">{{ widget.clear_checkbox_label }}</label>
</th>
<td>
<input type="checkbox" name="{{ widget.checkbox_name }}" id="{{ widget.checkbox_id }}" {% if widget.attrs.disabled %}disabled{% endif %}>
</td>
</tr>
{% endif %}
<tr>
<th>{{ widget.input_text }}</th>
<td>
{% else %}
<tr>
<td colspan="2">
{% endif %}
<input type="{{ widget.type }}" name="{{ widget.name }}" {% include "django/forms/widgets/attrs.html" %}>
</td>
</tr>
</table>
{% endcomment %}

View file

@ -1,6 +1,7 @@
{% load i18n %}
{% load main_extras %}
{% block fields %}
{% if form.autocomplete %}{{ form.autocomplete|json_script:"autocomplete" }}{% endif %}
{% if form.non_field_errors %}
<ul class="errorlist">
{% for error in form.non_field_errors %}<li>{{ error }}</li>{% endfor %}
@ -12,10 +13,16 @@
{% for fieldset in group %}
<div class="fieldset">
{% for field in fieldset %}
{% if field.errors %}<p class="error">{{ field.errors }}</p>{% endif %}
<div class="field">
<label for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
{% if field.errors %}
<ul class="errorlist">
{% for error in field.errors %}
<li class="wi">{{ "error-warning"|remix }}{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% endfor %}
</div>

View file

@ -0,0 +1,5 @@
{% load main_extras %}
<span class="ico-input icon-select">
{{ "square"|remix }}
{% include "main/forms/widgets/datalist.html" %}
</span>

View file

@ -1,65 +1,37 @@
{% extends "main/base.html" %}
{% load static %}
{% load main_extras %}
{% load main_extras account_extras history_extras statement_extras %}
{% load i18n %}
{% block link %}
{{ block.super }}
{% css "main/css/table.css" %}
{% css "main/css/plot.css" %}
{% js "main/js/index.js" %}
{% endblock link %}
{% block body %}
<div class="split">
<section class="accounts">
<section>
<h2>{% translate "Accounts" %}</h2>
<dl>
{% for acc in accounts %}
<div class="account {% if acc.archived %}archived{% endif %}">
<dt>
<a href="{{ acc.get_absolute_url }}">{{ acc.icon|remix }}{{ acc }}</a>
</dt>
<dd>
{% if acc.statement_set.first %}{{ acc.statement_set.first.value|value }}{% endif %}
</dd>
</div>
{% endfor %}
<div class="more account">
<dt>
<label class="wi" for="show-archived-accounts">
{{ "archive"|remix }}{% translate "Show archived" %}
</label>
</dt>
<dd>
<input type="checkbox" class="show-archived" id="show-archived-accounts" />
</dd>
</div>
<div class="new account">
<dt>
<a href="{% url "new_account" %}">{{ "add-box"|remix }}{% translate "Create account" %}</a>
</dt>
</div>
</dl>
{% account_table user.account_set.all index=True %}
</section>
<section class="statements">
<section>
<h2>{% translate "Statements" %}</h2>
{% include "statement/statement_table.html" %}
{% url "statements" as s_url %}
{% statement_table statements statements_url=s_url %}
</section>
</div>
<section class="categories">
<section>
<h2>{% translate "Categories" %}</h2>
{% spaceless %}
<p>
{% for cat in categories %}
{% for cat in user.category_set.all %}
<a class="category" href="{{ cat.get_absolute_url }}">{{ cat.icon|remix }}{{ cat }}</a>
{% endfor %}
<a class="category add" href="{% url "new_category" %}">{{ "add"|remix }}{% translate "Create category" %}</a>
</p>
{% endspaceless %}
</section>
{% if history %}
<section>
<h2>{% translate "History" %}</h2>
{% include "history/plot.html" %}
{% history_plot user.transaction_set.all %}
</section>
{% endif %}
{% endblock body %}

View file

@ -1,32 +1,4 @@
{% load i18n transaction_extras %}
{% load i18n main_extras transaction_extras %}
{% if page_obj %}
<p class="pagination">
{% for page in paginator.page_range %}
<a href="?page={{ page }}"
{% if page == page_obj.number %}class="cur"{% endif %}>{{ page }}</a>
{% endfor %}
</p>
{% endif %}
{% if month %}
<p class="pagination">{% year_url month %}</p>
<p class="pagination n3">
{% if previous_month %}
{% month_url previous_month fmt="F Y" %}
{% endif %}
{% month_url month cls="cur" fmt="F Y" %}
{% if next_month %}
{% month_url next_month fmt="F Y" %}
{% endif %}
</p>
{% endif %}
{% if year %}
<p class="pagination n3">
{% if previous_year %}
{% year_url previous_year cls="prev" %}
{% endif %}
{% year_url year cls="cur" %}
{% if next_year %}
{% year_url next_year cls="next" %}
{% endif %}
</p>
<p class="pagination">{% pagination_links page_obj %}</p>
{% endif %}

View file

@ -0,0 +1,13 @@
{% load i18n main_extras %}
{% if first.show %}
<a href="?{% page_url 1 %}" class="first">1</a>
{% if first.dots %}<span></span>{% endif %}
{% endif %}
{% for page in pages %}
<a href="?{% page_url page.number %}"
{% if page.current %}class="cur"{% endif %}>{{ page.number }}</a>
{% endfor %}
{% if last.show %}
{% if last.dots %}<span></span>{% endif %}
<a href="?{% page_url last.number %}" class="last">{{ last.number }}</a>
{% endif %}

View file

@ -1,5 +1,9 @@
from urllib import parse
from dateutil.relativedelta import relativedelta
from django import template
from django.templatetags.static import static
from django.urls import reverse
from django.utils import formats
from django.utils.safestring import mark_safe
@ -59,7 +63,7 @@ def messageicon(level):
@register.filter
def extension(file):
return file.name.split(".")[-1].upper()
return file.name.split(".", 1)[1].upper()
@register.filter
@ -89,3 +93,48 @@ def balance(accounts):
return sum(
statement.value for acc in accounts if (statement := acc.statement_set.first())
)
@register.inclusion_tag("main/pagination_links.html", takes_context=True)
def pagination_links(context, page_obj):
_n = 3
return {
"request": context["request"],
"pages": [
{"number": p, "current": p == page_obj.number}
for p in page_obj.paginator.page_range
if abs(p - page_obj.number) < _n
],
"first": {
"show": page_obj.number > _n,
"dots": page_obj.number > _n + 1,
},
"last": {
"show": page_obj.number <= page_obj.paginator.num_pages - _n,
"dots": page_obj.number < page_obj.paginator.num_pages - _n,
"number": page_obj.paginator.num_pages,
},
}
@register.simple_tag(takes_context=True)
def page_url(context, page):
query = context["request"].GET.copy()
query["page"] = page
return query.urlencode()
@register.simple_tag
def url_get(name, **kwargs):
kwargs = {k: v for k, v in kwargs.items() if v}
return f"{reverse(name)}?{parse.urlencode(kwargs)}"
@register.filter
def end_of_month(month):
return month + relativedelta(months=1, days=-1)
@register.filter
def end_of_year(year):
return year + relativedelta(years=1, days=-1)

View file

@ -10,5 +10,6 @@ urlpatterns = [
path("category/", include("category.urls")),
path("statement/", include("statement.urls")),
path("transaction/", include("transaction.urls")),
path("history/", include("history.urls")),
path("search/", include("search.urls")),
]

18
nummi/main/utils.py Normal file
View file

@ -0,0 +1,18 @@
import json
import pathlib
from django.contrib.staticfiles import finders
def get_icons():
with open(finders.find("main/icons/remixicon.glyph.json"), "r") as f:
data = json.load(f)
return [i.removesuffix("-line") for i in data.keys() if i.endswith("-line")]
def pdf_outline_to_str(outline):
return " ".join(
(
dest.title if not isinstance(dest, list) else pdf_outline_to_str(dest)
for dest in outline
)
)

View file

@ -1,5 +1,3 @@
from account.models import Account
from category.models import Category
from django.contrib import messages
from django.contrib.auth import views as auth_views
from django.contrib.auth.mixins import LoginRequiredMixin
@ -14,36 +12,19 @@ from django.views.generic import (
TemplateView,
UpdateView,
)
from history.utils import history
from statement.models import Statement
from transaction.models import Transaction
class IndexView(LoginRequiredMixin, TemplateView):
template_name = "main/index.html"
def get_context_data(self, **kwargs):
_max = 8
_transactions = Transaction.objects.filter(user=self.request.user)
_accounts = Account.objects.filter(user=self.request.user)
_statements = (
Statement.objects.filter(user=self.request.user)
.exclude(account__archived=True)
return super().get_context_data(**kwargs) | {
"statements": (
self.request.user.statement_set.exclude(account__archived=True)
.order_by("account__id", "-date")
.distinct("account__id")
)
res = {
"accounts": _accounts,
"categories": Category.objects.filter(user=self.request.user),
"statements": _statements,
"history": history(_transactions.exclude(category__budget=False)),
}
if _transactions.count() > _max:
res["transactions_url"] = reverse_lazy("transactions")
res["statements_url"] = reverse_lazy("statements")
return super().get_context_data(**kwargs) | res
class UserMixin(LoginRequiredMixin):
@ -105,4 +86,4 @@ class LogoutView(auth_views.LogoutView):
class NummiListView(UserMixin, ListView):
paginate_by = 96
pass

View file

@ -22,6 +22,7 @@ if CONFIG_PATH is not None:
# Build paths inside the project like this: BASE_DIR / 'subdir'.
MEDIA_CONF = CONFIG.get("media", {})
STATIC_CONF = CONFIG.get("static", {})
BASE_DIR = Path(__file__).resolve().parent.parent
MEDIA_ROOT = Path(MEDIA_CONF.get("root", "/var/lib/nummi"))
MEDIA_URL = "media/"
@ -124,7 +125,7 @@ USE_TZ = True
# https://docs.djangoproject.com/en/4.0/howto/static-files/
STATIC_URL = "static/"
STATIC_ROOT = "/srv/nummi"
STATIC_ROOT = STATIC_CONF.get("root", "/srv/nummi")
LOGIN_URL = "login"
# Default primary key field type

View file

@ -1,6 +1,7 @@
{% extends "main/form/form_base.html" %}
{% load i18n %}
{% block buttons %}
<input type="reset" />
<input type="submit" value="{% translate "Search" %}" />
{% endblock %}
<input type="reset" value="{% translate "Reset" %}" />
<a href="{% url "search" %}">{% translate "Clear" %}</a>
{% endblock buttons %}

View file

@ -0,0 +1,59 @@
{% extends "main/base.html" %}
{% load i18n static %}
{% load main_extras account_extras transaction_extras statement_extras %}
{% block title %}
{% translate "Search" %} Nummi
{% endblock title %}
{% block link %}
{{ block.super }}
{% css "main/css/form.css" %}
{% css "main/css/table.css" %}
{% endblock link %}
{% block body %}
<h2>{% translate "Search" %}</h2>
<form method="post" action="{% url "search" %}">
{% csrf_token %}
{{ form }}
</form>
{% if accounts %}
<section>
<h3>{% translate "Accounts" %}</h3>
<div class="split">{% account_table accounts search=True %}</div>
</section>
{% endif %}
{% if statements %}
<section>
<h3>{% translate "Statements" %}</h3>
{% statement_table statements %}
</section>
{% endif %}
{% if categories %}
<section>
<h3>{% translate "Categories" %}</h3>
<p>
{% for cat in categories %}
<a class="category" href="{{ cat.get_absolute_url }}">{{ cat.icon|remix }}{{ cat }}</a>
{% endfor %}
</p>
</section>
{% endif %}
{% if transactions %}
<section>
<h3>{% translate "Transactions" %}</h3>
{% url_get "transactions" search=search as t_url %}
<p>
<a class="big-link" href="{{ t_url }}">{{ "list-check"|remixnl }}{% translate "Show all transactions" %}</a>
</p>
{% transaction_table transactions n_max=8 transactions_url=t_url %}
</section>
{% endif %}
{% if invoices %}
<section>
<h3>{% translate "Invoices" %}</h3>
{% invoice_table invoices=invoices %}
</section>
{% endif %}
{% if not accounts and not categories and not transactions and not invoices %}
<p>{% translate "No results found." %}</p>
{% endif %}
{% endblock body %}

View file

@ -1,14 +1,7 @@
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 import TemplateView
from django.views.generic.edit import FormView
from transaction.views import TransactionListView
from .forms import SearchForm
@ -21,24 +14,19 @@ class SearchFormView(LoginRequiredMixin, FormView):
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")
)
class SearchView(LoginRequiredMixin, TemplateView):
template_name = "search/search_results.html"
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {"search": self.kwargs["search"]}
context = super().get_context_data(**kwargs)
_user = self.request.user
context["form"] = SearchForm(initial={"search": self.kwargs["search"]})
context["search"] = self.kwargs["search"]
context["transactions"] = _user.transaction_set.search(self.kwargs["search"])
context["accounts"] = _user.account_set.search(self.kwargs["search"])
context["statements"] = _user.statement_set.search(self.kwargs["search"])
context["categories"] = _user.category_set.search(self.kwargs["search"])
context["invoices"] = _user.invoice_set.search(self.kwargs["search"])[:10]
return context

View file

@ -1,5 +1,8 @@
from account.models import Account
import json
from account.forms import AccountSelect
from django import forms
from django.forms.widgets import Select
from django.utils.translation import gettext_lazy as _
from main.forms import NummiFileInput, NummiForm
from transaction.models import Transaction
@ -13,6 +16,7 @@ class StatementForm(NummiForm):
fields = ["account", "start_date", "date", "start_value", "value", "file"]
widgets = {
"file": NummiFileInput,
"account": AccountSelect,
}
meta_fieldsets = [
@ -25,10 +29,10 @@ class StatementForm(NummiForm):
]
def __init__(self, *args, **kwargs):
_user = kwargs.get("user")
_user = kwargs.pop("user")
_disable_account = kwargs.pop("disable_account", False)
super().__init__(*args, **kwargs)
self.fields["account"].queryset = Account.objects.filter(user=_user)
self.fields["account"].queryset = _user.account_set.exclude(archived=True)
self.fields["transactions"] = forms.MultipleChoiceField(
choices=(
((_transaction.id), _transaction)
@ -40,6 +44,16 @@ class StatementForm(NummiForm):
required=False,
)
self.fields["account"].widget.attrs |= {
"class": "account",
"data-icons": json.dumps(
{
str(acc.id): acc.icon
for acc in self.fields["account"].queryset.only("id", "icon")
}
),
}
if _disable_account:
self.fields["account"].disabled = True
@ -51,3 +65,7 @@ class StatementForm(NummiForm):
instance.transaction_set.add(*new_transactions, bulk=False)
return instance
class StatementSelect(Select):
template_name = "statement/forms/widgets/statement.html"

View file

@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-01-03 15:51+0100\n"
"POT-Creation-Date: 2025-01-04 18:51+0100\n"
"PO-Revision-Date: 2023-04-22 15:22+0200\n"
"Last-Translator: Edgar P. Burkhart <traduction@edgarpierre.fr>\n"
"Language-Team: \n"
@ -17,7 +17,7 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: Poedit 3.2.2\n"
#: .\statement\forms.py:28
#: .\statement\forms.py:43
msgid "Add transactions"
msgstr "Ajouter des transactions"
@ -37,36 +37,53 @@ msgstr "Valeur finale"
msgid "Start value"
msgstr "Valeur initiale"
#: .\statement\models.py:29
#: .\statement\templates\statement\statement_table.html:30
msgid "Difference"
msgstr "Différence"
#: .\statement\models.py:36
msgid "Transaction difference"
msgstr "Différence des transactions"
#: .\statement\models.py:42
#: .\statement\models.py:28
msgid "File"
msgstr "Fichier"
#: .\statement\models.py:49
#: .\statement\models.py:35
#, python-format
msgid "%(date)s statement"
msgstr "Relevé du %(date)s"
#: .\statement\models.py:89
#: .\statement\models.py:68
msgid "Statement"
msgstr "Relevé"
#: .\statement\models.py:90
#: .\statement\models.py:69
#: .\statement\templates\statement\statement_list.html:4
#: .\statement\templates\statement\statement_list.html:7
msgid "Statements"
msgstr "Relevés"
#: .\statement\templates\statement\confirm_delete.html:5
msgid "This will delete all transactions in this statement."
msgstr "Ceci va supprimer toutes les transactions de ce relevé."
#: .\statement\templates\statement\statement_detail.html:19
#: .\statement\templates\statement\statement_detail.html:30
msgid "Edit statement"
msgstr "Modifier le relevé"
#: .\statement\templates\statement\statement_detail.html:31
#: .\statement\templates\statement\statement_detail.html:61
msgid "Add transaction"
msgstr "Ajouter une transaction"
#: .\statement\templates\statement\statement_detail.html:57
msgid "Transactions"
msgstr "Transactions"
#: .\statement\templates\statement\statement_detail.html:62
msgid "View all transactions"
msgstr "Afficher toutes les transactions"
#: .\statement\templates\statement\statement_detail.html:67
msgid "Categories"
msgstr "Catégories"
#: .\statement\templates\statement\statement_form.html:4
#: .\statement\templates\statement\statement_table.html:18
#: .\statement\templates\statement\statement_table.html:7
msgid "Create statement"
msgstr "Créer un relevé"
@ -74,34 +91,28 @@ msgstr "Créer un relevé"
msgid "New statement"
msgstr "Nouveau relevé"
#: .\statement\templates\statement\statement_form.html:23
msgid "Categories"
msgstr "Catégories"
#: .\statement\templates\statement\statement_form.html:12
msgid "Back"
msgstr "Retour"
#: .\statement\templates\statement\statement_form.html:27
#: .\statement\templates\statement\statement_table.html:31
msgid "Transactions"
msgstr "Transactions"
#: .\statement\templates\statement\statement_table.html:25
msgid "Date"
msgstr "Date"
#: .\statement\templates\statement\statement_table.html:27
msgid "Account"
msgstr "Compte"
#: .\statement\templates\statement\statement_table.html:29
msgid "Value"
msgstr "Valeur"
#: .\statement\templates\statement\statement_table.html:62
msgid "No statement"
msgstr "Aucun relevé"
#: .\statement\templates\statement\statement_table.html:70
#: .\statement\templates\statement\statement_table.html:28
msgid "View all statements"
msgstr "Voir tous les relevés"
#~ msgid "Difference"
#~ msgstr "Différence"
#~ msgid "Transaction difference"
#~ msgstr "Différence des transactions"
#~ msgid "Date"
#~ msgstr "Date"
#~ msgid "Account"
#~ msgstr "Compte"
#~ msgid "Value"
#~ msgstr "Valeur"
#~ msgid "No transaction"
#~ msgstr "Aucune transaction"

View file

@ -0,0 +1,20 @@
# Generated by Django 4.2 on 2025-01-03 13:23
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("statement", "0002_alter_statement_table"),
]
operations = [
migrations.RemoveField(
model_name="statement",
name="diff",
),
migrations.RemoveField(
model_name="statement",
name="sum",
),
]

View file

@ -0,0 +1,22 @@
# Generated by Django 4.2.7 on 2025-01-05 17:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("statement", "0003_remove_statement_diff_remove_statement_sum"),
]
operations = [
migrations.AddField(
model_name="statement",
name="metadata",
field=models.TextField(blank=True),
),
migrations.AddField(
model_name="statement",
name="tags",
field=models.TextField(blank=True),
),
]

View file

@ -4,10 +4,20 @@ from uuid import uuid4
from account.models import AccountModel
from django.core.validators import FileExtensionValidator
from django.db import models, transaction
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from main.models import NummiQuerySet
from main.utils import pdf_outline_to_str
from media.utils import get_path
from pypdf import PdfReader
class StatementQuerySet(NummiQuerySet):
main_field = "metadata"
fields = {
"tags": "B",
}
class Statement(AccountModel):
@ -22,20 +32,6 @@ class Statement(AccountModel):
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"])],
@ -44,6 +40,10 @@ class Statement(AccountModel):
blank=True,
default="",
)
metadata = models.TextField(blank=True)
tags = models.TextField(blank=True)
objects = StatementQuerySet.as_manager()
def __str__(self):
return _("%(date)s statement") % {"date": self.date}
@ -54,21 +54,39 @@ class Statement(AccountModel):
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()
if self.file:
reader = PdfReader(self.file)
self.diff = self.value - self.start_value
self.sum = (
self.transaction_set.aggregate(sum=models.Sum("value")).get("sum", 0) or 0
if reader.metadata:
self.metadata = " ".join(
(
m.replace("\x00", "").strip()
for m in (
reader.metadata.title,
reader.metadata.author,
reader.metadata.subject,
)
if m
)
)
_tags = pdf_outline_to_str(reader.outline)
_tags += " ".join(
(page.extract_text().replace("\x00", "") for page in reader.pages)
)
self.tags = " ".join((tag for tag in _tags.split() if len(tag) >= 3))
super().save(*args, **kwargs)
def update_sum(self):
self.sum = (
self.transaction_set.aggregate(sum=models.Sum("value")).get("sum", 0) or 0
@property
def sum(self):
return self.transaction_set.aggregate(sum=models.Sum("value", default=0)).get(
"sum"
)
super().save()
@property
def diff(self):
return self.value - self.start_value
def delete(self, *args, **kwargs):
if self.file:

View file

@ -0,0 +1,11 @@
{% load main_extras %}
{% if widget.attrs.disabled %}
{% for group_name, group_choices, group_index in widget.optgroups %}
{% for option in group_choices %}
{% if option.selected %}
<a href="{% url "statement" statement=option.value %}">{{ "file"|remix }}{{ option.label }}</a>
{% endif %}
{% endfor %}
{% endfor %}
{% endif %}
{% include "django/forms/widgets/select.html" %}

View file

@ -1,15 +1,15 @@
{% extends "main/base.html" %}
{% load i18n main_extras statement_extras category %}
{% load i18n main_extras statement_extras transaction_extras category %}
{% block title %}
{{ statement }}
Nummi
{% endblock %}
{% endblock title %}
{% block link %}
{{ block.super }}
{% css "main/css/form.css" %}
{% css "main/css/table.css" %}
{% css "main/css/plot.css" %}
{% endblock %}
{% endblock link %}
{% block body %}
<h2>{{ statement }}</h2>
<p>
@ -19,29 +19,52 @@
<a href="{% url "edit_statement" statement.id %}">{{ "file-edit"|remix }}{% translate "Edit statement" %}</a>
</p>
<div class="statement-details">
<span>
{% if statement.sum != statement.diff %}
<span class="incons">
{{ statement.sum|check:statement.diff }}
<span class="ds">
<span class="diff value">{{ statement.diff|pmvalue }}</span>
<span class="sum value">{{ statement.sum|pmvalue }}</span>
</span>
<span class="links">
<a class="edit" href="{% url "edit_statement" statement.id %}">{{ "file-edit"|remix }}{% translate "Edit statement" %}</a>
<a href="{% url "new_transaction" statement=statement.id %}">{{ "add-circle"|remix }}{% translate "Add transaction" %}</a>
</span>
</span>
{% endif %}
{% if statement.file %}
<span class="file">
<a class="title" href="{{ statement.file.url }}">{{ "file"|remix }}{{ statement }} [{{ statement.file|extension }}]</a>
</span>
{% endif %}
<span class="start">
<span class="date">{{ statement.start_date|date:"Y-m-d" }}</span>
<span class="value">{{ statement.start_value|value }}</span>
</span>
<span>
<span class="evolution">
<span class="value">{{ statement.sum|pmvalue }}</span>
{{ "arrow-right"|remix }}
{{ "funds"|remix }}
{% if statement.diff != statement.sum %}
<span class="value">{{ statement.diff|pmvalue }}</span>
{% endif %}
</span>
<span>
<span class="end">
<span class="date">{{ statement.date|date:"Y-m-d" }}</span>
<span class="value">{{ statement.value|value }}</span>
</span>
{{ statement.sum|check:statement.diff }}
</div>
<section>
<h3>{% translate "Transactions" %}</h3>
{% include "transaction/transaction_table.html" %}
{% url_get "transactions" account=account.id statement=statement.id as t_url %}
<p>
<a class="big-link"
href="{% url "new_transaction" statement=statement.id %}">{{ "add-circle"|remix }}{% translate "Add transaction" %}</a>
<a class="big-link" href="{{ t_url }}">{{ "list-check"|remixnl }}{% translate "View all transactions" %}</a>
</p>
{% transaction_table statement.transaction_set.all n_max=8 transactions_url=t_url %}
</section>
<section>
<h3>{% translate "Categories" %}</h3>
{% category_plot transactions budget=False statement=object %}
{% category_plot transactions statement=object %}
</section>
{% endblock %}
{% endblock body %}

View file

@ -1,12 +1,12 @@
{% extends "main/list.html" %}
{% load i18n %}
{% load i18n statement_extras %}
{% block name %}
{% translate "Statements" %}
{% endblock %}
{% endblock name %}
{% block h2 %}
{% translate "Statements" %}
{% endblock %}
{% endblock h2 %}
{% block table %}
{% url "new_statement" as new_statement_url %}
{% include "statement/statement_table.html" %}
{% endblock %}
{% url "new_statement" as ns_url %}
{% statement_table statements new_statement_url=ns_url %}
{% endblock table %}

View file

@ -12,3 +12,15 @@ def check(s, diff):
return remix("check", "green")
else:
return remix("close", "red")
@register.inclusion_tag("statement/statement_table.html")
def statement_table(statements, **kwargs):
if (n_max := kwargs.get("n_max")) is not None:
if statements.count() <= n_max:
del kwargs["statements_url"]
statements = statements[:n_max]
return kwargs | {
"statements": statements,
}

View file

@ -84,6 +84,7 @@ class StatementDetailView(NummiDetailView):
class StatementListView(NummiListView):
model = Statement
context_object_name = "statements"
paginate_by = 32
class StatementMixin:

View file

@ -1,5 +1,11 @@
from django.forms.widgets import TextInput
from main.forms import NummiFileInput, NummiForm
import json
from account.forms import AccountSelect
from category.forms import CategorySelect
from django import forms
from django.utils.translation import gettext_lazy as _
from main.forms import DatalistInput, NummiFileInput, NummiForm
from statement.forms import StatementSelect
from .models import Invoice, Transaction
from .utils import get_datalist
@ -19,6 +25,13 @@ class TransactionForm(NummiForm):
"payment",
"description",
]
widgets = {
"statement": StatementSelect(),
"category": CategorySelect(),
"name": DatalistInput(),
"trader": DatalistInput(),
"payment": DatalistInput(),
}
meta_fieldsets = [
[
@ -36,23 +49,63 @@ class TransactionForm(NummiForm):
]
def __init__(self, *args, **kwargs):
_user = kwargs.get("user")
_user = kwargs.pop("user")
_disable_statement = kwargs.pop("disable_statement", False)
_autocomplete = kwargs.pop("autocomplete", False)
super().__init__(*args, **kwargs)
self.fields["category"].queryset = _user.category_set
self.fields["statement"].queryset = _user.statement_set
self.fields["category"].queryset = self.fields["category"].queryset.filter(
user=_user
)
self.fields["statement"].queryset = self.fields["statement"].queryset.filter(
user=_user
)
self.fields["name"].widget = DatalistInput(options=get_datalist(_user, "name"))
self.fields["trader"].widget = DatalistInput(
options=get_datalist(_user, "trader")
)
self.fields["payment"].widget = DatalistInput(
options=get_datalist(_user, "payment")
self.fields["name"].widget.options = get_datalist(_user, "name")
self.fields["trader"].widget.options = get_datalist(_user, "trader")
self.fields["payment"].widget.options = get_datalist(_user, "payment")
self.fields["category"].widget.attrs |= {
"class": "category",
"data-icons": json.dumps(
{
str(cat.id): cat.icon
for cat in self.fields["category"].queryset.only("id", "icon")
}
),
}
if _autocomplete:
self.autocomplete = {
"field": "name",
"data": {
t.name: [
{
"field": "value",
"value": t.value,
},
{
"field": "category",
"value": t.category.id if t.category else "",
},
{
"field": "trader",
"value": t.trader,
},
{
"field": "payment",
"value": t.payment,
},
]
for t in _user.transaction_set.order_by("name", "-date").distinct(
"name"
)
},
}
if _disable_statement:
self.fields["statement"].disabled = True
self.fields["statement"].widget.attrs["hidden"] = True
class InvoiceForm(NummiForm):
@ -69,17 +122,96 @@ class InvoiceForm(NummiForm):
}
class DatalistInput(TextInput):
template_name = "transaction/forms/widgets/datalist.html"
class MultipleFileInput(forms.ClearableFileInput):
allow_multiple_selected = True
def __init__(self, *args, options=[]):
self.options = options
super().__init__(*args)
def get_context(self, *args):
context = super().get_context(*args)
name = context["widget"]["name"]
context["widget"]["attrs"]["list"] = f"{name}-list"
context["widget"]["attrs"]["autocomplete"] = "off"
context["widget"]["options"] = self.options
return context
class MultipleFileField(forms.FileField):
def __init__(self, *args, **kwargs):
kwargs.setdefault("widget", MultipleFileInput())
super().__init__(*args, **kwargs)
def clean(self, data, initial=None):
single_file_clean = super().clean
if isinstance(data, (list, tuple)):
result = [single_file_clean(d, initial) for d in data]
else:
result = single_file_clean(data, initial)
return result
class MultipleInvoicesForm(forms.Form):
prefix = "invoices"
invoices = MultipleFileField()
class DateInput(forms.DateInput):
input_type = "date"
def __init__(self, attrs=None):
super().__init__(attrs)
self.format = "%Y-%m-%d"
class DateField(forms.DateField):
def __init__(self, *args, **kwargs):
kwargs.setdefault("widget", DateInput())
super().__init__(*args, **kwargs)
class TransactionFiltersForm(forms.Form):
start_date = DateField(required=False)
end_date = DateField(required=False)
category = forms.ModelChoiceField(
queryset=None, required=False, widget=CategorySelect()
)
account = forms.ModelChoiceField(
queryset=None, required=False, widget=AccountSelect()
)
statement = forms.ModelChoiceField(queryset=None, required=False)
search = forms.CharField(label=_("Search"), required=False)
sort_by = forms.ChoiceField(
label=_("Sort by"),
choices=[
("", _("Default")),
("date", _("Date +")),
("-date", _("Date -")),
("value", _("Value +")),
("-value", _("Value -")),
],
required=False,
)
def __init__(self, *args, **kwargs):
_user = kwargs.pop("user")
super().__init__(*args, **kwargs)
self.fields["category"].queryset = _user.category_set
self.fields["account"].queryset = _user.account_set
if acc_id := kwargs.get("initial", {}).get("account"):
self.fields["statement"].queryset = (
self.fields["account"].queryset.get(id=acc_id).statement_set
)
else:
self.fields["statement"].queryset = _user.statement_set.none()
self.fields["statement"].disabled = True
self.fields["category"].widget.attrs |= {
"class": "category",
"data-icons": json.dumps(
{
str(cat.id): cat.icon
for cat in self.fields["category"].queryset.only("id", "icon")
}
),
}
self.fields["account"].widget.attrs |= {
"class": "account",
"data-icons": json.dumps(
{
str(acc.id): acc.icon
for acc in self.fields["account"].queryset.only("id", "icon")
}
),
}

View file

@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-01-03 15:51+0100\n"
"POT-Creation-Date: 2025-01-04 18:51+0100\n"
"PO-Revision-Date: 2023-04-23 08:03+0200\n"
"Last-Translator: Edgar P. Burkhart <traduction@edgarpierre.fr>\n"
"Language-Team: \n"
@ -17,103 +17,119 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: Poedit 3.2.2\n"
#: .\transaction\models.py:19 .\transaction\models.py:82
#: .\transaction\forms.py:175
msgid "Search"
msgstr "Rechercher"
#: .\transaction\forms.py:177
msgid "Sort by"
msgstr "Trier par"
#: .\transaction\forms.py:179
msgid "Default"
msgstr "Défaut"
#: .\transaction\forms.py:180
msgid "Date +"
msgstr "Date +"
#: .\transaction\forms.py:181
msgid "Date -"
msgstr "Date -"
#: .\transaction\forms.py:182
msgid "Value +"
msgstr "Valeur +"
#: .\transaction\forms.py:183
msgid "Value -"
msgstr "Valeur -"
#: .\transaction\models.py:18 .\transaction\models.py:63
msgid "Transaction"
msgstr "Transaction"
#: .\transaction\models.py:19 .\transaction\models.py:89
#: .\transaction\templates\transaction\invoice_table.html:10
#: .\transaction\templates\transaction\transaction_table.html:32
#: .\transaction\models.py:18 .\transaction\models.py:70
#: .\transaction\templates\transaction\transaction_table.html:19
msgid "Name"
msgstr "Nom"
#: .\transaction\models.py:21
#: .\transaction\models.py:20
msgid "Description"
msgstr "Description"
#: .\transaction\models.py:23
#: .\transaction\models.py:22
msgid "Value"
msgstr "Valeur"
#: .\transaction\models.py:25
#: .\transaction\templates\transaction\transaction_table.html:31
#: .\transaction\models.py:24
#: .\transaction\templates\transaction\transaction_table.html:18
msgid "Date"
msgstr "Date"
#: .\transaction\models.py:26
#: .\transaction\models.py:25
msgid "Real date"
msgstr "Date réelle"
#: .\transaction\models.py:28
#: .\transaction\templates\transaction\transaction_table.html:35
#: .\transaction\models.py:27
#: .\transaction\templates\transaction\transaction_table.html:22
msgid "Trader"
msgstr "Commerçant"
#: .\transaction\models.py:31
#: .\transaction\models.py:30
msgid "Payment"
msgstr "Paiement"
#: .\transaction\models.py:38
#: .\transaction\templates\transaction\transaction_table.html:37
#: .\transaction\models.py:37
#: .\transaction\templates\transaction\transaction_table.html:24
msgid "Category"
msgstr "Catégorie"
#: .\transaction\models.py:43
#: .\transaction\models.py:44
msgid "Statement"
msgstr "Relevé"
#: .\transaction\models.py:48
#: .\transaction\templates\transaction\transaction_table.html:40
msgid "Account"
msgstr "Compte"
#: .\transaction\models.py:83
#: .\transaction\templates\transaction\transaction_archive_month.html:27
#: .\transaction\models.py:64
#: .\transaction\templates\transaction\transaction_archive_month.html:25
#: .\transaction\templates\transaction\transaction_archive_year.html:31
#: .\transaction\templates\transaction\transaction_list.html:4
#: .\transaction\templates\transaction\transaction_list.html:7
#: .\transaction\templates\transaction\transaction_list.html:11
msgid "Transactions"
msgstr "Transactions"
#: .\transaction\models.py:89 .\transaction\models.py:122
#: .\transaction\models.py:70 .\transaction\models.py:103
msgid "Invoice"
msgstr "Facture"
#: .\transaction\models.py:94
#: .\transaction\templates\transaction\invoice_table.html:11
#: .\transaction\templates\transaction\invoice_table.html:22
#: .\transaction\models.py:75
msgid "File"
msgstr "Fichier"
#: .\transaction\models.py:123
#: .\transaction\templates\transaction\transaction_form.html:20
#: .\transaction\models.py:104
#: .\transaction\templates\transaction\transaction_detail.html:46
msgid "Invoices"
msgstr "Factures"
#: .\transaction\templates\transaction\invoice_form.html:4
#: .\transaction\templates\transaction\invoice_table.html:37
msgid "Create invoice"
msgstr "Créer une facture"
#: .\transaction\templates\transaction\invoice_form.html:7
#: .\transaction\templates\transaction\invoice_table.html:12
msgid "New invoice"
msgstr "Nouvelle facture"
#: .\transaction\templates\transaction\invoice_table.html:12
#: .\transaction\templates\transaction\invoice_table.html:25
msgid "Delete"
msgstr "Supprimer"
#: .\transaction\templates\transaction\invoice_table.html:7
msgid "Edit"
msgstr "Modifier"
#: .\transaction\templates\transaction\invoice_table.html:30
msgid "No invoice"
msgstr "Aucune facture"
#: .\transaction\templates\transaction\transaction_archive_month.html:14
#: .\transaction\templates\transaction\transaction_archive_month.html:13
#: .\transaction\templates\transaction\transaction_archive_year.html:13
#: .\transaction\templates\transaction\transaction_form.html:18
msgid "Back"
msgstr "Retour"
#: .\transaction\templates\transaction\transaction_archive_month.html:22
#: .\transaction\templates\transaction\transaction_archive_month.html:20
#: .\transaction\templates\transaction\transaction_archive_year.html:26
msgid "Categories"
msgstr "Catégories"
@ -122,8 +138,31 @@ msgstr "Catégories"
msgid "History"
msgstr "Historique"
#: .\transaction\templates\transaction\transaction_detail.html:39
msgid "Edit transaction"
msgstr "Modifier la transaction"
#: .\transaction\templates\transaction\transaction_detail.html:57
msgid "Add invoice"
msgstr "Ajouter une facture"
#: .\transaction\templates\transaction\transaction_filters.html:3
msgid "Filters"
msgstr "Filtres"
#: .\transaction\templates\transaction\transaction_filters.html:12
msgid "Filter"
msgstr "Filtrer"
#: .\transaction\templates\transaction\transaction_filters.html:13
msgid "Reset"
msgstr "Réinitialiser"
#: .\transaction\templates\transaction\transaction_filters.html:15
msgid "Clear"
msgstr "Effacer"
#: .\transaction\templates\transaction\transaction_form.html:5
#: .\transaction\templates\transaction\transaction_table.html:25
msgid "Create transaction"
msgstr "Créer une transaction"
@ -131,18 +170,33 @@ msgstr "Créer une transaction"
msgid "New transaction"
msgstr "Nouvelle transaction"
#: .\transaction\templates\transaction\transaction_table.html:33
#: .\transaction\templates\transaction\transaction_list.html:15
msgid "Add transaction"
msgstr "Ajouter une transaction"
#: .\transaction\templates\transaction\transaction_table.html:20
msgid "Expenses"
msgstr "Dépenses"
#: .\transaction\templates\transaction\transaction_table.html:34
#: .\transaction\templates\transaction\transaction_table.html:21
msgid "Income"
msgstr "Recettes"
#: .\transaction\templates\transaction\transaction_table.html:87
#: .\transaction\templates\transaction\transaction_table.html:27
msgid "Account"
msgstr "Compte"
#: .\transaction\templates\transaction\transaction_table.html:74
msgid "No transaction"
msgstr "Aucune transaction"
#: .\transaction\templates\transaction\transaction_table.html:95
#: .\transaction\templates\transaction\transaction_table.html:82
msgid "View all transactions"
msgstr "Voir toutes les transactions"
#: .\transaction\views.py:101
msgid "Error processing file"
msgstr "Erreur lors du traitement du fichier"
#~ msgid "Delete"
#~ msgstr "Supprimer"

View file

@ -0,0 +1,16 @@
# Generated by Django 4.2 on 2025-01-02 13:03
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("transaction", "0003_alter_transaction_account_and_more"),
]
operations = [
migrations.RemoveField(
model_name="transaction",
name="account",
),
]

View file

@ -0,0 +1,22 @@
# Generated by Django 4.2.7 on 2025-01-05 14:51
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("transaction", "0004_remove_transaction_account"),
]
operations = [
migrations.AddField(
model_name="invoice",
name="metadata",
field=models.TextField(blank=True),
),
migrations.AddField(
model_name="invoice",
name="tags",
field=models.TextField(blank=True),
),
]

View file

@ -2,18 +2,27 @@ 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 main.models import NummiModel, NummiQuerySet
from main.utils import pdf_outline_to_str
from media.utils import get_path
from pypdf import PdfReader
from statement.models import Statement
class Transaction(UserModel):
class TransactionQuerySet(NummiQuerySet):
fields = {
"description": "B",
"trader": "B",
"category__name": "C",
}
class Transaction(NummiModel):
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
name = models.CharField(
max_length=256, default=_("Transaction"), verbose_name=_("Name")
@ -44,27 +53,8 @@ class Transaction(UserModel):
null=True,
verbose_name=_("Statement"),
)
account = models.ForeignKey(
Account,
on_delete=models.CASCADE,
blank=True,
null=True,
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
if self.statement:
self.account = self.statement.account
super().save(*args, **kwargs)
if prev_self is not None and prev_self.statement:
prev_self.statement.update_sum()
if self.statement:
self.statement.update_sum()
objects = TransactionQuerySet.as_manager()
def __str__(self):
return f"{self.name}"
@ -76,12 +66,9 @@ class Transaction(UserModel):
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
def account(self):
if self.statement:
return self.statement.account
class Meta:
ordering = ["-date", "statement"]
@ -89,7 +76,14 @@ class Transaction(UserModel):
verbose_name_plural = _("Transactions")
class Invoice(UserModel):
class InvoiceQuerySet(NummiQuerySet):
fields = {
"metadata": "B",
"tags": "C",
}
class Invoice(NummiModel):
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
name = models.CharField(
max_length=256, default=_("Invoice"), verbose_name=_("Name")
@ -103,12 +97,39 @@ class Invoice(UserModel):
transaction = models.ForeignKey(
Transaction, on_delete=models.CASCADE, editable=False
)
metadata = models.TextField(blank=True)
tags = models.TextField(blank=True)
objects = InvoiceQuerySet.as_manager()
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)
if self.file:
reader = PdfReader(self.file)
if reader.metadata:
self.metadata = " ".join(
(
m.replace("\x00", "").strip()
for m in (
reader.metadata.title,
reader.metadata.author,
reader.metadata.subject,
)
if m
)
)
_tags = pdf_outline_to_str(reader.outline)
_tags += " ".join(
(page.extract_text().replace("\x00", "") for page in reader.pages)
)
self.tags = " ".join((tag for tag in _tags.split() if len(tag) >= 3))
super().save(*args, **kwargs)
def __str__(self):

View file

@ -0,0 +1,22 @@
const dropArea = document.querySelector("main");
const form = document.querySelector("form.invoices");
dropArea.addEventListener("dragover", (event) => {
event.preventDefault();
dropArea.classList.add("highlight");
});
dropArea.addEventListener("dragleave", () => {
dropArea.classList.remove("highlight");
});
dropArea.addEventListener("drop", (event) => {
console.log(event);
event.preventDefault();
dropArea.classList.remove("highlight");
const files = event.dataTransfer.files;
console.log(files);
const input = form.querySelector("input[type=file]");
input.files = files;
form.submit();
});

View file

@ -1,17 +1,25 @@
{% load main_extras %}
{% load i18n %}
{% load i18n main_extras %}
<div id="invoices">
<ul class="invoices">
{% for invoice in transaction.invoices %}
{% for invoice in invoices %}
<li>
{% if not transaction %}
<a class="transaction" href="{{ invoice.transaction.get_absolute_url }}">
{{ "receipt"|remix }}{{ invoice.transaction.name }}
</a>
{% endif %}
<a class="title" href="{{ invoice.file.url }}">{{ "file"|remix }}{{ invoice.name }} [{{ invoice.file|extension }}]</a>
{% if transaction %}
<a href="{{ invoice.get_absolute_url }}">{{ "file-edit"|remix }}{% translate "Edit" %}</a>
{% endif %}
</li>
{% endfor %}
{% if transaction %}
<li class="new">
<span>
<a href="{% url "new_invoice" transaction.pk %}">{{ "file-add"|remix }}{% translate "New invoice" %}</a>
</span>
</li>
{% endif %}
</ul>
</div>

View file

@ -1,28 +0,0 @@
{% extends "transaction/transaction_list.html" %}
{% load i18n main_extras transaction_extras static category %}
{% block link %}
{{ block.super }}
{% css "main/css/plot.css" %}
{% endblock %}
{% block name %}{{ month|date:"F Y"|capfirst }}{% endblock %}
{% block h2 %}{{ month|date:"F Y"|capfirst }}{% endblock %}
{% block backlinks %}
{{ block.super }}
{% if account or category %}
<p class="back">
<a href="{% url "transaction_month" month.year month.month %}">{% translate "Back" %}{{ "arrow-go-back"|remix }}</a>
</p>
{% endif %}
{% endblock %}
{% block table %}
{% if not category %}
<section>
<h3>{% translate "Categories" %}</h3>
{% category_plot transactions month=month %}
</section>
{% endif %}
<section>
<h3>{% translate "Transactions" %}</h3>
{{ block.super }}
</section>
{% endblock %}

View file

@ -1,34 +0,0 @@
{% extends "transaction/transaction_list.html" %}
{% load i18n main_extras static category %}
{% block link %}
{{ block.super }}
{% css "main/css/plot.css" %}
{% endblock %}
{% block name %}{{ year|date:"Y" }}{% endblock %}
{% block h2 %}{{ year|date:"Y" }}{% endblock %}
{% block backlinks %}
{{ block.super }}
{% if account or category %}
<p class="back">
<a href="{% url "transaction_year" year.year %}">{% translate "Back" %}{{ "arrow-go-back"|remix }}</a>
</p>
{% endif %}
{% endblock %}
{% block table %}
{% if history %}
<section>
<h3>{% translate "History" %}</h3>
{% include "history/plot.html" %}
</section>
{% endif %}
{% if not category %}
<section>
<h3>{% translate "Categories" %}</h3>
{% category_plot transactions year=year %}
</section>
{% endif %}
<section>
<h3>{% translate "Transactions" %}</h3>
{{ block.super }}
</section>
{% endblock %}

View file

@ -1,31 +1,32 @@
{% extends "main/form/base.html" %}
{% load i18n %}
{% load main_extras %}
{% load main_extras transaction_extras %}
{% block title %}
{{ transaction }}
Nummi
{% endblock %}
{% endblock title %}
{% block link %}
{{ block.super }}
{% css "main/css/form.css" %}
{% css "main/css/table.css" %}
{% css "main/css/plot.css" %}
{% endblock %}
{% js "transaction/js/invoice_form.js" %}
{% endblock link %}
{% block body %}
<h2>{{ transaction }}</h2>
<section class="transaction-details">
<ul>
{% if statement %}
{% if transaction.statement %}
<li>
{% with statement.account as account %}
{% with transaction.statement.account as account %}
<a href="{{ account.get_absolute_url }}">{{ account.icon|remix }}{{ account }}</a>
{% endwith %}
<a href="{{ statement.get_absolute_url }}">{{ statement }}</a>
<a href="{{ transaction.statement.get_absolute_url }}">{{ transaction.statement }}</a>
</li>
{% endif %}
{% if category %}
{% if transaction.category %}
<li>
<a href="{{ category.get_absolute_url }}">{{ category.icon|remix }}{{ category }}</a>
<a href="{{ transaction.category.get_absolute_url }}">{{ transaction.category.icon|remix }}{{ transaction.category }}</a>
</li>
{% endif %}
{% if transaction.trader %}
@ -43,6 +44,16 @@
</section>
<section>
<h3>{% translate "Invoices" %}</h3>
{% include "transaction/invoice_table.html" %}
{% invoice_table transaction %}
<form class="hidden invoices"
method="post"
action="{% url "multiple_invoice" transaction=transaction.pk %}"
enctype="multipart/form-data">
{% csrf_token %}
{{ invoices_form }}
</form>
</section>
{% endblock %}
<div class="drop-zone">
<span class="wi">{{ "file-add"|remix }}{% translate "Add invoice" %}</span>
</div>
{% endblock body %}

View file

@ -0,0 +1,21 @@
{% load i18n %}
{% if form %}
<details {% if filters %}open{% endif %}>
<summary>{% translate "Filters" %}</summary>
<form class="filter" method="get">
{% for field in form %}
<div class="field">
<label>{{ field.label }}</label>
{{ field }}
</div>
{% endfor %}
<div class="buttons">
<input type="submit" value="{% translate "Filter" %}">
<input type="reset" value="{% translate "Reset" %}">
{% if filters %}
<a href="?" class="del">{% translate "Clear" %}</a>
{% endif %}
</div>
</form>
</details>
{% endif %}

View file

@ -1,12 +1,19 @@
{% extends "main/list.html" %}
{% load i18n %}
{% load i18n main_extras transaction_extras %}
{% block name %}
{% translate "Transactions" %}
{% endblock %}
{% endblock name %}
{% block link %}
{{ block.super }}
{% css "main/css/form.css" %}
{% endblock link %}
{% block h2 %}
{% translate "Transactions" %}
{% endblock %}
{% endblock h2 %}
{% block table %}
{% url "new_transaction" as new_transaction_url %}
{% include "transaction/transaction_table.html" %}
{% endblock %}
<p>
<a class="big-link" href="{% url "new_transaction" %}">{{ "add-circle"|remix }}{% translate "Add transaction" %}</a>
</p>
{% transaction_filters form=filter_form %}
{% transaction_table transactions %}
{% endblock table %}

View file

@ -9,17 +9,10 @@
<col class="value">
<col class="value">
<col class="desc">
{% if not category %}<col class="desc">{% endif %}
{% if not account %}<col class="desc">{% endif %}
{% if not hide_category %}<col class="desc">{% endif %}
{% if not hide_account %}<col class="desc">{% endif %}
</colgroup>
<thead>
{% if new_transaction_url %}
<tr class="new">
<td colspan="{% tr_colspan %}">
<a href="{{ new_transaction_url }}">{% translate "Create transaction" %}</a>
</td>
</tr>
{% endif %}
<tr>
<th>{{ "attachment"|remix }}</th>
<th>{% translate "Date" %}</th>
@ -27,10 +20,10 @@
<th>{% translate "Expenses" %}</th>
<th>{% translate "Income" %}</th>
<th>{% translate "Trader" %}</th>
{% if not category %}
{% if not hide_category %}
<th>{% translate "Category" %}</th>
{% endif %}
{% if not account %}
{% if not hide_account %}
<th>{% translate "Account" %}</th>
{% endif %}
</tr>
@ -39,7 +32,7 @@
{% for trans in transactions %}
<tr>
<td class="c">
{% for invoice in trans.invoices %}
{% for invoice in trans.invoice_set.all %}
<a class="i" href="{{ invoice.file.url }}">{{ "attachment"|remix }}</a>
{% endfor %}
</td>
@ -61,31 +54,31 @@
<td class="value">{{ trans.value|pmvalue }}</td>
{% if trans.value < 0 %}<td></td>{% endif %}
<td>{{ trans.trader|default_if_none:"" }}</td>
{% if not category %}
{% if not hide_category %}
<td>
{% if trans.category %}
<td>
<a href="{{ trans.category.get_absolute_url }}">{{ trans.category.icon|remix }}{{ trans.category }}</a>
{% endif %}
</td>
{% else %}
<td></td>
{% endif %}
{% endif %}
{% if not account %}
{% if not hide_account %}
<td>
<a href="{{ trans.account.get_absolute_url }}">{{ trans.account.icon|remix }}{{ trans.account }}</a>
{% if trans.account %}
<a href="{{ trans.account.get_absolute_url }}">{{ trans.account.icon|remix }}{{ trans.account|default_if_none:"" }}</a>
{% endif %}
</td>
{% endif %}
</tr>
{% empty %}
<tr>
<td class="empty" colspan="{% tr_colspan %}">{% translate "No transaction" %}</td>
<td class="empty" colspan="{{ ncol }}">{% translate "No transaction" %}</td>
</tr>
{% endfor %}
</tbody>
{% if transactions_url %}
<tfoot>
<tr class="more">
<td colspan="{% tr_colspan %}">
<td colspan="{{ ncol }}">
<a href="{{ transactions_url }}">{% translate "View all transactions" %}</a>
</td>
</tr>

View file

@ -10,6 +10,41 @@ from ..utils import ac_url
register = template.Library()
@register.inclusion_tag("transaction/transaction_table.html", takes_context=True)
def transaction_table(context, transactions, n_max=None, **kwargs):
if n_max is not None:
if transactions.count() <= n_max:
del kwargs["transactions_url"]
transactions = transactions[:n_max]
kwargs.setdefault("hide_account", "account" in context or "statement" in context)
kwargs.setdefault("hide_category", "category" in context)
ncol = 8
if kwargs.get("hide_account"):
ncol -= 1
if kwargs.get("hide_category"):
ncol -= 1
return kwargs | {"transactions": transactions, "ncol": ncol}
@register.inclusion_tag("transaction/transaction_filters.html", takes_context=True)
def transaction_filters(context, **kwargs):
kwargs.setdefault("filters", context.get("filters"))
return kwargs
@register.inclusion_tag("transaction/invoice_table.html")
def invoice_table(transaction=None, **kwargs):
if transaction:
kwargs.setdefault("invoices", transaction.invoice_set.all())
return kwargs | {
"transaction": transaction,
}
@register.simple_tag(takes_context=True)
def month_url(context, month, cls="", fmt="Y-m"):
url_name, url_params = ac_url(
@ -34,13 +69,3 @@ def year_url(context, year, cls=""):
f"""<a class="{cls}" href="{url}">"""
f"""<time datetime="{year}">{year}</time></a>"""
)
@register.simple_tag(takes_context=True)
def tr_colspan(context):
ncol = 8
if context.get("category"):
ncol -= 1
if context.get("account"):
ncol -= 1
return ncol

View file

@ -4,16 +4,6 @@ from . import views
urlpatterns = [
path("list", views.TransactionListView.as_view(), name="transactions"),
path(
"history/<int:year>",
views.TransactionYearView.as_view(),
name="transaction_year",
),
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.TransactionDetailView.as_view(), name="transaction"),
path(
@ -31,6 +21,11 @@ urlpatterns = [
views.InvoiceCreateView.as_view(),
name="new_invoice",
),
path(
"<transaction>/invoice/multiple",
views.MultipleInvoiceCreateView.as_view(),
name="multiple_invoice",
),
path(
"<transaction>/invoice/<invoice>",
views.InvoiceUpdateView.as_view(),

View file

@ -1,20 +1,24 @@
from account.models import Account
from category.models import Category
from django.contrib import messages
from django.forms import ValidationError
from django.shortcuts import get_object_or_404
from django.urls import reverse_lazy
from django.views.generic.dates import MonthArchiveView, YearArchiveView
from history.utils import history
from django.utils.html import format_html
from django.utils.translation import gettext as _
from django.views.generic.edit import FormView
from main.views import (
NummiCreateView,
NummiDeleteView,
NummiDetailView,
NummiListView,
NummiUpdateView,
UserMixin,
)
from statement.models import Statement
from .forms import InvoiceForm, TransactionForm
from .forms import (
InvoiceForm,
MultipleInvoicesForm,
TransactionFiltersForm,
TransactionForm,
)
from .models import Invoice, Transaction
@ -23,22 +27,27 @@ class TransactionCreateView(NummiCreateView):
form_class = TransactionForm
def get_initial(self):
_queryset = Statement.objects.filter(user=self.request.user)
initial = super().get_initial()
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}
initial["statement"] = get_object_or_404(
self.request.user.statement_set, pk=self.kwargs["statement"]
)
if (
last_transaction := initial["statement"]
.transaction_set.order_by("-date")
.first()
):
initial["date"] = last_transaction.date
return initial
def get_form_kwargs(self):
form_kwargs = super().get_form_kwargs()
if "statement" in self.kwargs:
return super().get_form_kwargs() | {"disable_statement": True}
return super().get_form_kwargs()
form_kwargs["disable_statement"] = True
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)
form_kwargs["autocomplete"] = True
return form_kwargs
class InvoiceCreateView(NummiCreateView):
@ -56,6 +65,42 @@ class InvoiceCreateView(NummiCreateView):
return reverse_lazy("transaction", args=(self.object.transaction.pk,))
class MultipleInvoiceCreateView(FormView):
form_class = MultipleInvoicesForm
def form_valid(self, form):
transaction = get_object_or_404(
self.request.user.transaction_set, pk=self.kwargs["transaction"]
)
for file in form.cleaned_data["invoices"]:
invoice = Invoice(
transaction=transaction,
user=self.request.user,
file=file,
name=file.name.split(".", 1)[0],
)
try:
invoice.full_clean()
invoice.save()
except ValidationError as err:
for msg in err.messages:
messages.error(
self.request,
format_html(
"{msg} {file}. {err}",
msg=_("Error processing file"),
file=file.name,
err=msg,
),
)
return super().form_valid(form)
def get_success_url(self):
return reverse_lazy("transaction", args=(self.kwargs["transaction"],))
class TransactionUpdateView(NummiUpdateView):
model = Transaction
form_class = TransactionForm
@ -69,12 +114,8 @@ class TransactionDetailView(NummiDetailView):
def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs)
transaction = data.get("transaction")
return data | {
"statement": transaction.statement,
"category": transaction.category,
}
data["invoices_form"] = MultipleInvoicesForm()
return data
class InvoiceUpdateView(NummiUpdateView):
@ -124,53 +165,36 @@ class InvoiceDeleteView(NummiDeleteView):
class TransactionListView(NummiListView):
model = Transaction
context_object_name = "transactions"
paginate_by = 32
def get_queryset(self, **kwargs):
queryset = super().get_queryset(**kwargs)
class TransactionACMixin:
model = Transaction
if date := self.request.GET.get("start_date"):
queryset = queryset.filter(date__gte=date)
if date := self.request.GET.get("end_date"):
queryset = queryset.filter(date__lte=date)
if category := self.request.GET.get("category"):
queryset = queryset.filter(category=category)
if account := self.request.GET.get("account"):
queryset = queryset.filter(statement__account=account)
if statement := self.request.GET.get("statement"):
queryset = queryset.filter(statement=statement)
if search := self.request.GET.get("search"):
queryset = queryset.search(search)
if sort_by := self.request.GET.get("sort_by"):
queryset = queryset.order_by(sort_by)
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()
return queryset
def get_context_data(self, **kwargs):
context_data = super().get_context_data(**kwargs)
if "category" in self.kwargs:
return context_data | {"category": self.category}
if "account" in self.kwargs:
return context_data | {"account": self.account}
return context_data
data = super().get_context_data(**kwargs)
class TransactionMonthView(UserMixin, TransactionACMixin, MonthArchiveView):
model = Transaction
date_field = "date"
context_object_name = "transactions"
month_format = "%m"
class TransactionYearView(UserMixin, TransactionACMixin, YearArchiveView):
model = Transaction
date_field = "date"
context_object_name = "transactions"
make_object_list = True
def get_context_data(self, **kwargs):
context_data = super().get_context_data(**kwargs)
h_data = context_data.get("transactions")
if not (context_data.get("account") or context_data.get("category")):
h_data = h_data.exclude(category__budget=False)
return context_data | {"history": history(h_data)}
filters = self.request.GET.copy()
filters.pop("page", None)
if filters:
data["filters"] = True
data["filter_form"] = TransactionFiltersForm(
initial=filters, user=self.request.user
)
return data

View file

@ -10,6 +10,8 @@ depends=(
"python-django"
"python-toml"
"python-psycopg"
"python-dateutil"
"python-pypdf"
)
makedepends=("git")
optdepends=("postgresql: database")

View file

@ -1,3 +1,16 @@
[project]
name = "nummi"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"django>=5.2",
"gunicorn>=23.0.0",
"psycopg[binary]>=3.2.6",
"pypdf>=5.4.0",
"python-dateutil>=2.9.0.post0",
]
[tool.djlint]
indent=2
max_blank_lines=1

172
uv.lock generated Normal file
View file

@ -0,0 +1,172 @@
version = 1
revision = 2
requires-python = ">=3.12"
[[package]]
name = "asgiref"
version = "3.8.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/29/38/b3395cc9ad1b56d2ddac9970bc8f4141312dbaec28bc7c218b0dfafd0f42/asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590", size = 35186, upload_time = "2024-03-22T14:39:36.863Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/39/e3/893e8757be2612e6c266d9bb58ad2e3651524b5b40cf56761e985a28b13e/asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", size = 23828, upload_time = "2024-03-22T14:39:34.521Z" },
]
[[package]]
name = "django"
version = "5.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "sqlparse" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4c/1b/c6da718c65228eb3a7ff7ba6a32d8e80fa840ca9057490504e099e4dd1ef/Django-5.2.tar.gz", hash = "sha256:1a47f7a7a3d43ce64570d350e008d2949abe8c7e21737b351b6a1611277c6d89", size = 10824891, upload_time = "2025-04-02T13:08:06.874Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/e0/6a5b5ea350c5bd63fe94b05e4c146c18facb51229d9dee42aa39f9fc2214/Django-5.2-py3-none-any.whl", hash = "sha256:91ceed4e3a6db5aedced65e3c8f963118ea9ba753fc620831c77074e620e7d83", size = 8301361, upload_time = "2025-04-02T13:08:01.465Z" },
]
[[package]]
name = "gunicorn"
version = "23.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
]
sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031, upload_time = "2024-08-10T20:25:27.378Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029, upload_time = "2024-08-10T20:25:24.996Z" },
]
[[package]]
name = "nummi"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "django" },
{ name = "gunicorn" },
{ name = "psycopg", extra = ["binary"] },
{ name = "pypdf" },
{ name = "python-dateutil" },
]
[package.metadata]
requires-dist = [
{ name = "django", specifier = ">=5.2" },
{ name = "gunicorn", specifier = ">=23.0.0" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2.6" },
{ name = "pypdf", specifier = ">=5.4.0" },
{ name = "python-dateutil", specifier = ">=2.9.0.post0" },
]
[[package]]
name = "packaging"
version = "25.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload_time = "2025-04-19T11:48:59.673Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload_time = "2025-04-19T11:48:57.875Z" },
]
[[package]]
name = "psycopg"
version = "3.2.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/67/97/eea08f74f1c6dd2a02ee81b4ebfe5b558beb468ebbd11031adbf58d31be0/psycopg-3.2.6.tar.gz", hash = "sha256:16fa094efa2698f260f2af74f3710f781e4a6f226efe9d1fd0c37f384639ed8a", size = 156322, upload_time = "2025-03-12T20:43:12.228Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/7d/0ba52deff71f65df8ec8038adad86ba09368c945424a9bd8145d679a2c6a/psycopg-3.2.6-py3-none-any.whl", hash = "sha256:f3ff5488525890abb0566c429146add66b329e20d6d4835662b920cbbf90ac58", size = 199077, upload_time = "2025-03-12T20:38:07.112Z" },
]
[package.optional-dependencies]
binary = [
{ name = "psycopg-binary", marker = "implementation_name != 'pypy'" },
]
[[package]]
name = "psycopg-binary"
version = "3.2.6"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a3/c7/220b1273f0befb2cd9fe83d379b3484ae029a88798a90bc0d36f10bea5df/psycopg_binary-3.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f27a46ff0497e882e8c0286e8833c785b4d1a80f23e1bf606f4c90e5f9f3ce75", size = 3857986, upload_time = "2025-03-12T20:39:54.482Z" },
{ url = "https://files.pythonhosted.org/packages/8a/d8/30176532826cf87c608a6f79dd668bf9aff0cdf8eb80209eddf4c5aa7229/psycopg_binary-3.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b30ee4821ded7de48b8048b14952512588e7c5477b0a5965221e1798afba61a1", size = 3940060, upload_time = "2025-03-12T20:39:58.835Z" },
{ url = "https://files.pythonhosted.org/packages/54/7c/fa7cd1f057f33f7ae483d6bc5a03ec6eff111f8aa5c678d9aaef92705247/psycopg_binary-3.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e57edf3b1f5427f39660225b01f8e7b97f5cfab132092f014bf1638bc85d81d2", size = 4499082, upload_time = "2025-03-12T20:40:03.605Z" },
{ url = "https://files.pythonhosted.org/packages/b8/81/1606966f6146187c273993ea6f88f2151b26741df8f4e01349a625983be9/psycopg_binary-3.2.6-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6c5172ce3e4ae7a4fd450070210f801e2ce6bc0f11d1208d29268deb0cda34de", size = 4307509, upload_time = "2025-03-12T20:40:07.996Z" },
{ url = "https://files.pythonhosted.org/packages/69/ad/01c87aab17a4b89128b8036800d11ab296c7c2c623940cc7e6f2668f375a/psycopg_binary-3.2.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bcfab3804c43571a6615e559cdc4c4115785d258a4dd71a721be033f5f5f378d", size = 4547813, upload_time = "2025-03-12T20:40:12.48Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/f93a193846ee738ffe5d2a4837e7ddeb7279707af81d088cee96cae853a0/psycopg_binary-3.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8fa1c920cce16f1205f37b20c685c58b9656b170b8b4c93629100d342d0d118e", size = 4259847, upload_time = "2025-03-12T20:40:17.684Z" },
{ url = "https://files.pythonhosted.org/packages/8e/73/65c4ae71be86675a62154407c92af4b917146f9ff3baaf0e4166c0734aeb/psycopg_binary-3.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2e118d818101c1608c6b5ba52a6c977614d8f05aa89467501172ba4d10588e11", size = 3846550, upload_time = "2025-03-12T20:40:22.336Z" },
{ url = "https://files.pythonhosted.org/packages/53/cc/a24626cac3f208c776bb22e15e9a5e483aa81145221e6427e50381f40811/psycopg_binary-3.2.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:763319a8bfeca77d31512da71f5a33459b9568a7621c481c3828c62f9c38f351", size = 3320269, upload_time = "2025-03-12T20:40:26.919Z" },
{ url = "https://files.pythonhosted.org/packages/55/e6/68c76fb9d6c53d5e4170a0c9216c7aa6c2903808f626d84d002b47a16931/psycopg_binary-3.2.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:2fbc05819560389dbece046966bc88e0f2ea77673497e274c4293b8b4c1d0703", size = 3399365, upload_time = "2025-03-12T20:40:30.945Z" },
{ url = "https://files.pythonhosted.org/packages/b4/2c/55b140f5a2c582dae42ef38502c45ef69c938274242a40bd04c143081029/psycopg_binary-3.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5a57f99bb953b4bd6f32d0a9844664e7f6ca5ead9ba40e96635be3cd30794813", size = 3438908, upload_time = "2025-03-12T20:40:34.926Z" },
{ url = "https://files.pythonhosted.org/packages/ae/f6/589c95cceccee2ab408b6b2e16f1ed6db4536fb24f2f5c9ce568cf43270c/psycopg_binary-3.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:5de6809e19a465dcb9c269675bded46a135f2d600cd99f0735afbb21ddad2af4", size = 2782886, upload_time = "2025-03-12T20:40:38.493Z" },
{ url = "https://files.pythonhosted.org/packages/bf/32/3d06c478fd3070ac25a49c2e8ca46b6d76b0048fa9fa255b99ee32f32312/psycopg_binary-3.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54af3fbf871baa2eb19df96fd7dc0cbd88e628a692063c3d1ab5cdd00aa04322", size = 3852672, upload_time = "2025-03-12T20:40:42.083Z" },
{ url = "https://files.pythonhosted.org/packages/34/97/e581030e279500ede3096adb510f0e6071874b97cfc047a9a87b7d71fc77/psycopg_binary-3.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ad5da1e4636776c21eaeacdec42f25fa4612631a12f25cd9ab34ddf2c346ffb9", size = 3936562, upload_time = "2025-03-12T20:40:46.709Z" },
{ url = "https://files.pythonhosted.org/packages/74/b6/6a8df4cb23c3d327403a83406c06c9140f311cb56c4e4d720ee7abf6fddc/psycopg_binary-3.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7956b9ea56f79cd86eddcfbfc65ae2af1e4fe7932fa400755005d903c709370", size = 4499167, upload_time = "2025-03-12T20:40:51.978Z" },
{ url = "https://files.pythonhosted.org/packages/e4/5b/950eafef61e5e0b8ddb5afc5b6b279756411aa4bf70a346a6f091ad679bb/psycopg_binary-3.2.6-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e2efb763188008cf2914820dcb9fb23c10fe2be0d2c97ef0fac7cec28e281d8", size = 4311651, upload_time = "2025-03-12T20:40:56.99Z" },
{ url = "https://files.pythonhosted.org/packages/72/b9/b366c49afc854c26b3053d4d35376046eea9aebdc48ded18ea249ea1f80c/psycopg_binary-3.2.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4b3aab3451679f1e7932270e950259ed48c3b79390022d3f660491c0e65e4838", size = 4547852, upload_time = "2025-03-12T20:41:01.379Z" },
{ url = "https://files.pythonhosted.org/packages/ab/d4/0e047360e2ea387dc7171ca017ffcee5214a0762f74b9dd982035f2e52fb/psycopg_binary-3.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:849a370ac4e125f55f2ad37f928e588291a67ccf91fa33d0b1e042bb3ee1f986", size = 4261725, upload_time = "2025-03-12T20:41:05.576Z" },
{ url = "https://files.pythonhosted.org/packages/e3/ea/a1b969804250183900959ebe845d86be7fed2cbd9be58f64cd0fc24b2892/psycopg_binary-3.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:566d4ace928419d91f1eb3227fc9ef7b41cf0ad22e93dd2c3368d693cf144408", size = 3850073, upload_time = "2025-03-12T20:41:10.362Z" },
{ url = "https://files.pythonhosted.org/packages/e5/71/ec2907342f0675092b76aea74365b56f38d960c4c635984dcfe25d8178c8/psycopg_binary-3.2.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f1981f13b10de2f11cfa2f99a8738b35b3f0a0f3075861446894a8d3042430c0", size = 3320323, upload_time = "2025-03-12T20:41:14.729Z" },
{ url = "https://files.pythonhosted.org/packages/d7/d7/0d2cb4b42f231e2efe8ea1799ce917973d47486212a2c4d33cd331e7ac28/psycopg_binary-3.2.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:36f598300b55b3c983ae8df06473ad27333d2fd9f3e2cfdb913b3a5aaa3a8bcf", size = 3402335, upload_time = "2025-03-12T20:41:19.103Z" },
{ url = "https://files.pythonhosted.org/packages/66/92/7050c372f78e53eba14695cec6c3a91b2d9ca56feaf0bfe95fe90facf730/psycopg_binary-3.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0f4699fa5fe1fffb0d6b2d14b31fd8c29b7ea7375f89d5989f002aaf21728b21", size = 3440442, upload_time = "2025-03-12T20:41:23.979Z" },
{ url = "https://files.pythonhosted.org/packages/5f/4c/bebcaf754189283b2f3d457822a3d9b233d08ff50973d8f1e8d51f4d35ed/psycopg_binary-3.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:afe697b8b0071f497c5d4c0f41df9e038391534f5614f7fb3a8c1ca32d66e860", size = 2783465, upload_time = "2025-03-12T20:41:30.32Z" },
]
[[package]]
name = "pypdf"
version = "5.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/43/4026f6ee056306d0e0eb04fcb9f2122a0f1a5c57ad9dc5e0d67399e47194/pypdf-5.4.0.tar.gz", hash = "sha256:9af476a9dc30fcb137659b0dec747ea94aa954933c52cf02ee33e39a16fe9175", size = 5012492, upload_time = "2025-03-16T09:44:11.656Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/27/d83f8f2a03ca5408dc2cc84b49c0bf3fbf059398a6a2ea7c10acfe28859f/pypdf-5.4.0-py3-none-any.whl", hash = "sha256:db994ab47cadc81057ea1591b90e5b543e2b7ef2d0e31ef41a9bfe763c119dab", size = 302306, upload_time = "2025-03-16T09:44:09.757Z" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload_time = "2024-03-01T18:36:20.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload_time = "2024-03-01T18:36:18.57Z" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload_time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload_time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "sqlparse"
version = "0.5.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e5/40/edede8dd6977b0d3da179a342c198ed100dd2aba4be081861ee5911e4da4/sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272", size = 84999, upload_time = "2024-12-10T12:05:30.728Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/5c/bfd6bd0bf979426d405cc6e71eceb8701b148b16c21d2dc3c261efc61c7b/sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca", size = 44415, upload_time = "2024-12-10T12:05:27.824Z" },
]
[[package]]
name = "typing-extensions"
version = "4.13.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967, upload_time = "2025-04-10T14:19:05.416Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806, upload_time = "2025-04-10T14:19:03.967Z" },
]
[[package]]
name = "tzdata"
version = "2025.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload_time = "2025-03-23T13:54:43.652Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload_time = "2025-03-23T13:54:41.845Z" },
]