feat: Start/Ende fuer Fixposten in der GUI + Datumsvalidierung

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 11:35:32 +02:00
parent 805cb4d852
commit cbcf2e0439
4 changed files with 54 additions and 3 deletions

View File

@@ -3,7 +3,7 @@ from decimal import Decimal
from typing import Literal from typing import Literal
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field, model_validator
from sqlalchemy import delete, select from sqlalchemy import delete, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -28,6 +28,13 @@ class RecurringIn(BaseModel):
end_date: date | None = None end_date: date | None = None
category_id: int | None = None category_id: int | None = None
@model_validator(mode="after")
def _ende_nicht_vor_start(self):
if (self.start_date is not None and self.end_date is not None
and self.end_date < self.start_date):
raise ValueError("Ende darf nicht vor Start liegen")
return self
class RecurringOut(RecurringIn): class RecurringOut(RecurringIn):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@@ -90,6 +97,10 @@ def patch_recurring(item_id: int, data: RecurringPatch,
_check_category(session, fields["category_id"]) _check_category(session, fields["category_id"])
for key, value in fields.items(): for key, value in fields.items():
setattr(item, key, value) setattr(item, key, value)
if (item.start_date is not None and item.end_date is not None
and item.end_date < item.start_date):
session.rollback()
raise HTTPException(422, "Ende darf nicht vor Start liegen")
session.commit() session.commit()
session.refresh(item) session.refresh(item)
return RecurringOut.model_validate(item) return RecurringOut.model_validate(item)

View File

@@ -7,7 +7,7 @@
<h2>Wiederkehrende Posten</h2> <h2>Wiederkehrende Posten</h2>
<table> <table>
<thead> <thead>
<tr><th>Name</th><th>Betrag</th><th>Rhythmus</th><th>Fälligkeitstag</th><th>Kategorie</th><th></th></tr> <tr><th>Name</th><th>Betrag</th><th>Rhythmus</th><th>Fälligkeitstag</th><th>Start</th><th>Ende</th><th>Kategorie</th><th></th></tr>
</thead> </thead>
<tbody> <tbody>
{% for r in recurring %} {% for r in recurring %}
@@ -16,6 +16,8 @@
<td class="{{ 'neg' if r.amount < 0 else '' }}">{{ r.amount|eur }} €</td> <td class="{{ 'neg' if r.amount < 0 else '' }}">{{ r.amount|eur }} €</td>
<td>{{ r.rhythm|de_label }}</td> <td>{{ r.rhythm|de_label }}</td>
<td>{{ r.due_day }}</td> <td>{{ r.due_day }}</td>
<td>{{ r.start_date.strftime('%d.%m.%Y') if r.start_date else '' }}</td>
<td>{{ r.end_date.strftime('%d.%m.%Y') if r.end_date else '' }}</td>
<td>{{ category_names.get(r.category_id, '') }}</td> <td>{{ category_names.get(r.category_id, '') }}</td>
<td> <td>
<form class="inline-form" hx-delete="/api/recurring/{{ r.id }}" hx-swap="none" <form class="inline-form" hx-delete="/api/recurring/{{ r.id }}" hx-swap="none"
@@ -26,7 +28,7 @@
</td> </td>
</tr> </tr>
{% else %} {% else %}
<tr><td colspan="6">Noch keine wiederkehrenden Posten.</td></tr> <tr><td colspan="8">Noch keine wiederkehrenden Posten.</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
@@ -43,6 +45,8 @@
</select> </select>
</label> </label>
<label>Fälligkeitstag <input type="number" name="due_day" data-type="int" min="1" max="31" required></label> <label>Fälligkeitstag <input type="number" name="due_day" data-type="int" min="1" max="31" required></label>
<label>Start (optional) <input type="date" name="start_date"></label>
<label>Ende (optional) <input type="date" name="end_date"></label>
<label>Kategorie <label>Kategorie
<select name="category_id" data-type="int"> <select name="category_id" data-type="int">
<option value=""></option> <option value=""></option>

View File

@@ -225,6 +225,18 @@ def test_planning_page_has_all_sections(client):
assert heading in r.text assert heading in r.text
def test_planung_fixposten_zeigt_start_und_ende(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
db.add(RecurringItem(name="Befristet-GUI", amount=Decimal("-5.00"),
rhythm="monthly", due_day=1,
start_date=date(2026, 1, 1), end_date=date(2026, 12, 31)))
db.commit()
r = client.get("/planung").text
assert "<th>Start</th>" in r and "<th>Ende</th>" in r
assert "01.01.2026" in r and "31.12.2026" in r
assert 'name="start_date"' in r and 'name="end_date"' in r
def test_planning_page_shows_empty_suggestions_hint(client): def test_planning_page_shows_empty_suggestions_hint(client):
# UX-Regel: das "Vorschläge aus Buchungen"-Fieldset ist immer sichtbar, # UX-Regel: das "Vorschläge aus Buchungen"-Fieldset ist immer sichtbar,
# auch ohne Daten - statt komplett zu verschwinden zeigt es einen Hinweis. # auch ohne Daten - statt komplett zu verschwinden zeigt es einen Hinweis.

View File

@@ -87,6 +87,30 @@ def test_suggest_recurring_two_months_no_suggestion(db):
assert suggest_recurring(db) == [] assert suggest_recurring(db) == []
def test_recurring_ende_vor_start_wird_abgelehnt(client):
r = client.post("/api/recurring", headers=H, json={
"name": "Falschrum", "amount": "-10.00", "rhythm": "monthly", "due_day": 1,
"start_date": "2026-10-01", "end_date": "2026-09-01"})
assert r.status_code == 422
def test_recurring_start_ende_roundtrip_und_patch_validierung(client):
r = client.post("/api/recurring", headers=H, json={
"name": "Befristet", "amount": "-10.00", "rhythm": "monthly", "due_day": 1,
"start_date": "2026-01-01", "end_date": "2026-12-31"})
assert r.status_code == 201
item = r.json()
assert item["start_date"] == "2026-01-01" and item["end_date"] == "2026-12-31"
# PATCH, der Ende vor den (bestehenden) Start schieben will -> 422:
bad = client.patch(f"/api/recurring/{item['id']}", headers=H,
json={"end_date": "2025-06-30"})
assert bad.status_code == 422
# Datum explizit loeschen (null) ist erlaubt:
ok = client.patch(f"/api/recurring/{item['id']}", headers=H,
json={"end_date": None})
assert ok.status_code == 200 and ok.json()["end_date"] is None
def test_suggest_recurring_excludes_existing_recurring_item(db): def test_suggest_recurring_excludes_existing_recurring_item(db):
acc = Account(bank="dkb", iban="DE01", name="G", type="giro") acc = Account(bank="dkb", iban="DE01", name="G", type="giro")
db.add(acc) db.add(acc)