feat: Start/Ende fuer Fixposten in der GUI + Datumsvalidierung
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user