Compare commits

..

1 commit

Author SHA1 Message Date
e2fb8b9703
Test history chart using echarts 2023-12-28 14:22:55 +01:00
121 changed files with 69693 additions and 4002 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:
@ -23,7 +17,7 @@ repos:
rev: v1.23.3 rev: v1.23.3
hooks: hooks:
- id: djlint-django - id: djlint-django
args: ["--reformat", "--lint", "--quiet"] args: ["--reformat"]
- repo: https://github.com/pre-commit/mirrors-prettier - repo: https://github.com/pre-commit/mirrors-prettier
rev: "v3.0.0-alpha.6" rev: "v3.0.0-alpha.6"
hooks: hooks:

View file

@ -1 +0,0 @@
endOfLine = "auto"

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,5 +1,4 @@
from django.forms.widgets import Select from main.forms import NummiForm
from main.forms import IconInput, NummiForm
from .models import Account from .models import Account
@ -11,12 +10,4 @@ class AccountForm(NummiForm):
"name", "name",
"icon", "icon",
"default", "default",
"archived",
] ]
widgets = {
"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: 2023-04-22 15:16+0200\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,45 +17,27 @@ 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
msgid "Edit account"
msgstr "Modifier le compte"
#: .\account\templates\account\account_detail.html:18
msgid "Statements"
msgstr "Relevés"
#: .\account\templates\account\account_detail.html:24
msgid "History"
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"
@ -63,13 +45,14 @@ msgstr "Créer un compte"
msgid "New account" msgid "New account"
msgstr "Nouveau compte" msgstr "Nouveau compte"
#: .\account\templates\account\account_table.html:16 #: .\account\templates\account\account_form.html:13
msgid "All accounts" msgid "Statements"
msgstr "Tous les comptes" msgstr "Relevés"
#: .\account\templates\account\account_table.html:26 #: .\account\templates\account\account_form.html:15
msgid "Show archived" msgid "Transactions"
msgstr "Afficher archivés" msgstr "Transactions"
#~ msgid "Transactions" #: .\account\templates\account\account_form.html:18
#~ msgstr "Transactions" msgid "History"
msgstr "Historique"

View file

@ -1,17 +0,0 @@
# Generated by Django 4.2 on 2024-12-29 09:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("account", "0002_alter_account_table"),
]
operations = [
migrations.AddField(
model_name="account",
name="archived",
field=models.BooleanField(default=False, verbose_name="Archived"),
),
]

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

@ -1,13 +1,12 @@
from uuid import uuid4 from uuid import uuid4
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(
@ -16,7 +15,6 @@ class Account(NummiModel):
verbose_name=_("Icon"), verbose_name=_("Icon"),
) )
default = models.BooleanField(default=False, verbose_name=_("Default")) default = models.BooleanField(default=False, verbose_name=_("Default"))
archived = models.BooleanField(default=False, verbose_name=_("Archived"))
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self.default: if self.default:
@ -34,19 +32,13 @@ class Account(NummiModel):
def get_delete_url(self): def get_delete_url(self):
return reverse("del_account", args=(self.pk,)) return reverse("del_account", args=(self.pk,))
@property
def transactions(self):
return apps.get_model("transaction", "Transaction").objects.filter(
statement__account=self
)
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

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

View file

@ -8,3 +8,15 @@
{% translate "New account" %} {% translate "New account" %}
{% endblock %} {% endblock %}
{% block h2 %}{{ form.instance.icon|remix }}{{ form.instance }}{% endblock %} {% block h2 %}{{ form.instance.icon|remix }}{{ form.instance }}{% endblock %}
{% block tables %}
{% if not form.instance|adding %}
<h3>{% translate "Statements" %}</h3>
{% include "statement/statement_table.html" %}
<h3>{% translate "Transactions" %}</h3>
{% include "transaction/transaction_table.html" %}
{% if history.data %}
<h3>{% translate "History" %}</h3>
{% include "history/plot.html" %}
{% endif %}
{% endif %}
{% endblock %}

View file

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

View file

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

View file

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

View file

