Compare commits

..

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

146 changed files with 9116 additions and 4527 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

6
.gitignore vendored
View file

@ -1,3 +1,7 @@
/env*
env*
__pycache__
*.pkg.tar.zst
/nummi-git/
/pkg/
/src/
/media

View file

@ -1,10 +1,4 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: check-added-large-files
- id: no-commit-to-branch
args: ["--branch", "main"]
- repo: https://github.com/PyCQA/isort
rev: 5.12.0
hooks:
@ -23,9 +17,9 @@ repos:
rev: v1.23.3
hooks:
- id: djlint-django
args: ["--reformat", "--lint", "--quiet"]
args: ["--reformat"]
- repo: https://github.com/pre-commit/mirrors-prettier
rev: "v3.0.0-alpha.6"
hooks:
- id: prettier
types_or: ["css", "javascript", "svg"]
types_or: ["css", "javascript"]

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,5 +1,5 @@
pkgname=nummi
pkgver=v0.9.1
pkgname=nummi-git
pkgver=r298.2c95379
pkgrel=1
pkgdesc="Web-based accounting interface"
arch=("any")
@ -9,20 +9,17 @@ depends=(
"gunicorn"
"python-django"
"python-toml"
"python-psycopg"
"python-dateutil"
"python-pypdf"
"python-psycopg2"
)
makedepends=("git")
optdepends=("postgresql: database")
backup=("etc/${pkgname}/config.toml")
_tag=bd6447606b8fb6a8f5006ef504f73618b179f9ac
backup=("etc/${pkgname%-git}/config.toml")
source=(
"${pkgname}::git+https://git.edgarpierre.fr/edpibu/nummi?signed#tag=$_tag"
"${pkgname}.service"
"${pkgname}.tmpfiles"
"${pkgname}.sysusers"
"${pkgname}.nginx"
"${pkgname}::git+https://git.edgarpierre.fr/edpibu/nummi"
"${pkgname%-git}.service"
"${pkgname%-git}.tmpfiles"
"${pkgname%-git}.sysusers"
"${pkgname%-git}.nginx"
"config.toml"
)
b2sums=('SKIP'
@ -34,16 +31,16 @@ b2sums=('SKIP'
pkgver() {
cd "$pkgname"
git describe
printf "r%s.%s" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
}
package() {
install -Dm644 ${pkgname}.service -t "${pkgdir}"/usr/lib/systemd/system/
install -Dm644 ${pkgname}.tmpfiles "${pkgdir}"/usr/lib/tmpfiles.d/${pkgname}.conf
install -Dm644 ${pkgname}.sysusers "${pkgdir}"/usr/lib/sysusers.d/${pkgname}.conf
install -Dm644 ${pkgname}.nginx "${pkgdir}"/etc/nginx/sites-enabled/${pkgname}.conf
install -Dm600 -o nummi -g nummi config.toml -t "${pkgdir}"/etc/${pkgname}/
install -Dm644 ${pkgname%-git}.service -t "${pkgdir}"/usr/lib/systemd/system/
install -Dm644 ${pkgname%-git}.tmpfiles "${pkgdir}"/usr/lib/tmpfiles.d/${pkgname%-git}.conf
install -Dm644 ${pkgname%-git}.sysusers "${pkgdir}"/usr/lib/sysusers.d/${pkgname%-git}.conf
install -Dm644 ${pkgname%-git}.nginx "${pkgdir}"/etc/nginx/sites-enabled/${pkgname%-git}.conf
install -Dm750 -o nummi -g nummi config.toml -t "${pkgdir}"/etc/${pkgname%-git}/
cd ${pkgname}/${pkgname}
find * -type f -exec install -Dm0644 "{}" "${pkgdir}/usr/share/webapps/${pkgname}/{}" \;
cd ${pkgname}/${pkgname%-git}
find * -type f -exec install -Dm0644 "{}" "${pkgdir}/usr/share/webapps/${pkgname%-git}/{}" \;
}

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 IconInput, NummiForm
from main.forms import NummiForm
from .models import Account
@ -11,12 +10,4 @@ class AccountForm(NummiForm):
"name",
"icon",
"default",
"archived",
]
widgets = {
"icon": IconInput(),
}
class AccountSelect(Select):
template_name = "account/forms/widgets/account.html"

View file

@ -1,75 +0,0 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-01-04 18:51+0100\n"
"PO-Revision-Date: 2023-04-22 15:17+0200\n"
"Last-Translator: Edgar P. Burkhart <traduction@edgarpierre.fr>\n"
"Language-Team: \n"
"Language: fr_FR\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: Poedit 3.2.2\n"
#: .\account\models.py:12 .\account\models.py:45 .\account\models.py:53
#: .\account\templates\account\account_list.html:9
msgid "Account"
msgstr "Compte"
#: .\account\models.py:12
msgid "Name"
msgstr "Nom"
#: .\account\models.py:16
msgid "Icon"
msgstr "Icône"
#: .\account\models.py:18
msgid "Default"
msgstr "Défaut"
#: .\account\models.py:19
msgid "Archived"
msgstr "Archivé"
#: .\account\models.py:46 .\account\templates\account\account_list.html:12
msgid "Accounts"
msgstr "Comptes"
#: .\account\templates\account\account_detail.html: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_table.html:35
msgid "Create account"
msgstr "Créer un compte"
#: .\account\templates\account\account_form.html:8
msgid "New account"
msgstr "Nouveau compte"
#: .\account\templates\account\account_table.html:16
msgid "All accounts"
msgstr "Tous les comptes"
#: .\account\templates\account\account_table.html:26
msgid "Show archived"
msgstr "Afficher archivés"
#~ msgid "Transactions"
#~ msgstr "Transactions"

View file

@ -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 django.apps import apps
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from main.models import NummiModel
from main.models import UserModel
class Account(NummiModel):
class Account(UserModel):
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
name = models.CharField(max_length=64, default=_("Account"), verbose_name=_("Name"))
icon = models.SlugField(
@ -16,7 +15,6 @@ class Account(NummiModel):
verbose_name=_("Icon"),
)
default = models.BooleanField(default=False, verbose_name=_("Default"))
archived = models.BooleanField(default=False, verbose_name=_("Archived"))
def save(self, *args, **kwargs):
if self.default:
@ -34,19 +32,13 @@ class Account(NummiModel):
def get_delete_url(self):
return reverse("del_account", args=(self.pk,))
@property
def transactions(self):
return apps.get_model("transaction", "Transaction").objects.filter(
statement__account=self
)
class Meta:
ordering = ["-default", "archived", "name"]
ordering = ["-default", "name"]
verbose_name = _("Account")
verbose_name_plural = _("Accounts")
class AccountModel(NummiModel):
class AccountModel(UserModel):
account = models.ForeignKey(
Account,
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" %}
{% endblock %}
{% block h2 %}{{ form.instance.icon|remix }}{{ form.instance }}{% endblock %}
{% block tables %}
{% if not form.instance|adding %}
<h3>{% translate "Statements" %}</h3>
{% include "main/table/statement.html" %}
{% endif %}
{% if transactions %}
<h3>{% translate "Transactions" %}</h3>
{% include "main/table/transaction.html" %}
<h3>{% translate "History" %}</h3>
{% include "main/plot/history.html" %}
{% 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 statement.views import StatementCreateView
from transaction.views import TransactionMonthView
from . import views
urlpatterns = [
path("list", views.AccountListView.as_view(), name="accounts"),
path("new", views.AccountCreateView.as_view(), name="new_account"),
path("<account>", views.AccountDetailView.as_view(), name="account"),
path("<account>/edit", views.AccountUpdateView.as_view(), name="edit_account"),
path("", views.AccountCreateView.as_view(), name="new_account"),
path("<account>", views.AccountUpdateView.as_view(), name="account"),
path(
"<account>/transactions",
views.AccountTListView.as_view(),
@ -28,4 +27,9 @@ urlpatterns = [
views.AccountDeleteView.as_view(),
name="del_account",
),
path(
"<account>/history/<int:year>/<int:month>",
TransactionMonthView.as_view(),
name="transaction_month",
),
]

View file

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

View file

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

View file

@ -1,83 +0,0 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-01-04 18:51+0100\n"
"PO-Revision-Date: 2023-04-22 15:18+0200\n"
"Last-Translator: Edgar P. Burkhart <traduction@edgarpierre.fr>\n"
"Language-Team: \n"
"Language: fr_FR\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: Poedit 3.2.2\n"
#: .\category\models.py:12 .\category\models.py:32
#: .\category\templates\category\category_plot.html:13
msgid "Category"
msgstr "Catégorie"
#: .\category\models.py:12
msgid "Name"
msgstr "Nom"
#: .\category\models.py:17
msgid "Icon"
msgstr "Icône"
#: .\category\models.py:19
msgid "Budget"
msgstr "Budget"
#: .\category\models.py:33
msgid "Categories"
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
msgid "Create category"
msgstr "Créer une catégorie"
#: .\category\templates\category\category_form.html:8
msgid "New category"
msgstr "Nouvelle catégorie"
#: .\category\templates\category\category_plot.html:14
msgid "Expenses"
msgstr "Dépenses"
#: .\category\templates\category\category_plot.html:15
msgid "Income"
msgstr "Revenus"
#: .\category\templates\category\category_plot.html:58
msgid "No transaction"
msgstr "Aucune transaction"
#: .\category\templates\category\category_plot.html:66
msgid "Total"
msgstr "Total"
#: .\category\templates\category\category_plot.html:89
msgid "Expected total"
msgstr "Total attendu"

View file

@ -3,10 +3,10 @@ from uuid import uuid4
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from main.models import NummiModel
from main.models import UserModel
class Category(NummiModel):
class Category(UserModel):
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
name = models.CharField(
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,13 @@
{% translate "New category" %}
{% endblock %}
{% block h2 %}{{ form.instance.icon|remix }}{{ form.instance }}{% endblock %}
{% block tables %}
{% if transactions %}
<h3>{% translate "Transactions" %}</h3>
{% include "main/table/transaction.html" %}
{% endif %}
{% if history.data %}
<h3>{% translate "History" %}</h3>
{% include "main/plot/history.html" %}
{% endif %}
{% endblock %}

View file

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

@ -1,34 +0,0 @@
from django import template
from django.db import models
from django.db.models.functions import Greatest
register = template.Library()
@register.inclusion_tag("category/category_plot.html", takes_context=True)
def category_plot(context, transactions, **kwargs):
kwargs.setdefault("account", context.get("account"))
if not kwargs.get("account"):
transactions = transactions.exclude(category__budget=False)
categories = (
transactions.values("category", "category__name", "category__icon")
.annotate(
sum=models.Sum("value"),
sum_m=models.Sum("value", filter=models.Q(value__lt=0)),
sum_p=models.Sum("value", filter=models.Q(value__gt=0)),
)
.order_by("-sum")
)
return (
kwargs
| {
"categories": categories,
}
| categories.aggregate(
max=Greatest(-models.Sum("sum_m"), models.Sum("sum_p")),
total_m=models.Sum("sum_m"),
total_p=models.Sum("sum_p"),
total=models.Sum("sum"),
)
)

View file

@ -1,15 +1,20 @@
from django.urls import path
from transaction.views import TransactionMonthView
from . import views
urlpatterns = [
path("new", views.CategoryCreateView.as_view(), name="new_category"),
path("<category>", views.CategoryDetailView.as_view(), name="category"),
path("<category>/edit", views.CategoryUpdateView.as_view(), name="edit_category"),
path("", views.CategoryCreateView.as_view(), name="new_category"),
path("<category>", views.CategoryUpdateView.as_view(), name="category"),
path(
"<category>/transactions",
views.CategoryTListView.as_view(),
name="category_transactions",
),
path("<category>/delete", views.CategoryDeleteView.as_view(), name="del_category"),
path(
"<category>/history/<int:year>/<int:month>",
TransactionMonthView.as_view(),
name="transaction_month",
),
]

View file

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

View file

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

View file

@ -1,38 +0,0 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-01-04 18:51+0100\n"
"PO-Revision-Date: 2023-04-22 15:18+0200\n"
"Last-Translator: Edgar P. Burkhart <traduction@edgarpierre.fr>\n"
"Language-Team: \n"
"Language: fr_FR\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: Poedit 3.2.2\n"
#: .\history\templates\history\plot.html:10
msgid "Year"
msgstr "Année"
#: .\history\templates\history\plot.html:13
msgid "Total"
msgstr "Total"
#: .\history\templates\history\plot.html:56
msgid "Month"
msgstr "Mois"
#: .\history\templates\history\plot.html:57
msgid "Expenses"
msgstr "Dépenses"
#: .\history\templates\history\plot.html:58
msgid "Income"
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,92 +0,0 @@
{% load main_extras %}
{% load history_extras %}
{% load transaction_extras %}
{% load i18n %}
<div class="calendar">
<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">
<colgroup>
<col class="icon">
<col class="desc">
<col class="value">
<col span="2" class="bar">
<col class="value">
</colgroup>
<thead>
<tr>
<th scope="col">{{ "expand-up-down"|remix }}</th>
<th scope="col">{% translate "Month" %}</th>
<th scope="col" colspan="2">{% translate "Expenses" %}</th>
<th scope="col" colspan="2">{% translate "Income" %}</th>
</tr>
</thead>
<tbody>
{% for date in history.data reversed %}
{% ifchanged %}
{% if date.sum_m or date.sum_p %}
<tr {% if not date.month.month|divisibleby:"2" %}class="even"{% endif %}>
<td class="icon">{% up_down_icon date.sum %}</td>
<th class="date" scope="row">
<a href="{% history_url year=date.month.year month=date.month.month account=account.id category=category.id %}">
{% if year %}
{{ date.month|date:"F"|capfirst }}
{% else %}
{{ date.month|date:"Y-m" }}
{% endif %}
</a>
</th>
<td class="value">{{ date.sum_m|pmrvalue }}</td>
<td class="bar m">{% plot_bar date.sum date.sum_m history.max.pm %}</td>
<td class="bar p">{% plot_bar date.sum date.sum_p history.max.pm %}</td>
<td class="value">{{ date.sum_p|pmrvalue }}</td>
</tr>
{% else %}
<tr class="empty">
<td colspan="6" class="empty"></td>
</tr>
{% endif %}
{% endifchanged %}
{% endfor %}
</tbody>
</table>
</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 +0,0 @@
import datetime
from django.db.models import Q, Sum
from django.db.models.functions import Abs, Greatest, TruncMonth
def history(transaction_set):
if not transaction_set.exists():
return None
_transaction_month = transaction_set.values(month=TruncMonth("date")).order_by(
"-date"
)
_first_month = _transaction_month.last()["month"]
_last_month = _transaction_month.first()["month"]
_history = (
_transaction_month.annotate(
sum_p=Sum("value", filter=Q(value__gt=0), default=0),
sum_m=Sum("value", filter=Q(value__lt=0), default=0),
sum=Sum("value"),
)
.annotate(max_sum=Greatest("sum_p", Abs("sum_m")))
.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 {
"data": _data,
"max": {
"pm": 125
* _history.order_by("-max_sum")[len(_history.exclude(max_sum=0)) // 10][
"max_sum"
]
/ 100,
"sum": 125
* _history.annotate(abs_sum=Abs("sum")).order_by("-abs_sum")[
len(_history) // 10
]["abs_sum"]
/ 100,
},
}

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.forms.widgets import TextInput
from .utils import get_icons
class NummiFileInput(forms.ClearableFileInput):
@ -10,42 +7,6 @@ class NummiFileInput(forms.ClearableFileInput):
class NummiForm(forms.ModelForm):
template_name = "main/form/form_base.html"
meta_fieldsets = []
def __init__(self, *args, **kwargs):
kwargs.pop("user", None)
def __init__(self, *args, user, **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

Binary file not shown.

View file

@ -0,0 +1,324 @@
# NUMMI.
# Copyright (C) 2022
# This file is distributed under the same license as the nummi package.
# edpibu <git@edgarpierre.fr>, 2022.
#
msgid ""
msgstr ""
"Project-Id-Version: 0.0.1\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-04-22 09:41+0200\n"
"PO-Revision-Date: 2022-12-21 17:30+0100\n"
"Last-Translator: edpibu <git@edgarpierre.fr>\n"
"Language-Team: edpibu <git@edgarpierre.fr>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
#: .\forms.py:95
msgid "Add transactions"
msgstr "Ajouter des transactions"
#: .\forms.py:113 .\templates\main\base.html:70
#: .\templates\main\form\search.html:5 .\templates\main\list\transaction.html:9
#: .\templates\main\list\transaction.html:26 .\templates\main\search.html:6
#: .\templates\main\search.html:18
msgid "Search"
msgstr "Rechercher"
#: .\models.py:18
msgid "User"
msgstr "Utilisateur"
#: .\models.py:37 .\models.py:71 .\models.py:79 .\models.py:229
#: .\templates\main\table\snapshot.html:24
#: .\templates\main\table\transaction.html:35
msgid "Account"
msgstr "Compte"
#: .\models.py:37 .\models.py:89 .\models.py:200 .\models.py:270
#: .\templates\main\table\invoice.html:9
#: .\templates\main\table\transaction.html:28
msgid "Name"
msgstr "Nom"
#: .\models.py:41 .\models.py:94
msgid "Icon"
msgstr "Icône"
#: .\models.py:43
msgid "Default"
msgstr "Défaut"
#: .\models.py:72 .\templates\main\index.html:16
msgid "Accounts"
msgstr "Comptes"
#: .\models.py:89 .\models.py:113 .\models.py:219
#: .\templates\main\plot\category.html:14
#: .\templates\main\table\transaction.html:32
msgid "Category"
msgstr "Catégorie"
#: .\models.py:96
msgid "Budget"
msgstr "Budget"
#: .\models.py:114 .\templates\main\form\snapshot.html:23
#: .\templates\main\index.html:30
msgid "Categories"
msgstr "Catégories"
#: .\models.py:119
msgid "End date"
msgstr "Date de fin"
#: .\models.py:121
msgid "Start date"
msgstr "Date de début"
#: .\models.py:124
msgid "End value"
msgstr "Valeur de fin"
#: .\models.py:127
msgid "Start value"
msgstr "Valeur de début"
#: .\models.py:133 .\templates\main\table\snapshot.html:27
msgid "Difference"
msgstr "Différence"
#: .\models.py:140
msgid "Transaction difference"
msgstr "Différence des transactions"
#: .\models.py:146 .\models.py:275 .\templates\main\form\fileinput.html:8
#: .\templates\main\table\invoice.html:10
#: .\templates\main\table\invoice.html:21
msgid "File"
msgstr "Fichier"
#: .\models.py:153
#, python-format
msgid "%(date)s statement"
msgstr "Relevé du %(date)s"
#: .\models.py:193 .\models.py:224
msgid "Statement"
msgstr "Relevé"
#: .\models.py:194 .\templates\main\form\account.html:13
#: .\templates\main\list\snapshot.html:6 .\templates\main\list\snapshot.html:20
msgid "Statements"
msgstr "Relevés"
#: .\models.py:200 .\models.py:263
msgid "Transaction"
msgstr "Transaction"
#: .\models.py:202
msgid "Description"
msgstr "Description"
#: .\models.py:204 .\templates\main\table\snapshot.html:26
#: .\templates\main\table\transaction.html:29
msgid "Value"
msgstr "Valeur"
#: .\models.py:206 .\templates\main\table\snapshot.html:22
#: .\templates\main\table\transaction.html:27
msgid "Date"
msgstr "Date"
#: .\models.py:207
msgid "Real date"
msgstr "Date réelle"
#: .\models.py:209 .\templates\main\table\transaction.html:30
msgid "Trader"
msgstr "Commerçant"
#: .\models.py:212
msgid "Payment"
msgstr "Paiement"
#: .\models.py:264 .\templates\main\base.html:44
#: .\templates\main\form\account.html:17 .\templates\main\form\category.html:13
#: .\templates\main\form\snapshot.html:27 .\templates\main\index.html:26
#: .\templates\main\list\transaction.html:6
#: .\templates\main\list\transaction.html:23
#: .\templates\main\month\transaction.html:15
#: .\templates\main\table\snapshot.html:28
msgid "Transactions"
msgstr "Transactions"
#: .\models.py:270 .\models.py:307
msgid "Invoice"
msgstr "Facture"
#: .\models.py:308 .\templates\main\form\transaction.html:18
msgid "Invoices"
msgstr "Factures"
#: .\templates\main\base.html:27
msgid "Skip to main content"
msgstr "Aller au contenu principal"
#: .\templates\main\base.html:33
msgid "Home"
msgstr "Accueil"
#: .\templates\main\base.html:38 .\templates\main\index.html:40
msgid "Snapshots"
msgstr "Relevés"
#: .\templates\main\base.html:50 .\templates\main\form\account.html:5
msgid "Create account"
msgstr "Créer un compte"
#: .\templates\main\base.html:55 .\templates\main\form\snapshot.html:5
msgid "Create snapshot"
msgstr "Créer un relevé"
#: .\templates\main\base.html:60 .\templates\main\form\category.html:5
msgid "Create category"
msgstr "Créer une catégorie"
#: .\templates\main\base.html:65 .\templates\main\form\transaction.html:4
#: .\templates\main\table\transaction.html:5
msgid "Create transaction"
msgstr "Créer une transaction"
#: .\templates\main\base.html:73
msgid "Log out"
msgstr "Se déconnecter"
#: .\templates\main\base.html:78 .\templates\main\form\login.html:6
msgid "Log in"
msgstr "Se connecter"
#: .\templates\main\base.html:83
msgid "Logged in as <strong>%(user)s</strong>"
msgstr "Connecté en tant que <strong>%(user)s</strong>"
#: .\templates\main\confirm_delete.html:19
#, python-format
msgid "Are you sure you want do delete <strong>%(object)s</strong> ?"
msgstr "Êtes-vous sûr de vouloir supprimer <strong>%(object)s</strong> ?"
#: .\templates\main\confirm_delete.html:23
msgid "Cancel"
msgstr "Annuler"
#: .\templates\main\confirm_delete.html:24
msgid "Confirm"
msgstr "Confirmer"
#: .\templates\main\form\account.html:8
msgid "New account"
msgstr "Nouveau compte"
#: .\templates\main\form\account.html:19 .\templates\main\form\category.html:15
#: .\templates\main\index.html:44
msgid "History"
msgstr "Historique"
#: .\templates\main\form\category.html:8
msgid "New category"
msgstr "Nouvelle catégorie"
#: .\templates\main\form\form_base.html:29
#: .\templates\main\table\invoice.html:11
#: .\templates\main\table\invoice.html:24
msgid "Delete"
msgstr "Supprimer"
#: .\templates\main\form\form_base.html:31
msgid "Reset"
msgstr "Réinitialiser"
#: .\templates\main\form\form_base.html:33
msgid "Create"
msgstr "Créer"
#: .\templates\main\form\form_base.html:35
msgid "Save"
msgstr "Enregistrer"
#: .\templates\main\form\invoice.html:4 .\templates\main\table\invoice.html:37
msgid "Create invoice"
msgstr "Créer une facture"
#: .\templates\main\form\invoice.html:7
msgid "New invoice"
msgstr "Nouvelle facture"
#: .\templates\main\form\snapshot.html:8
msgid "New snapshot"
msgstr "Nouveau relevé"
#: .\templates\main\form\transaction.html:7
msgid "New transaction"
msgstr "Nouvelle transaction"
#: .\templates\main\list\snapshot.html:27
msgid "No snapshots to show"
msgstr "Aucun relevé à afficher"
#: .\templates\main\list\transaction.html:33
msgid "No transactions to show"
msgstr "Aucune transaction à afficher"
#: .\templates\main\login.html:14
msgid "Log In"
msgstr "Se connecter"
#: .\templates\main\plot\category.html:15 .\templates\main\plot\history.html:16
msgid "Expenses"
msgstr "Dépenses"
#: .\templates\main\plot\category.html:16 .\templates\main\plot\history.html:17
msgid "Income"
msgstr "Revenus"
#: .\templates\main\plot\history.html:15
msgid "Month"
msgstr "Mois"
#: .\templates\main\table\invoice.html:30
msgid "No invoice"
msgstr "Aucune facture"
#: .\templates\main\table\snapshot.html:5
msgid "Create statement"
msgstr "Créer un relevé"
#: .\templates\main\table\snapshot.html:60
msgid "View all statements"
msgstr "Voir tous les relevés"
#: .\templates\main\table\transaction.html:73
msgid "View all transactions"
msgstr "Voir toutes les transactions"
#~ msgid "New statement"
#~ msgstr "Nouveau relevé"
#~ msgid "Create Account"
#~ msgstr "Créer Compte"
#~ msgid "Create Snapshot"
#~ msgstr "Créer Relevé"
#~ msgid "Create Category"
#~ msgstr "Créer Catégorie"
#~ msgid "Create Transaction"
#~ msgstr "Créer Transaction"
#, python-format
#~ msgid "Create %(name)s"
#~ msgstr "Créer %(name)s"

View file

@ -1,134 +0,0 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-01-04 18:51+0100\n"
"PO-Revision-Date: 2023-04-23 08:03+0200\n"
"Last-Translator: Edgar P. Burkhart <traduction@edgarpierre.fr>\n"
"Language-Team: \n"
"Language: fr_FR\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: Poedit 3.2.2\n"
#: .\main\models.py:10
msgid "User"
msgstr "Utilisateur"
#: .\main\templates\main\base.html:28
msgid "Skip to main content"
msgstr "Aller au contenu principal"
#: .\main\templates\main\base.html:35
msgid "Home"
msgstr "Accueil"
#: .\main\templates\main\base.html:42 .\main\templates\main\index.html:17
msgid "Statements"
msgstr "Relevés"
#: .\main\templates\main\base.html:49
msgid "Transactions"
msgstr "Transactions"
#: .\main\templates\main\base.html:57 .\main\templates\main\list.html:10
#: .\main\templates\main\list.html:37
msgid "Search"
msgstr "Rechercher"
#: .\main\templates\main\base.html:62
#, python-format
msgid "Logged in as <strong>%(user)s</strong>"
msgstr "Connecté en tant que <strong>%(user)s</strong>"
#: .\main\templates\main\base.html:66
msgid "Log out"
msgstr "Se déconnecter"
#: .\main\templates\main\base.html:74 .\main\templates\main\form\login.html:5
#: .\main\templates\main\login.html:11
msgid "Log in"
msgstr "Se connecter"
#: .\main\templates\main\confirm_delete.html:15
#, python-format
msgid "Are you sure you want do delete <strong>%(object)s</strong> ?"
msgstr "Êtes-vous sûr de vouloir supprimer <strong>%(object)s</strong> ?"
#: .\main\templates\main\confirm_delete.html:20
msgid "Cancel"
msgstr "Annuler"
#: .\main\templates\main\confirm_delete.html:21
msgid "Confirm"
msgstr "Confirmer"
#: .\main\templates\main\form\fileinput.html:6
msgid "File"
msgstr "Fichier"
#: .\main\templates\main\form\form_base.html:46
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"
msgstr "Supprimer"
#: .\main\templates\main\index.html:13
msgid "Accounts"
msgstr "Comptes"
#: .\main\templates\main\index.html:23
msgid "Categories"
msgstr "Catégories"
#: .\main\templates\main\index.html:29
msgid "Create category"
msgstr "Créer une catégorie"
#: .\main\templates\main\index.html:34
msgid "History"
msgstr "Historique"
#: .\main\views.py:54
msgid "was created successfully"
msgstr "a été créé avec succès"
#~ msgid "Account"
#~ msgstr "Compte"
#~ msgid "Balance"
#~ msgstr "Solde"
#~ msgid "Edit"
#~ msgstr "Modifier"
#~ msgid "No account"
#~ msgstr "Aucun compte"
#~ msgid "Create account"
#~ msgstr "Créer un compte"
#~ msgid "Create statement"
#~ msgstr "Créer un relevé"
#~ 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.contrib.postgres.search import (
SearchQuery,
SearchRank,
SearchVector,
TrigramSimilarity,
)
from django.db import models
from django.utils.translation import gettext_lazy as _
class NummiQuerySet(models.QuerySet):
main_field = "name"
fields = dict()
def search(self, search):
return (
self.annotate(
rank=SearchRank(
sum(
(
SearchVector(field, weight=weight)
for field, weight in self.fields.items()
),
start=SearchVector(self.main_field, weight="A"),
),
SearchQuery(search, search_type="websearch"),
),
similarity=TrigramSimilarity(self.main_field, search),
)
.filter(models.Q(rank__gte=0.1) | models.Q(similarity__gte=0.3))
.order_by("-rank")
)
class NummiModel(models.Model):
class UserModel(models.Model):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
verbose_name=_("User"),
editable=False,
)
objects = NummiQuerySet.as_manager()
class Meta:
abstract = True

View file

@ -1,217 +1,62 @@
.drop-zone {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
grid-template-columns: 1fr;
grid-template-rows: 1fr;
align-items: center;
text-align: center;
color: transparent;
display: grid;
transition-property: backdrop-filter;
transition-duration: 750ms;
z-index: -1;
> span {
font-weight: 650;
font-size: 2rem;
transition-property: color;
transition-duration: inherit;
}
main.highlight > & {
z-index: 100;
backdrop-filter: blur(0.1rem);
> span {
color: var(--green);
}
}
}
form {
display: grid;
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 {
form ul.errorlist {
color: var(--red);
font-weight: 550;
list-style: none;
padding: 0;
list-style-type: "! ";
margin: 0;
}
.field {
display: grid;
grid-auto-rows: min-content;
align-items: center;
column-gap: 0.5rem;
ul.errorlist {
font-size: 0.8rem;
form > table > tbody > tr > th {
background: var(--bg-01);
background-clip: padding-box;
}
&:has(> textarea) {
grid-template-rows: min-content 1fr;
textarea {
resize: block;
}
}
&:has(> input[type="checkbox"]) {
grid-template-columns: min-content 1fr;
> label {
font-size: inherit;
grid-row: 1;
grid-column: 2;
padding: 0.5rem;
line-height: initial;
}
> input {
grid-row: 1;
grid-column: 1;
margin: 0.5rem;
}
&:has(> :focus) {
background: var(--bg-1);
}
}
> label {
font-size: 0.8rem;
line-height: 0.8rem;
z-index: 10;
}
> a {
padding: 0.5rem;
}
input,
select,
textarea {
form tbody input,
form tbody select,
form tbody textarea {
font: inherit;
line-height: initial;
border: none;
border: 1px solid transparent;
border-bottom: 1px solid var(--gray);
background: none;
z-index: 1;
padding: 0.5rem;
&:has(~ ul.errorlist) {
border-color: var(--red);
}
&.autocompleted:not(.mod) {
border-bottom-color: var(--green);
}
&:not([type="checkbox"]) {
background: var(--bg);
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;
font-feature-settings: var(--num);
}
&[name*="date"] {
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 {
.buttons input {
font: inherit;
cursor: pointer;
line-height: 1.5;
margin-left: var(--gap);
border-radius: var(--radius);
padding: 0 var(--gap);
border: var(--gray) 1px solid;
margin-right: 0.5rem;
margin-bottom: 0.5rem;
color: inherit;
&:hover {
cursor: pointer;
}
.buttons input:hover {
text-decoration: underline;
}
}
input[type="submit"] {
.buttons input[type="submit"] {
border: 0.1rem solid var(--green);
background: var(--green-1);
border-color: var(--green);
}
input[type="reset"] {
background: var(--bg-1);
.buttons input[type="reset"] {
border: 0.1rem solid var(--red);
background: var(--red-1);
}
a.del {
.buttons a.del {
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://cdn.jsdelivr.net/npm/remixicon@4.5.0/fonts/remixicon.css";
*,
*::before,
@ -24,7 +23,7 @@
--bg-inv: var(--theme-1);
--text-inv: #ffffffde;
--bg-1: #f0f0f0;
--bg-01: #f0f0f0;
--text-green: #296629;
--text-link: var(--text-green);
@ -40,8 +39,7 @@
--border: 0.5em;
--radius: 0.25em;
--default-ffs: "dlig", "ss01", "ss04";
--num: var(--default-ffs), "tnum", "case";
--num: "tnum", "ss01", "ss02", "case";
}
body {
@ -53,7 +51,6 @@ body {
display: grid;
grid-template-columns: max-content 1fr;
font-feature-settings: var(--default-ffs);
}
p {
@ -63,10 +60,9 @@ a {
color: var(--text-link);
text-decoration: none;
display: inline-block;
&:is(:hover, :focus) {
text-decoration: underline;
}
a:hover {
text-decoration: underline;
}
.red {
@ -81,33 +77,12 @@ a {
main,
nav,
footer {
padding: 2rem 1rem;
@media (width > 720px) {
padding: 2rem;
}
background: var(--bg);
}
main {
position: relative;
grid-column: 2;
grid-row: 1;
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 {
grid-column: 1;
@ -116,50 +91,41 @@ nav {
height: 100vh;
position: sticky;
top: 0;
overflow-y: auto;
background: var(--bg-1);
background: var(--bg-01);
line-height: 2rem;
h1 img {
}
nav h1 img {
height: 1cap;
}
ul {
nav ul {
list-style: none;
padding: 0;
margin: 0;
position: relative;
}
a {
&.skip-link {
nav .skip-link {
opacity: 0.8;
font-weight: 300;
&:is(:active, :focus) {
}
nav .skip-link:active,
nav .skip-link:focus {
opacity: initial;
font-weight: 500;
}
nav a {
display: block;
}
display: grid;
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 {
nav a.cur {
font-weight: 550;
[class^="ri-"] {
background: var(--text-link);
color: var(--bg);
}
nav a.cur::after {
content: "◎";
position: absolute;
right: 0;
}
}
}
:is(nav, main) > :first-child,
main > section:first-child > :first-child {
nav > :first-child,
main > :first-child {
margin-top: 0;
}
footer {
@ -168,46 +134,16 @@ footer {
font-weight: 250;
}
.pagination {
#pagination {
text-align: center;
font-feature-settings: var(--num);
a {
min-width: 1rem;
padding: 0 0.5rem;
&.cur {
}
#pagination a {
width: 2rem;
}
#pagination a.cur {
font-weight: 650;
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) {
@ -223,38 +159,13 @@ footer {
height: initial;
}
}
a.big-link {
margin-right: 1em;
}
[class^="ri-"] {
display: inline-block;
text-align: center;
margin-right: 0.5em;
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,
@ -274,299 +185,9 @@ h2 {
h3 {
font-size: 1.5rem;
}
main h2.new {
opacity: 0.8;
}
p {
margin: 0.5em 0;
}
ul.messages {
font-weight: 550;
list-style-type: none;
margin: 0;
margin-bottom: var(--gap);
background: var(--bg-1);
padding: 0;
li {
--message-color: var(--text);
padding: calc(var(--gap) / 2) var(--gap);
border-left: var(--message-color) solid var(--border);
[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 {
--message-color: var(--green);
}
&.msg-level-25 {
--message-color: var(--green-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);
}
}

View file

@ -0,0 +1,3 @@
.pagination .current {
font-feature-settings: "tnum", "ss01";
}

View file

@ -1,34 +1,52 @@
table.full-width col.bar {
width: auto;
}
.plot {
overflow-x: auto;
table {
min-width: 40rem;
}
td.bar {
.plot td.bar {
position: relative;
padding: 0;
overflow: hidden;
div {
}
.plot td.bar div {
position: absolute;
height: 0.5rem;
top: 0;
&:not(.tot) {
}
.plot td.bar div:not(.tot) {
width: 0;
box-sizing: border-box;
z-index: 1;
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;
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;
display: inline-block;
white-space: nowrap;
@ -39,85 +57,16 @@ table.full-width col.bar {
height: 1.5rem;
font-feature-settings: var(--num);
}
}
}
&.p div {
left: 0;
border-radius: 0 var(--radius) var(--radius) 0;
background: var(--green-1);
&.tot {
background: var(--green);
span {
.plot td.bar.p div.tot span {
left: 0;
}
}
}
&.m div {
right: 0;
border-radius: var(--radius) 0 0 var(--radius);
background: var(--red-1);
&.tot {
background: var(--red);
span {
.plot td.bar.m div.tot span {
right: 0;
}
}
}
}
&.history tbody tr {
background: initial;
}
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);
}
}
@media (width < 720px) {
.plot .bar {
width: 0;
overflow: hidden;
}
}

View file

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

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;
}
}
});
}

File diff suppressed because it is too large Load diff

Binary file not shown.

View file

@ -0,0 +1,22 @@
<svg version="1.1"
width="100"
height="100"
xmlns="http://www.w3.org/2000/svg">
<defs>
<rect id="basemask" x="5" y="-5" width="90" height="110" />
<rect id="line" x="45" y="-10" width="10" height="120" />
</defs>
<mask id="mask">
<use href="#basemask" fill="white" />
<use href="#line" fill="black" />
<use href="#line" transform="translate(20)" fill="black" />
</mask>
<circle cx="50" cy="50" r="50"
fill="#ffffff"
mask="url(#mask)"
transform-origin="center center" transform="rotate(10) scale(.8)" />
</svg>

After

Width:  |  Height:  |  Size: 539 B

View file

@ -6,10 +6,6 @@
<defs>
<rect id="basemask" x="5" y="-5" width="90" height="110" />
<rect id="line" x="45" y="-10" width="10" height="120" />
<linearGradient id="grad" gradientTransform="rotate(90)">
<stop offset="5%" stop-color="#66cc66" />
<stop offset="95%" stop-color="#cc6699" />
</linearGradient>
</defs>
<mask id="mask">
@ -19,8 +15,8 @@
</mask>
<circle cx="50" cy="50" r="50"
fill="url('#grad')"
mask="url('#mask')"
transform-origin="center center" transform="rotate(10)" />
fill="#000000"
mask="url(#mask)"
transform-origin="center center" transform="rotate(10) scale(.8)" />
</svg>

Before

Width:  |  Height:  |  Size: 707 B

After

Width:  |  Height:  |  Size: 539 B

Before After
Before After

View file

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

View file

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

View file

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

View file

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

View file

@ -1,56 +1,43 @@
{% load i18n %}
{% load main_extras %}
{% block fields %}
{% if form.autocomplete %}{{ form.autocomplete|json_script:"autocomplete" }}{% endif %}
{% if form.non_field_errors %}
<ul class="errorlist">
<ul class='errorlist'>
{% for error in form.non_field_errors %}<li>{{ error }}</li>{% endfor %}
</ul>
{% endif %}
{% if form.fieldsets %}
{% for group in form.fieldsets %}
<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">
<table>
<tbody>
{% for field in form %}
{% if field.errors %}<p class="error">{{ field.errors }}</p>{% endif %}
<div class="field">
{% if field.errors %}
<tr>
<td colspan="2">{{ field.errors }}</td>
</tr>
{% endif %}
<tr>
<th class="l">
<label for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
</div>
</th>
<td>{{ field }}</td>
</tr>
{% endfor %}
</div>
{% endif %}
<div class="buttons">
</tbody>
<tfoot>
<tr class="buttons">
<td colspan="2">
{% 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 %}
<a class="del" href="{{ form.instance.get_delete_url }}">{% translate "Delete" %}</a>
{% endif %}
{% endblock buttons %}
</div>
{% endblock fields %}
<input type="reset" value="{% translate "Reset" %}" />
{% if form.instance|adding %}
<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" %}
{% load i18n %}
{% block buttons %}
<input hidden value="{{ next }}" name="next">
<input type="submit" value="{% translate "Log in" %}">
<input type="reset">
{% endblock buttons %}
<input hidden value="{{ next }}" name="next" />
<input type="reset" />
<input type="submit" value="{% translate "Log in" %}" />
{% endblock %}

View file

@ -0,0 +1,6 @@
{% extends "main/form/form_base.html" %}
{% load i18n %}
{% block buttons %}
<input type="reset" />
<input type="submit" value="{% translate "Search" %}" />
{% 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,47 @@
{% extends "main/base.html" %}
{% load static %}
{% load main_extras account_extras history_extras statement_extras %}
{% load main_extras %}
{% load i18n %}
{% block link %}
{{ block.super }}
{% css "main/css/table.css" %}
{% css "main/css/plot.css" %}
{% endblock link %}
<link rel="stylesheet"
href="{% static 'main/css/table.css' %}"
type="text/css" />
<link rel="stylesheet"
href="{% static 'main/css/plot.css' %}"
type="text/css" />
{% endblock %}
{% block body %}
<div class="split">
<section>
{% if accounts %}
<h2>{% translate "Accounts" %}</h2>
{% account_table user.account_set.all index=True %}
</section>
<section>
<h2>{% translate "Statements" %}</h2>
{% url "statements" as s_url %}
{% statement_table statements statements_url=s_url %}
</section>
</div>
<section>
{% spaceless %}
<p>
{% for acc in accounts %}
<a class="big-link" href="{% url 'account' acc.id %}">{{ acc.icon|remix }}{{ acc }}</a>
{% endfor %}
</p>
{% endspaceless %}
{% endif %}
{% if transactions %}
<h2>{% translate "Transactions" %}</h2>
{% include "main/table/transaction.html" %}
{% endif %}
{% if categories %}
<h2>{% translate "Categories" %}</h2>
{% spaceless %}
<p>
{% for cat in user.category_set.all %}
<a class="category" href="{{ cat.get_absolute_url }}">{{ cat.icon|remix }}{{ cat }}</a>
{% for cat in categories %}
<a class="big-link" href="{% url 'category' cat.id %}">{{ cat.icon|remix }}{{ cat }}</a>
{% endfor %}
<a class="category add" href="{% url "new_category" %}">{{ "add"|remix }}{% translate "Create category" %}</a>
</p>
{% endspaceless %}
</section>
<section>
{% endif %}
{% if statements %}
<h2>{% translate "Statements" %}</h2>
{% include "main/table/statement.html" %}
{% endif %}
{% if history.data %}
<h2>{% translate "History" %}</h2>
{% history_plot user.transaction_set.all %}
</section>
{% endblock body %}
{% include "main/plot/history.html" %}
{% endif %}
{% endblock %}

View file

@ -1,47 +0,0 @@
{% extends "main/base.html" %}
{% load static %}
{% load main_extras %}
{% load i18n %}
{% block title %}
{% block name %}{% endblock %}
{% if account %} {{ account }}{% endif %}
{% if category %} {{ category }}{% endif %}
{% if search %}
{% translate "Search" %}
{% endif %}
Nummi
{% endblock %}
{% block link %}
{{ block.super }}
{% css "main/css/table.css" %}
{% endblock %}
{% block body %}
<h2>
{% block h2 %}{% endblock %}
</h2>
{% if account or category or search %}
<div class="backlinks">
{% block backlinks %}
{% if account %}
<p>
<a class="big-link" href="{{ account.get_absolute_url }}">{{ account.icon|remix }}{{ account }}</a>
</p>
{% endif %}
{% if category %}
<p>
<a class="big-link" href="{{ category.get_absolute_url }}">{{ category.icon|remix }}{{ category }}</a>
</p>
{% endif %}
{% if search %}
<p>
<a href="{% url "search" %}">{% translate "Search" %}</a>
</p>
{% endif %}
{% endblock %}
</div>
{% endif %}
{% include "main/pagination.html" %}
{% block table %}{% endblock %}
{% include "main/pagination.html" %}
{% block body_more %}{% endblock %}
{% endblock %}

View file

@ -0,0 +1,9 @@
{% load i18n %}
{% if page_obj %}
<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 %}

View file

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

View file

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

@ -0,0 +1,54 @@
{% load main_extras %}
{% load i18n %}
<div class="plot">
<table class="full-width">
<colgroup>
<col class="desc">
<col class="icon">
<col class="value">
<col span="2" class="bar">
<col class="value">
</colgroup>
<thead>
<tr>
<th scope="col" colspan="2">{% translate "Category" %}</th>
<th scope="col" colspan="2">{% translate "Expenses" %}</th>
<th scope="col" colspan="2">{% translate "Income" %}</th>
</tr>
</thead>
<tbody>
{% spaceless %}
{% for cat in categories.data %}
<tr>
<th scope="row">
{% if cat.category %}{{ cat.category__name }}{% endif %}
</th>
<td class="c">
{% if cat.category %}{{ cat.category__icon|remix }}{% endif %}
</td>
<td class="value">{{ cat.sum_m|pmrvalue }}</td>
<td class="bar m">
<div style="width: {% widthratio cat.sum_m categories.max -100 %}%"></div>
{% if cat.sum < 0 %}
<div class="tot"
style="width:{% widthratio cat.sum categories.max -100 %}%">
<span>{{ cat.sum|pmrvalue }}</span>
</div>
{% endif %}
</td>
<td class="bar p">
<div style="width: {% widthratio cat.sum_p categories.max 100 %}%"></div>
{% if cat.sum > 0 %}
<div class="tot"
style="width:{% widthratio cat.sum categories.max 100 %}%">
<span>{{ cat.sum|pmrvalue }}</span>
</div>
{% endif %}
</td>
<td class="value">{{ cat.sum_p|pmrvalue }}</td>
</tr>
{% endfor %}
{% endspaceless %}
</tbody>
</table>
</div>

View file

@ -0,0 +1,63 @@
{% load main_extras %}
{% load i18n %}
<div class="plot">
<table class="full-width">
<colgroup>
<col class="icon">
<col class="desc">
<col class="value">
<col span="2" class="bar">
<col class="value">
</colgroup>
<thead>
<tr>
<th scope="col">{{ "expand-up-down"|remix }}</th>
<th scope="col">{% translate "Month" %}</th>
<th scope="col" colspan="2">{% translate "Expenses" %}</th>
<th scope="col" colspan="2">{% translate "Income" %}</th>
</tr>
</thead>
<tbody>
{% spaceless %}
{% for date in history.data %}
<tr>
<td class="icon">
<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">
{% if date.has_transactions %}
{% if account %}
<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 %}
{{ date.month|date:"Y-m" }}
{% endif %}
</th>
<td class="value">{{ date.sum_m|pmrvalue }}</td>
<td class="bar m">
<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>
</tr>
{% endfor %}
{% endspaceless %}
</tbody>
</table>
</div>

View file

@ -0,0 +1,42 @@
{% load main_extras %}
{% load i18n %}
<div id="invoices">
<table>
<colgroup>
<col class="desc" span="3">
</colgroup>
<thead>
<th>{% translate "Name" %}</th>
<th>{% translate "File" %}</th>
<th>{% translate "Delete" %}</th>
</thead>
<tbody>
{% if transaction.invoices %}
{% for invoice in transaction.invoices %}
<tr>
<th scope="row" class="l">
<a href="{{ invoice.get_absolute_url }}">{{ invoice.name }}</a>
</th>
<td>
<a href="{{ invoice.file.url }}">{% translate "File" %} [{{ invoice.file|extension }}]</a>
</td>
<td>
<a href="{{ invoice.get_delete_url }}">{% translate "Delete" %}
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="3">{% translate "No invoice" %}</td>
</tr>
{% endif %}
</tbody>
<tfoot>
<tr>
<td colspan="3">
<a href="{% url "new_invoice" transaction.pk %}">{% translate "Create invoice" %}</a>
</td>
</tr>
</tfoot>
</table>
</div>

View file

@ -0,0 +1,62 @@
{% load main_extras %}
{% load i18n %}
{% if new_statement_url %}
<p>
<a href="{{ new_statement_url }}">{% translate "Create statement" %}</a>
</p>
{% endif %}
<div id="statements" class="table">
<table class="full-width {% if statements_url %}more{% endif %}">
<colgroup>
<col class="icon" span="2">
<col class="date">
{% if not account %}
<col class="icon">
<col class="desc">
{% endif %}
<col class="value" span="3">
</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="{% url "statement" snap.id %}">{{ snap.date|date:"Y-m-d" }}</a>
</th>
{% if not account %}
<td class="r">{{ snap.account.icon|remix }}</td>
<td>
<a href="{% url "account" snap.account.id %}">{{ 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>
{% endfor %}
</tbody>
</table>
</div>
{% if statements_url %}
<p>
<a href="{{ statements_url }}">{% translate "View all statements" %}</a>
</p>
{% endif %}

View file

@ -0,0 +1,75 @@
{% load main_extras %}
{% load i18n %}
{% if new_transaction_url %}
<p>
<a href="{{ new_transaction_url }}">{% translate "Create transaction" %}</a>
</p>
{% endif %}
<div id="transactions" class="table">
<table class="full-width {% if transactions_url %}more{% endif %}">
<colgroup>
<col class="icon">
<col class="date">
<col class="desc">
<col class="value">
<col class="desc">
{% if not category %}
<col class="icon">
<col class="desc">
{% endif %}
{% if not account %}
<col class="icon">
<col class="desc">
{% endif %}
</colgroup>
<thead>
<th>{{ "attachment"|remix }}</th>
<th>{% translate "Date" %}</th>
<th>{% translate "Name" %}</th>
<th>{% translate "Value" %}</th>
<th>{% translate "Trader" %}</th>
{% if not category %}
<th colspan="2">{% translate "Category" %}</th>
{% endif %}
{% if not account %}
<th colspan="2">{% translate "Account" %}</th>
{% endif %}
</thead>
<tbody>
{% for trans in transactions %}
<tr>
<td class="c">
{% for invoice in trans.invoices %}<a href="{{ invoice.file.url }}">{{ "attachment"|remix }}</a>{% endfor %}
</td>
<td class="date">{{ trans.date|date:"Y-m-d" }}</td>
<th scope="row" class="l">
<a href="{% url "transaction" trans.id %}">{{ trans.name }}</a>
</th>
<td class="value">{{ trans.value|pmvalue }}</td>
<td>{{ trans.trader|default_if_none:"" }}</td>
{% if not category %}
{% if trans.category %}
<td class="r">{{ trans.category.icon|remix }}</td>
<td>
<a href="{% url "category" trans.category.id %}">{{ trans.category }}</a>
</td>
{% else %}
<td colspan="2"></td>
{% endif %}
{% endif %}
{% if not account %}
<td class="r">{{ trans.account.icon|remix }}</td>
<td>
<a href="{% url "account" trans.account.id %}">{{ trans.account }}</a>
</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if transactions_url %}
<p>
<a href="{{ transactions_url }}">{% translate "View all transactions" %}</a>
</p>
{% endif %}

View file

@ -0,0 +1,9 @@
{% extends "main/tag/value.html" %}
{% block "value" %}
{% if value %}
{% if value > 0 %}+{% endif %}
{{ block.super }}
{% else %}
{% endif %}
{% endblock %}

View file

@ -0,0 +1,5 @@
{% spaceless %}
<span>
{% block "value" %}{{ value }} €{% endblock %}
</span>
{% endspaceless %}

View file

@ -1,9 +1,4 @@
from urllib import parse
from dateutil.relativedelta import relativedelta
from django import template
from django.templatetags.static import static
from django.urls import reverse
from django.utils import formats
from django.utils.safestring import mark_safe
@ -12,16 +7,16 @@ register = template.Library()
@register.filter
def value(val, pm=False, r=2):
if val is None:
if not val:
return ""
_prefix = ""
_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 pm:
_prefix += "&plus;&nbsp;"
elif val < 0:
else:
_val = _val[1:]
_prefix += "&minus;&nbsp;"
@ -39,31 +34,21 @@ def pmrvalue(val):
@register.filter
def remix(icon, *args):
return remixnl(f"{icon}-line", *args)
def remix(icon, cls=""):
return mark_safe(f"""<span class="ri-{icon}-line {cls}"></span>""")
@register.filter
def remixnl(icon, cls=""):
return mark_safe(f"""<span class="ri-{icon} {cls}"></span>""")
@register.filter
def messageicon(level):
ico = {
10: "bug",
20: "information",
25: "check",
30: "alert",
40: "error-warning",
}
return remix(ico.get(level, "question"))
def check(sum, diff):
if sum == diff:
return remix("check", "green")
else:
return remix("close", "red")
@register.filter
def extension(file):
return file.name.split(".", 1)[1].upper()
return file.name.split(".")[-1].upper()
@register.filter
@ -74,67 +59,3 @@ def verbose_name(obj):
@register.filter
def adding(obj):
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("statement/", include("statement.urls")),
path("transaction/", include("transaction.urls")),
path("history/", include("history.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,30 +1,41 @@
from django.contrib import messages
from account.models import Account
from category.models import Category
from django.contrib.auth import views as auth_views
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse_lazy
from django.utils.html import format_html
from django.utils.translation import gettext as _
from django.views.generic import (
CreateView,
DeleteView,
DetailView,
ListView,
TemplateView,
UpdateView,
)
from statement.models import Statement
from transaction.models import Transaction
from transaction.utils import history
class IndexView(LoginRequiredMixin, TemplateView):
template_name = "main/index.html"
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
"statements": (
self.request.user.statement_set.exclude(account__archived=True)
.order_by("account__id", "-date")
.distinct("account__id")
)
_max = 8
_transactions = Transaction.objects.filter(user=self.request.user)
_statements = Statement.objects.filter(user=self.request.user)
res = {
"accounts": Account.objects.filter(user=self.request.user),
"transactions": _transactions[:_max],
"categories": Category.objects.filter(user=self.request.user),
"statements": _statements[:_max],
"history": history(_transactions.exclude(category__budget=False)),
}
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):
@ -44,28 +55,13 @@ class NummiCreateView(UserMixin, CreateView):
return super().form_valid(form)
def get_success_url(self):
surl = super().get_success_url()
messages.success(
self.request,
format_html(
"<a href='{surl}'>{name}</a> {msg}",
surl=surl,
name=self.object,
msg=_("was created successfully"),
),
)
return self.next or surl
return self.next or super().get_success_url()
class NummiUpdateView(UserMixin, UpdateView):
pass
class NummiDetailView(UserMixin, DetailView):
pass
class NummiDeleteView(UserMixin, DeleteView):
template_name = "main/confirm_delete.html"
success_url = reverse_lazy("index")
@ -86,4 +82,4 @@ class LogoutView(auth_views.LogoutView):
class NummiListView(UserMixin, ListView):
pass
paginate_by = 96

View file

@ -11,18 +11,18 @@ https://docs.djangoproject.com/en/4.0/ref/settings/
"""
import os
import tomllib
from pathlib import Path
import toml
CONFIG_PATH = os.environ.get("NUMMI_CONFIG", None)
if CONFIG_PATH is None:
CONFIG = dict()
if CONFIG_PATH is not None:
with Path(CONFIG_PATH).open("rb") as CONFIG_FILE:
CONFIG = tomllib.load(CONFIG_FILE)
else:
CONFIG = toml.load(CONFIG_PATH)
# Build paths inside the project like this: BASE_DIR / 'subdir'.
MEDIA_CONF = CONFIG.get("media", {})
STATIC_CONF = CONFIG.get("static", {})
BASE_DIR = Path(__file__).resolve().parent.parent
MEDIA_ROOT = Path(MEDIA_CONF.get("root", "/var/lib/nummi"))
MEDIA_URL = "media/"
@ -51,7 +51,6 @@ INSTALLED_APPS = [
"statement",
"transaction",
"search",
"history",
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
@ -118,14 +117,16 @@ AUTH_PASSWORD_VALIDATORS = []
LANGUAGE_CODE = "fr-fr"
TIME_ZONE = CONFIG.get("time_zone", "CET")
USE_I18N = True
USE_L10N = True
USE_TZ = True
LOCALE_PATHS = [
"locale",
]
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/4.0/howto/static-files/
STATIC_URL = "static/"
STATIC_ROOT = STATIC_CONF.get("root", "/srv/nummi")
STATIC_ROOT = "/srv/nummi"
LOGIN_URL = "login"
# Default primary key field type

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