Compare commits

..

No commits in common. "main" and "v0.10.5" have entirely different histories.

82 changed files with 8135 additions and 1979 deletions

View file

@ -1,29 +0,0 @@
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,10 +1,4 @@
repos: 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 - repo: https://github.com/PyCQA/isort
rev: 5.12.0 rev: 5.12.0
hooks: hooks:

View file

@ -1 +0,0 @@
3.12

22
.vscode/launch.json vendored
View file

@ -1,22 +0,0 @@
{
// 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
}
]
}

View file

@ -1,17 +0,0 @@
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"]

View file

@ -1,23 +0,0 @@
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

View file

@ -1,5 +0,0 @@
#!/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,3 @@
from django.forms.widgets import Select
from main.forms import IconInput, NummiForm from main.forms import IconInput, NummiForm
from .models import Account from .models import Account
@ -16,7 +15,3 @@ class AccountForm(NummiForm):
widgets = { widgets = {
"icon": IconInput(), "icon": IconInput(),
} }
class AccountSelect(Select):
template_name = "account/forms/widgets/account.html"

View file

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

@ -1,20 +0,0 @@
# 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

@ -4,10 +4,10 @@ from django.apps import apps
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from main.models import NummiModel from main.models import UserModel
class Account(NummiModel): class Account(UserModel):
id = models.UUIDField(primary_key=True, default=uuid4, editable=False) id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
name = models.CharField(max_length=64, default=_("Account"), verbose_name=_("Name")) name = models.CharField(max_length=64, default=_("Account"), verbose_name=_("Name"))
icon = models.SlugField( icon = models.SlugField(
@ -41,12 +41,12 @@ class Account(NummiModel):
) )
class Meta: class Meta:
ordering = ["-default", "archived", "name"] ordering = ["-default", "name"]
verbose_name = _("Account") verbose_name = _("Account")
verbose_name_plural = _("Accounts") verbose_name_plural = _("Accounts")
class AccountModel(NummiModel): class AccountModel(UserModel):
account = models.ForeignKey( account = models.ForeignKey(
Account, Account,
on_delete=models.CASCADE, on_delete=models.CASCADE,

View file

@ -22,6 +22,6 @@
</section> </section>
<section> <section>
<h3>{% translate "History" %}</h3> <h3>{% translate "History" %}</h3>
{% history_plot account.transactions account=account %} {% history_plot account.transactions %}
</section> </section>
{% endblock body %} {% endblock body %}

View file

@ -1,7 +1,7 @@
{% load i18n main_extras %} {% load i18n main_extras %}
<dl class="accounts"> <dl class="accounts">
{% for acc in accounts %} {% for acc in accounts %}
<div class="account {% if not search and acc.archived %}archived{% endif %}"> <div class="account {% if acc.archived %}archived{% endif %}">
<dt> <dt>
<a href="{{ acc.get_absolute_url }}">{{ acc.icon|remix }}{{ acc }}</a> <a href="{{ acc.get_absolute_url }}">{{ acc.icon|remix }}{{ acc }}</a>
</dt> </dt>
@ -19,7 +19,7 @@
{{ accounts|balance|value }} {{ accounts|balance|value }}
</dd> </dd>
</div> </div>
{% elif not search %} {% else %}
<div class="more account"> <div class="more account">
<dt> <dt>
<label class="wi" for="show-archived-accounts"> <label class="wi" for="show-archived-accounts">

View file

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

View file

@ -1,5 +1,6 @@
from django.urls import path from django.urls import path
from statement.views import StatementCreateView from statement.views import StatementCreateView
from transaction.views import TransactionMonthView, TransactionYearView
from . import views from . import views
@ -28,4 +29,14 @@ urlpatterns = [
views.AccountDeleteView.as_view(), views.AccountDeleteView.as_view(),
name="del_account", 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

@ -7,7 +7,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: \n" "Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-01-04 18:51+0100\n" "POT-Creation-Date: 2024-01-04 16:18+0100\n"
"PO-Revision-Date: 2023-04-22 15:18+0200\n" "PO-Revision-Date: 2023-04-22 15:18+0200\n"
"Last-Translator: Edgar P. Burkhart <traduction@edgarpierre.fr>\n" "Last-Translator: Edgar P. Burkhart <traduction@edgarpierre.fr>\n"
"Language-Team: \n" "Language-Team: \n"
@ -18,7 +18,7 @@ msgstr ""
"X-Generator: Poedit 3.2.2\n" "X-Generator: Poedit 3.2.2\n"
#: .\category\models.py:12 .\category\models.py:32 #: .\category\models.py:12 .\category\models.py:32
#: .\category\templates\category\category_plot.html:13 #: .\category\templates\category\category_plot.html:14
msgid "Category" msgid "Category"
msgstr "Catégorie" msgstr "Catégorie"
@ -38,19 +38,15 @@ msgstr "Budget"
msgid "Categories" msgid "Categories"
msgstr "Catégories" msgstr "Catégories"
#: .\category\templates\category\category_detail.html:14 #: .\category\templates\category\category_detail.html:12
msgid "Edit category" msgid "Edit category"
msgstr "Modifier la catégorie" msgstr "Modifier la catégorie"
#: .\category\templates\category\category_detail.html:17 #: .\category\templates\category\category_detail.html:15
msgid "Transactions" msgid "Transactions"
msgstr "Transactions" msgstr "Transactions"
#: .\category\templates\category\category_detail.html:20 #: .\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" msgid "History"
msgstr "Historique" msgstr "Historique"
@ -62,22 +58,18 @@ msgstr "Créer une catégorie"
msgid "New category" msgid "New category"
msgstr "Nouvelle catégorie" msgstr "Nouvelle catégorie"
#: .\category\templates\category\category_plot.html:14 #: .\category\templates\category\category_plot.html:15
msgid "Expenses" msgid "Expenses"
msgstr "Dépenses" msgstr "Dépenses"
#: .\category\templates\category\category_plot.html:15 #: .\category\templates\category\category_plot.html:16
msgid "Income" msgid "Income"
msgstr "Revenus" msgstr "Revenus"
#: .\category\templates\category\category_plot.html:58 #: .\category\templates\category\category_plot.html:62
msgid "No transaction" msgid "No transaction"
msgstr "Aucune transaction" msgstr "Aucune transaction"
#: .\category\templates\category\category_plot.html:66 #: .\category\templates\category\category_plot.html:70
msgid "Total" msgid "Total"
msgstr "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.db import models
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from main.models import NummiModel from main.models import UserModel
class Category(NummiModel): class Category(UserModel):
id = models.UUIDField(primary_key=True, default=uuid4, editable=False) id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
name = models.CharField( name = models.CharField(
max_length=64, default=_("Category"), verbose_name=_("Name") max_length=64, default=_("Category"), verbose_name=_("Name")

View file

@ -15,14 +15,11 @@
</p> </p>
<section> <section>
<h3>{% translate "Transactions" %}</h3> <h3>{% translate "Transactions" %}</h3>
{% url_get "transactions" category=category.id as t_url %} {% url "category_transactions" 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 %} {% transaction_table category.transaction_set.all n_max=8 transactions_url=t_url %}
</section> </section>
<section> <section>
<h3>{% translate "History" %}</h3> <h3>{% translate "History" %}</h3>
{% history_plot category.transaction_set category=category %} {% history_plot category.transaction_set.all %}
</section> </section>
{% endblock body %} {% endblock body %}

View file

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

View file

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

View file

@ -1,4 +1,5 @@
from django.urls import path from django.urls import path
from transaction.views import TransactionMonthView, TransactionYearView
from . import views from . import views
@ -12,4 +13,14 @@ urlpatterns = [
name="category_transactions", name="category_transactions",
), ),
path("<category>/delete", views.CategoryDeleteView.as_view(), name="del_category"), 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

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

View file

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

View file

@ -1,51 +0,0 @@
{% 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

@ -1,8 +0,0 @@
{% 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

@ -1,8 +0,0 @@
{% 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,9 +1,6 @@
import datetime
import math import math
from urllib import parse
from django import template from django import template
from django.urls import reverse
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from history.utils import history from history.utils import history
from main.templatetags.main_extras import pmrvalue, remix from main.templatetags.main_extras import pmrvalue, remix
@ -11,24 +8,18 @@ from main.templatetags.main_extras import pmrvalue, remix
register = template.Library() register = template.Library()
@register.inclusion_tag("history/plot.html", takes_context=True) @register.inclusion_tag("history/plot.html")
def history_plot(context, transactions, **kwargs): def history_plot(transactions, **kwargs):
kwargs.setdefault("account", context.get("account")) return kwargs | {
kwargs.setdefault("category", context.get("category")) "history": history(transactions),
}
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 @register.simple_tag
def calendar_opacity(v, vmax): def calendar_opacity(v, vmax):
if v is None: if v is None:
return "0%" 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 @register.simple_tag
@ -65,11 +56,11 @@ def plot_bar(s, sum_pm, s_max):
if s_max: if s_max:
if sum_pm: if sum_pm:
_w = abs(sum_pm / s_max) _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: if sum_pm is not None and s * sum_pm > 0:
_w = abs(s / s_max) _w = abs(s / s_max)
_res += ( _res += (
f"""<div class="tot" style="width: {_w: .1%}">""" f"""<div class="tot" style="width: {_w:.1%}">"""
f"""<span>{pmrvalue(s)}</span></div>""" f"""<span>{pmrvalue(s)}</span></div>"""
) )
else: else:
@ -81,7 +72,7 @@ def plot_bar(s, sum_pm, s_max):
@register.simple_tag @register.simple_tag
def calendar_head(): def calendar_head():
months = range(1, 13) 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)) return mark_safe("".join(th))
@ -89,31 +80,3 @@ def calendar_head():
@register.filter @register.filter
def sum_year(y_data): def sum_year(y_data):
return sum(y["sum"] or 0 for y in 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

View file

@ -1,13 +0,0 @@
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"),
]

View file

@ -1,54 +0,0 @@
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

@ -12,8 +12,7 @@ class NummiForm(forms.ModelForm):
template_name = "main/form/form_base.html" template_name = "main/form/form_base.html"
meta_fieldsets = [] meta_fieldsets = []
def __init__(self, *args, **kwargs): def __init__(self, *args, user, **kwargs):
kwargs.pop("user", None)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@property @property

View file

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

View file

@ -1,46 +1,15 @@
from django.conf import settings from django.conf import settings
from django.contrib.postgres.search import (
SearchQuery,
SearchRank,
SearchVector,
TrigramSimilarity,
)
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
class NummiQuerySet(models.QuerySet): class UserModel(models.Model):
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( user = models.ForeignKey(
settings.AUTH_USER_MODEL, settings.AUTH_USER_MODEL,
on_delete=models.CASCADE, on_delete=models.CASCADE,
verbose_name=_("User"), verbose_name=_("User"),
editable=False, editable=False,
) )
objects = NummiQuerySet.as_manager()
class Meta: class Meta:
abstract = True abstract = True

View file

@ -1,33 +1,8 @@
.drop-zone { form ul.errorlist {
position: absolute; color: var(--red);
top: 0; font-weight: 550;
left: 0; list-style-type: "! ";
right: 0; margin: 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 { form {
@ -38,17 +13,12 @@ form {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
&.hidden {
display: none;
}
.column { .column {
display: grid; display: grid;
gap: 0.5rem; gap: 0.5rem;
border: 1px solid var(--gray); border: 1px solid var(--gray);
padding: var(--gap); padding: var(--gap);
block-size: min-content;
}
.fieldset { .fieldset {
display: grid; display: grid;
grid-auto-columns: 1fr; grid-auto-columns: 1fr;
@ -59,46 +29,26 @@ form {
border: none; border: none;
} }
ul.errorlist {
color: var(--red);
font-weight: 550;
list-style: none;
padding: 0;
margin: 0;
}
.field { .field {
display: grid; display: grid;
grid-auto-rows: min-content; grid-auto-rows: min-content;
align-items: center; align-items: center;
overflow: hidden;
column-gap: 0.5rem; column-gap: 0.5rem;
ul.errorlist {
font-size: 0.8rem;
}
&:has(> textarea) { &:has(> textarea) {
grid-template-rows: min-content 1fr; grid-template-rows: min-content 1fr;
textarea {
resize: block;
} }
} &:has(input[type="checkbox"]) {
&:has(> input[type="checkbox"]) {
grid-template-columns: min-content 1fr; grid-template-columns: min-content 1fr;
> label { > label {
font-size: inherit; font-size: inherit;
grid-row: 1; grid-row: 1;
grid-column: 2; grid-column: 2;
padding: 0.5rem;
line-height: initial;
} }
> input { > input {
grid-row: 1; grid-row: 1;
grid-column: 1; grid-column: 1;
margin: 0.5rem;
}
&:has(> :focus) {
background: var(--bg-1);
} }
} }
@ -108,32 +58,19 @@ form {
z-index: 10; z-index: 10;
} }
> a {
padding: 0.5rem;
}
input, input,
select, select,
textarea { textarea {
font: inherit; font: inherit;
line-height: initial; line-height: initial;
border: none; border: none;
border: 1px solid transparent;
border-bottom: 1px solid var(--gray); border-bottom: 1px solid var(--gray);
background: none; background: none;
z-index: 1; z-index: 1;
padding: 0.5rem; padding: 0;
&:has(~ ul.errorlist) {
border-color: var(--red);
}
&.autocompleted:not(.mod) {
border-bottom-color: var(--green);
}
&:not([type="checkbox"]) { &:not([type="checkbox"]) {
width: 100%; width: 100%;
margin: 0;
} }
&[name*="value"] { &[name*="value"] {
@ -146,7 +83,7 @@ form {
&:focus { &:focus {
outline: none; outline: none;
background: var(--bg-1); background: var(--bg-01);
} }
} }
@ -158,9 +95,6 @@ form {
grid-template-columns: 1fr; grid-template-columns: 1fr;
grid-auto-columns: max-content; grid-auto-columns: max-content;
grid-auto-flow: column; grid-auto-flow: column;
a {
padding: 0.5rem;
}
} }
input[type="file"] { input[type="file"] {
@ -173,45 +107,38 @@ form {
display: grid; display: grid;
grid-template-columns: min-content 1fr; grid-template-columns: min-content 1fr;
column-gap: 0.5rem; column-gap: 0.5rem;
align-items: center;
span[class|="ri"] {
padding: 0.5rem;
}
&:has(> :focus) {
background: var(--bg-1);
} }
} }
} }
.buttons { .buttons {
grid-column: 1 / -1; grid-column: 1 / -1;
line-height: 2rem; display: grid;
input, grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
a { grid-auto-rows: 1fr;
gap: 0.5rem;
align-items: center;
input {
font: inherit; font: inherit;
cursor: pointer; line-height: 1.5;
border-radius: var(--radius);
padding: 0 var(--gap); padding: 0 var(--gap);
border: var(--gray) 1px solid; cursor: pointer;
margin-right: 0.5rem;
margin-bottom: 0.5rem;
color: inherit;
&:hover { &:hover {
text-decoration: underline; text-decoration: underline;
} }
} &[type="submit"] {
border: 0.1rem solid var(--green);
input[type="submit"] {
background: var(--green-1); background: var(--green-1);
border-color: var(--green);
} }
input[type="reset"] { &[type="reset"] {
background: var(--bg-1); border: 0.1rem solid var(--red);
background: var(--red-1);
}
} }
a.del { a.del {
color: var(--red); 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://rsms.me/inter/inter.css";
@import "https://cdn.jsdelivr.net/npm/remixicon@4.5.0/fonts/remixicon.css"; @import "https://cdn.jsdelivr.net/npm/remixicon@4.0.0/fonts/remixicon.css";
*, *,
*::before, *::before,
@ -24,7 +24,7 @@
--bg-inv: var(--theme-1); --bg-inv: var(--theme-1);
--text-inv: #ffffffde; --text-inv: #ffffffde;
--bg-1: #f0f0f0; --bg-01: #f0f0f0;
--text-green: #296629; --text-green: #296629;
--text-link: var(--text-green); --text-link: var(--text-green);
@ -85,7 +85,6 @@ footer {
@media (width > 720px) { @media (width > 720px) {
padding: 2rem; padding: 2rem;
} }
background: var(--bg);
} }
main { main {
position: relative; position: relative;
@ -118,7 +117,7 @@ nav {
top: 0; top: 0;
overflow-y: auto; overflow-y: auto;
background: var(--bg-1); background: var(--bg-01);
line-height: 2rem; line-height: 2rem;
h1 img { h1 img {
@ -240,7 +239,7 @@ footer {
color: var(--bg); color: var(--bg);
} }
&.white { &.white {
background: var(--bg-1); background: var(--bg-01);
} }
border-radius: var(--radius); border-radius: var(--radius);
height: 1.5em; height: 1.5em;
@ -283,7 +282,7 @@ ul.messages {
list-style-type: none; list-style-type: none;
margin: 0; margin: 0;
margin-bottom: var(--gap); margin-bottom: var(--gap);
background: var(--bg-1); background: var(--bg-01);
padding: 0; padding: 0;
li { li {
@ -387,9 +386,6 @@ ul.invoices {
> * { > * {
&.title { &.title {
font-weight: 650; font-weight: 650;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
} }
@ -426,19 +422,14 @@ ul.statements {
} }
} }
ul.invoices {
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
}
.statement-details { .statement-details {
overflow-x: auto;
display: grid; display: grid;
@media (width > 720px) { grid-template-columns: repeat(4, min-content);
grid-template-columns: repeat(3, min-content);
}
gap: var(--gap); gap: var(--gap);
align-items: center; align-items: center;
> span.evolution { > span:nth-child(2) {
display: grid; display: grid;
grid-auto-rows: min-content; grid-auto-rows: min-content;
> span[class^="ri-"] { > span[class^="ri-"] {
@ -448,49 +439,12 @@ ul.invoices {
text-align: right; text-align: right;
} }
} }
> span.start, > span:first-child,
> span.end, > span:nth-child(3) {
> span.file,
> span.incons {
display: grid; display: grid;
border: var(--gray) 1px solid; border: var(--gray) 1px solid;
padding: var(--gap); 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 { > .date {
text-align: right; text-align: right;
} }
@ -529,8 +483,7 @@ ul.invoices {
} }
} }
.category, .category {
.big-link {
padding: 0 var(--gap); padding: 0 var(--gap);
border: var(--gray) 1px solid; border: var(--gray) 1px solid;
margin-right: 0.5rem; margin-right: 0.5rem;
@ -545,28 +498,3 @@ ul.invoices {
.value { .value {
font-feature-settings: var(--num); 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,4 +1,5 @@
.table { .table,
form {
overflow-x: auto; overflow-x: auto;
width: 100%; width: 100%;
} }
@ -16,7 +17,7 @@ table {
width: 1ch; width: 1ch;
} }
thead tr:not(.new) { thead tr:not(.new) {
background: var(--bg-1); background: var(--bg-01);
} }
tr { tr {
border: 1px solid var(--gray); border: 1px solid var(--gray);
@ -44,7 +45,9 @@ table {
font-weight: 300; font-weight: 300;
} }
} }
tfoot tr:not(.new) {
background: var(--bg-01);
}
.l { .l {
text-align: left; text-align: left;
} }

File diff suppressed because one or more lines are too long

View file

@ -34,21 +34,6 @@ for (let form of forms) {
setTimeout(setIcon, 0); 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"); let iconSelect = form.querySelector(".icon-select");
if (iconSelect) { if (iconSelect) {
@ -59,6 +44,7 @@ for (let form of forms) {
); );
function setIcon(event) { function setIcon(event) {
console.log(input.value);
icon.className = `ri-${ icon.className = `ri-${
icons.includes(input.value) ? input.value : "square" icons.includes(input.value) ? input.value : "square"
}-line`; }-line`;
@ -69,75 +55,6 @@ for (let form of forms) {
setTimeout(setIcon, 0); 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"); let accounts = document.querySelector("dl.accounts");
@ -150,32 +67,3 @@ if (accounts) {
}); });
} }
} }
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;
}
}
});
}

File diff suppressed because it is too large Load diff

Binary file not shown.

View file

@ -5,7 +5,7 @@
<div class="current"> <div class="current">
<a href="{{ widget.value.url }}" target="_blank">{{ "file"|remix }}{% translate "File" %} [{{ widget.value|extension }}]</a> <a href="{{ widget.value.url }}" target="_blank">{{ "file"|remix }}{% translate "File" %} [{{ widget.value|extension }}]</a>
{% if not widget.required %} {% if not widget.required %}
<span class="field checkbox"> <span class="checkbox">
<label for="{{ widget.checkbox_id }}">{{ widget.clear_checkbox_label }}</label> <label for="{{ widget.checkbox_id }}">{{ widget.clear_checkbox_label }}</label>
<input type="checkbox" <input type="checkbox"
name="{{ widget.checkbox_name }}" name="{{ widget.checkbox_name }}"
@ -19,3 +19,34 @@
name="{{ widget.name }}" name="{{ widget.name }}"
{% include "django/forms/widgets/attrs.html" %}> {% include "django/forms/widgets/attrs.html" %}>
</div> </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,7 +1,6 @@
{% load i18n %} {% load i18n %}
{% load main_extras %} {% load main_extras %}
{% block fields %} {% block fields %}
{% if form.autocomplete %}{{ form.autocomplete|json_script:"autocomplete" }}{% endif %}
{% if form.non_field_errors %} {% if form.non_field_errors %}
<ul class="errorlist"> <ul class="errorlist">
{% for error in form.non_field_errors %}<li>{{ error }}</li>{% endfor %} {% for error in form.non_field_errors %}<li>{{ error }}</li>{% endfor %}
@ -13,16 +12,10 @@
{% for fieldset in group %} {% for fieldset in group %}
<div class="fieldset"> <div class="fieldset">
{% for field in fieldset %} {% for field in fieldset %}
{% if field.errors %}<p class="error">{{ field.errors }}</p>{% endif %}
<div class="field"> <div class="field">
<label for="{{ field.id_for_label }}">{{ field.label }}</label> <label for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }} {{ field }}
{% if field.errors %}
<ul class="errorlist">
{% for error in field.errors %}
<li class="wi">{{ "error-warning"|remix }}{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
</div> </div>
{% endfor %} {% endfor %}
</div> </div>

View file

@ -1,4 +1,32 @@
{% load i18n main_extras transaction_extras %} {% load i18n transaction_extras %}
{% if page_obj %} {% if page_obj %}
<p class="pagination">{% pagination_links page_obj %}</p> <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>
{% endif %} {% endif %}

View file

@ -1,13 +0,0 @@
{% 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,9 +1,5 @@
from urllib import parse
from dateutil.relativedelta import relativedelta
from django import template from django import template
from django.templatetags.static import static from django.templatetags.static import static
from django.urls import reverse
from django.utils import formats from django.utils import formats
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
@ -63,7 +59,7 @@ def messageicon(level):
@register.filter @register.filter
def extension(file): def extension(file):
return file.name.split(".", 1)[1].upper() return file.name.split(".")[-1].upper()
@register.filter @register.filter
@ -93,48 +89,3 @@ def balance(accounts):
return sum( return sum(
statement.value for acc in accounts if (statement := acc.statement_set.first()) 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,6 +10,5 @@ urlpatterns = [
path("category/", include("category.urls")), path("category/", include("category.urls")),
path("statement/", include("statement.urls")), path("statement/", include("statement.urls")),
path("transaction/", include("transaction.urls")), path("transaction/", include("transaction.urls")),
path("history/", include("history.urls")),
path("search/", include("search.urls")), path("search/", include("search.urls")),
] ]

View file

@ -1,18 +1,9 @@
import json import json
import pathlib from urllib import request
from django.contrib.staticfiles import finders
def get_icons(): def get_icons():
with open(finders.find("main/icons/remixicon.glyph.json"), "r") as f: url = "https://cdn.jsdelivr.net/npm/remixicon@4.5.0/fonts/remixicon.glyph.json"
data = json.load(f) data = json.loads(request.urlopen(url).read())
return [i.removesuffix("-line") for i in data.keys() if i.endswith("-line")] 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

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

View file

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

View file

@ -1,59 +0,0 @@
{% 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,7 +1,14 @@
from django.contrib.auth.mixins import LoginRequiredMixin 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.shortcuts import redirect
from django.views.generic import TemplateView
from django.views.generic.edit import FormView from django.views.generic.edit import FormView
from transaction.views import TransactionListView
from .forms import SearchForm from .forms import SearchForm
@ -14,19 +21,24 @@ class SearchFormView(LoginRequiredMixin, FormView):
return redirect("search", search=form.cleaned_data.get("search")) return redirect("search", search=form.cleaned_data.get("search"))
class SearchView(LoginRequiredMixin, TemplateView): class SearchView(TransactionListView):
template_name = "search/search_results.html" def get_queryset(self):
self.search = self.kwargs["search"]
return (
super()
.get_queryset()
.annotate(
rank=SearchRank(
SearchVector("name", weight="A")
+ SearchVector("description", weight="B")
+ SearchVector("trader", weight="B"),
SearchQuery(self.search, search_type="websearch"),
),
similarity=TrigramSimilarity("name", self.search),
)
.filter(models.Q(rank__gte=0.1) | models.Q(similarity__gte=0.3))
.order_by("-rank", "-date")
)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) return super().get_context_data(**kwargs) | {"search": self.kwargs["search"]}
_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,8 +1,5 @@
import json from account.models import Account
from account.forms import AccountSelect
from django import forms from django import forms
from django.forms.widgets import Select
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from main.forms import NummiFileInput, NummiForm from main.forms import NummiFileInput, NummiForm
from transaction.models import Transaction from transaction.models import Transaction
@ -16,7 +13,6 @@ class StatementForm(NummiForm):
fields = ["account", "start_date", "date", "start_value", "value", "file"] fields = ["account", "start_date", "date", "start_value", "value", "file"]
widgets = { widgets = {
"file": NummiFileInput, "file": NummiFileInput,
"account": AccountSelect,
} }
meta_fieldsets = [ meta_fieldsets = [
@ -29,10 +25,10 @@ class StatementForm(NummiForm):
] ]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
_user = kwargs.pop("user") _user = kwargs.get("user")
_disable_account = kwargs.pop("disable_account", False) _disable_account = kwargs.pop("disable_account", False)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields["account"].queryset = _user.account_set.exclude(archived=True) self.fields["account"].queryset = Account.objects.filter(user=_user)
self.fields["transactions"] = forms.MultipleChoiceField( self.fields["transactions"] = forms.MultipleChoiceField(
choices=( choices=(
((_transaction.id), _transaction) ((_transaction.id), _transaction)
@ -44,16 +40,6 @@ class StatementForm(NummiForm):
required=False, 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: if _disable_account:
self.fields["account"].disabled = True self.fields["account"].disabled = True
@ -65,7 +51,3 @@ class StatementForm(NummiForm):
instance.transaction_set.add(*new_transactions, bulk=False) instance.transaction_set.add(*new_transactions, bulk=False)
return instance return instance
class StatementSelect(Select):
template_name = "statement/forms/widgets/statement.html"

View file

@ -7,7 +7,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: \n" "Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-01-04 18:51+0100\n" "POT-Creation-Date: 2024-01-03 15:51+0100\n"
"PO-Revision-Date: 2023-04-22 15:22+0200\n" "PO-Revision-Date: 2023-04-22 15:22+0200\n"
"Last-Translator: Edgar P. Burkhart <traduction@edgarpierre.fr>\n" "Last-Translator: Edgar P. Burkhart <traduction@edgarpierre.fr>\n"
"Language-Team: \n" "Language-Team: \n"
@ -17,7 +17,7 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"X-Generator: Poedit 3.2.2\n" "X-Generator: Poedit 3.2.2\n"
#: .\statement\forms.py:43 #: .\statement\forms.py:28
msgid "Add transactions" msgid "Add transactions"
msgstr "Ajouter des transactions" msgstr "Ajouter des transactions"
@ -37,53 +37,36 @@ msgstr "Valeur finale"
msgid "Start value" msgid "Start value"
msgstr "Valeur initiale" msgstr "Valeur initiale"
#: .\statement\models.py:28 #: .\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
msgid "File" msgid "File"
msgstr "Fichier" msgstr "Fichier"
#: .\statement\models.py:35 #: .\statement\models.py:49
#, python-format #, python-format
msgid "%(date)s statement" msgid "%(date)s statement"
msgstr "Relevé du %(date)s" msgstr "Relevé du %(date)s"
#: .\statement\models.py:68 #: .\statement\models.py:89
msgid "Statement" msgid "Statement"
msgstr "Relevé" msgstr "Relevé"
#: .\statement\models.py:69 #: .\statement\models.py:90
#: .\statement\templates\statement\statement_list.html:4 #: .\statement\templates\statement\statement_list.html:4
#: .\statement\templates\statement\statement_list.html:7 #: .\statement\templates\statement\statement_list.html:7
msgid "Statements" msgid "Statements"
msgstr "Relevés" 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_form.html:4
#: .\statement\templates\statement\statement_table.html:7 #: .\statement\templates\statement\statement_table.html:18
msgid "Create statement" msgid "Create statement"
msgstr "Créer un relevé" msgstr "Créer un relevé"
@ -91,28 +74,34 @@ msgstr "Créer un relevé"
msgid "New statement" msgid "New statement"
msgstr "Nouveau relevé" msgstr "Nouveau relevé"
#: .\statement\templates\statement\statement_form.html:12 #: .\statement\templates\statement\statement_form.html:23
msgid "Back" msgid "Categories"
msgstr "Retour" msgstr "Catégories"
#: .\statement\templates\statement\statement_table.html:28 #: .\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
msgid "View all statements" msgid "View all statements"
msgstr "Voir tous les relevés" 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" #~ msgid "No transaction"
#~ msgstr "Aucune transaction" #~ msgstr "Aucune transaction"

View file

@ -1,20 +0,0 @@
# 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

@ -1,22 +0,0 @@
# 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,20 +4,10 @@ from uuid import uuid4
from account.models import AccountModel from account.models import AccountModel
from django.core.validators import FileExtensionValidator from django.core.validators import FileExtensionValidator
from django.db import models from django.db import models, transaction
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext_lazy as _ 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 media.utils import get_path
from pypdf import PdfReader
class StatementQuerySet(NummiQuerySet):
main_field = "metadata"
fields = {
"tags": "B",
}
class Statement(AccountModel): class Statement(AccountModel):
@ -32,6 +22,20 @@ class Statement(AccountModel):
start_value = models.DecimalField( start_value = models.DecimalField(
max_digits=12, decimal_places=2, default=0, verbose_name=_("Start value") 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( file = models.FileField(
upload_to=get_path, upload_to=get_path,
validators=[FileExtensionValidator(["pdf"])], validators=[FileExtensionValidator(["pdf"])],
@ -40,10 +44,6 @@ class Statement(AccountModel):
blank=True, blank=True,
default="", default="",
) )
metadata = models.TextField(blank=True)
tags = models.TextField(blank=True)
objects = StatementQuerySet.as_manager()
def __str__(self): def __str__(self):
return _("%(date)s statement") % {"date": self.date} return _("%(date)s statement") % {"date": self.date}
@ -54,39 +54,21 @@ class Statement(AccountModel):
if _prever.file and _prever.file != self.file: if _prever.file and _prever.file != self.file:
Path(_prever.file.path).unlink(missing_ok=True) Path(_prever.file.path).unlink(missing_ok=True)
if self.file: with transaction.atomic():
reader = PdfReader(self.file) for trans in self.transaction_set.all():
trans.save()
if reader.metadata: self.diff = self.value - self.start_value
self.metadata = " ".join( self.sum = (
( self.transaction_set.aggregate(sum=models.Sum("value")).get("sum", 0) or 0
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) super().save(*args, **kwargs)
@property def update_sum(self):
def sum(self): self.sum = (
return self.transaction_set.aggregate(sum=models.Sum("value", default=0)).get( self.transaction_set.aggregate(sum=models.Sum("value")).get("sum", 0) or 0
"sum"
) )
super().save()
@property
def diff(self):
return self.value - self.start_value
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):
if self.file: if self.file:

View file

@ -1,11 +0,0 @@
{% 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

@ -19,52 +19,31 @@
<a href="{% url "edit_statement" statement.id %}">{{ "file-edit"|remix }}{% translate "Edit statement" %}</a> <a href="{% url "edit_statement" statement.id %}">{{ "file-edit"|remix }}{% translate "Edit statement" %}</a>
</p> </p>
<div class="statement-details"> <div class="statement-details">
{% if statement.sum != statement.diff %} <span>
<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="date">{{ statement.start_date|date:"Y-m-d" }}</span>
<span class="value">{{ statement.start_value|value }}</span> <span class="value">{{ statement.start_value|value }}</span>
</span> </span>
<span class="evolution"> <span>
<span class="value">{{ statement.sum|pmvalue }}</span> <span class="value">{{ statement.sum|pmvalue }}</span>
{{ "funds"|remix }} {{ "arrow-right"|remix }}
{% if statement.diff != statement.sum %} {% if statement.diff != statement.sum %}
<span class="value">{{ statement.diff|pmvalue }}</span> <span class="value">{{ statement.diff|pmvalue }}</span>
{% endif %} {% endif %}
</span> </span>
<span class="end"> <span>
<span class="date">{{ statement.date|date:"Y-m-d" }}</span> <span class="date">{{ statement.date|date:"Y-m-d" }}</span>
<span class="value">{{ statement.value|value }}</span> <span class="value">{{ statement.value|value }}</span>
</span> </span>
{{ statement.sum|check:statement.diff }}
</div> </div>
<section> <section>
<h3>{% translate "Transactions" %}</h3> <h3>{% translate "Transactions" %}</h3>
{% url_get "transactions" account=account.id statement=statement.id as t_url %} {% url "statement_transactions" statement.id as t_url %}
<p> {% url "new_transaction" statement=statement.id as nt_url %}
<a class="big-link" {% transaction_table statement.transaction_set.all n_max=8 transactions_url=t_url new_transaction_url=nt_url %}
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>
<section> <section>
<h3>{% translate "Categories" %}</h3> <h3>{% translate "Categories" %}</h3>
{% category_plot transactions statement=object %} {% category_plot transactions budget=False statement=object %}
</section> </section>
{% endblock body %} {% endblock body %}

View file

@ -1,11 +1,7 @@
import json import json
from account.forms import AccountSelect
from category.forms import CategorySelect 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 main.forms import DatalistInput, NummiFileInput, NummiForm
from statement.forms import StatementSelect
from .models import Invoice, Transaction from .models import Invoice, Transaction
from .utils import get_datalist from .utils import get_datalist
@ -26,11 +22,7 @@ class TransactionForm(NummiForm):
"description", "description",
] ]
widgets = { widgets = {
"statement": StatementSelect(),
"category": CategorySelect(), "category": CategorySelect(),
"name": DatalistInput(),
"trader": DatalistInput(),
"payment": DatalistInput(),
} }
meta_fieldsets = [ meta_fieldsets = [
@ -49,22 +41,20 @@ class TransactionForm(NummiForm):
] ]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
_user = kwargs.pop("user") _user = kwargs.get("user")
_disable_statement = kwargs.pop("disable_statement", False) _disable_statement = kwargs.pop("disable_statement", False)
_autocomplete = kwargs.pop("autocomplete", False)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields["category"].queryset = self.fields["category"].queryset.filter( self.fields["category"].queryset = _user.category_set
user=_user self.fields["statement"].queryset = _user.statement_set
)
self.fields["statement"].queryset = self.fields["statement"].queryset.filter(
user=_user
)
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["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["category"].widget.attrs |= { self.fields["category"].widget.attrs |= {
"class": "category", "class": "category",
"data-icons": json.dumps( "data-icons": json.dumps(
@ -75,37 +65,8 @@ class TransactionForm(NummiForm):
), ),
} }
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: if _disable_statement:
self.fields["statement"].disabled = True self.fields["statement"].disabled = True
self.fields["statement"].widget.attrs["hidden"] = True
class InvoiceForm(NummiForm): class InvoiceForm(NummiForm):
@ -120,98 +81,3 @@ class InvoiceForm(NummiForm):
widgets = { widgets = {
"file": NummiFileInput, "file": NummiFileInput,
} }
class MultipleFileInput(forms.ClearableFileInput):
allow_multiple_selected = True
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 "" msgstr ""
"Project-Id-Version: \n" "Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-01-04 18:51+0100\n" "POT-Creation-Date: 2024-01-03 15:51+0100\n"
"PO-Revision-Date: 2023-04-23 08:03+0200\n" "PO-Revision-Date: 2023-04-23 08:03+0200\n"
"Last-Translator: Edgar P. Burkhart <traduction@edgarpierre.fr>\n" "Last-Translator: Edgar P. Burkhart <traduction@edgarpierre.fr>\n"
"Language-Team: \n" "Language-Team: \n"
@ -17,119 +17,103 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"X-Generator: Poedit 3.2.2\n" "X-Generator: Poedit 3.2.2\n"
#: .\transaction\forms.py:175 #: .\transaction\models.py:19 .\transaction\models.py:82
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" msgid "Transaction"
msgstr "Transaction" msgstr "Transaction"
#: .\transaction\models.py:18 .\transaction\models.py:70 #: .\transaction\models.py:19 .\transaction\models.py:89
#: .\transaction\templates\transaction\transaction_table.html:19 #: .\transaction\templates\transaction\invoice_table.html:10
#: .\transaction\templates\transaction\transaction_table.html:32
msgid "Name" msgid "Name"
msgstr "Nom" msgstr "Nom"
#: .\transaction\models.py:20 #: .\transaction\models.py:21
msgid "Description" msgid "Description"
msgstr "Description" msgstr "Description"
#: .\transaction\models.py:22 #: .\transaction\models.py:23
msgid "Value" msgid "Value"
msgstr "Valeur" msgstr "Valeur"
#: .\transaction\models.py:24 #: .\transaction\models.py:25
#: .\transaction\templates\transaction\transaction_table.html:18 #: .\transaction\templates\transaction\transaction_table.html:31
msgid "Date" msgid "Date"
msgstr "Date" msgstr "Date"
#: .\transaction\models.py:25 #: .\transaction\models.py:26
msgid "Real date" msgid "Real date"
msgstr "Date réelle" msgstr "Date réelle"
#: .\transaction\models.py:27 #: .\transaction\models.py:28
#: .\transaction\templates\transaction\transaction_table.html:22 #: .\transaction\templates\transaction\transaction_table.html:35
msgid "Trader" msgid "Trader"
msgstr "Commerçant" msgstr "Commerçant"
#: .\transaction\models.py:30 #: .\transaction\models.py:31
msgid "Payment" msgid "Payment"
msgstr "Paiement" msgstr "Paiement"
#: .\transaction\models.py:37 #: .\transaction\models.py:38
#: .\transaction\templates\transaction\transaction_table.html:24 #: .\transaction\templates\transaction\transaction_table.html:37
msgid "Category" msgid "Category"
msgstr "Catégorie" msgstr "Catégorie"
#: .\transaction\models.py:44 #: .\transaction\models.py:43
msgid "Statement" msgid "Statement"
msgstr "Relevé" msgstr "Relevé"
#: .\transaction\models.py:64 #: .\transaction\models.py:48
#: .\transaction\templates\transaction\transaction_archive_month.html:25 #: .\transaction\templates\transaction\transaction_table.html:40
msgid "Account"
msgstr "Compte"
#: .\transaction\models.py:83
#: .\transaction\templates\transaction\transaction_archive_month.html:27
#: .\transaction\templates\transaction\transaction_archive_year.html:31 #: .\transaction\templates\transaction\transaction_archive_year.html:31
#: .\transaction\templates\transaction\transaction_list.html:4 #: .\transaction\templates\transaction\transaction_list.html:4
#: .\transaction\templates\transaction\transaction_list.html:11 #: .\transaction\templates\transaction\transaction_list.html:7
msgid "Transactions" msgid "Transactions"
msgstr "Transactions" msgstr "Transactions"
#: .\transaction\models.py:70 .\transaction\models.py:103 #: .\transaction\models.py:89 .\transaction\models.py:122
msgid "Invoice" msgid "Invoice"
msgstr "Facture" msgstr "Facture"
#: .\transaction\models.py:75 #: .\transaction\models.py:94
#: .\transaction\templates\transaction\invoice_table.html:11
#: .\transaction\templates\transaction\invoice_table.html:22
msgid "File" msgid "File"
msgstr "Fichier" msgstr "Fichier"
#: .\transaction\models.py:104 #: .\transaction\models.py:123
#: .\transaction\templates\transaction\transaction_detail.html:46 #: .\transaction\templates\transaction\transaction_form.html:20
msgid "Invoices" msgid "Invoices"
msgstr "Factures" msgstr "Factures"
#: .\transaction\templates\transaction\invoice_form.html:4 #: .\transaction\templates\transaction\invoice_form.html:4
#: .\transaction\templates\transaction\invoice_table.html:37
msgid "Create invoice" msgid "Create invoice"
msgstr "Créer une facture" msgstr "Créer une facture"
#: .\transaction\templates\transaction\invoice_form.html:7 #: .\transaction\templates\transaction\invoice_form.html:7
#: .\transaction\templates\transaction\invoice_table.html:12
msgid "New invoice" msgid "New invoice"
msgstr "Nouvelle facture" msgstr "Nouvelle facture"
#: .\transaction\templates\transaction\invoice_table.html:7 #: .\transaction\templates\transaction\invoice_table.html:12
msgid "Edit" #: .\transaction\templates\transaction\invoice_table.html:25
msgstr "Modifier" msgid "Delete"
msgstr "Supprimer"
#: .\transaction\templates\transaction\transaction_archive_month.html:13 #: .\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_year.html:13 #: .\transaction\templates\transaction\transaction_archive_year.html:13
#: .\transaction\templates\transaction\transaction_form.html:18
msgid "Back" msgid "Back"
msgstr "Retour" msgstr "Retour"
#: .\transaction\templates\transaction\transaction_archive_month.html:20 #: .\transaction\templates\transaction\transaction_archive_month.html:22
#: .\transaction\templates\transaction\transaction_archive_year.html:26 #: .\transaction\templates\transaction\transaction_archive_year.html:26
msgid "Categories" msgid "Categories"
msgstr "Catégories" msgstr "Catégories"
@ -138,31 +122,8 @@ msgstr "Catégories"
msgid "History" msgid "History"
msgstr "Historique" 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_form.html:5
#: .\transaction\templates\transaction\transaction_table.html:25
msgid "Create transaction" msgid "Create transaction"
msgstr "Créer une transaction" msgstr "Créer une transaction"
@ -170,33 +131,18 @@ msgstr "Créer une transaction"
msgid "New transaction" msgid "New transaction"
msgstr "Nouvelle transaction" msgstr "Nouvelle transaction"
#: .\transaction\templates\transaction\transaction_list.html:15 #: .\transaction\templates\transaction\transaction_table.html:33
msgid "Add transaction"
msgstr "Ajouter une transaction"
#: .\transaction\templates\transaction\transaction_table.html:20
msgid "Expenses" msgid "Expenses"
msgstr "Dépenses" msgstr "Dépenses"
#: .\transaction\templates\transaction\transaction_table.html:21 #: .\transaction\templates\transaction\transaction_table.html:34
msgid "Income" msgid "Income"
msgstr "Recettes" msgstr "Recettes"
#: .\transaction\templates\transaction\transaction_table.html:27 #: .\transaction\templates\transaction\transaction_table.html:87
msgid "Account"
msgstr "Compte"
#: .\transaction\templates\transaction\transaction_table.html:74
msgid "No transaction" msgid "No transaction"
msgstr "Aucune transaction" msgstr "Aucune transaction"
#: .\transaction\templates\transaction\transaction_table.html:82 #: .\transaction\templates\transaction\transaction_table.html:95
msgid "View all transactions" msgid "View all transactions"
msgstr "Voir toutes les 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

@ -1,22 +0,0 @@
# 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

@ -7,22 +7,12 @@ from django.core.validators import FileExtensionValidator
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from main.models import NummiModel, NummiQuerySet from main.models import UserModel
from main.utils import pdf_outline_to_str
from media.utils import get_path from media.utils import get_path
from pypdf import PdfReader
from statement.models import Statement from statement.models import Statement
class TransactionQuerySet(NummiQuerySet): class Transaction(UserModel):
fields = {
"description": "B",
"trader": "B",
"category__name": "C",
}
class Transaction(NummiModel):
id = models.UUIDField(primary_key=True, default=uuid4, editable=False) id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
name = models.CharField( name = models.CharField(
max_length=256, default=_("Transaction"), verbose_name=_("Name") max_length=256, default=_("Transaction"), verbose_name=_("Name")
@ -54,7 +44,16 @@ class Transaction(NummiModel):
verbose_name=_("Statement"), verbose_name=_("Statement"),
) )
objects = TransactionQuerySet.as_manager() def save(self, *args, **kwargs):
if Transaction.objects.filter(pk=self.pk):
prev_self = Transaction.objects.get(pk=self.pk)
else:
prev_self = None
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()
def __str__(self): def __str__(self):
return f"{self.name}" return f"{self.name}"
@ -70,20 +69,17 @@ class Transaction(NummiModel):
if self.statement: if self.statement:
return self.statement.account return self.statement.account
@property
def has_invoice(self):
return self.invoices.count() > 0
class Meta: class Meta:
ordering = ["-date", "statement"] ordering = ["-date", "statement"]
verbose_name = _("Transaction") verbose_name = _("Transaction")
verbose_name_plural = _("Transactions") verbose_name_plural = _("Transactions")
class InvoiceQuerySet(NummiQuerySet): class Invoice(UserModel):
fields = {
"metadata": "B",
"tags": "C",
}
class Invoice(NummiModel):
id = models.UUIDField(primary_key=True, default=uuid4, editable=False) id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
name = models.CharField( name = models.CharField(
max_length=256, default=_("Invoice"), verbose_name=_("Name") max_length=256, default=_("Invoice"), verbose_name=_("Name")
@ -97,39 +93,12 @@ class Invoice(NummiModel):
transaction = models.ForeignKey( transaction = models.ForeignKey(
Transaction, on_delete=models.CASCADE, editable=False 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): def save(self, *args, **kwargs):
if Invoice.objects.filter(id=self.id).exists(): if Invoice.objects.filter(id=self.id).exists():
_prever = Invoice.objects.get(id=self.id) _prever = Invoice.objects.get(id=self.id)
if _prever.file and _prever.file != self.file: if _prever.file and _prever.file != self.file:
Path(_prever.file.path).unlink(missing_ok=True) 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) super().save(*args, **kwargs)
def __str__(self): def __str__(self):

View file

@ -1,22 +0,0 @@
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,25 +1,17 @@
{% load i18n main_extras %} {% load main_extras %}
{% load i18n %}
<div id="invoices"> <div id="invoices">
<ul class="invoices"> <ul class="invoices">
{% for invoice in invoices %} {% for invoice in transaction.invoices %}
<li> <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> <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> <a href="{{ invoice.get_absolute_url }}">{{ "file-edit"|remix }}{% translate "Edit" %}</a>
{% endif %}
</li> </li>
{% endfor %} {% endfor %}
{% if transaction %}
<li class="new"> <li class="new">
<span> <span>
<a href="{% url "new_invoice" transaction.pk %}">{{ "file-add"|remix }}{% translate "New invoice" %}</a> <a href="{% url "new_invoice" transaction.pk %}">{{ "file-add"|remix }}{% translate "New invoice" %}</a>
</span> </span>
</li> </li>
{% endif %}
</ul> </ul>
</div> </div>

View file

@ -0,0 +1,28 @@
{% 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

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

View file

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

View file

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

View file

@ -10,38 +10,15 @@ from ..utils import ac_url
register = template.Library() register = template.Library()
@register.inclusion_tag("transaction/transaction_table.html", takes_context=True) @register.inclusion_tag("transaction/transaction_table.html")
def transaction_table(context, transactions, n_max=None, **kwargs): def transaction_table(transactions, **kwargs):
if n_max is not None: if (n_max := kwargs.get("n_max")) is not None:
if transactions.count() <= n_max: if transactions.count() <= n_max:
del kwargs["transactions_url"] del kwargs["transactions_url"]
transactions = transactions[:n_max] 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 | { return kwargs | {
"transaction": transaction, "transactions": transactions,
} }
@ -69,3 +46,13 @@ def year_url(context, year, cls=""):
f"""<a class="{cls}" href="{url}">""" f"""<a class="{cls}" href="{url}">"""
f"""<time datetime="{year}">{year}</time></a>""" 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,6 +4,16 @@ from . import views
urlpatterns = [ urlpatterns = [
path("list", views.TransactionListView.as_view(), name="transactions"), 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("new", views.TransactionCreateView.as_view(), name="new_transaction"),
path("<transaction>", views.TransactionDetailView.as_view(), name="transaction"), path("<transaction>", views.TransactionDetailView.as_view(), name="transaction"),
path( path(
@ -21,11 +31,6 @@ urlpatterns = [
views.InvoiceCreateView.as_view(), views.InvoiceCreateView.as_view(),
name="new_invoice", name="new_invoice",
), ),
path(
"<transaction>/invoice/multiple",
views.MultipleInvoiceCreateView.as_view(),
name="multiple_invoice",
),
path( path(
"<transaction>/invoice/<invoice>", "<transaction>/invoice/<invoice>",
views.InvoiceUpdateView.as_view(), views.InvoiceUpdateView.as_view(),

View file

@ -1,24 +1,20 @@
from django.contrib import messages from account.models import Account
from django.forms import ValidationError from category.models import Category
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils.html import format_html from django.views.generic.dates import MonthArchiveView, YearArchiveView
from django.utils.translation import gettext as _ from history.utils import history
from django.views.generic.edit import FormView
from main.views import ( from main.views import (
NummiCreateView, NummiCreateView,
NummiDeleteView, NummiDeleteView,
NummiDetailView, NummiDetailView,
NummiListView, NummiListView,
NummiUpdateView, NummiUpdateView,
UserMixin,
) )
from statement.models import Statement
from .forms import ( from .forms import InvoiceForm, TransactionForm
InvoiceForm,
MultipleInvoicesForm,
TransactionFiltersForm,
TransactionForm,
)
from .models import Invoice, Transaction from .models import Invoice, Transaction
@ -27,27 +23,22 @@ class TransactionCreateView(NummiCreateView):
form_class = TransactionForm form_class = TransactionForm
def get_initial(self): def get_initial(self):
initial = super().get_initial() _queryset = Statement.objects.filter(user=self.request.user)
if "statement" in self.kwargs: if "statement" in self.kwargs:
initial["statement"] = get_object_or_404( self.statement = get_object_or_404(_queryset, pk=self.kwargs["statement"])
self.request.user.statement_set, pk=self.kwargs["statement"] else:
) self.statement = _queryset.first()
if ( return {"statement": self.statement}
last_transaction := initial["statement"]
.transaction_set.order_by("-date")
.first()
):
initial["date"] = last_transaction.date
return initial
def get_form_kwargs(self): def get_form_kwargs(self):
form_kwargs = super().get_form_kwargs()
if "statement" in self.kwargs: if "statement" in self.kwargs:
form_kwargs["disable_statement"] = True return super().get_form_kwargs() | {"disable_statement": True}
return super().get_form_kwargs()
form_kwargs["autocomplete"] = True def get_context_data(self, **kwargs):
return form_kwargs if "statement" in self.kwargs:
return super().get_context_data(**kwargs) | {"statement": self.statement}
return super().get_context_data(**kwargs)
class InvoiceCreateView(NummiCreateView): class InvoiceCreateView(NummiCreateView):
@ -65,42 +56,6 @@ class InvoiceCreateView(NummiCreateView):
return reverse_lazy("transaction", args=(self.object.transaction.pk,)) 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): class TransactionUpdateView(NummiUpdateView):
model = Transaction model = Transaction
form_class = TransactionForm form_class = TransactionForm
@ -114,8 +69,12 @@ class TransactionDetailView(NummiDetailView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs) data = super().get_context_data(**kwargs)
data["invoices_form"] = MultipleInvoicesForm() transaction = data.get("transaction")
return data
return data | {
"statement": transaction.statement,
"category": transaction.category,
}
class InvoiceUpdateView(NummiUpdateView): class InvoiceUpdateView(NummiUpdateView):
@ -165,36 +124,54 @@ class InvoiceDeleteView(NummiDeleteView):
class TransactionListView(NummiListView): class TransactionListView(NummiListView):
model = Transaction model = Transaction
context_object_name = "transactions" context_object_name = "transactions"
paginate_by = 32 paginate_by = 50
def get_queryset(self, **kwargs):
queryset = super().get_queryset(**kwargs)
if date := self.request.GET.get("start_date"): class TransactionACMixin:
queryset = queryset.filter(date__gte=date) model = Transaction
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)
return queryset def get_queryset(self):
if "account" in self.kwargs:
self.account = get_object_or_404(
Account.objects.filter(user=self.request.user),
pk=self.kwargs["account"],
)
return super().get_queryset().filter(account=self.account)
if "category" in self.kwargs:
self.category = get_object_or_404(
Category.objects.filter(user=self.request.user),
pk=self.kwargs["category"],
)
return super().get_queryset().filter(category=self.category)
return super().get_queryset()
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
data = super().get_context_data(**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
filters = self.request.GET.copy()
filters.pop("page", None) class TransactionMonthView(UserMixin, TransactionACMixin, MonthArchiveView):
if filters: model = Transaction
data["filters"] = True date_field = "date"
data["filter_form"] = TransactionFiltersForm( context_object_name = "transactions"
initial=filters, user=self.request.user month_format = "%m"
)
return data
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)}

View file

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

View file

@ -1,16 +1,3 @@
[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] [tool.djlint]
indent=2 indent=2
max_blank_lines=1 max_blank_lines=1

172
uv.lock generated
View file

@ -1,172 +0,0 @@
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" },
]