@ -1,13 +1,12 @@
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
from . import views from . import views
urlpatterns = [ urlpatterns = [
path("list", views.AccountListView.as_view(), name="accounts"),
path("new", views.AccountCreateView.as_view(), name="new_account"), path("new", views.AccountCreateView.as_view(), name="new_account"),
path("<account>", views.AccountDetailView.as_view(), name="account"), path("<account>", views.AccountUpdateView.as_view(), name="account"),
path("<account>/edit", views.AccountUpdateView.as_view(), name="edit_account"),
path( path(
"<account>/transactions", "<account>/transactions",
views.AccountTListView.as_view(), views.AccountTListView.as_view(),
@ -28,4 +27,9 @@ urlpatterns = [
views.AccountDeleteView.as_view(), views.AccountDeleteView.as_view(),
name="del_account", name="del_account",
), ),
path(
"<account>/history/<int:year>/<int:month>",
TransactionMonthView.as_view(),
name="transaction_month",
),
] ]

View file

@ -1,11 +1,7 @@
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from main.views import ( from django.urls import reverse_lazy
NummiCreateView, from history.utils import history
NummiDeleteView, from main.views import NummiCreateView, NummiDeleteView, NummiUpdateView
NummiDetailView,
NummiListView,
NummiUpdateView,
)
from statement.views import StatementListView from statement.views import StatementListView
from transaction.views import TransactionListView from transaction.views import TransactionListView
@ -23,18 +19,37 @@ class AccountUpdateView(NummiUpdateView):
form_class = AccountForm form_class = AccountForm
pk_url_kwarg = "account" pk_url_kwarg = "account"
def get_context_data(self, **kwargs):
_max = 8
data = super().get_context_data(**kwargs)
account = data["form"].instance
_transactions = account.transaction_set.all()
if _transactions.count() > _max:
data["transactions_url"] = reverse_lazy(
"account_transactions", args=(account.pk,)
)
_statements = account.statement_set.all()
if _statements.count() > _max:
data["statements_url"] = reverse_lazy(
"account_statements", args=(account.pk,)
)
return data | {
"transactions": _transactions[:8],
"new_statement_url": reverse_lazy(
"new_statement", kwargs={"account": account.pk}
),
"statements": _statements[:8],
"history": history(account.transaction_set),
}
class AccountDeleteView(NummiDeleteView): class AccountDeleteView(NummiDeleteView):
model = Account model = Account
pk_url_kwarg = "account" pk_url_kwarg = "account"
class AccountDetailView(NummiDetailView):
model = Account
pk_url_kwarg = "account"
context_object_name = "account"
class AccountMixin: class AccountMixin:
def get_queryset(self): def get_queryset(self):
self.account = get_object_or_404( self.account = get_object_or_404(
@ -47,11 +62,6 @@ class AccountMixin:
return super().get_context_data(**kwargs) | {"account": self.account} return super().get_context_data(**kwargs) | {"account": self.account}
class AccountListView(NummiListView):
model = Account
context_object_name = "accounts"
class AccountTListView(AccountMixin, TransactionListView): class AccountTListView(AccountMixin, TransactionListView):
pass pass

View file

@ -1,5 +1,4 @@
from django.forms.widgets import Select from main.forms import NummiForm
from main.forms import IconInput, NummiForm
from .models import Category from .models import Category
@ -12,10 +11,3 @@ class CategoryForm(NummiForm):
"icon", "icon",
"budget", "budget",
] ]
widgets = {
"icon": IconInput,
}
class CategorySelect(Select):
template_name = "category/forms/widgets/category.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: 2023-11-25 12:05+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,22 +38,6 @@ msgstr "Budget"
msgid "Categories" msgid "Categories"
msgstr "Catégories" msgstr "Catégories"
#: .\category\templates\category\category_detail.html:14
msgid "Edit category"
msgstr "Modifier la catégorie"
#: .\category\templates\category\category_detail.html:17
msgid "Transactions"
msgstr "Transactions"
#: .\category\templates\category\category_detail.html:20
msgid "View all transactions"
msgstr "Voir toutes les transactions"
#: .\category\templates\category\category_detail.html:25
msgid "History"
msgstr "Historique"
#: .\category\templates\category\category_form.html:5 #: .\category\templates\category\category_form.html:5
msgid "Create category" msgid "Create category"
msgstr "Créer une catégorie" msgstr "Créer une catégorie"
@ -62,22 +46,26 @@ 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_form.html:12
msgid "Transactions"
msgstr "Transactions"
#: .\category\templates\category\category_form.html:15
msgid "History"
msgstr "Historique"
#: .\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:56
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:64
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

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

View file

@ -8,3 +8,11 @@
{% translate "New category" %} {% translate "New category" %}
{% endblock %} {% endblock %}
{% block h2 %}{{ form.instance.icon|remix }}{{ form.instance }}{% endblock %} {% block h2 %}{{ form.instance.icon|remix }}{{ form.instance }}{% endblock %}
{% block tables %}
<h3>{% translate "Transactions" %}</h3>
{% include "transaction/transaction_table.html" %}
{% if history.data %}
<h3>{% translate "History" %}</h3>
{% include "history/plot.html" %}
{% endif %}
{% endblock %}

View file

@ -1,16 +1,17 @@
{% load main_extras statement_extras history_extras %} {% load main_extras %}
{% load i18n %} {% load i18n %}
<div class="plot"> <div class="plot">
<table class="full-width"> <table class="full-width">
<colgroup> <colgroup>
<col class="desc"> <col class="desc">
<col class="icon">
<col class="value"> <col class="value">
<col span="2" class="bar"> <col span="2" class="bar">
<col class="value"> <col class="value">
</colgroup> </colgroup>
<thead> <thead>
<tr> <tr>
<th scope="col">{% translate "Category" %}</th> <th scope="col" colspan="2">{% translate "Category" %}</th>
<th scope="col" colspan="2">{% translate "Expenses" %}</th> <th scope="col" colspan="2">{% translate "Expenses" %}</th>
<th scope="col" colspan="2">{% translate "Income" %}</th> <th scope="col" colspan="2">{% translate "Income" %}</th>
</tr> </tr>
@ -19,61 +20,54 @@
{% spaceless %} {% spaceless %}
{% for cat in categories %} {% for cat in categories %}
<tr> <tr>
<th scope="row" class="l wi"> <th scope="row" class="l">
{% 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 "transaction_month" cat.category month.year month.month %}">{{ cat.category__name }}</a>
{% elif month %}
<a href="{% url_get "transactions" start_date=month end_date=month|end_of_month account=account.id category=cat.category %}">{{ cat.category__icon|remix }}{{ cat.category__name }}</a>
{% elif statement %}
<a href="{% url_get "transactions" account=statement.account.id statement=statement.id category=cat.category %}">{{ cat.category_.icon|remix }}{{ cat.category__name }}</a>
{% elif account %}
<a href="{% url_get "transactions" account=account.id category=cat.category %}">{{ cat.category__icon|remix }}{{ cat.category__name }}</a>
{% else %} {% else %}
{{ cat.category__icon|remix }}{{ cat.category__name }} {{ cat.category__name }}
{% endif %} {% endif %}
{% endif %} {% endif %}
</th> </th>
<td class="value">{{ cat.sum_m|pmvalue }}</td> <td class="c">
{% if cat.category %}{{ cat.category__icon|remix }}{% endif %}
</td>
<td class="value">{{ cat.sum_m|pmrvalue }}</td>
<td class="bar m"> <td class="bar m">
{% if cat.sum_m %}
<div style="width: {% widthratio cat.sum_m max -100 %}%"></div> <div style="width: {% widthratio cat.sum_m max -100 %}%"></div>
{% endif %}
{% if cat.sum < 0 %} {% if cat.sum < 0 %}
<div class="tot" style="width:{% widthratio cat.sum max -100 %}%"> <div class="tot" style="width:{% widthratio cat.sum max -100 %}%">
<span>{{ cat.sum|pmvalue }}</span> <span>{{ cat.sum|pmrvalue }}</span>
</div> </div>
{% endif %} {% endif %}
</td> </td>
<td class="bar p"> <td class="bar p">
{% if cat.sum_p %}
<div style="width: {% widthratio cat.sum_p max 100 %}%"></div> <div style="width: {% widthratio cat.sum_p max 100 %}%"></div>
{% endif %}
{% if cat.sum > 0 %} {% if cat.sum > 0 %}
<div class="tot" style="width:{% widthratio cat.sum max 100 %}%"> <div class="tot" style="width:{% widthratio cat.sum max 100 %}%">
<span>{{ cat.sum|pmvalue }}</span> <span>{{ cat.sum|pmrvalue }}</span>
</div> </div>
{% endif %} {% endif %}
</td> </td>
<td class="value">{{ cat.sum_p|pmvalue }}</td> <td class="value">{{ cat.sum_p|pmrvalue }}</td>
</tr> </tr>
{% empty %} {% empty %}
<tr> <tr>
<td class="empty" colspan="5">{% translate "No transaction" %}</td> <td class="empty" colspan="6">{% translate "No transaction" %}</td>
</tr> </tr>
{% endfor %} {% endfor %}
{% endspaceless %} {% endspaceless %}
</tbody> </tbody>
<tfoot>
{% if categories %} {% if categories %}
<tfoot>
<tr> <tr>
<th scope="row" class="l">{% translate "Total" %}</th> <th scope="row" colspan="2" class="l">{% translate "Total" %}</th>
<td class="value">{{ total_m|pmvalue }}</td> <td class="value">{{ total_m|pmrvalue }}</td>
<td class="bar m"> <td class="bar m">
<div style="width: {% widthratio total_m max -100 %}%"></div> <div style="width: {% widthratio total_m max -100 %}%"></div>
{% if total < 0 %} {% if total < 0 %}
<div class="tot" style="width:{% widthratio total max -100 %}%"> <div class="tot" style="width:{% widthratio total max -100 %}%">
<span>{{ total|pmvalue }}</span> <span>{{ total|pmrvalue }}</span>
</div> </div>
{% endif %} {% endif %}
</td> </td>
@ -81,34 +75,13 @@
<div style="width: {% widthratio total_p max 100 %}%"></div> <div style="width: {% widthratio total_p max 100 %}%"></div>
{% if total > 0 %} {% if total > 0 %}
<div class="tot" style="width:{% widthratio total max 100 %}%"> <div class="tot" style="width:{% widthratio total max 100 %}%">
<span>{{ total|pmvalue }}</span> <span>{{ total|pmrvalue }}</span>
</div> </div>
{% endif %} {% endif %}
</td> </td>
<td class="value">{{ total_p|pmvalue }}</td> <td class="value">{{ total_p|pmrvalue }}</td>
</tr> </tr>
{% endif %}
{% if statement and statement.diff != statement.sum %}
<tr>
<th scope="row" class="l">{% translate "Expected total" %}</th>
<td class="c">{{ total|check:statement.diff }}</td>
<td class="bar m">
{% if statement.diff < 0 %}
<div class="tot" style="width:{% widthratio statement.diff max -100 %}%">
<span>{{ statement.diff|pmvalue }}</span>
</div>
{% endif %}
</td>
<td class="bar p">
{% if statement.diff >= 0 %}
<div class="tot" style="width:{% widthratio statement.diff max 100 %}%">
<span>{{ statement.diff|pmvalue }}</span>
</div>
{% endif %}
</td>
<td></td>
</tr>
{% endif %}
</tfoot> </tfoot>
{% endif %}
</table> </table>
</div> </div>

View file

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

View file

@ -5,14 +5,11 @@ 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, **kwargs):
kwargs.setdefault("account", context.get("account"))
if not kwargs.get("account"):
transactions = transactions.exclude(category__budget=False)
categories = ( categories = (
transactions.values("category", "category__name", "category__icon") transactions.exclude(category__budget=False)
.values("category", "category__name", "category__icon")
.annotate( .annotate(
sum=models.Sum("value"), sum=models.Sum("value"),
sum_m=models.Sum("value", filter=models.Q(value__lt=0)), sum_m=models.Sum("value", filter=models.Q(value__lt=0)),

View file

@ -1,15 +1,20 @@
from django.urls import path from django.urls import path
from transaction.views import TransactionMonthView
from . import views from . import views
urlpatterns = [ urlpatterns = [
path("new", views.CategoryCreateView.as_view(), name="new_category"), path("new", views.CategoryCreateView.as_view(), name="new_category"),
path("<category>", views.CategoryDetailView.as_view(), name="category"), path("<category>", views.CategoryUpdateView.as_view(), name="category"),
path("<category>/edit", views.CategoryUpdateView.as_view(), name="edit_category"),
path( path(
"<category>/transactions", "<category>/transactions",
views.CategoryTListView.as_view(), views.CategoryTListView.as_view(),
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>/<int:month>",
TransactionMonthView.as_view(),
name="transaction_month",
),
] ]

View file

@ -1,10 +1,7 @@
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from main.views import ( from django.urls import reverse_lazy
NummiCreateView, from history.utils import history
NummiDeleteView, from main.views import NummiCreateView, NummiDeleteView, NummiUpdateView
NummiDetailView,
NummiUpdateView,
)
from transaction.views import TransactionListView from transaction.views import TransactionListView
from .forms import CategoryForm from .forms import CategoryForm
@ -21,11 +18,20 @@ class CategoryUpdateView(NummiUpdateView):
form_class = CategoryForm form_class = CategoryForm
pk_url_kwarg = "category" pk_url_kwarg = "category"
def get_context_data(self, **kwargs):
_max = 8
data = super().get_context_data(**kwargs)
category = data["form"].instance
class CategoryDetailView(NummiDetailView): data["transactions"] = category.transaction_set.all()[:_max]
model = Category if len(data["transactions"]) == _max:
pk_url_kwarg = "category" data["transactions_url"] = reverse_lazy(
context_object_name = "category" "category_transactions", args=(category.pk,)
)
return data | {
"history": history(category.transaction_set),
}
class CategoryDeleteView(NummiDeleteView): class CategoryDeleteView(NummiDeleteView):

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: 2023-04-22 15:16+0200\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,14 @@ 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:15
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:16
msgid "Expenses" msgid "Expenses"
msgstr "Dépenses" msgstr "Dépenses"
#: .\history\templates\history\plot.html:58 #: .\history\templates\history\plot.html:17
msgid "Income" msgid "Income"
msgstr "Revenus" msgstr "Revenus"

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

@ -1,49 +1,6 @@
{% load main_extras %} {% load main_extras %}
{% load history_extras %}
{% load transaction_extras %}
{% load i18n %} {% load i18n %}
<div class="calendar"> <div class="plot">
<table>
<thead>
<tr>
{% if not year %}
<th scope="col">{% translate "Year" %}</th>
{% endif %}
{% calendar_head %}
<th scope="col">{% translate "Total" %}</th>
</tr>
</thead>
<tbody>
{% regroup history.data by month.year as years_list %}
{% for y, y_data in years_list reversed %}
<tr>
{% if not year %}
<th class="date" scope="row">
<a href="{% history_url year=y account=account.id category=category.id %}">{{ y }}</a>
</th>
{% endif %}
{% for m in y_data %}
{% if forloop.parentloop.last and forloop.first %}
{% empty_calendar_cells_start m.month.month %}
{% endif %}
{% if m %}
<td class="month {% if m.sum > 0 %}p{% else %}m{% endif %}"
style="--opacity: {% calendar_opacity m.sum history.max.sum %}"
title="{{ m.sum|pmrvalue }}">{% up_down_icon m.sum %}</td>
{% else %}
<td class="month"></td>
{% endif %}
{% if forloop.parentloop.first and forloop.last %}
{% empty_calendar_cells_end m.month.month %}
{% endif %}
{% endfor %}
<td class="total">{{ y_data|sum_year|pmrvalue }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="history plot">
<table class="full-width"> <table class="full-width">
<colgroup> <colgroup>
<col class="icon"> <col class="icon">
@ -61,32 +18,46 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for date in history.data reversed %} {% spaceless %}
{% ifchanged %} {% for date in history.data %}
{% if date.sum_m or date.sum_p %} <tr>
<tr {% if not date.month.month|divisibleby:"2" %}class="even"{% endif %}> <td class="icon">
<td class="icon">{% up_down_icon date.sum %}</td> <span class="ri-{% if date.sum > 0 %}arrow-up-s-line green{% elif date.sum < 0 %}arrow-down-s-line red{% endif %}"></span>
</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 date.has_transactions %}
{% if year %} {% if account %}
{{ date.month|date:"F"|capfirst }} <a href="{% url "transaction_month" account=account.pk year=date.month.year month=date.month.month %}">{{ date.month|date:"Y-m" }}</a>
{% elif category %}
<a href="{% url "transaction_month" category=category.pk year=date.month.year month=date.month.month %}">{{ date.month|date:"Y-m" }}</a>
{% else %}
<a href="{% url "transaction_month" year=date.month.year month=date.month.month %}">{{ date.month|date:"Y-m" }}</a>
{% endif %}
{% else %} {% else %}
{{ date.month|date:"Y-m" }} {{ date.month|date:"Y-m" }}
{% 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">
<td class="bar p">{% plot_bar date.sum date.sum_p history.max.pm %}</td> <div style="width: {% widthratio date.sum_m history.max -100 %}%"></div>
{% if date.sum < 0 %}
<div class="tot" style="width:{% widthratio date.sum history.max -100 %}%">
<span>{{ date.sum|pmrvalue }}</span>
</div>
{% endif %}
</td>
<td class="bar p">
<div style="width: {% widthratio date.sum_p history.max 100 %}%"></div>
{% if date.sum > 0 %}
<div class="tot" style="width:{% widthratio date.sum history.max 100 %}%">
<span>{{ date.sum|pmrvalue }}</span>
</div>
{% endif %}
</td>
<td class="value">{{ date.sum_p|pmrvalue }}</td> <td class="value">{{ date.sum_p|pmrvalue }}</td>
</tr> </tr>
{% else %}
<tr class="empty">
<td colspan="6" class="empty"></td>
</tr>
{% endif %}
{% endifchanged %}
{% endfor %} {% endfor %}
{% endspaceless %}
</tbody> </tbody>
</table> </table>
</div> </div>

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,119 +0,0 @@
import datetime
import math
from urllib import parse
from django import template
from django.urls import reverse
from django.utils.safestring import mark_safe
from history.utils import history
from main.templatetags.main_extras import pmrvalue, remix
register = template.Library()
@register.inclusion_tag("history/plot.html", takes_context=True)
def history_plot(context, transactions, **kwargs):
kwargs.setdefault("account", context.get("account"))
kwargs.setdefault("category", context.get("category"))
if kwargs.get("category") or kwargs.get("account"):
kwargs["history"] = history(transactions.all())
else:
kwargs["history"] = history(transactions.exclude(category__budget=False))
return kwargs
@register.simple_tag
def calendar_opacity(v, vmax):
if v is None:
return "0%"
return f"{math.sin(min(1, math.fabs(v/vmax))*math.pi/2): .0%}"
@register.simple_tag
def empty_calendar_cells(n):
return mark_safe(n * "<td></td>")
@register.simple_tag
def empty_calendar_cells_start(n):
return empty_calendar_cells(n - 1)
@register.simple_tag
def empty_calendar_cells_end(n):
return empty_calendar_cells(12 - n)
@register.simple_tag
def up_down_icon(val):
if val is None:
return ""
if val > 0:
return remix("arrow-up-s", "green")
elif val < 0:
return remix("arrow-down-s", "red")
return remix("equal", "white")
@register.simple_tag
def plot_bar(s, sum_pm, s_max):
_res = ""
if s_max:
if sum_pm:
_w = abs(sum_pm / s_max)
_res += f"""<div style="width: {_w: .1%}"></div>"""
if sum_pm is not None and s * sum_pm > 0:
_w = abs(s / s_max)
_res += (
f"""<div class="tot" style="width: {_w: .1%}">"""
f"""<span>{pmrvalue(s)}</span></div>"""
)
else:
_res += "<div></div>"
return mark_safe(_res)
@register.simple_tag
def calendar_head():
months = range(1, 13)
th = (f"""<th>{month: 02d}</th>""" for month in months)
return mark_safe("".join(th))
@register.filter
def sum_year(y_data):
return sum(y["sum"] or 0 for y in y_data)
@register.inclusion_tag("history/pagination.html", takes_context=True)
def history_pagination(context):
return context
@register.simple_tag(takes_context=True)
def history_url(context, month=None, year=None, clear=False, **kwargs):
if not clear:
kwargs.setdefault("account", getattr(context.get("account"), "id", None))
kwargs.setdefault("category", getattr(context.get("category"), "id", None))
if month:
if isinstance(month, datetime.date):
year = month.year
month = month.month
url = reverse("history:month", kwargs={"year": year, "month": month})
elif year:
if isinstance(year, datetime.date):
year = year.year
url = reverse("history:year", kwargs={"year": year})
kwargs = {k: v for k, v in kwargs.items() if v}
if kwargs:
return f"{url}?{parse.urlencode(kwargs)}"
return url

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 +1,58 @@
import datetime from django.db import models
from django.db.models import Func, Max, Min, Q, Sum, Value
from django.db.models.functions import Abs, Now, TruncMonth
from django.db.models import Q, Sum
from django.db.models.functions import Abs, Greatest, TruncMonth class GenerateMonth(Func):
function = "generate_series"
template = "%(function)s(%(expressions)s, '1 month')::date"
def history(transaction_set): def history(transaction_set):
if not transaction_set.exists(): if not transaction_set.exists():
return None return None
_transaction_month = transaction_set.values(month=TruncMonth("date")).order_by( _transaction_month = transaction_set.values(month=TruncMonth("date")).order_by(
"-date" "-date"
) )
_first_month = _transaction_month.last()["month"] _months = (
_last_month = _transaction_month.first()["month"] transaction_set.values(
month=GenerateMonth(
_history = ( _transaction_month.last()["month"],
Now(output_field=models.DateField()),
)
)
.annotate(
sum_m=Value(0), sum_p=Value(0), sum=Value(0), has_transactions=Value(0)
)
.difference(
_transaction_month.annotate( _transaction_month.annotate(
sum_p=Sum("value", filter=Q(value__gt=0), default=0), sum_m=Value(0), sum_p=Value(0), sum=Value(0), has_transactions=Value(0)
sum_m=Sum("value", filter=Q(value__lt=0), default=0), )
)
)
_history = _transaction_month.annotate(
sum_p=Sum("value", filter=Q(value__gt=0)),
sum_m=Sum("value", filter=Q(value__lt=0)),
sum=Sum("value"), sum=Sum("value"),
) has_transactions=Value(1),
.annotate(max_sum=Greatest("sum_p", Abs("sum_m"))) ).order_by("-month")
.order_by("-month")
)
_data = [
_history.filter(month=datetime.date(y, m + 1, 1)).first()
or {"month": datetime.date(y, m + 1, 1), "sum": None}
for y in range(
_first_month.year,
_last_month.year + 1,
)
for m in range(
_first_month.month - 1 if _first_month.year == y else 0,
_last_month.month if _last_month.year == y else 12,
)
]
return { return {
"data": _data, "data": _history.union(_months).order_by("-month"),
"max": { "max": max(
"pm": 125 _history.aggregate(
* _history.order_by("-max_sum")[len(_history.exclude(max_sum=0)) // 10][ max=Max("sum_p", default=0),
"max_sum" min=-Min("sum_m", default=0),
] ).values(),
/ 100, ),
"sum": 125 }
* _history.annotate(abs_sum=Abs("sum")).order_by("-abs_sum")[
len(_history) // 10
]["abs_sum"] def echarts(transaction_set):
/ 100, if not transaction_set.exists():
}, return None
_data = transaction_set.values_list("date").annotate(sum=Sum("value"))
return {
"data": list(_data),
"max": _data.aggregate(max=Max(Abs("sum")))["max"],
} }

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

@ -1,7 +1,4 @@
from django import forms from django import forms
from django.forms.widgets import TextInput
from .utils import get_icons
class NummiFileInput(forms.ClearableFileInput): class NummiFileInput(forms.ClearableFileInput):
@ -10,42 +7,6 @@ class NummiFileInput(forms.ClearableFileInput):
class NummiForm(forms.ModelForm): class NummiForm(forms.ModelForm):
template_name = "main/form/form_base.html" template_name = "main/form/form_base.html"
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
def fieldsets(self):
if self.meta_fieldsets:
for group in self.meta_fieldsets:
yield ((self[f] for f in fieldset) for fieldset in group)
else:
yield ([f] for f in self)
class DatalistInput(TextInput):
template_name = "main/forms/widgets/datalist.html"
def __init__(self, *args, options=[]):
self.options = options
super().__init__(*args)
def get_context(self, *args):
context = super().get_context(*args)
name = context["widget"]["name"]
context["widget"]["attrs"]["list"] = f"{name}-list"
context["widget"]["attrs"]["autocomplete"] = "off"
context["widget"]["options"] = self.options
return context
class IconInput(DatalistInput):
template_name = "main/forms/widgets/icon.html"
icon_list = get_icons()
def get_context(self, *args):
context = super().get_context(*args)
context["widget"]["options"] = self.icon_list
return context

View file

@ -7,7 +7,7 @@ msgid ""
msgstr "" 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: 2023-11-25 12:05+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"
@ -21,114 +21,110 @@ msgstr ""
msgid "User" msgid "User"
msgstr "Utilisateur" msgstr "Utilisateur"
#: .\main\templates\main\base.html:28 #: .\main\templates\main\base.html:27
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:33
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:38 .\main\templates\main\index.html:40
msgid "Statements" msgid "Statements"
msgstr "Relevés" msgstr "Relevés"
#: .\main\templates\main\base.html:49 #: .\main\templates\main\base.html:44 .\main\templates\main\index.html:24
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:50
#: .\main\templates\main\list.html:37 msgid "Create account"
msgstr "Créer un compte"
#: .\main\templates\main\base.html:55
msgid "Create statement"
msgstr "Créer un relevé"
#: .\main\templates\main\base.html:60
msgid "Create category"
msgstr "Créer une catégorie"
#: .\main\templates\main\base.html:65
msgid "Create transaction"
msgstr "Créer une transaction"
#: .\main\templates\main\base.html:70 .\main\templates\main\list.html:10
#: .\main\templates\main\list.html:36
msgid "Search" msgid "Search"
msgstr "Rechercher" msgstr "Rechercher"
#: .\main\templates\main\base.html:62 #: .\main\templates\main\base.html:73
msgid "Log out"
msgstr "Se déconnecter"
#: .\main\templates\main\base.html:78 .\main\templates\main\form\login.html:6
#: .\main\templates\main\login.html:14
msgid "Log in"
msgstr "Se connecter"
#: .\main\templates\main\base.html:84
#, python-format #, python-format
msgid "Logged in as <strong>%(user)s</strong>" msgid "Logged in as <strong>%(user)s</strong>"
msgstr "Connecté en tant que <strong>%(user)s</strong>" msgstr "Connecté en tant que <strong>%(user)s</strong>"
#: .\main\templates\main\base.html:66 #: .\main\templates\main\confirm_delete.html:19
msgid "Log out"
msgstr "Se déconnecter"
#: .\main\templates\main\base.html:74 .\main\templates\main\form\login.html:5
#: .\main\templates\main\login.html:11
msgid "Log in"
msgstr "Se connecter"
#: .\main\templates\main\confirm_delete.html:15
#, python-format #, 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:23
msgid "Cancel" msgid "Cancel"
msgstr "Annuler" msgstr "Annuler"
#: .\main\templates\main\confirm_delete.html:21 #: .\main\templates\main\confirm_delete.html:24
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"
msgstr "Créer"
#: .\main\templates\main\form\form_base.html:48
msgid "Save"
msgstr "Enregistrer"
#: .\main\templates\main\form\form_base.html:50
msgid "Reset"
msgstr "Réinitialiser"
#: .\main\templates\main\form\form_base.html:52
msgid "Delete" msgid "Delete"
msgstr "Supprimer" msgstr "Supprimer"
#: .\main\templates\main\index.html:13 #: .\main\templates\main\form\form_base.html:32
msgid "Reset"
msgstr "Réinitialiser"
#: .\main\templates\main\form\form_base.html:34
msgid "Create"
msgstr "Créer"
#: .\main\templates\main\form\form_base.html:36
msgid "Save"
msgstr "Enregistrer"
#: .\main\templates\main\index.html:15
msgid "Accounts" msgid "Accounts"
msgstr "Comptes" msgstr "Comptes"
#: .\main\templates\main\index.html:23 #: .\main\templates\main\index.html:20
msgid "No account"
msgstr "Aucun compte"
#: .\main\templates\main\index.html:28
msgid "Categories" msgid "Categories"
msgstr "Catégories" msgstr "Catégories"
#: .\main\templates\main\index.html:29
msgid "Create category"
msgstr "Créer une catégorie"
#: .\main\templates\main\index.html:34 #: .\main\templates\main\index.html:34
msgid "No category"
msgstr "Aucune catégorie"
#: .\main\templates\main\index.html:44
msgid "History" msgid "History"
msgstr "Historique" msgstr "Historique"
#: .\main\views.py:54 #: .\main\views.py:68
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"
#~ msgstr "Créer un relevé"
#~ msgid "Create transaction"
#~ msgstr "Créer une transaction"
#~ msgid "No category"
#~ msgstr "Aucune catégorie"

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

@ -0,0 +1,3 @@
.echarts {
height: 12rem;
}

View file

@ -1,217 +1,62 @@
.drop-zone { form ul.errorlist {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
grid-template-columns: 1fr;
grid-template-rows: 1fr;
align-items: center;
text-align: center;
color: transparent;
display: grid;
transition-property: backdrop-filter;
transition-duration: 750ms;
z-index: -1;
> span {
font-weight: 650;
font-size: 2rem;
transition-property: color;
transition-duration: inherit;
}
main.highlight > & {
z-index: 100;
backdrop-filter: blur(0.1rem);
> span {
color: var(--green);
}
}
}
form {
display: grid;
gap: 0.5rem;
grid-template-columns: repeat(auto-fill, 32rem);
@media (width < 1024px) {
grid-template-columns: 1fr;
}
&.hidden {
display: none;
}
.column {
display: grid;
gap: 0.5rem;
border: 1px solid var(--gray);
padding: var(--gap);
block-size: min-content;
}
.fieldset {
display: grid;
grid-auto-columns: 1fr;
grid-auto-flow: column;
gap: inherit;
padding: 0;
margin: 0;
border: none;
}
ul.errorlist {
color: var(--red); color: var(--red);
font-weight: 550; font-weight: 550;
list-style: none; list-style-type: "! ";
padding: 0;
margin: 0; margin: 0;
} }
.field { form > table > tbody > tr > th {
display: grid; background: var(--bg-01);
grid-auto-rows: min-content; background-clip: padding-box;
align-items: center;
column-gap: 0.5rem;
ul.errorlist {
font-size: 0.8rem;
} }
form tbody input,
&:has(> textarea) { form tbody select,
grid-template-rows: min-content 1fr; form tbody textarea {
textarea {
resize: block;
}
}
&:has(> input[type="checkbox"]) {
grid-template-columns: min-content 1fr;
> label {
font-size: inherit;
grid-row: 1;
grid-column: 2;
padding: 0.5rem;
line-height: initial;
}
> input {
grid-row: 1;
grid-column: 1;
margin: 0.5rem;
}
&:has(> :focus) {
background: var(--bg-1);
}
}
> label {
font-size: 0.8rem;
line-height: 0.8rem;
z-index: 10;
}
> a {
padding: 0.5rem;
}
input,
select,
textarea {
font: inherit; font: inherit;
line-height: initial;
border: none; border: none;
border: 1px solid transparent; background: var(--bg);
border-bottom: 1px solid var(--gray);
background: none;
z-index: 1;
padding: 0.5rem;
&:has(~ ul.errorlist) {
border-color: var(--red);
}
&.autocompleted:not(.mod) {
border-bottom-color: var(--green);
}
&:not([type="checkbox"]) {
width: 100%; width: 100%;
margin: 0; height: 100%;
line-height: 1.5;
}
form input[type="checkbox"] {
width: initial;
}
table.file-input tr {
border: none;
}
table.file-input th {
text-align: left;
}
table.file-input tr :first-child {
padding-left: 0;
}
table.file-input tr :last-child {
padding-right: 0;
} }
&[name*="value"] { form tfoot {
text-align: right; text-align: right;
font-feature-settings: var(--num);
} }
&[name*="date"] { .buttons input {
font-feature-settings: var(--num);
}
&:focus {
outline: none;
background: var(--bg-1);
}
}
> .file-input {
display: grid;
> .current {
display: grid;
grid-template-columns: 1fr;
grid-auto-columns: max-content;
grid-auto-flow: column;
a {
padding: 0.5rem;
}
}
input[type="file"] {
&::file-selector-button {
display: none;
}
}
}
> .ico-input {
display: grid;
grid-template-columns: min-content 1fr;
column-gap: 0.5rem;
align-items: center;
span[class|="ri"] {
padding: 0.5rem;
}
&:has(> :focus) {
background: var(--bg-1);
}
}
}
.buttons {
grid-column: 1 / -1;
line-height: 2rem;
input,
a {
font: inherit; font: inherit;
cursor: pointer; line-height: 1.5;
margin-left: var(--gap);
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; .buttons input:hover {
color: inherit;
&:hover {
text-decoration: underline; text-decoration: underline;
} }
} .buttons input[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"] { .buttons input[type="reset"] {
background: var(--bg-1); border: 0.1rem solid var(--red);
background: var(--red-1);
} }
a.del { .buttons a.del {
color: var(--red); color: var(--red);
border-color: var(--red);
border-style: dashed;
}
}
} }

View file

@ -1,5 +1,4 @@
@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";
*, *,
*::before, *::before,
@ -24,7 +23,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);
@ -40,8 +39,7 @@
--border: 0.5em; --border: 0.5em;
--radius: 0.25em; --radius: 0.25em;
--default-ffs: "dlig", "ss01", "ss04"; --num: "tnum", "ss01", "ss02", "case";
--num: var(--default-ffs), "tnum", "case";
} }
body { body {
@ -53,7 +51,6 @@ body {
display: grid; display: grid;
grid-template-columns: max-content 1fr; grid-template-columns: max-content 1fr;
font-feature-settings: var(--default-ffs);
} }
p { p {
@ -63,10 +60,9 @@ a {
color: var(--text-link); color: var(--text-link);
text-decoration: none; text-decoration: none;
display: inline-block; display: inline-block;
&:is(:hover, :focus) {
text-decoration: underline;
} }
a:hover {
text-decoration: underline;
} }
.red { .red {
@ -81,33 +77,12 @@ a {
main, main,
nav, nav,
footer { footer {
padding: 2rem 1rem;
@media (width > 720px) {
padding: 2rem; padding: 2rem;
} }
background: var(--bg);
}
main { main {
position: relative;
grid-column: 2; grid-column: 2;
grid-row: 1; grid-row: 1;
overflow-x: hidden; overflow-x: hidden;
h2.new {
opacity: 0.8;
}
.split {
display: grid;
gap: var(--gap);
grid-template-columns: 100%;
@media (width > 720px) {
grid-template-columns: minmax(20rem, max-content) 1fr;
}
& > section > :first-child {
margin-top: 0;
}
}
} }
nav { nav {
grid-column: 1; grid-column: 1;
@ -118,48 +93,40 @@ 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 { nav h1 img {
height: 1cap; height: 1cap;
} }
ul { nav ul {
list-style: none; list-style: none;
padding: 0; padding: 0;
margin: 0; margin: 0;
position: relative; position: relative;
} }
a { nav .skip-link {
&.skip-link { opacity: 0.8;
font-weight: 300; font-weight: 300;
}
&:is(:active, :focus) { nav .skip-link:active,
nav .skip-link:focus {
opacity: initial;
font-weight: 500; font-weight: 500;
} }
nav a {
display: block;
} }
display: grid; nav a.cur {
grid-template-columns: 1fr max-content;
align-items: baseline;
[class^="ri-"] {
height: 1.5em;
width: 1.5em;
line-height: 1.5em;
border-radius: var(--radius);
}
&.cur {
font-weight: 550; font-weight: 550;
[class^="ri-"] {
background: var(--text-link);
color: var(--bg);
} }
nav a.cur::after {
content: "◎";
position: absolute;
right: 0;
} }
} nav > :first-child,
} main > :first-child {
:is(nav, main) > :first-child,
main > section:first-child > :first-child {
margin-top: 0; margin-top: 0;
} }
footer { footer {
@ -168,46 +135,16 @@ footer {
font-weight: 250; font-weight: 250;
} }
.pagination { #pagination {
text-align: center; text-align: center;
font-feature-settings: var(--num); font-feature-settings: var(--num);
}
a { #pagination a {
min-width: 1rem; width: 2rem;
padding: 0 0.5rem; }
#pagination a.cur {
&.cur {
font-weight: 650; font-weight: 650;
text-decoration: underline dotted; text-decoration: underline dotted;
&:is(:hover, :focus) {
text-decoration: underline;
}
}
}
@media (width > 720px) {
&.n3 {
display: grid;
grid-template-columns: repeat(3, 1fr);
width: max-content;
margin: 0.5rem auto;
.prev {
grid-column: 1;
}
.cur {
grid-column: 2;
}
.next {
grid-column: 3;
}
}
}
& + section :first-child {
margin-top: 0;
}
} }
@media (width < 1024px) { @media (width < 1024px) {
@ -223,38 +160,15 @@ footer {
height: initial; height: initial;
} }
} }
a.big-link {
margin-right: 1em;
}
a.big-link [class^="ri-"] {
margin-right: 0.5em;
}
[class^="ri-"] { [class^="ri-"] {
display: inline-block;
text-align: center;
font-weight: normal; font-weight: normal;
&.green,
&.red,
&.white {
&.green {
background: var(--green);
color: var(--bg);
}
&.red {
background: var(--red);
color: var(--bg);
}
&.white {
background: var(--bg-1);
}
border-radius: var(--radius);
height: 1.5em;
width: 1.5em;
line-height: 1.5em;
}
a:not(.i) &,
.wi &,
h2 & {
&:first-child::after {
content: "\2002";
}
}
} }
h1, h1,
@ -274,6 +188,9 @@ h2 {
h3 { h3 {
font-size: 1.5rem; font-size: 1.5rem;
} }
main h2.new {
opacity: 0.8;
}
p { p {
margin: 0.5em 0; margin: 0.5em 0;
} }
@ -282,291 +199,20 @@ ul.messages {
font-weight: 550; font-weight: 550;
list-style-type: none; list-style-type: none;
margin: 0; margin: 0;
margin-bottom: var(--gap); background: var(--bg-01);
background: var(--bg-1);
padding: 0; padding: 0;
}
li { ul.messages li {
--message-color: var(--text);
padding: calc(var(--gap) / 2) var(--gap); padding: calc(var(--gap) / 2) var(--gap);
border-left: var(--message-color) solid var(--border); border-left: var(--border) solid var(--gray);
[class^="ri-"] {
height: 1.5em;
width: 1.5em;
line-height: 1.5em;
border-radius: var(--radius);
background: var(--message-color);
color: var(--bg);
margin-right: 0.5rem;
} }
&.msg-level-20 { ul.messages li.msg-level-25 {
--message-color: var(--green); border-left-color: var(--green-1);
} }
&.msg-level-25 { ul.messages li.msg-level-30 {
--message-color: var(--green-1); border-left-color: var(--red-1);
}
&.msg-level-30 {
--message-color: var(--red-1);
}
&.msg-level-40 {
--message-color: var(--red);
}
}
}
.backlinks {
display: grid;
grid-template-columns: repeat(2, 1fr);
p {
grid-column: 1;
&.back {
grid-column: 2;
text-align: right;
a {
margin-right: 0;
margin-left: 1em;
[class^="ri-"] {
margin-right: 0em;
margin-left: 0.5em;
}
}
}
}
}
dl.accounts {
margin: 0;
dt,
dd {
margin: 0;
}
.account {
padding: 0.5rem;
margin-bottom: 0.5rem;
border: 1px solid var(--gray);
display: grid;
grid-template-columns: 1fr min-content;
&.new,
&.more {
border-style: dashed;
}
&.more label {
display: block;
}
}
&:not(.show-archive) .account.archived {
display: none;
}
}
ul.statements,
ul.invoices {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
grid-auto-rows: 1fr;
gap: 0.5rem;
list-style: none;
padding: 0;
margin: 0;
li {
display: grid;
gap: 0.5rem;
padding: var(--gap);
border: var(--gray) 1px solid;
text-align: right;
align-items: center;
> * {
&.title {
font-weight: 650;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
&.new,
&.more {
border-style: dashed;
}
}
}
ul.statements {
li {
> * {
display: grid;
align-items: center;
&.date :first-child {
font-size: 2rem;
}
&.value :first-child {
font-weight: 650;
}
&.icon {
span {
margin: auto;
}
}
&.account a {
overflow: hidden;
text-overflow: ".";
white-space: nowrap;
}
}
}
}
ul.invoices {
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
}
.statement-details {
display: grid;
@media (width > 720px) {
grid-template-columns: repeat(3, min-content);
}
gap: var(--gap);
align-items: center;
> span.evolution {
display: grid;
grid-auto-rows: min-content;
> span[class^="ri-"] {
font-size: 2rem;
}
> span.value {
text-align: right;
}
}
> span.start,
> span.end,
> span.file,
> span.incons {
display: grid;
border: var(--gray) 1px solid;
padding: var(--gap);
&.file,
&.incons {
grid-column: 1 / -1;
}
&.incons {
border-color: var(--red);
grid-template-columns: min-content 1fr;
grid-auto-flow: column;
align-items: center;
gap: 0.5rem;
> .ds {
display: grid;
grid-auto-rows: min-content;
}
.value {
text-align: right;
}
.diff {
font-weight: 650;
}
.sum {
text-decoration: line-through;
}
.links {
grid-column: 1 / -1;
grid-row: 2;
display: grid;
gap: inherit;
a {
color: var(--red);
}
}
}
> .date {
text-align: right;
}
> .value {
font-size: 2rem;
}
}
}
.multilink {
display: grid;
grid-auto-columns: max-content;
}
.transaction-details {
display: grid;
grid-auto-columns: minmax(1fr, max-content);
grid-auto-rows: min-content;
max-width: 32rem;
ul {
list-style: none;
display: grid;
li.value {
font-size: 1.5rem;
text-align: right;
}
}
p.description,
ul {
border: var(--gray) 1px solid;
padding: var(--gap);
}
}
.category,
.big-link {
padding: 0 var(--gap);
border: var(--gray) 1px solid;
margin-right: 0.5rem;
margin-bottom: 0.5rem;
&.add {
border-style: dashed;
}
}
.date,
.value {
font-feature-settings: var(--num);
}
details {
border: var(--gray) 1px solid;
margin-bottom: var(--gap);
summary {
font-weight: 650;
cursor: pointer;
padding: var(--gap);
&::marker {
content: "\ed27\2002";
font-family: remixicon;
font-weight: initial;
}
}
&[open] summary {
background: var(--bg-1);
}
form {
padding: var(--gap);
} }
ul.messages li.msg-level-40 {
border-left-color: var(--red);
} }

View file

@ -1,34 +1,52 @@
table.full-width col.bar { table.full-width col.bar {
width: auto; width: auto;
} }
.plot { .plot {
overflow-x: auto; overflow-x: auto;
table {
min-width: 40rem;
} }
td.bar { .plot td.bar {
position: relative; position: relative;
padding: 0; padding: 0;
overflow: hidden; }
.plot td.bar div {
div {
position: absolute; position: absolute;
height: 0.5rem; height: 0.5rem;
top: 0; top: 0;
&:not(.tot) { }
.plot td.bar div:not(.tot) {
width: 0; width: 0;
box-sizing: border-box; box-sizing: border-box;
z-index: 1; z-index: 1;
display: inline-block; display: inline-block;
} }
&.tot { .plot td.bar.p div {
left: 0;
border-radius: 0 var(--radius) var(--radius) 0;
}
.plot td.bar.m div {
right: 0;
border-radius: var(--radius) 0 0 var(--radius);
}
.plot td.bar.m div {
background: var(--red-1);
}
.plot td.bar.p div {
background: var(--green-1);
}
.plot td.bar div.tot {
z-index: 10; z-index: 10;
height: 0.5rem; height: 0.5rem;
}
span { .plot td.bar.m div.tot {
background: var(--red);
}
.plot td.bar.p div.tot {
background: var(--green);
}
.plot td.bar div.tot span {
position: absolute; position: absolute;
display: inline-block; display: inline-block;
white-space: nowrap; white-space: nowrap;
@ -39,85 +57,20 @@ table.full-width col.bar {
height: 1.5rem; height: 1.5rem;
font-feature-settings: var(--num); font-feature-settings: var(--num);
} }
} .plot td.bar.p div.tot span {
}
&.p div {
left: 0;
border-radius: 0 var(--radius) var(--radius) 0;
background: var(--green-1);
&.tot {
background: var(--green);
span {
left: 0; left: 0;
} }
} .plot td.bar.m div.tot span {
}
&.m div {
right: 0;
border-radius: var(--radius) 0 0 var(--radius);
background: var(--red-1);
&.tot {
background: var(--red);
span {
right: 0; right: 0;
} }
}
@media (width < 720px) {
.plot .bar {
width: 0;
overflow: hidden;
} }
} }
&.history tbody tr { .plot tfoot {
background: initial; background: var(--bg-01);
}
tbody tr {
&.empty {
height: 0.5rem;
}
&.even {
background: #eeeeff;
}
}
}
.calendar {
overflow-x: auto;
margin-bottom: var(--gap);
font-feature-settings: var(--num);
table {
tbody tr {
background: initial;
&:not(:last-child) {
border-bottom: none;
}
&:not(:first-child) {
border-top: none;
}
td.month {
text-align: center;
background-color: color-mix(
in hsl,
var(--td-bg, var(--bg)) var(--opacity),
var(--bg)
);
padding: 0;
width: 4rem;
height: 4rem;
&.p {
--td-bg: var(--green);
}
&.m {
--td-bg: var(--red);
}
}
td.total {
text-align: right;
font-weight: 650;
font-feature-settings: var(--num);
}
}
}
} }

View file

@ -1,36 +1,29 @@
.table { .table,
form {
overflow-x: auto; overflow-x: auto;
width: 100%;
} }
table { table {
border-collapse: collapse; border-collapse: collapse;
}
&.full-width { table.more tbody:last-child tr:last-child {
border-bottom: 0.1rem dashed var(--gray);
}
table.full-width {
width: 100%; width: 100%;
}
col { thead {
background: var(--bg-01);
}
table.full-width col {
width: 8rem; width: 8rem;
} }
} table col.icon {
col.icon {
width: 1ch; width: 1ch;
} }
thead tr:not(.new) {
background: var(--bg-1);
}
tr { tr {
border: 1px solid var(--gray); border: 0.1rem solid var(--gray);
height: 2rem; height: 2rem;
line-height: 2rem; line-height: 2rem;
tbody &:where(:nth-of-type(even)) {
background: #eeeeff;
}
&.more,
&.new {
text-align: center;
border-style: dashed;
}
} }
td, td,
th { th {
@ -38,13 +31,11 @@ table {
position: relative; position: relative;
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
&.empty {
text-align: center;
opacity: 0.8;
font-weight: 300;
} }
.date,
.value {
font-feature-settings: var(--num);
} }
.l { .l {
text-align: left; text-align: left;
} }
@ -56,4 +47,8 @@ table {
.date { .date {
text-align: center; text-align: center;
} }
td.empty {
text-align: center;
opacity: 0.8;
font-weight: 300;
} }

File diff suppressed because one or more lines are too long

View file

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

View file

@ -0,0 +1,36 @@
// Initialize the echarts instance based on the prepared dom
const myChart = echarts.init(document.getElementById("echarts-year"), null, {
renderer: "svg",
});
const data = JSON.parse(document.getElementById("echarts-history").textContent);
console.log(data);
option = {
tooltip: {},
visualMap: {
min: -data.max,
max: +data.max,
type: "continuous",
orient: "horizontal",
show: false,
inRange: {
color: ["#802653", "#66cc66", "white", "#cc6699", "#338033"],
},
},
calendar: {
top: 30,
bottom: 30,
range: "2022",
left: "center",
},
series: {
type: "heatmap",
coordinateSystem: "calendar",
data: data.data,
},
};
// Display the chart using the configuration items and data just specified.
myChart.setOption(option);
window.addEventListener("resize", function () {
myChart.resize();
});

60757
nummi/main/static/main/js/echarts.min.js vendored Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

Binary file not shown.

View file

@ -1,18 +1,17 @@
{% load static %} {% load static %}
{% load i18n %} {% load i18n %}
{% load main_extras %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="fr"> <html lang="fr">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1" />
<title> <title>
{% block title %}Nummi{% endblock %} {% block title %}Nummi{% endblock %}
</title> </title>
{% block link %} {% block link %}
<link rel="icon" href="{% static "main/svg/logo.svg" %}" type="image/svg+xml"> <link rel="icon" href="{% static "main/svg/logo.svg" %}" type="image/svg+xml" />
{% css "main/css/main.css" %} <link rel="stylesheet" href="{% static "main/css/main.css" %}" type="text/css" />
{% js "main/js/base.js" %} <link rel="stylesheet" href="{% static "main/remixicon/remixicon.css" %}" type="text/css" />
{% endblock %} {% endblock %}
</head> </head>
<body> <body>
@ -20,7 +19,7 @@
{% spaceless %} {% spaceless %}
<nav> <nav>
<h1> <h1>
<img src="{% static "main/svg/logo.svg" %}" alt=""> <img src="{% static "main/svg/logo.svg" %}" alt="" />
Nummi Nummi
</h1> </h1>
<ul> <ul>
@ -31,63 +30,67 @@
<li> <li>
<a href="{% url "index" %}" <a href="{% url "index" %}"
class="home{% if request.resolver_match.url_name == "index" %} cur{% endif %}" class="home{% if request.resolver_match.url_name == "index" %} cur{% endif %}"
accesskey="h"> accesskey="h">{% translate "Home" %}</a>
<span>{% translate "Home" %}</span>
{{ "home"|remix }}
</a>
</li> </li>
<li> <li>
<a href="{% url "statements" %}" <a href="{% url "statements" %}"
class="{% if request.resolver_match.url_name == "statements" %}cur{% endif %}"> class="{% if request.resolver_match.url_name == "statements" %}cur{% endif %}">
<span>{% translate "Statements" %}</span> {% translate "Statements" %}
{{ "file"|remix }}
</a> </a>
</li> </li>
<li> <li>
<a href="{% url "transactions" %}" <a href="{% url "transactions" %}"
class="{% if request.resolver_match.url_name == "transactions" %}cur{% endif %}"> class="{% if request.resolver_match.url_name == "transactions" %}cur{% endif %}">
<span>{% translate "Transactions" %}</span> {% translate "Transactions" %}
{{ "receipt"|remix }}
</a> </a>
</li> </li>
<li>
<a href="{% url "new_account" %}"
class="{% if request.resolver_match.url_name == "new_account" %}cur{% endif %}"
accesskey="a">{% translate "Create account" %}</a>
</li>
<li>
<a href="{% url "new_statement" %}"
class="{% if request.resolver_match.url_name == "new_statement" %}cur{% endif %}"
accesskey="s">{% translate "Create statement" %}</a>
</li>
<li>
<a href="{% url "new_category" %}"
class="{% if request.resolver_match.url_name == "new_category" %}cur{% endif %}"
accesskey="c">{% translate "Create category" %}</a>
</li>
<li>
<a href="{% url "new_transaction" %}"
class="{% if request.resolver_match.url_name == "new_transaction" %}cur{% endif %}"
accesskey="t">{% translate "Create transaction" %}</a>
</li>
<li> <li>
<a href="{% url "search" %}" <a href="{% url "search" %}"
class="{% if request.resolver_match.url_name == "search" %}cur{% endif %}" class="{% if request.resolver_match.url_name == "search" %}cur{% endif %}"
accesskey="r"> accesskey="r">{% translate "Search" %}</a>
<span>{% translate "Search" %}</span>
{{ "search"|remix }}
</a>
</li> </li>
<li> <li>
{% blocktranslate %}Logged in as <strong>{{ user }}</strong>{% endblocktranslate %} <a href="{% url "logout" %}" accesskey="l">{% translate "Log out" %}</a>
</li>
<li>
<a href="{% url "logout" %}" accesskey="l">
<span>{% translate "Log out" %}</span>
{{ "close-circle"|remix }}
</a>
</li> </li>
{% else %} {% else %}
<li> <li>
<a {% if request.resolver_match.url_name == "login" %}class="cur"{% endif %} <a {% if request.resolver_match.url_name == "login" %}class="cur"{% endif %}
href="{% url "login" %}"> href="{% url "login" %}">{% translate "Log in" %}</a>
<span>{% translate "Log in" %}</span>
{{ "user"|remix }}
</a>
</li> </li>
{% endif %} {% endif %}
</ul> </ul>
{% if user.is_authenticated %}
<p>
{% blocktranslate %}Logged in as <strong>{{ user }}</strong>{% endblocktranslate %}
</p>
{% endif %}
</nav> </nav>
{% endspaceless %} {% endspaceless %}
{% endblock %} {% endblock %}
<main id="main"> <main id="main">
{% if messages %} {% if messages %}
<ul class="messages"> <ul class="messages">
{% for message in messages %} {% for message in messages %}<li class="msg-level-{{ message.level }}">{{ message }}</li>{% endfor %}
<li class="msg-level-{{ message.level }}">
{{ message.level|messageicon }}{{ message }}
</li>
{% endfor %}
</ul> </ul>
{% endif %} {% endif %}
{% block body %}{% endblock %} {% block body %}{% endblock %}

View file

@ -4,8 +4,12 @@
{% load i18n %} {% load i18n %}
{% block link %} {% block link %}
{{ block.super }} {{ block.super }}
{% css "main/css/form.css" %} <link rel="stylesheet"
{% css "main/css/table.css" %} href="{% static 'main/css/form.css' %}"
type="text/css" />
<link rel="stylesheet"
href="{% static 'main/css/table.css' %}"
type="text/css" />
{% endblock %} {% endblock %}
{% block body %} {% block body %}
{% spaceless %} {% spaceless %}
@ -14,7 +18,6 @@
<p> <p>
{% blocktranslate %}Are you sure you want do delete <strong>{{ object }}</strong> ?{% endblocktranslate %} {% blocktranslate %}Are you sure you want do delete <strong>{{ object }}</strong> ?{% endblocktranslate %}
</p> </p>
{% block additionalinfo %}{% endblock %}
{{ form }} {{ form }}
<div class="buttons"> <div class="buttons">
<a href="{{ object.get_absolute_url }}">{% translate "Cancel" %}</a> <a href="{{ object.get_absolute_url }}">{% translate "Cancel" %}</a>

View file

@ -12,9 +12,15 @@
{% endblock %} {% endblock %}
{% block link %} {% block link %}
{{ block.super }} {{ block.super }}
{% css "main/css/form.css" %} <link rel="stylesheet"
{% css "main/css/table.css" %} href="{% static 'main/css/form.css' %}"
{% css "main/css/plot.css" %} type="text/css" />
<link rel="stylesheet"
href="{% static 'main/css/table.css' %}"
type="text/css" />
<link rel="stylesheet"
href="{% static 'main/css/plot.css' %}"
type="text/css" />
{% endblock %} {% endblock %}
{% block body %} {% block body %}
{% with instance=form.instance %} {% with instance=form.instance %}
@ -30,9 +36,7 @@
{% block pre %}{% endblock %} {% block pre %}{% endblock %}
<form method="post" enctype="multipart/form-data"> <form method="post" enctype="multipart/form-data">
{% csrf_token %} {% csrf_token %}
{% if instance|adding %} {% if instance|adding %}<input hidden name="next" value="{{ request.path }}" />{% endif %}
<input hidden name="next" value="{{ request.path }}">
{% endif %}
{{ form }} {{ form }}
</form> </form>
{% block tables %}{% endblock %} {% block tables %}{% endblock %}

View file

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

View file

@ -1,56 +1,43 @@
{% 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 %}
</ul> </ul>
{% endif %} {% endif %}
{% if form.fieldsets %} <table>
{% for group in form.fieldsets %} <tbody>
<div class="column">
{% for fieldset in group %}
<div class="fieldset">
{% for field in fieldset %}
<div class="field">
<label for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
{% if field.errors %}
<ul class="errorlist">
{% for error in field.errors %}
<li class="wi">{{ "error-warning"|remix }}{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% endfor %}
</div>
{% endfor %}
</div>
{% endfor %}
{% else %}
<div class="column">
{% for field in form %} {% for field in form %}
{% if field.errors %}<p class="error">{{ field.errors }}</p>{% endif %} {% if field.errors %}
<div class="field"> <tr>
<td colspan="2">{{ field.errors }}</td>
</tr>
{% endif %}
<tr>
<th class="l">
<label for="{{ field.id_for_label }}">{{ field.label }}</label> <label for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }} </th>
</div> <td>{{ field }}</td>
</tr>
{% endfor %} {% endfor %}
</div> </tbody>
{% endif %} <tfoot>
<div class="buttons"> <tr class="buttons">
<td colspan="2">
{% block buttons %} {% block buttons %}
{% if form.instance|adding %}
<input type="submit" value="{% translate "Create" %}">
{% else %}
<input type="submit" value="{% translate "Save" %}">
{% endif %}
<input type="reset" value="{% translate "Reset" %}">
{% if not form.instance|adding %} {% if not form.instance|adding %}
<a class="del" href="{{ form.instance.get_delete_url }}">{% translate "Delete" %}</a> <a class="del" href="{{ form.instance.get_delete_url }}">{% translate "Delete" %}</a>
{% endif %} {% endif %}
{% endblock buttons %} <input type="reset" value="{% translate "Reset" %}" />
</div> {% if form.instance|adding %}
{% endblock fields %} <input type="submit" value="{% translate "Create" %}" />
{% else %}
<input type="submit" value="{% translate "Save" %}" />
{% endif %}
{% endblock %}
</td>
</tr>
</tfoot>
</table>
{% endblock %}

View file

@ -1,7 +1,7 @@
{% extends "main/form/form_base.html" %} {% extends "main/form/form_base.html" %}
{% load i18n %} {% load i18n %}
{% block buttons %} {% block buttons %}
<input hidden value="{{ next }}" name="next"> <input hidden value="{{ next }}" name="next" />
<input type="submit" value="{% translate "Log in" %}"> <input type="reset" />
<input type="reset"> <input type="submit" value="{% translate "Log in" %}" />
{% endblock buttons %} {% endblock %}

View file

@ -1,4 +0,0 @@
{% include "django/forms/widgets/text.html" %}
<datalist id="{{ widget.attrs.list }}">
{% for option in widget.options %}<option value="{{ option }}"></option>{% endfor %}
</datalist>

View file

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

View file

@ -1,37 +1,54 @@
{% extends "main/base.html" %} {% extends "main/base.html" %}
{% load static %} {% load static %}
{% load main_extras account_extras history_extras statement_extras %} {% load main_extras %}
{% load i18n %} {% load i18n %}
{% block link %} {% block link %}
{{ block.super }} {{ block.super }}
{% css "main/css/table.css" %} <link rel="stylesheet"
{% css "main/css/plot.css" %} href="{% static 'main/css/table.css' %}"
{% endblock link %} type="text/css" />
<link rel="stylesheet"
href="{% static 'main/css/plot.css' %}"
type="text/css" />
<link rel="stylesheet"
href="{% static 'main/css/echarts.css' %}"
type="text/css" />
<script src="{% static 'main/js/echarts.min.js' %}"></script>
<script src="{% static 'main/js/echarts-year.js' %}" defer></script>
{{ echarts_history | json_script:"echarts-history" }}
{% endblock %}
{% block body %} {% block body %}
<div class="split">
<section>
<h2>{% translate "Accounts" %}</h2> <h2>{% translate "Accounts" %}</h2>
{% account_table user.account_set.all index=True %} <p>
</section> {% for acc in accounts %}
<section> <a class="big-link" href="{{ acc.get_absolute_url }}">{{ acc.icon|remix }}{{ acc }}</a>
<h2>{% translate "Statements" %}</h2> {% empty %}
{% url "statements" as s_url %} {% translate "No account" %}
{% statement_table statements statements_url=s_url %} {% endfor %}
</section> </p>
</div> {% if transactions %}
<section> <h2>{% translate "Transactions" %}</h2>
{% include "transaction/transaction_table.html" %}
{% endif %}
{% if categories %}
<h2>{% translate "Categories" %}</h2> <h2>{% translate "Categories" %}</h2>
{% spaceless %} {% spaceless %}
<p> <p>
{% for cat in user.category_set.all %} {% for cat in categories %}
<a class="category" href="{{ cat.get_absolute_url }}">{{ cat.icon|remix }}{{ cat }}</a> <a class="big-link" href="{{ cat.get_absolute_url }}">{{ cat.icon|remix }}{{ cat }}</a>
{% empty %}
{% translate "No category" %}
{% endfor %} {% endfor %}
<a class="category add" href="{% url "new_category" %}">{{ "add"|remix }}{% translate "Create category" %}</a>
</p> </p>
{% endspaceless %} {% endspaceless %}
</section> {% endif %}
<section> {% if statements %}
<h2>{% translate "Statements" %}</h2>
{% include "statement/statement_table.html" %}
{% endif %}
{% if history.data %}
<h2>{% translate "History" %}</h2> <h2>{% translate "History" %}</h2>
{% history_plot user.transaction_set.all %} {% include "history/plot.html" %}
</section> {% endif %}
{% endblock body %} <div id="echarts-year" class="echarts"></div>
{% endblock %}

View file

@ -13,15 +13,14 @@
{% endblock %} {% endblock %}
{% block link %} {% block link %}
{{ block.super }} {{ block.super }}
{% css "main/css/table.css" %} <link rel="stylesheet"
href="{% static 'main/css/table.css' %}"
type="text/css" />
{% endblock %} {% endblock %}
{% block body %} {% block body %}
<h2> <h2>
{% block h2 %}{% endblock %} {% block h2 %}{% endblock %}
</h2> </h2>
{% if account or category or search %}
<div class="backlinks">
{% block backlinks %}
{% if account %} {% if account %}
<p> <p>
<a class="big-link" href="{{ account.get_absolute_url }}">{{ account.icon|remix }}{{ account }}</a> <a class="big-link" href="{{ account.get_absolute_url }}">{{ account.icon|remix }}{{ account }}</a>
@ -37,11 +36,7 @@
<a href="{% url "search" %}">{% translate "Search" %}</a> <a href="{% url "search" %}">{% translate "Search" %}</a>
</p> </p>
{% endif %} {% endif %}
{% endblock %}
</div>
{% endif %}
{% include "main/pagination.html" %} {% include "main/pagination.html" %}
{% block table %}{% endblock %} {% block table %}{% endblock %}
{% include "main/pagination.html" %} {% include "main/pagination.html" %}
{% block body_more %}{% endblock %}
{% endblock %} {% endblock %}

View file

@ -1,11 +1,14 @@
{% extends "main/base.html" %} {% extends "main/base.html" %}
{% load main_extras %}
{% load static %} {% load static %}
{% load i18n %} {% load i18n %}
{% block link %} {% block link %}
{{ block.super }} {{ block.super }}
{% css "main/css/table.css" %} <link rel="stylesheet"
{% css "main/css/form.css" %} href="{% static 'main/css/table.css' %}"
type="text/css" />
<link rel="stylesheet"
href="{% static 'main/css/form.css' %}"
type="text/css" />
{% endblock %} {% endblock %}
{% block body %} {% block body %}
<h2>{% translate "Log in" %}</h2> <h2>{% translate "Log in" %}</h2>

View file

@ -1,4 +1,9 @@
{% load i18n main_extras transaction_extras %} {% load i18n %}
{% if page_obj %} {% if page_obj %}
<p class="pagination">{% pagination_links page_obj %}</p> <p id="pagination">
{% for page in paginator.page_range %}
<a href="?page={{ page }}"
{% if page == page_obj.number %}class="cur"{% endif %}>{{ page }}</a>
{% endfor %}
</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,4 @@
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.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
@ -12,16 +7,16 @@ register = template.Library()
@register.filter @register.filter
def value(val, pm=False, r=2): def value(val, pm=False, r=2):
if val is None: if not val:
return "" return ""
_prefix = "" _prefix = ""
_suffix = "&nbsp;€" _suffix = "&nbsp;€"
_val = formats.number_format(val, decimal_pos=r, use_l10n=True, force_grouping=True) _val = formats.number_format(round(val, r), r, use_l10n=True, force_grouping=True)
if val > 0: if val > 0:
if pm: if pm:
_prefix += "&plus;&nbsp;" _prefix += "&plus;&nbsp;"
elif val < 0: else:
_val = _val[1:] _val = _val[1:]
_prefix += "&minus;&nbsp;" _prefix += "&minus;&nbsp;"
@ -39,31 +34,21 @@ def pmrvalue(val):
@register.filter @register.filter
def remix(icon, *args): def remix(icon, cls=""):
return remixnl(f"{icon}-line", *args) return mark_safe(f"""<span class="ri-{icon}-line {cls}"></span>""")
@register.filter @register.filter
def remixnl(icon, cls=""): def check(sum, diff):
return mark_safe(f"""<span class="ri-{icon} {cls}"></span>""") if sum == diff:
return remix("check", "green")
else:
@register.filter return remix("close", "red")
def messageicon(level):
ico = {
10: "bug",
20: "information",
25: "check",
30: "alert",
40: "error-warning",
}
return remix(ico.get(level, "question"))
@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
@ -74,67 +59,3 @@ def verbose_name(obj):
@register.filter @register.filter
def adding(obj): def adding(obj):
return obj._state.adding return obj._state.adding
@register.simple_tag
def css(href):
return mark_safe(
f"""<link rel="stylesheet" href="{static(href)}" type="text/css">"""
)
@register.simple_tag
def js(href):
return mark_safe(f"""<script src="{static(href)}" defer></script>""")
@register.filter
def balance(accounts):
return sum(
statement.value for acc in accounts if (statement := acc.statement_set.first())
)
@register.inclusion_tag("main/pagination_links.html", takes_context=True)
def pagination_links(context, page_obj):
_n = 3
return {
"request": context["request"],
"pages": [
{"number": p, "current": p == page_obj.number}
for p in page_obj.paginator.page_range
if abs(p - page_obj.number) < _n
],
"first": {
"show": page_obj.number > _n,
"dots": page_obj.number > _n + 1,
},
"last": {
"show": page_obj.number <= page_obj.paginator.num_pages - _n,
"dots": page_obj.number < page_obj.paginator.num_pages - _n,
"number": page_obj.paginator.num_pages,
},
}
@register.simple_tag(takes_context=True)
def page_url(context, page):
query = context["request"].GET.copy()
query["page"] = page
return query.urlencode()
@register.simple_tag
def url_get(name, **kwargs):
kwargs = {k: v for k, v in kwargs.items() if v}
return f"{reverse(name)}?{parse.urlencode(kwargs)}"
@register.filter
def end_of_month(month):
return month + relativedelta(months=1, days=-1)
@register.filter
def end_of_year(year):
return year + relativedelta(years=1, days=-1)

View file

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

View file

@ -1,3 +1,5 @@
from account.models import Account
from category.models import Category
from django.contrib import messages from django.contrib import messages
from django.contrib.auth import views as auth_views from django.contrib.auth import views as auth_views
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
@ -7,24 +9,38 @@ from django.utils.translation import gettext as _
from django.views.generic import ( from django.views.generic import (
CreateView, CreateView,
DeleteView, DeleteView,
DetailView,
ListView, ListView,
TemplateView, TemplateView,
UpdateView, UpdateView,
) )
from history.utils import echarts, history
from statement.models import Statement
from transaction.models import Transaction
class IndexView(LoginRequiredMixin, TemplateView): class IndexView(LoginRequiredMixin, TemplateView):
template_name = "main/index.html" template_name = "main/index.html"
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | { _max = 8
"statements": ( _transactions = Transaction.objects.filter(user=self.request.user)
self.request.user.statement_set.exclude(account__archived=True) _statements = Statement.objects.filter(user=self.request.user)
.order_by("account__id", "-date") _history = history(_transactions.exclude(category__budget=False))
.distinct("account__id")
) res = {
"accounts": Account.objects.filter(user=self.request.user),
"transactions": _transactions[:_max],
"categories": Category.objects.filter(user=self.request.user),
"statements": _statements[:_max],
"history": _history,
"echarts_history": echarts(_transactions),
} }
if _transactions.count() > _max:
res["transactions_url"] = reverse_lazy("transactions")
if _statements.count() > _max:
res["statements_url"] = reverse_lazy("statements")
return super().get_context_data(**kwargs) | res
class UserMixin(LoginRequiredMixin): class UserMixin(LoginRequiredMixin):
@ -62,10 +78,6 @@ class NummiUpdateView(UserMixin, UpdateView):
pass pass
class NummiDetailView(UserMixin, DetailView):
pass
class NummiDeleteView(UserMixin, DeleteView): class NummiDeleteView(UserMixin, DeleteView):
template_name = "main/confirm_delete.html" template_name = "main/confirm_delete.html"
success_url = reverse_lazy("index") success_url = reverse_lazy("index")
@ -86,4 +98,4 @@ class LogoutView(auth_views.LogoutView):
class NummiListView(UserMixin, ListView): class NummiListView(UserMixin, ListView):
pass paginate_by = 96

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

@ -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: 2024-01-02 15:52+0100\n" "POT-Creation-Date: 2023-04-22 15:16+0200\n"
"PO-Revision-Date: 2023-04-22 15:20+0200\n" "PO-Revision-Date: 2023-04-22 15:20+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"
#: .\search\forms.py:7 .\search\templates\search\search.html:6 #: .\search\forms.py:7 .\search\templates\search\search.html:6
#: .\search\templates\search\search.html:14 #: .\search\templates\search\search.html:18
#: .\search\templates\search\search_form.html:5 #: .\search\templates\search\search_form.html:5
msgid "Search" msgid "Search"
msgstr "Rechercher" msgstr "Rechercher"

View file

@ -7,8 +7,12 @@
{% endblock %} {% endblock %}
{% block link %} {% block link %}
{{ block.super }} {{ block.super }}
{% css "main/css/form.css" %} <link rel="stylesheet"
{% css "main/css/table.css" %} href="{% static 'main/css/form.css' %}"
type="text/css" />
<link rel="stylesheet"
href="{% static 'main/css/table.css' %}"
type="text/css" />
{% endblock %} {% endblock %}
{% block body %} {% block body %}
<h2>{% translate "Search" %}</h2> <h2>{% translate "Search" %}</h2>

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,44 +13,21 @@ 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 = [
[
["account"],
["start_date", "start_value"],
["date", "value"],
["file"],
],
]
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)
for _transaction in Transaction.objects.filter( for _transaction in Transaction.objects.filter(user=_user)
user=_user, statement=None
)
), ),
label=_("Add transactions"), label=_("Add transactions"),
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 +39,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: 2023-11-25 12:05+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:27
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:5
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:22
msgid "Back" msgid "Categories"
msgstr "Retour" msgstr "Catégories"
#: .\statement\templates\statement\statement_form.html:24
#: .\statement\templates\statement\statement_table.html:28 #: .\statement\templates\statement\statement_table.html:28
msgid "Transactions"
msgstr "Transactions"
#: .\statement\templates\statement\statement_table.html:22
msgid "Date"
msgstr "Date"
#: .\statement\templates\statement\statement_table.html:24
msgid "Account"
msgstr "Compte"
#: .\statement\templates\statement\statement_table.html:26
msgid "Value"
msgstr "Valeur"
#: .\statement\templates\statement\statement_table.html:56
msgid "No statement"
msgstr "Aucun relevé"
#: .\statement\templates\statement\statement_table.html:64
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,13 +44,12 @@ 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} desc = _("%(date)s statement") % {"date": self.date}
if hasattr(self, "account"):
return f"{desc} {self.account}"
return desc
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if Statement.objects.filter(id=self.id).exists(): if Statement.objects.filter(id=self.id).exists():
@ -54,39 +57,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,7 +0,0 @@
{% extends "main/confirm_delete.html" %}
{% load i18n %}
{% block additionalinfo %}
<p>
{% blocktranslate %}This will delete all transactions in this statement.{% endblocktranslate %}
</p>
{% endblock %}

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

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

View file

@ -1,15 +1,27 @@
{% extends "main/form/base.html" %} {% extends "main/form/base.html" %}
{% load i18n main_extras statement_extras category %} {% load i18n main_extras category %}
{% block title_new %} {% block title_new %}
{% translate "Create statement" %} {% translate "Create statement" %}
{% endblock %} {% endblock %}
{% block h2_new %} {% block h2_new %}
{% translate "New statement" %} {% translate "New statement" %}
{% endblock %} {% endblock %}
{% block h2 %}
{{ form.instance.sum|check:form.instance.diff }}
{{ form.instance }}
{% endblock %}
{% block pre %} {% block pre %}
{% if not instance|adding %} {% if account %}
<p> <p>
<a href="{{ instance.get_absolute_url }}">{{ "arrow-go-back"|remix }}{% translate "Back" %}</a> <a href="{{ account.get_absolute_url }}">{{ account.icon|remix }}{{ account }}</a>
</p> </p>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block tables %}
{% if not form.instance|adding %}
<h3>{% translate "Categories" %}</h3>
{% category_plot transactions %}
<h3>{% translate "Transactions" %} ({{ form.instance.sum|pmvalue }} / {{ form.instance.diff|pmvalue }})</h3>
{% include "transaction/transaction_table.html" %}
{% endif %}
{% endblock %}

View file

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

View file

@ -1,33 +1,66 @@
{% load i18n main_extras statement_extras %} {% load main_extras %}
<div id="statements"> {% load i18n %}
<ul class="statements">
{% if new_statement_url %} {% if new_statement_url %}
<li class="new"> <p>
<span> <a href="{{ new_statement_url }}">{% translate "Create statement" %}</a>
<a href="{{ new_statement_url }}">{{ "file-add"|remix }}{% translate "Create statement" %}</a> </p>
</span>
</li>
{% endif %} {% endif %}
{% for sta in statements %} <div id="statements" class="table">
<li> <table class="full-width {% if statements_url %}more{% endif %}">
<a href="{{ sta.get_absolute_url }}" class="date"><span>{{ sta.date|date:"d" }}</span> <colgroup>
<span>{{ sta.date|date:"M y" }}</span></a> <col class="icon" span="2">
<span class="value"><span>{{ sta.value|value }}</span> <col class="date">
<span>{{ sta.diff|pmvalue }}</span></span>
{% if not account %} {% if not account %}
<span class="account"> <col class="icon">
<a href="{{ sta.account.get_absolute_url }}">{{ sta.account.icon|remix }}{{ sta.account }}</a> <col class="desc">
</span>
{% endif %} {% endif %}
<span class="icon">{{ sta.sum|check:sta.diff }}</span> <col class="value" span="3">
</li> </colgroup>
<thead>
<th>{{ "check"|remix }}</th>
<th>{{ "attachment"|remix }}</th>
<th>{% translate "Date" %}</th>
{% if not account %}
<th colspan="2">{% translate "Account" %}</th>
{% endif %}
<th>{% translate "Value" %}</th>
<th>{% translate "Difference" %}</th>
<th>{% translate "Transactions" %}</th>
</thead>
<tbody>
{% for snap in statements %}
<tr>
{% if snap.sum == snap.diff %}
<td class="c green">{{ "check"|remix }}</td>
{% else %}
<td class="c red">{{ "close"|remix }}</td>
{% endif %}
<td class="c">
{% if snap.file %}<a href="{{ snap.file.url }}">{{ "attachment"|remix }}</a>{% endif %}
</td>
<th class="date" scope="row">
<a href="{{ snap.get_absolute_url }}">{{ snap.date|date:"Y-m-d" }}</a>
</th>
{% if not account %}
<td class="r">{{ snap.account.icon|remix }}</td>
<td>
<a href="{{ snap.account.get_absolute_url }}">{{ snap.account }}</a>
</td>
{% endif %}
<td class="value">{{ snap.value|value }}</td>
<td class="value">{{ snap.diff|pmvalue }}</td>
<td class="value">{{ snap.sum|pmvalue }}</td>
</tr>
{% empty %}
<tr>
<td class="empty" colspan="{% if account %}6{% else %}8{% endif %}">{% translate "No statement" %}</td>
</tr>
{% endfor %} {% endfor %}
{% if statements_url %} </tbody>
<li class="more"> </table>
<span>
<a href="{{ statements_url }}">{{ "gallery-view"|remixnl }}{% translate "View all statements" %}</a>
</span>
</li>
{% endif %}
</ul>
</div> </div>
{% if statements_url %}
<p>
<a href="{{ statements_url }}">{% translate "View all statements" %}</a>
</p>
{% endif %}

View file

@ -1,26 +0,0 @@
from django import template
from main.templatetags.main_extras import remix
register = template.Library()
@register.filter
def check(s, diff):
if s is None:
s = 0
if s == diff:
return remix("check", "green")
else:
return remix("close", "red")
@register.inclusion_tag("statement/statement_table.html")
def statement_table(statements, **kwargs):
if (n_max := kwargs.get("n_max")) is not None:
if statements.count() <= n_max:
del kwargs["statements_url"]
statements = statements[:n_max]
return kwargs | {
"statements": statements,
}

View file

@ -6,10 +6,7 @@ from . import views
urlpatterns = [ urlpatterns = [
path("list", views.StatementListView.as_view(), name="statements"), path("list", views.StatementListView.as_view(), name="statements"),
path("new", views.StatementCreateView.as_view(), name="new_statement"), path("new", views.StatementCreateView.as_view(), name="new_statement"),
path("<statement>", views.StatementDetailView.as_view(), name="statement"), path("<statement>", views.StatementUpdateView.as_view(), name="statement"),
path(
"<statement>/edit", views.StatementUpdateView.as_view(), name="edit_statement"
),
path( path(
"<statement>/transaction/list", "<statement>/transaction/list",
views.StatementTListView.as_view(), views.StatementTListView.as_view(),

View file

@ -1,13 +1,7 @@
from account.models import Account from account.models import Account
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 main.views import ( from main.views import NummiCreateView, NummiDeleteView, NummiListView, NummiUpdateView
NummiCreateView,
NummiDeleteView,
NummiDetailView,
NummiListView,
NummiUpdateView,
)
from transaction.views import TransactionListView from transaction.views import TransactionListView
from .forms import StatementForm from .forms import StatementForm
@ -58,33 +52,13 @@ class StatementUpdateView(NummiUpdateView):
class StatementDeleteView(NummiDeleteView): class StatementDeleteView(NummiDeleteView):
template_name = "statement/confirm_delete.html"
model = Statement model = Statement
pk_url_kwarg = "statement" pk_url_kwarg = "statement"
class StatementDetailView(NummiDetailView):
model = Statement
pk_url_kwarg = "statement"
context_object_name = "statement"
def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs)
statement = data.get("statement")
return data | {
"account": statement.account,
"new_transaction_url": reverse_lazy(
"new_transaction", kwargs={"statement": statement.pk}
),
"transactions": statement.transaction_set.all(),
}
class StatementListView(NummiListView): class StatementListView(NummiListView):
model = Statement model = Statement
context_object_name = "statements" context_object_name = "statements"
paginate_by = 32
class StatementMixin: class StatementMixin:

View file

@ -1,14 +1,8 @@
import json from category.models import Category
from main.forms import NummiFileInput, NummiForm
from account.forms import AccountSelect from statement.models import Statement
from category.forms import CategorySelect
from django import forms
from django.utils.translation import gettext_lazy as _
from main.forms import DatalistInput, NummiFileInput, NummiForm
from statement.forms import StatementSelect
from .models import Invoice, Transaction from .models import Invoice, Transaction
from .utils import get_datalist
class TransactionForm(NummiForm): class TransactionForm(NummiForm):
@ -25,87 +19,15 @@ class TransactionForm(NummiForm):
"payment", "payment",
"description", "description",
] ]
widgets = {
"statement": StatementSelect(),
"category": CategorySelect(),
"name": DatalistInput(),
"trader": DatalistInput(),
"payment": DatalistInput(),
}
meta_fieldsets = [
[
["statement"],
["name"],
["value"],
[
"date",
"real_date",
],
["category"],
["trader", "payment"],
],
[["description"]],
]
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 = Category.objects.filter(user=_user)
self.fields["category"].queryset = self.fields["category"].queryset.filter( self.fields["statement"].queryset = Statement.objects.filter(user=_user)
user=_user
)
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["category"].widget.attrs |= {
"class": "category",
"data-icons": json.dumps(
{
str(cat.id): cat.icon
for cat in self.fields["category"].queryset.only("id", "icon")
}
),
}
if _autocomplete:
self.autocomplete = {
"field": "name",
"data": {
t.name: [
{
"field": "value",
"value": t.value,
},
{
"field": "category",
"value": t.category.id if t.category else "",
},
{
"field": "trader",
"value": t.trader,
},
{
"field": "payment",
"value": t.payment,
},
]
for t in _user.transaction_set.order_by("name", "-date").distinct(
"name"
)
},
}
if _disable_statement: 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 +42,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")
}
),
}

Some files were not shown because too many files have changed in this diff Show more