Merge pull request 'Release 1.0.0' (#3) from dev into main

Reviewed-on: https://git.hiabuto.net/C3MA/CCMA/pulls/3
This commit is contained in:
Marcel Peterkau
2026-06-27 14:39:00 +00:00
33 changed files with 3540 additions and 154 deletions
+1 -1
View File
@@ -1 +1 @@
0.0.1-dev1 0.0.1-dev2
+11 -10
View File
@@ -1,16 +1,17 @@
[ [
{ {
"version": "0.0.1-dev0", "version": "1.0.0",
"date": "2026-06-21", "date": "2026-06-27",
"changes": [ "changes": [
"Erste Entwicklungsversion der dateibasierten CCMA-Mitgliederverwaltung.", "First Release der CCMA-Mitgliederverwaltung für lokale, dateibasierte Vereinsverwaltung.",
"Mitgliederakten mit Stammdaten, Anschrift, Bank-/SEPA-Daten, Dokumentordner und nachvollziehbarer Chronik.", "Mitglieder können mit Stammdaten, Anschrift, Kontaktangaben, Status, Bank-/SEPA-Daten und internen Notizen verwaltet werden.",
"Dashboard, Freitextsuche, Mitgliederliste und parallele Arbeits-Tabs in einer deutschen Light-/Dark-Oberfläche.", "Mitgliederlisten, Suche und parallele Arbeits-Tabs helfen beim schnellen Finden und Bearbeiten von Akten.",
"Konfigurierbare Mitgliedsnummern, validierte Datums- und Bankdaten sowie zentrale Vereins- und Absenderangaben.", "Assets und Inventar können angelegt, Mitgliedern zugeordnet, zurückgenommen und mit Kautionen oder Forderungen verbunden werden.",
"Regelbasierter Hausmeister für Datenprüfung, Geburtstage, Jubiläen, Forderungen und anstehende Aufgaben.", "Forderungen, Zahlungen, Gutschriften, Mahnungen und GnuCash-Referenzen können pro Mitglied nachvollziehbar gepflegt werden.",
"Forderungsmanagement mit Positionen, Teilzahlungen, GnuCash-Referenzen, Gebühren und mehrstufigem Mahnworkflow.", "Dokumente lassen sich aus OpenDocument-Vorlagen erzeugen und als PDF in den jeweiligen Akten ablegen.",
"OpenDocument-Templates für Mitglieder, Forderungen und Mahnungen mit Platzhaltern, Tabellenzeilen und lokaler PDF-Erzeugung.", "Chroniken halten wichtige Ereignisse, Kommentare und automatisch erzeugte Vorgänge nachvollziehbar fest.",
"Transparente JSON-Speicherung, atomare Schreibvorgänge und portable Mitgliedsordner für Backup und DSGVO-Auskunft." "Der Hausmeister prüft Daten, erinnert an Aufgaben und meldet mögliche Probleme wie beschädigte oder extern geänderte Akten.",
"Alle Daten bleiben als transparente JSON-Dateien im lokalen Store lesbar und können ohne Datenbank gesichert oder geprüft werden."
] ]
} }
] ]
@@ -313,13 +313,13 @@ namespace eval ttk::theme::forest-dark {
# Switch # Switch
ttk::style element create Switch.indicator image \ ttk::style element create Switch.indicator image \
[list $I(off-accent) \ [list $I(off-basic) \
{selected disabled} $I(on-basic) \ {selected disabled} $I(on-basic) \
disabled $I(off-basic) \ disabled $I(off-basic) \
{pressed selected} $I(on-accent) \ {pressed selected} $I(on-accent) \
{active selected} $I(on-hover) \ {active selected} $I(on-hover) \
selected $I(on-accent) \ selected $I(on-accent) \
{pressed !selected} $I(off-accent) \ {pressed !selected} $I(off-hover) \
active $I(off-hover) \ active $I(off-hover) \
] -width 46 -sticky w ] -width 46 -sticky w
@@ -313,13 +313,13 @@ namespace eval ttk::theme::forest-light {
# Switch # Switch
ttk::style element create Switch.indicator image \ ttk::style element create Switch.indicator image \
[list $I(off-accent) \ [list $I(off-basic) \
{selected disabled} $I(on-basic) \ {selected disabled} $I(on-basic) \
disabled $I(off-basic) \ disabled $I(off-basic) \
{pressed selected} $I(on-accent) \ {pressed selected} $I(on-accent) \
{active selected} $I(on-hover) \ {active selected} $I(on-hover) \
selected $I(on-accent) \ selected $I(on-accent) \
{pressed !selected} $I(off-accent) \ {pressed !selected} $I(off-hover) \
active $I(off-hover) \ active $I(off-hover) \
] -width 46 -sticky w ] -width 46 -sticky w
+11
View File
@@ -7,6 +7,7 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from ccma.domain.models import DEFAULT_OPTIONAL_MEMBER_FIELDS, normalize_optional_member_fields
from ccma.storage.atomic import write_json_atomic from ccma.storage.atomic import write_json_atomic
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -25,6 +26,8 @@ class AppConfig:
anniversary_days_before: int = 14 anniversary_days_before: int = 14
anniversary_days_after: int = 7 anniversary_days_after: int = 7
anniversary_intervals: str = "1Y;5Y;10Y;25Y;50Y" anniversary_intervals: str = "1Y;5Y;10Y;25Y;50Y"
retroactive_claims: bool = False
optional_member_fields: tuple[str, ...] = DEFAULT_OPTIONAL_MEMBER_FIELDS
window_geometry: str = "" window_geometry: str = ""
window_state: str = "normal" window_state: str = "normal"
monitor_bounds: tuple[int, int, int, int] | None = None monitor_bounds: tuple[int, int, int, int] | None = None
@@ -48,6 +51,8 @@ class AppConfig:
"anniversary_days_before": self.anniversary_days_before, "anniversary_days_before": self.anniversary_days_before,
"anniversary_days_after": self.anniversary_days_after, "anniversary_days_after": self.anniversary_days_after,
"anniversary_intervals": self.anniversary_intervals, "anniversary_intervals": self.anniversary_intervals,
"retroactive_claims": self.retroactive_claims,
"optional_member_fields": list(normalize_optional_member_fields(self.optional_member_fields)),
"window_geometry": self.window_geometry, "window_geometry": self.window_geometry,
"window_state": self.window_state, "window_state": self.window_state,
"monitor_bounds": list(self.monitor_bounds) if self.monitor_bounds else None, "monitor_bounds": list(self.monitor_bounds) if self.monitor_bounds else None,
@@ -65,6 +70,8 @@ class AppConfig:
anniversary_days_before=self.anniversary_days_before, anniversary_days_before=self.anniversary_days_before,
anniversary_days_after=self.anniversary_days_after, anniversary_days_after=self.anniversary_days_after,
anniversary_intervals=self.anniversary_intervals, anniversary_intervals=self.anniversary_intervals,
retroactive_claims=self.retroactive_claims,
optional_member_fields=self.optional_member_fields,
) )
except IntervalValidationError: except IntervalValidationError:
return HousekeeperSettings() return HousekeeperSettings()
@@ -101,6 +108,10 @@ def load_config() -> AppConfig:
anniversary_days_before=int(data.get("anniversary_days_before", 14)), anniversary_days_before=int(data.get("anniversary_days_before", 14)),
anniversary_days_after=int(data.get("anniversary_days_after", 7)), anniversary_days_after=int(data.get("anniversary_days_after", 7)),
anniversary_intervals=str(data.get("anniversary_intervals", "1Y;5Y;10Y;25Y;50Y")), anniversary_intervals=str(data.get("anniversary_intervals", "1Y;5Y;10Y;25Y;50Y")),
retroactive_claims=bool(data.get("retroactive_claims", False)),
optional_member_fields=normalize_optional_member_fields(
data.get("optional_member_fields", DEFAULT_OPTIONAL_MEMBER_FIELDS)
),
window_geometry=str(data.get("window_geometry", "")), window_geometry=str(data.get("window_geometry", "")),
window_state=str(data.get("window_state", "normal")), window_state=str(data.get("window_state", "normal")),
monitor_bounds=monitor_bounds, monitor_bounds=monitor_bounds,
+29 -1
View File
@@ -14,6 +14,7 @@ CLAIM_STATUS_LABELS = {
"paid": "BEZAHLT", "paid": "BEZAHLT",
"overpaid": "ÜBERZAHLT", "overpaid": "ÜBERZAHLT",
"overdue": "ÜBERFÄLLIG", "overdue": "ÜBERFÄLLIG",
"credit": "GUTSCHRIFT",
"cancelled": "STORNIERT", "cancelled": "STORNIERT",
} }
@@ -57,10 +58,17 @@ def claim_total(claim: dict[str, Any]) -> Decimal:
return sum((decimal_value(item.get("amount", "0")) for item in claim_items(claim)), Decimal("0")) return sum((decimal_value(item.get("amount", "0")) for item in claim_items(claim)), Decimal("0"))
def allocation_effect(data: ContributionData, allocation: dict[str, Any]) -> Decimal:
amount = decimal_value(allocation.get("amount", "0"))
if str(allocation.get("credit_id", "")):
return -amount
return amount
def allocated_total(data: ContributionData, claim_id: str) -> Decimal: def allocated_total(data: ContributionData, claim_id: str) -> Decimal:
return sum( return sum(
( (
decimal_value(allocation.get("amount", "0")) allocation_effect(data, allocation)
for allocation in data.allocations for allocation in data.allocations
if str(allocation.get("claim_id", "")) == claim_id if str(allocation.get("claim_id", "")) == claim_id
), ),
@@ -68,6 +76,13 @@ def allocated_total(data: ContributionData, claim_id: str) -> Decimal:
) )
def claim_settled_total(data: ContributionData, claim: dict[str, Any]) -> Decimal:
allocated = allocated_total(data, str(claim.get("claim_id", "")))
if claim_total(claim) < 0:
return abs(allocated).quantize(CENT)
return allocated.quantize(CENT)
def payment_allocated_total(data: ContributionData, payment_id: str) -> Decimal: def payment_allocated_total(data: ContributionData, payment_id: str) -> Decimal:
return sum( return sum(
( (
@@ -79,6 +94,17 @@ def payment_allocated_total(data: ContributionData, payment_id: str) -> Decimal:
) )
def credit_allocated_total(data: ContributionData, credit_id: str) -> Decimal:
return sum(
(
decimal_value(allocation.get("amount", "0"))
for allocation in data.allocations
if str(allocation.get("credit_id", "")) == credit_id
),
Decimal("0"),
)
def claim_balance(data: ContributionData, claim: dict[str, Any]) -> Decimal: def claim_balance(data: ContributionData, claim: dict[str, Any]) -> Decimal:
return (claim_total(claim) - allocated_total(data, str(claim.get("claim_id", "")))).quantize(CENT) return (claim_total(claim) - allocated_total(data, str(claim.get("claim_id", "")))).quantize(CENT)
@@ -89,6 +115,8 @@ def claim_status(data: ContributionData, claim: dict[str, Any], *, today: date |
total = claim_total(claim) total = claim_total(claim)
paid = allocated_total(data, str(claim.get("claim_id", ""))) paid = allocated_total(data, str(claim.get("claim_id", "")))
balance = total - paid balance = total - paid
if total < 0:
return "credit"
if balance < 0: if balance < 0:
return "overpaid" return "overpaid"
if balance == 0: if balance == 0:
+89 -1
View File
@@ -20,6 +20,37 @@ MEMBERSHIP_STATUS_LABELS = {
"ended": "BEENDET", "ended": "BEENDET",
} }
ASSET_STATUS_LABELS = {
"available": "VERFUEGBAR",
"issued": "AUSGEGEBEN",
"lost": "VERLOREN",
"retired": "AUSGEMUSTERT",
}
HOUSEKEEPER_MEMBER_FIELD_LABELS = {
"nickname": "Nickname",
"email": "E-Mail-Adresse",
"phone": "Telefonnummer",
"birth_date": "Geburtsdatum",
"street": "Straße und Hausnummer",
"postal_code": "Postleitzahl",
"city": "Ort",
"country": "Land",
"accepted_at": "Aufnahmebeschluss",
"membership_started_at": "Mitglied seit",
}
DEFAULT_OPTIONAL_MEMBER_FIELDS = tuple(
field for field in HOUSEKEEPER_MEMBER_FIELD_LABELS if field != "birth_date"
)
def normalize_optional_member_fields(values: Any) -> tuple[str, ...]:
if not isinstance(values, (list, tuple, set, frozenset)):
return ()
selected = {str(value).strip() for value in values}
return tuple(field for field in HOUSEKEEPER_MEMBER_FIELD_LABELS if field in selected)
@dataclass(slots=True) @dataclass(slots=True)
class Member: class Member:
@@ -27,6 +58,7 @@ class Member:
member_number: str member_number: str
first_name: str first_name: str
last_name: str last_name: str
nickname: str = ""
email: str = "" email: str = ""
phone: str = "" phone: str = ""
birth_date: str = "" birth_date: str = ""
@@ -65,6 +97,7 @@ class Member:
"person": { "person": {
"first_name": self.first_name, "first_name": self.first_name,
"last_name": self.last_name, "last_name": self.last_name,
"nickname": self.nickname,
"birth_date": self.birth_date, "birth_date": self.birth_date,
"email": self.email, "email": self.email,
"phone": self.phone, "phone": self.phone,
@@ -113,6 +146,7 @@ class Member:
member_number=str(data.get("member_number", "")), member_number=str(data.get("member_number", "")),
first_name=str(person.get("first_name", "")), first_name=str(person.get("first_name", "")),
last_name=str(person.get("last_name", "")), last_name=str(person.get("last_name", "")),
nickname=str(person.get("nickname", "")),
email=str(person.get("email", "")), email=str(person.get("email", "")),
phone=str(person.get("phone", "")), phone=str(person.get("phone", "")),
birth_date=str(person.get("birth_date", "")), birth_date=str(person.get("birth_date", "")),
@@ -140,6 +174,55 @@ class Member:
) )
@dataclass(slots=True)
class Asset:
asset_id: str
label: str
category: str = ""
inventory_number: str = ""
serial_number: str = ""
status: str = "available"
current_holder_member_id: str = ""
deposit_amount_default: str = "0.00"
notes: str = ""
created_at: str = field(default_factory=_iso_now)
updated_at: str = field(default_factory=_iso_now)
schema_version: int = 1
def to_dict(self) -> dict[str, Any]:
return {
"schema_version": self.schema_version,
"asset_id": self.asset_id,
"label": self.label,
"category": self.category,
"inventory_number": self.inventory_number,
"serial_number": self.serial_number,
"status": self.status,
"current_holder_member_id": self.current_holder_member_id,
"deposit_amount_default": self.deposit_amount_default,
"notes": self.notes,
"created_at": self.created_at,
"updated_at": self.updated_at,
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Asset:
return cls(
schema_version=int(data.get("schema_version", 1)),
asset_id=str(data["asset_id"]),
label=str(data.get("label", "")),
category=str(data.get("category", "")),
inventory_number=str(data.get("inventory_number", "")),
serial_number=str(data.get("serial_number", "")),
status=str(data.get("status", "available")),
current_holder_member_id=str(data.get("current_holder_member_id", "")),
deposit_amount_default=str(data.get("deposit_amount_default", "0.00")),
notes=str(data.get("notes", "")),
created_at=str(data.get("created_at", _iso_now())),
updated_at=str(data.get("updated_at", _iso_now())),
)
@dataclass(slots=True) @dataclass(slots=True)
class Event: class Event:
event_id: str event_id: str
@@ -182,6 +265,7 @@ class Event:
class ContributionData: class ContributionData:
claims: list[dict[str, Any]] = field(default_factory=list) claims: list[dict[str, Any]] = field(default_factory=list)
payments: list[dict[str, Any]] = field(default_factory=list) payments: list[dict[str, Any]] = field(default_factory=list)
credits: list[dict[str, Any]] = field(default_factory=list)
allocations: list[dict[str, Any]] = field(default_factory=list) allocations: list[dict[str, Any]] = field(default_factory=list)
reminders: list[dict[str, Any]] = field(default_factory=list) reminders: list[dict[str, Any]] = field(default_factory=list)
schema_version: int = 1 schema_version: int = 1
@@ -191,6 +275,7 @@ class ContributionData:
"schema_version": self.schema_version, "schema_version": self.schema_version,
"claims": self.claims, "claims": self.claims,
"payments": self.payments, "payments": self.payments,
"credits": self.credits,
"allocations": self.allocations, "allocations": self.allocations,
"reminders": self.reminders, "reminders": self.reminders,
} }
@@ -201,6 +286,7 @@ class ContributionData:
schema_version=int(data.get("schema_version", 1)), schema_version=int(data.get("schema_version", 1)),
claims=list(data.get("claims") or []), claims=list(data.get("claims") or []),
payments=list(data.get("payments") or []), payments=list(data.get("payments") or []),
credits=list(data.get("credits") or []),
allocations=list(data.get("allocations") or []), allocations=list(data.get("allocations") or []),
reminders=list(data.get("reminders") or []), reminders=list(data.get("reminders") or []),
) )
@@ -209,10 +295,12 @@ class ContributionData:
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class HousekeeperFinding: class HousekeeperFinding:
severity: str severity: str
member_id: str
code: str code: str
title: str title: str
detail: str detail: str
member_id: str = ""
asset_id: str = ""
target_type: str = "member"
due_date: date | None = None due_date: date | None = None
key: str = "" key: str = ""
+23 -10
View File
@@ -1,4 +1,5 @@
from ccma.domain.dates import DateValidationError, validate_member_dates from ccma.domain.dates import DateValidationError, validate_member_dates
from ccma.domain.models import HOUSEKEEPER_MEMBER_FIELD_LABELS
from ccma.rules.api import RuleContext, task from ccma.rules.api import RuleContext, task
RULE_ID = "birthdate-check" RULE_ID = "birthdate-check"
@@ -7,18 +8,30 @@ ORDER = 10
def evaluate(context: RuleContext): def evaluate(context: RuleContext):
member = context.member member = context.member
if not member.birth_date.strip(): actions = []
return [ optional_fields = set(getattr(context.settings, "optional_member_fields", ()))
for field, label in HOUSEKEEPER_MEMBER_FIELD_LABELS.items():
if field in optional_fields:
continue
if str(getattr(member, field, "")).strip():
continue
code = "missing_birth_date" if field == "birth_date" else f"missing_member_field:{field}"
detail = (
"Das Geburtsdatum muss in der Mitgliederakte ergänzt werden."
if field == "birth_date"
else f"Das Feld {label} muss in der Mitgliederakte ergänzt werden."
)
actions.append(
task( task(
rule_id=RULE_ID, rule_id=RULE_ID,
member=member, member=member,
key_suffix="missing", key_suffix=f"missing:{field}",
severity="warning", severity="warning",
title=f"{member.display_name}: Geburtsdatum fehlt", title=f"{member.display_name}: {label} fehlt",
detail="Das Geburtsdatum muss in der Mitgliederakte ergänzt werden.", detail=detail,
code="missing_birth_date", code=code,
) )
] )
try: try:
validate_member_dates( validate_member_dates(
birth_date=member.birth_date, birth_date=member.birth_date,
@@ -27,7 +40,7 @@ def evaluate(context: RuleContext):
today=context.today, today=context.today,
) )
except DateValidationError as exc: except DateValidationError as exc:
return [ actions.append(
task( task(
rule_id=RULE_ID, rule_id=RULE_ID,
member=member, member=member,
@@ -37,5 +50,5 @@ def evaluate(context: RuleContext):
detail=str(exc), detail=str(exc),
code="invalid_member_dates", code="invalid_member_dates",
) )
] )
return [] return actions
@@ -44,7 +44,12 @@ def evaluate(context: RuleContext):
) )
) )
for year in (context.today.year, context.today.year + 1): year_from = (
started_at.year
if getattr(context.settings, "retroactive_claims", False)
else context.today.year
)
for year in range(year_from, context.today.year + 2):
actions.extend(_membership_claims(context, started_at, accepted_at, year)) actions.extend(_membership_claims(context, started_at, accepted_at, year))
return actions return actions
@@ -77,6 +82,8 @@ def _membership_claims(context: RuleContext, started_at: date, accepted_at: date
actions = [] actions = []
monthly_amount = annual_amount / Decimal(12) monthly_amount = annual_amount / Decimal(12)
for suffix, first_month, last_month, regular_due in periods: for suffix, first_month, last_month, regular_due in periods:
# The entry year is intentionally billed from the entry month onward,
# even when retroactive claims create old membership-fee claims.
charged_from = max(first_month, period_start.month) charged_from = max(first_month, period_start.month)
months = max(0, last_month - charged_from + 1) months = max(0, last_month - charged_from + 1)
if months == 0: if months == 0:
+3 -2
View File
@@ -15,9 +15,9 @@ from xml.etree import ElementTree
from ccma.domain.contributions import ( from ccma.domain.contributions import (
CLAIM_STATUS_LABELS, CLAIM_STATUS_LABELS,
allocated_total,
claim_balance, claim_balance,
claim_items, claim_items,
claim_settled_total,
claim_status, claim_status,
claim_total, claim_total,
money_text, money_text,
@@ -203,6 +203,7 @@ def _template_values(
"member.number": member.member_number, "member.number": member.member_number,
"member.first_name": member.first_name, "member.first_name": member.first_name,
"member.last_name": member.last_name, "member.last_name": member.last_name,
"member.nickname": member.nickname,
"member.full_name": member.display_name, "member.full_name": member.display_name,
"member.email": member.email, "member.email": member.email,
"member.phone": member.phone, "member.phone": member.phone,
@@ -256,7 +257,7 @@ def _template_values(
), ),
"claim.created_at": _display_timestamp(str(claim.get("created_at", ""))), "claim.created_at": _display_timestamp(str(claim.get("created_at", ""))),
"claim.total": f"{money_text(claim_total(claim))} EUR", "claim.total": f"{money_text(claim_total(claim))} EUR",
"claim.paid": f"{money_text(allocated_total(data, claim_id))} EUR", "claim.paid": f"{money_text(claim_settled_total(data, claim))} EUR",
"claim.balance": f"{money_text(claim_balance(data, claim))} EUR", "claim.balance": f"{money_text(claim_balance(data, claim))} EUR",
"claim.status": CLAIM_STATUS_LABELS.get(status, status), "claim.status": CLAIM_STATUS_LABELS.get(status, status),
"claim.items": "; ".join(item_lines), "claim.items": "; ".join(item_lines),
+96 -3
View File
@@ -12,7 +12,7 @@ from pathlib import Path
from typing import Any from typing import Any
from uuid import uuid4 from uuid import uuid4
from ccma.domain.models import HousekeeperFinding from ccma.domain.models import DEFAULT_OPTIONAL_MEMBER_FIELDS, HousekeeperFinding
from ccma.rules.api import RuleAction, RuleContext from ccma.rules.api import RuleAction, RuleContext
from ccma.rules.loader import LoadedRule, load_rules from ccma.rules.loader import LoadedRule, load_rules
from ccma.services.intervals import AnniversaryInterval, parse_anniversary_intervals from ccma.services.intervals import AnniversaryInterval, parse_anniversary_intervals
@@ -29,6 +29,8 @@ class HousekeeperSettings:
anniversary_intervals: tuple[AnniversaryInterval, ...] = field( anniversary_intervals: tuple[AnniversaryInterval, ...] = field(
default_factory=lambda: tuple(parse_anniversary_intervals("1Y;5Y;10Y;25Y;50Y")) default_factory=lambda: tuple(parse_anniversary_intervals("1Y;5Y;10Y;25Y;50Y"))
) )
retroactive_claims: bool = False
optional_member_fields: tuple[str, ...] = DEFAULT_OPTIONAL_MEMBER_FIELDS
@classmethod @classmethod
def from_values( def from_values(
@@ -39,6 +41,8 @@ class HousekeeperSettings:
anniversary_days_before: int, anniversary_days_before: int,
anniversary_days_after: int, anniversary_days_after: int,
anniversary_intervals: str, anniversary_intervals: str,
retroactive_claims: bool = False,
optional_member_fields: tuple[str, ...] = (),
) -> HousekeeperSettings: ) -> HousekeeperSettings:
return cls( return cls(
birthday_days_before=min(365, max(0, birthday_days_before)), birthday_days_before=min(365, max(0, birthday_days_before)),
@@ -46,6 +50,8 @@ class HousekeeperSettings:
anniversary_days_before=min(365, max(0, anniversary_days_before)), anniversary_days_before=min(365, max(0, anniversary_days_before)),
anniversary_days_after=min(365, max(0, anniversary_days_after)), anniversary_days_after=min(365, max(0, anniversary_days_after)),
anniversary_intervals=tuple(parse_anniversary_intervals(anniversary_intervals)), anniversary_intervals=tuple(parse_anniversary_intervals(anniversary_intervals)),
retroactive_claims=bool(retroactive_claims),
optional_member_fields=tuple(optional_member_fields),
) )
@@ -73,7 +79,9 @@ class Housekeeper:
items = _items_by_key(working) items = _items_by_key(working)
successful_scopes: set[tuple[str, str]] = set() successful_scopes: set[tuple[str, str]] = set()
member_ids = set(self.repository.list_member_ids()) member_ids = set(self.repository.list_member_ids())
asset_ids = set(self.repository.list_asset_ids())
_remove_orphaned_member_items(items, member_ids) _remove_orphaned_member_items(items, member_ids)
_remove_orphaned_asset_items(items, asset_ids)
rules = load_rules(self.repository.root) rules = load_rules(self.repository.root)
repository_config = self.repository.get_configuration() repository_config = self.repository.get_configuration()
@@ -87,6 +95,15 @@ class Housekeeper:
successful_scopes.add(("member-record-check", member_id)) successful_scopes.add(("member-record-check", member_id))
continue continue
successful_scopes.add(("member-record-check", member_id)) successful_scopes.add(("member-record-check", member_id))
self._refresh_hash_integrity_tasks(
items,
target_type="member",
target_id=member_id,
warnings=self.repository.member_hash_warnings(member_id),
run_id=run_id,
now=now,
)
successful_scopes.add(("member-hash-check", member_id))
for rule in rules: for rule in rules:
scope = (rule.rule_id, member.member_id) scope = (rule.rule_id, member.member_id)
try: try:
@@ -124,6 +141,17 @@ class Housekeeper:
else: else:
successful_scopes.add(scope) successful_scopes.add(scope)
for asset_id in sorted(asset_ids):
self._refresh_hash_integrity_tasks(
items,
target_type="asset",
target_id=asset_id,
warnings=self.repository.asset_hash_warnings(asset_id),
run_id=run_id,
now=now,
)
successful_scopes.add(("asset-hash-check", asset_id))
self._resolve_stale_tasks(items, successful_scopes, run_id, now) self._resolve_stale_tasks(items, successful_scopes, run_id, now)
working.update( working.update(
{ {
@@ -192,6 +220,58 @@ class Housekeeper:
) )
items[key] = item items[key] = item
@staticmethod
def _refresh_hash_integrity_tasks(
items: dict[str, dict[str, Any]],
*,
target_type: str,
target_id: str,
warnings: list[str],
run_id: str,
now: str,
) -> None:
key = f"{target_type}-hash-check:{target_id}:json-hash-mismatch"
if not warnings:
item = items.get(key)
if item and item.get("status") == "open":
item["status"] = "resolved"
item["resolved_run"] = run_id
item["resolved_at"] = now
return
item = items.get(key, {})
was_resolved = item.get("status") == "resolved"
item.update(
{
"key": key,
"rule_id": f"{target_type}-hash-check",
"rule_file": "<hash-check>",
"rule_source": "housekeeper",
"member_id": target_id if target_type == "member" else "",
"asset_id": target_id if target_type == "asset" else "",
"target_type": target_type,
"action": "task",
"status": "open",
"severity": "warning",
"code": "json_hash_mismatch",
"title": (
f"Mitgliederakte {target_id}: JSON extern geändert"
if target_type == "member"
else f"Assetakte {target_id}: JSON extern geändert"
),
"detail": " ; ".join(warnings),
"due_date": None,
"first_seen_run": item.get("first_seen_run", run_id),
"first_seen_at": item.get("first_seen_at", now),
"last_seen_run": run_id,
"last_seen_at": now,
"seen_count": int(item.get("seen_count", 0)) + 1,
"reopened_count": int(item.get("reopened_count", 0)) + (1 if was_resolved else 0),
"resolved_run": None,
"resolved_at": None,
}
)
items[key] = item
def _apply_action( def _apply_action(
self, self,
items: dict[str, dict[str, Any]], items: dict[str, dict[str, Any]],
@@ -337,7 +417,8 @@ class Housekeeper:
for item in items.values(): for item in items.values():
if item.get("action") != "task" or item.get("status") != "open": if item.get("action") != "task" or item.get("status") != "open":
continue continue
scope = (str(item.get("rule_id", "")), str(item.get("member_id", ""))) target_id = str(item.get("member_id", "") or item.get("asset_id", ""))
scope = (str(item.get("rule_id", "")), target_id)
if scope not in successful_scopes or item.get("last_seen_run") == run_id: if scope not in successful_scopes or item.get("last_seen_run") == run_id:
continue continue
item["status"] = "resolved" item["status"] = "resolved"
@@ -390,10 +471,12 @@ def _open_findings(items: list[dict[str, Any]]) -> list[HousekeeperFinding]:
findings.append( findings.append(
HousekeeperFinding( HousekeeperFinding(
severity=str(item.get("severity", "info")), severity=str(item.get("severity", "info")),
member_id=str(item.get("member_id", "")),
code=str(item.get("code", item.get("rule_id", "housekeeper"))), code=str(item.get("code", item.get("rule_id", "housekeeper"))),
title=str(item.get("title", item.get("key", "Hausmeister"))), title=str(item.get("title", item.get("key", "Hausmeister"))),
detail=str(item.get("detail", "")), detail=str(item.get("detail", "")),
member_id=str(item.get("member_id", "")),
asset_id=str(item.get("asset_id", "")),
target_type=str(item.get("target_type", "member")),
due_date=due_date, due_date=due_date,
key=str(item.get("key", "")), key=str(item.get("key", "")),
) )
@@ -415,6 +498,16 @@ def _remove_orphaned_member_items(items: dict[str, dict[str, Any]], member_ids:
del items[key] del items[key]
def _remove_orphaned_asset_items(items: dict[str, dict[str, Any]], asset_ids: set[str]) -> None:
orphaned_keys = [
key
for key, item in items.items()
if item.get("asset_id") and str(item["asset_id"]) not in asset_ids
]
for key in orphaned_keys:
del items[key]
def _non_negative_delay(value: float) -> float: def _non_negative_delay(value: float) -> float:
try: try:
delay = float(value) delay = float(value)
+44 -1
View File
@@ -1,17 +1,60 @@
import hashlib
import json import json
import os import os
import tempfile import tempfile
from copy import deepcopy
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
CONTENT_HASH_FIELD = "content_hash"
def _hashable_copy(data: Any, *, hash_field: str = CONTENT_HASH_FIELD) -> Any:
if isinstance(data, dict):
return {
key: _hashable_copy(value, hash_field=hash_field)
for key, value in data.items()
if key != hash_field
}
if isinstance(data, list):
return [_hashable_copy(item, hash_field=hash_field) for item in data]
return data
def compute_json_content_hash(data: Any, *, hash_field: str = CONTENT_HASH_FIELD) -> str:
payload = json.dumps(
_hashable_copy(data, hash_field=hash_field),
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
return hashlib.sha256(payload).hexdigest()
def attach_json_content_hash(data: Any, *, hash_field: str = CONTENT_HASH_FIELD) -> Any:
cloned = deepcopy(data)
if isinstance(cloned, dict):
cloned[hash_field] = compute_json_content_hash(cloned, hash_field=hash_field)
return cloned
def json_content_hash_matches(data: Any, *, hash_field: str = CONTENT_HASH_FIELD) -> bool:
if not isinstance(data, dict):
return True
stored = str(data.get(hash_field, "")).strip()
if not stored:
return False
return stored == compute_json_content_hash(data, hash_field=hash_field)
def write_json_atomic(path: Path, data: Any) -> None: def write_json_atomic(path: Path, data: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent) descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
temporary = Path(temporary_name) temporary = Path(temporary_name)
payload = attach_json_content_hash(data)
try: try:
with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as handle: with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as handle:
json.dump(data, handle, ensure_ascii=False, indent=2) json.dump(payload, handle, ensure_ascii=False, indent=2)
handle.write("\n") handle.write("\n")
handle.flush() handle.flush()
os.fsync(handle.fileno()) os.fsync(handle.fileno())
+545 -20
View File
@@ -13,14 +13,22 @@ from uuid import uuid4
from ccma.domain.contributions import ( from ccma.domain.contributions import (
claim_balance, claim_balance,
claim_total, claim_total,
credit_allocated_total,
decimal_value, decimal_value,
materialize_claim_items, materialize_claim_items,
money_text, money_text,
payment_allocated_total, payment_allocated_total,
) )
from ccma.domain.dates import DateValidationError, normalize_date_input, validate_member_dates from ccma.domain.dates import DateValidationError, normalize_date_input, validate_member_dates
from ccma.domain.models import MEMBERSHIP_STATUS_LABELS, ContributionData, Event, Member from ccma.domain.models import (
from ccma.storage.atomic import read_json, write_json_atomic ASSET_STATUS_LABELS,
MEMBERSHIP_STATUS_LABELS,
Asset,
ContributionData,
Event,
Member,
)
from ccma.storage.atomic import json_content_hash_matches, read_json, write_json_atomic
class RepositoryError(RuntimeError): class RepositoryError(RuntimeError):
@@ -29,6 +37,39 @@ class RepositoryError(RuntimeError):
DEFAULT_MEMBER_NUMBER_PATTERN = "CCMA-{number:04d}" DEFAULT_MEMBER_NUMBER_PATTERN = "CCMA-{number:04d}"
DEFAULT_CONTRIBUTION_RULES = [
{
"rule_id": "standard-before-2022",
"name": "Regulärer Beitrag bis 2021",
"valid_from": "1900-01-01",
"valid_until": "2021-12-31",
"annual_amount": "120.00",
"admission_fee": "10.00",
"annual_due": "01-31",
"semiannual_due": ["01-31", "07-31"],
"entry_proration": {"mode": "monthly", "started_month": "included"},
"first_payment_due_days_after_acceptance": 28,
"issue_days_before_due": 30,
"reminder_fee": "5.00",
"failed_debit_fee": "5.00",
},
{
"rule_id": "standard-2022",
"name": "Regulärer Beitrag ab 2022",
"valid_from": "2022-01-01",
"valid_until": None,
"annual_amount": "150.00",
"admission_fee": "10.00",
"annual_due": "01-31",
"semiannual_due": ["01-31", "07-31"],
"entry_proration": {"mode": "monthly", "started_month": "included"},
"first_payment_due_days_after_acceptance": 28,
"issue_days_before_due": 30,
"reminder_fee": "5.00",
"failed_debit_fee": "5.00",
},
]
DEFAULT_CONFIGURATION = { DEFAULT_CONFIGURATION = {
"schema_version": 1, "schema_version": 1,
@@ -73,23 +114,7 @@ DEFAULT_CONFIGURATION = {
}, },
], ],
}, },
"contribution_rules": [ "contribution_rules": DEFAULT_CONTRIBUTION_RULES,
{
"rule_id": "standard-2022",
"name": "Regulärer Beitrag ab 2022",
"valid_from": "2022-01-01",
"valid_until": None,
"annual_amount": "150.00",
"admission_fee": "10.00",
"annual_due": "01-31",
"semiannual_due": ["01-31", "07-31"],
"entry_proration": {"mode": "monthly", "started_month": "included"},
"first_payment_due_days_after_acceptance": 28,
"issue_days_before_due": 30,
"reminder_fee": "5.00",
"failed_debit_fee": "5.00",
}
],
} }
@@ -97,9 +122,11 @@ class MemberRepository:
def __init__(self, root: Path | str): def __init__(self, root: Path | str):
self.root = Path(root).expanduser().resolve() self.root = Path(root).expanduser().resolve()
self.members_root = self.root / "members" self.members_root = self.root / "members"
self.assets_root = self.root / "assets"
def initialize(self) -> None: def initialize(self) -> None:
self.members_root.mkdir(parents=True, exist_ok=True) self.members_root.mkdir(parents=True, exist_ok=True)
self.assets_root.mkdir(parents=True, exist_ok=True)
(self.root / "rules").mkdir(parents=True, exist_ok=True) (self.root / "rules").mkdir(parents=True, exist_ok=True)
templates_root = self.root / "templates" templates_root = self.root / "templates"
templates_root.mkdir(parents=True, exist_ok=True) templates_root.mkdir(parents=True, exist_ok=True)
@@ -122,6 +149,10 @@ class MemberRepository:
errors: list[str] = [] errors: list[str] = []
try: try:
config = read_json(self.root / "repository.json") config = read_json(self.root / "repository.json")
if not json_content_hash_matches(config):
errors.append(
"repository.json: Hash fehlt oder stimmt nicht; Datei wurde vermutlich extern geändert."
)
if int(config.get("schema_version", 0)) != 1: if int(config.get("schema_version", 0)) != 1:
errors.append("repository.json: nicht unterstützte schema_version") errors.append("repository.json: nicht unterstützte schema_version")
policy = config.get("member_number_policy") or {} policy = config.get("member_number_policy") or {}
@@ -135,6 +166,10 @@ class MemberRepository:
for member_dir in self._member_directories(): for member_dir in self._member_directories():
try: try:
member, _contributions = self.preflight_member_record(member_dir.name) member, _contributions = self.preflight_member_record(member_dir.name)
errors.extend(
f"{member_dir.name}/{warning}"
for warning in self.member_hash_warnings(member_dir.name)
)
validate_member_dates( validate_member_dates(
birth_date=member.birth_date, birth_date=member.birth_date,
accepted_at=member.accepted_at, accepted_at=member.accepted_at,
@@ -160,6 +195,35 @@ class MemberRepository:
DateValidationError, DateValidationError,
) as exc: ) as exc:
errors.append(f"{member_dir.name}/member.json: {exc}") errors.append(f"{member_dir.name}/member.json: {exc}")
for asset_dir in self._asset_directories():
try:
asset = self.get_asset(asset_dir.name)
errors.extend(
f"{asset_dir.name}/{warning}"
for warning in self.asset_hash_warnings(asset_dir.name)
)
if asset.asset_id != asset_dir.name:
errors.append(f"{asset_dir.name}/asset.json: asset_id stimmt nicht mit Ordner überein")
if asset.schema_version != 1:
errors.append(
f"{asset_dir.name}/asset.json: "
f"nicht unterstützte schema_version {asset.schema_version}"
)
if asset.status not in ASSET_STATUS_LABELS:
errors.append(f"{asset_dir.name}/asset.json: ungültiger Asset-Status")
if asset.current_holder_member_id:
self.get_member(asset.current_holder_member_id)
if asset.status != "issued":
errors.append(
f"{asset_dir.name}/asset.json: zugeordnetes Asset muss Status issued haben"
)
elif asset.status == "issued":
errors.append(f"{asset_dir.name}/asset.json: issued benötigt current_holder_member_id")
self.get_asset_events(asset.asset_id)
except RepositoryError as exc:
errors.append(str(exc))
except (OSError, ValueError, TypeError, KeyError, json.JSONDecodeError) as exc:
errors.append(f"{asset_dir.name}/asset.json: {exc}")
return errors return errors
def list_members(self) -> list[Member]: def list_members(self) -> list[Member]:
@@ -209,6 +273,7 @@ class MemberRepository:
*, *,
first_name: str, first_name: str,
last_name: str, last_name: str,
nickname: str = "",
email: str = "", email: str = "",
phone: str = "", phone: str = "",
birth_date: str = "", birth_date: str = "",
@@ -238,6 +303,7 @@ class MemberRepository:
member_number=selected_number, member_number=selected_number,
first_name=first_name.strip(), first_name=first_name.strip(),
last_name=last_name.strip(), last_name=last_name.strip(),
nickname=nickname.strip(),
email=email.strip(), email=email.strip(),
phone=phone.strip(), phone=phone.strip(),
birth_date=birth_date, birth_date=birth_date,
@@ -294,6 +360,301 @@ class MemberRepository:
actor_name=actor_name, actor_name=actor_name,
) )
def list_assets(self) -> list[Asset]:
assets: list[Asset] = []
for asset_id in self.list_asset_ids():
try:
assets.append(self.get_asset(asset_id))
except RepositoryError:
continue
return sorted(assets, key=lambda item: (item.label.casefold(), item.inventory_number.casefold()))
def list_asset_ids(self) -> list[str]:
return sorted(directory.name for directory in self._asset_directories())
def get_asset(self, asset_id: str) -> Asset:
path = self._asset_path(asset_id) / "asset.json"
if not path.is_file():
raise RepositoryError(f"Asset nicht gefunden: {asset_id}")
try:
raw = read_json(path)
if not isinstance(raw, dict):
raise TypeError("Wurzelelement muss ein JSON-Objekt sein")
return Asset.from_dict(raw)
except (OSError, ValueError, TypeError, KeyError, json.JSONDecodeError) as exc:
raise RepositoryError(f"{asset_id}/asset.json konnte nicht gelesen werden: {exc}") from exc
def create_asset(
self,
*,
label: str,
category: str = "",
inventory_number: str = "",
serial_number: str = "",
deposit_amount_default: str = "0",
notes: str = "",
) -> Asset:
if not label.strip():
raise RepositoryError("Eine Bezeichnung für das Asset ist erforderlich.")
try:
deposit_amount = decimal_value(deposit_amount_default or "0", "Kaution")
except ValueError as exc:
raise RepositoryError(str(exc)) from exc
if deposit_amount < 0:
raise RepositoryError("Die Kaution darf nicht negativ sein.")
asset_id = str(uuid4())
directory = self._asset_path(asset_id)
directory.mkdir(parents=True, exist_ok=False)
(directory / "files").mkdir()
asset = Asset(
asset_id=asset_id,
label=label.strip(),
category=category.strip(),
inventory_number=inventory_number.strip(),
serial_number=serial_number.strip(),
deposit_amount_default=money_text(deposit_amount),
notes=notes.strip(),
)
write_json_atomic(directory / "asset.json", asset.to_dict())
self.append_asset_event(
asset.asset_id,
event_type="asset_created",
summary="Asset angelegt",
actor_type="user",
actor_name="Vorstand",
)
return asset
def save_asset(self, asset: Asset, *, actor_name: str = "Vorstand") -> None:
existing = self.get_asset(asset.asset_id)
if not asset.label.strip():
raise RepositoryError("Eine Bezeichnung für das Asset ist erforderlich.")
if asset.status not in ASSET_STATUS_LABELS:
raise RepositoryError("Ungültiger Asset-Status.")
try:
deposit_amount = decimal_value(asset.deposit_amount_default or "0", "Kaution")
except ValueError as exc:
raise RepositoryError(str(exc)) from exc
if deposit_amount < 0:
raise RepositoryError("Die Kaution darf nicht negativ sein.")
if (
existing.current_holder_member_id
and money_text(deposit_amount) != str(existing.deposit_amount_default)
):
raise RepositoryError(
"Die Kaution kann nur geändert werden, wenn das Asset nicht ausgegeben ist."
)
asset.label = asset.label.strip()
asset.category = asset.category.strip()
asset.inventory_number = asset.inventory_number.strip()
asset.serial_number = asset.serial_number.strip()
asset.deposit_amount_default = money_text(deposit_amount)
asset.notes = asset.notes.strip()
if asset.current_holder_member_id:
self.get_member(asset.current_holder_member_id)
if asset.status != "issued":
raise RepositoryError("Ein zugeordnetes Asset muss den Status issued haben.")
elif asset.status == "issued":
raise RepositoryError("Status issued benötigt ein zugeordnetes Mitglied.")
changes = self._summarize_asset_changes(existing, asset)
asset.updated_at = datetime.now().astimezone().isoformat(timespec="seconds")
write_json_atomic(self._asset_path(asset.asset_id) / "asset.json", asset.to_dict())
if changes:
self.append_asset_event(
asset.asset_id,
event_type="asset_data_changed",
summary=f"Assetdaten geändert: {', '.join(changes)}",
actor_type="user",
actor_name=actor_name,
)
def assign_asset(self, asset_id: str, member_id: str, *, actor_name: str = "Vorstand") -> Asset:
asset = self.get_asset(asset_id)
member = self.get_member(member_id)
if asset.current_holder_member_id:
raise RepositoryError("Das Asset ist bereits einem Mitglied zugeordnet.")
if asset.status in {"lost", "retired"}:
raise RepositoryError("Verlorene oder ausgemusterte Assets können nicht ausgegeben werden.")
asset.current_holder_member_id = member.member_id
asset.status = "issued"
asset.updated_at = datetime.now().astimezone().isoformat(timespec="seconds")
write_json_atomic(self._asset_path(asset.asset_id) / "asset.json", asset.to_dict())
self.append_asset_event(
asset.asset_id,
event_type="asset_issued",
summary=f"Asset ausgegeben an {member.member_number or member.member_id}",
actor_type="user",
actor_name=actor_name,
references={"member_id": member.member_id},
)
self.append_event(
member.member_id,
event_type="asset_assigned",
summary=f"Asset ausgegeben: {asset.label}",
actor_type="user",
actor_name=actor_name,
references={"asset_id": asset.asset_id},
)
return asset
def return_asset(self, asset_id: str, *, actor_name: str = "Vorstand") -> Asset:
asset = self.get_asset(asset_id)
member_id = asset.current_holder_member_id
if not member_id:
raise RepositoryError("Das Asset ist aktuell keinem Mitglied zugeordnet.")
asset.current_holder_member_id = ""
asset.status = "available"
asset.updated_at = datetime.now().astimezone().isoformat(timespec="seconds")
write_json_atomic(self._asset_path(asset.asset_id) / "asset.json", asset.to_dict())
self.append_asset_event(
asset.asset_id,
event_type="asset_returned",
summary="Asset zurückgenommen",
actor_type="user",
actor_name=actor_name,
references={"member_id": member_id},
)
self.append_event(
member_id,
event_type="asset_returned",
summary=f"Asset zurückgegeben: {asset.label}",
actor_type="user",
actor_name=actor_name,
references={"asset_id": asset.asset_id},
)
return asset
def list_member_assets(self, member_id: str) -> list[Asset]:
self.get_member(member_id)
return [asset for asset in self.list_assets() if asset.current_holder_member_id == member_id]
def create_manual_claim(
self,
member_id: str,
*,
title: str,
amount: str,
due_date: str,
description: str = "",
claim_type: str = "asset_charge",
references: dict[str, str] | None = None,
actor_name: str = "Vorstand",
) -> dict:
self.get_member(member_id)
if not title.strip():
raise RepositoryError("Ein Forderungstitel ist erforderlich.")
try:
normalized_due_date = normalize_date_input(due_date, "Fälligkeitsdatum")
amount_value = decimal_value(amount)
except (DateValidationError, ValueError) as exc:
raise RepositoryError(str(exc)) from exc
if not normalized_due_date:
raise RepositoryError("Ein Fälligkeitsdatum ist erforderlich.")
if amount_value == 0:
raise RepositoryError("Der Betrag darf nicht null sein.")
now = datetime.now().astimezone().isoformat(timespec="seconds")
claim_id = str(uuid4())
item_type = "credit" if amount_value < 0 else "base"
claim = {
"claim_id": claim_id,
"claim_key": f"manual:{claim_type}:{claim_id}",
"title": title.strip(),
"amount": money_text(amount_value),
"due_date": normalized_due_date,
"status": "open",
"created_at": now,
"items": [
{
"item_id": str(uuid4()),
"type": item_type,
"description": description.strip() or title.strip(),
"quantity": "1.00",
"unit_price": money_text(amount_value),
"amount": money_text(amount_value),
"created_at": now,
}
],
"origin": {
"type": "manual",
"subtype": claim_type.strip() or "asset_charge",
"actor": actor_name,
"asset_id": (references or {}).get("asset_id", ""),
},
}
data = self.get_contributions(member_id)
data.claims.append(claim)
self.save_contributions(member_id, data)
event = self.append_event(
member_id,
event_type="claim_created",
summary=f"Forderung angelegt: {claim['title']}",
actor_type="user",
actor_name=actor_name,
references={"claim_id": claim_id, **(references or {})},
data={"amount": claim["amount"], "due_date": claim["due_date"], "claim_type": claim_type},
)
if references and references.get("asset_id"):
self.append_asset_event(
references["asset_id"],
event_type="asset_claim_created",
summary=f"Forderung für Asset angelegt: {claim['title']}",
actor_type="user",
actor_name=actor_name,
references={"member_id": member_id, "claim_id": claim_id},
data={"amount": claim["amount"], "due_date": claim["due_date"], "claim_type": claim_type},
)
return {"claim": claim, "event": event}
def append_asset_event(
self,
asset_id: str,
*,
event_type: str,
summary: str,
actor_type: str = "system",
actor_name: str = "CCMA",
references: dict[str, str] | None = None,
data: dict[str, object] | None = None,
) -> Event:
directory = self._asset_path(asset_id)
if not (directory / "asset.json").is_file():
raise RepositoryError(f"Asset nicht gefunden: {asset_id}")
event = Event(
event_id=str(uuid4()),
timestamp=datetime.now().astimezone().isoformat(timespec="seconds"),
event_type=event_type,
summary=summary.strip(),
actor_type=actor_type,
actor_name=actor_name,
references=references or {},
data=data or {},
)
path = directory / "events.jsonl"
line = json.dumps(event.to_dict(), ensure_ascii=False, separators=(",", ":")) + "\n"
with path.open("a", encoding="utf-8", newline="\n") as handle:
handle.write(line)
handle.flush()
os.fsync(handle.fileno())
return event
def get_asset_events(self, asset_id: str) -> list[Event]:
path = self._asset_path(asset_id) / "events.jsonl"
if not path.exists():
return []
events: list[Event] = []
with path.open("r", encoding="utf-8") as handle:
for line_number, line in enumerate(handle, start=1):
if not line.strip():
continue
try:
raw = json.loads(line)
if not isinstance(raw, dict):
raise TypeError("Event muss ein JSON-Objekt sein")
events.append(Event.from_dict(raw))
except (AttributeError, ValueError, TypeError, KeyError, json.JSONDecodeError) as exc:
raise RepositoryError(f"Ungültiges Event in Zeile {line_number}: {exc}") from exc
return events
def get_contributions(self, member_id: str) -> ContributionData: def get_contributions(self, member_id: str) -> ContributionData:
path = self._member_path(member_id) / "contributions.json" path = self._member_path(member_id) / "contributions.json"
if not path.exists(): if not path.exists():
@@ -302,7 +663,7 @@ class MemberRepository:
raw = read_json(path) raw = read_json(path)
if not isinstance(raw, dict): if not isinstance(raw, dict):
raise TypeError("Wurzelelement muss ein JSON-Objekt sein") raise TypeError("Wurzelelement muss ein JSON-Objekt sein")
for field_name in ("claims", "payments", "allocations", "reminders"): for field_name in ("claims", "payments", "credits", "allocations", "reminders"):
if field_name in raw and not isinstance(raw[field_name], list): if field_name in raw and not isinstance(raw[field_name], list):
raise TypeError(f"{field_name} muss eine JSON-Liste sein") raise TypeError(f"{field_name} muss eine JSON-Liste sein")
if field_name in raw and any(not isinstance(item, dict) for item in raw[field_name]): if field_name in raw and any(not isinstance(item, dict) for item in raw[field_name]):
@@ -457,6 +818,92 @@ class MemberRepository:
) )
return allocation return allocation
def record_credit(
self,
member_id: str,
claim_id: str,
*,
credit_date: str,
amount: str,
allocation_amount: str,
reference: str = "",
) -> dict:
try:
normalized_date = normalize_date_input(credit_date, "Gutschriftsdatum")
selected_amount = decimal_value(amount, "Gutschrift")
selected_allocation = decimal_value(allocation_amount, "Zuordnung")
except (DateValidationError, ValueError) as exc:
raise RepositoryError(str(exc)) from exc
if not normalized_date:
raise RepositoryError("Gutschriftsdatum ist erforderlich.")
if selected_amount <= 0:
raise RepositoryError("Der Gutschriftsbetrag muss größer als null sein.")
if selected_allocation <= 0 or selected_allocation > selected_amount:
raise RepositoryError(
"Die Zuordnung muss größer als null und höchstens so hoch wie die Gutschrift sein."
)
data, claim = self.get_claim(member_id, claim_id)
if claim_total(claim) >= 0:
raise RepositoryError("Gutschriften können nur negativen Forderungen zugeordnet werden.")
credit = {
"credit_id": str(uuid4()),
"date": normalized_date,
"amount": money_text(selected_amount),
"method": "payout",
"reference": reference.strip(),
"created_at": datetime.now().astimezone().isoformat(timespec="seconds"),
}
allocation = {
"allocation_id": str(uuid4()),
"credit_id": credit["credit_id"],
"claim_id": claim_id,
"amount": money_text(selected_allocation),
}
data.credits.append(credit)
data.allocations.append(allocation)
self.save_contributions(member_id, data)
self.append_event(
member_id,
event_type="credit_recorded",
summary=f"Gutschrift / Auszahlung erfasst: {credit['amount']} EUR",
references={"claim_id": claim_id, "credit_id": str(credit["credit_id"])},
data={"allocation_amount": allocation["amount"]},
)
return credit
def allocate_credit(self, member_id: str, claim_id: str, *, credit_id: str, amount: str) -> dict:
data, claim = self.get_claim(member_id, claim_id)
if claim_total(claim) >= 0:
raise RepositoryError("Gutschriften können nur negativen Forderungen zugeordnet werden.")
credit = next(
(item for item in data.credits if str(item.get("credit_id", "")) == credit_id),
None,
)
if credit is None:
raise RepositoryError("Gutschrift nicht gefunden.")
try:
selected_amount = decimal_value(amount, "Zuordnung")
available = decimal_value(credit.get("amount", "0")) - credit_allocated_total(data, credit_id)
except ValueError as exc:
raise RepositoryError(str(exc)) from exc
if selected_amount <= 0 or selected_amount > available:
raise RepositoryError(f"Es sind nur {money_text(available)} EUR dieser Gutschrift verfügbar.")
allocation = {
"allocation_id": str(uuid4()),
"credit_id": credit_id,
"claim_id": claim_id,
"amount": money_text(selected_amount),
}
data.allocations.append(allocation)
self.save_contributions(member_id, data)
self.append_event(
member_id,
event_type="credit_allocated",
summary=f"Gutschrift zugeordnet: {allocation['amount']} EUR",
references={"claim_id": claim_id, "credit_id": credit_id},
)
return allocation
def create_reminder_draft( def create_reminder_draft(
self, self,
member_id: str, member_id: str,
@@ -752,6 +1199,7 @@ class MemberRepository:
member.member_number, member.member_number,
member.first_name, member.first_name,
member.last_name, member.last_name,
member.nickname,
member.display_name, member.display_name,
member.email, member.email,
member.phone, member.phone,
@@ -773,6 +1221,9 @@ class MemberRepository:
def member_count(self) -> int: def member_count(self) -> int:
return sum(1 for _ in self._member_directories()) return sum(1 for _ in self._member_directories())
def asset_count(self) -> int:
return sum(1 for _ in self._asset_directories())
def get_configuration(self) -> dict: def get_configuration(self) -> dict:
try: try:
configuration = read_json(self.root / "repository.json") configuration = read_json(self.root / "repository.json")
@@ -782,6 +1233,49 @@ class MemberRepository:
raise RepositoryError("repository.json enthält keine gültige Konfiguration.") raise RepositoryError("repository.json enthält keine gültige Konfiguration.")
return configuration return configuration
def member_hash_warnings(self, member_id: str) -> list[str]:
warnings: list[str] = []
try:
member_raw = read_json(self._member_path(member_id) / "member.json")
if not json_content_hash_matches(member_raw):
warnings.append(
"member.json: Hash fehlt oder stimmt nicht; Datei wurde vermutlich extern geändert."
)
except (OSError, ValueError, TypeError, json.JSONDecodeError):
pass
try:
contributions_raw = read_json(self._member_path(member_id) / "contributions.json")
if not json_content_hash_matches(contributions_raw):
warnings.append(
"contributions.json: Hash fehlt oder stimmt nicht; "
"Datei wurde vermutlich extern geändert."
)
except (OSError, ValueError, TypeError, json.JSONDecodeError):
pass
return warnings
def asset_hash_warnings(self, asset_id: str) -> list[str]:
warnings: list[str] = []
try:
asset_raw = read_json(self._asset_path(asset_id) / "asset.json")
if not json_content_hash_matches(asset_raw):
warnings.append(
"asset.json: Hash fehlt oder stimmt nicht; Datei wurde vermutlich extern geändert."
)
except (OSError, ValueError, TypeError, json.JSONDecodeError):
pass
return warnings
def refresh_member_record_hashes(self, member_id: str) -> None:
member = self.get_member(member_id)
contributions = self.get_contributions(member_id)
write_json_atomic(self._member_path(member_id) / "member.json", member.to_dict())
write_json_atomic(self._member_path(member_id) / "contributions.json", contributions.to_dict())
def refresh_asset_record_hashes(self, asset_id: str) -> None:
asset = self.get_asset(asset_id)
write_json_atomic(self._asset_path(asset_id) / "asset.json", asset.to_dict())
def get_member_number_policy(self) -> dict[str, str]: def get_member_number_policy(self) -> dict[str, str]:
try: try:
config = read_json(self.root / "repository.json") config = read_json(self.root / "repository.json")
@@ -829,11 +1323,23 @@ class MemberRepository:
path for path in self.members_root.iterdir() if path.is_dir() and not path.name.startswith(".") path for path in self.members_root.iterdir() if path.is_dir() and not path.name.startswith(".")
) )
def _asset_directories(self) -> Iterable[Path]:
if not self.assets_root.exists():
return []
return (
path for path in self.assets_root.iterdir() if path.is_dir() and not path.name.startswith(".")
)
def _member_path(self, member_id: str) -> Path: def _member_path(self, member_id: str) -> Path:
if not member_id or Path(member_id).name != member_id or member_id in {".", ".."}: if not member_id or Path(member_id).name != member_id or member_id in {".", ".."}:
raise RepositoryError("Ungültige Mitglieds-ID.") raise RepositoryError("Ungültige Mitglieds-ID.")
return self.members_root / member_id return self.members_root / member_id
def _asset_path(self, asset_id: str) -> Path:
if not asset_id or Path(asset_id).name != asset_id or asset_id in {".", ".."}:
raise RepositoryError("Ungültige Asset-ID.")
return self.assets_root / asset_id
def _allocate_member_number(self, pattern: str) -> str: def _allocate_member_number(self, pattern: str) -> str:
config = read_json(self.root / "repository.json") config = read_json(self.root / "repository.json")
member_number, next_value = self._next_available_member_number(config, pattern) member_number, next_value = self._next_available_member_number(config, pattern)
@@ -880,6 +1386,7 @@ class MemberRepository:
"member_number": "Mitgliedsnummer", "member_number": "Mitgliedsnummer",
"first_name": "Vorname", "first_name": "Vorname",
"last_name": "Nachname", "last_name": "Nachname",
"nickname": "Nickname",
"email": "E-Mail-Adresse", "email": "E-Mail-Adresse",
"phone": "Telefonnummer", "phone": "Telefonnummer",
"birth_date": "Geburtsdatum", "birth_date": "Geburtsdatum",
@@ -915,6 +1422,24 @@ class MemberRepository:
changes.append(label) changes.append(label)
return changes return changes
@staticmethod
def _summarize_asset_changes(before: Asset, after: Asset) -> list[str]:
labels = {
"label": "Bezeichnung",
"category": "Kategorie",
"inventory_number": "Inventarnummer",
"serial_number": "Seriennummer",
"status": "Status",
"current_holder_member_id": "Zuordnung",
"deposit_amount_default": "Kaution",
"notes": "Notiz",
}
changes: list[str] = []
for field, label in labels.items():
if getattr(before, field) != getattr(after, field):
changes.append(label)
return changes
def normalize_iban(value: str) -> str: def normalize_iban(value: str) -> str:
return "".join(value.split()).upper() return "".join(value.split()).upper()
+498
View File
@@ -0,0 +1,498 @@
from __future__ import annotations
import tkinter as tk
from collections.abc import Callable
from datetime import datetime
from tkinter import messagebox, ttk
from ccma.domain.models import ASSET_STATUS_LABELS, Event
from ccma.storage.repository import MemberRepository, RepositoryError
from ccma.ui.dialogs import AssetClaimDialog, IntegrityWarningDialog
from ccma.ui.messages import MessageAction, MessageBannerList, TabMessage
from ccma.ui.scrolling import ScrollableFrame
class AssetTab(ttk.Frame):
def __init__(
self,
master: tk.Misc,
repository: MemberRepository,
asset_id: str,
on_close: Callable[[], None],
on_changed: Callable[[], None],
on_open_member: Callable[[str], None],
on_issue_asset: Callable[[str], None],
on_return_asset: Callable[[str], None],
on_open_claim: Callable[[str, str], None],
):
super().__init__(master, padding=12)
self.repository = repository
self.asset_id = asset_id
self.on_close = on_close
self.on_changed = on_changed
self.on_open_member = on_open_member
self.on_issue_asset = on_issue_asset
self.on_return_asset = on_return_asset
self.on_open_claim = on_open_claim
self.asset = repository.get_asset(asset_id)
self.variables: dict[str, tk.StringVar] = {}
self.notes_text: tk.Text | None = None
self._build_ui()
self.refresh()
def _build_ui(self) -> None:
self.columnconfigure(0, weight=1)
self.rowconfigure(2, weight=1)
header = ttk.Frame(self)
header.grid(row=0, column=0, sticky="ew", pady=(0, 10))
header.columnconfigure(0, weight=1)
header.columnconfigure(1, weight=0)
self.title_var = tk.StringVar()
self.subtitle_var = tk.StringVar()
self.id_var = tk.StringVar()
title_column = ttk.Frame(header)
title_column.grid(row=0, column=0, sticky="ew")
title_column.columnconfigure(0, weight=1)
ttk.Label(title_column, textvariable=self.title_var, style="TabTitle.TLabel").grid(
row=0, column=0, sticky="w"
)
ttk.Label(title_column, textvariable=self.subtitle_var, style="Mono.TLabel").grid(
row=1, column=0, sticky="w"
)
ttk.Label(title_column, textvariable=self.id_var, style="Mono.TLabel").grid(
row=2, column=0, sticky="w", pady=(3, 0)
)
ttk.Button(header, text="Tab schließen", command=self.on_close).grid(
row=0, column=1, sticky="ne", padx=(12, 0)
)
self.messages = MessageBannerList(self)
self.messages.grid(row=1, column=0, sticky="ew", pady=(0, 10))
self.messages.grid_remove()
self.pane = ttk.Panedwindow(self, orient="horizontal")
self.pane.grid(row=2, column=0, sticky="nsew")
self.details_pane = ttk.Frame(self.pane, padding=(0, 0, 10, 0))
self.timeline_pane = ttk.Frame(self.pane, padding=(10, 0, 0, 0))
self.pane.add(self.details_pane, weight=2)
self.pane.add(self.timeline_pane, weight=3)
self._build_details(self.details_pane)
self._build_timeline(self.timeline_pane)
self._pane_position_initialized = False
self.pane.bind("<Configure>", self._set_initial_pane_position, add="+")
def _set_initial_pane_position(self, event: tk.Event | None = None) -> None:
if self._pane_position_initialized:
return
try:
width = int(getattr(event, "width", 0)) or self.pane.winfo_width()
if width > 1:
self.pane.sashpos(0, max(360, int(width * 0.4)))
self._pane_position_initialized = True
except tk.TclError:
return
def _build_details(self, parent: ttk.Frame) -> None:
parent.columnconfigure(0, weight=1)
parent.rowconfigure(1, weight=1)
ttk.Label(parent, text="// ASSET", style="TimelineHeader.TLabel").grid(
row=0, column=0, sticky="w", pady=(0, 8)
)
notebook = ttk.Notebook(parent)
notebook.grid(row=1, column=0, sticky="nsew")
data_tab = self._create_form_tab(notebook, "Stammdaten")
actions = ttk.Frame(parent)
actions.grid(row=2, column=0, sticky="ew", pady=(10, 0))
actions.columnconfigure(0, weight=1)
action_buttons = ttk.Frame(actions)
action_buttons.grid(row=0, column=0, sticky="e")
finance_tab = ttk.Frame(notebook, padding=16)
notebook.add(finance_tab, text="Forderungen")
fields = [
("UUID / Ordner-ID", "asset_id"),
("Bezeichnung", "label"),
("Kategorie", "category"),
("Inventarnummer", "inventory_number"),
("Seriennummer", "serial_number"),
("Kaution (EUR)", "deposit_amount_default"),
]
for row, (label, key) in enumerate(fields):
self.variables[key] = tk.StringVar()
ttk.Label(data_tab, text=label).grid(row=row, column=0, sticky="w", pady=5, padx=(0, 12))
entry = ttk.Entry(data_tab, textvariable=self.variables[key], width=42)
entry.grid(row=row, column=1, sticky="ew", pady=5)
if key == "asset_id":
entry.configure(state="readonly")
if key == "deposit_amount_default":
self.deposit_entry = entry
self.variables["status"] = tk.StringVar()
holder_row = len(fields)
ttk.Label(data_tab, text="Status").grid(row=holder_row, column=0, sticky="w", pady=5, padx=(0, 12))
self.status_box = ttk.Combobox(
data_tab,
textvariable=self.variables["status"],
values=["VERFUEGBAR", "AUSGEGEBEN", "VERLOREN", "AUSGEMUSTERT"],
state="readonly",
width=39,
)
self.status_box.grid(row=holder_row, column=1, sticky="ew", pady=5)
self.holder_var = tk.StringVar()
ttk.Label(data_tab, text="Aktueller Halter").grid(
row=holder_row + 1,
column=0,
sticky="w",
pady=5,
padx=(0, 12),
)
self.holder_label = ttk.Label(
data_tab,
textvariable=self.holder_var,
style="TimelineHeader.TLabel",
)
self.holder_label.grid(row=holder_row + 1, column=1, sticky="w", pady=5)
self.holder_label.bind("<Button-1>", lambda _event: self._open_holder_member(), add="+")
ttk.Label(data_tab, text="Interne Notiz").grid(
row=holder_row + 2,
column=0,
sticky="nw",
pady=5,
padx=(0, 12),
)
self.notes_text = tk.Text(data_tab, width=42, height=6, wrap="word")
self.notes_text.grid(row=holder_row + 2, column=1, sticky="ew", pady=5)
data_tab.columnconfigure(1, weight=1)
self.issue_button = ttk.Button(
action_buttons,
text="Ausgeben",
command=lambda: self.on_issue_asset(self.asset_id),
)
self.issue_button.pack(side="left", padx=(0, 8))
self.return_button = ttk.Button(
action_buttons,
text="Zurücknehmen",
command=lambda: self.on_return_asset(self.asset_id),
)
self.return_button.pack(side="left", padx=(0, 8))
ttk.Button(
action_buttons,
text="Speichern",
style="Accent.TButton",
command=self._save,
).pack(side="left")
finance_tab.columnconfigure(0, weight=1)
self.finance_summary_var = tk.StringVar()
ttk.Label(finance_tab, textvariable=self.finance_summary_var, style="Mono.TLabel").grid(
row=0, column=0, sticky="w", pady=(0, 10)
)
finance_actions = ttk.Frame(finance_tab)
finance_actions.grid(row=1, column=0, sticky="ew")
ttk.Button(finance_actions, text="Kautionsforderung", command=self._create_deposit_claim).pack(
side="left", padx=(0, 8)
)
ttk.Button(finance_actions, text="Kautionsrückzahlung", command=self._create_refund_claim).pack(
side="left", padx=(0, 8)
)
ttk.Button(finance_actions, text="Verlustforderung", command=self._create_loss_claim).pack(
side="left", padx=(0, 8)
)
ttk.Button(
finance_actions,
text="Reparaturforderung",
command=self._create_repair_claim,
).pack(side="left")
self.asset_claims = ttk.Treeview(
finance_tab,
columns=("title", "due", "amount", "member"),
show="headings",
)
for key, title, width in (
("title", "Forderung", 240),
("due", "Fällig", 110),
("amount", "Betrag", 90),
("member", "Mitglied", 220),
):
self.asset_claims.heading(key, text=title)
self.asset_claims.column(key, width=width, anchor="w")
self.asset_claims.grid(row=2, column=0, sticky="nsew", pady=(10, 0))
finance_tab.rowconfigure(2, weight=1)
self.asset_claims.bind("<Double-1>", lambda _event: self._open_selected_asset_claim())
self.asset_claims.bind("<Return>", lambda _event: self._open_selected_asset_claim())
def _create_form_tab(self, notebook: ttk.Notebook, title: str) -> ttk.Frame:
tab = ttk.Frame(notebook)
tab.columnconfigure(0, weight=1)
tab.rowconfigure(0, weight=1)
scroller = ScrollableFrame(tab, padding=16)
scroller.grid(row=0, column=0, sticky="nsew")
notebook.add(tab, text=title)
return scroller.content
def _build_timeline(self, parent: ttk.Frame) -> None:
parent.columnconfigure(0, weight=1)
parent.rowconfigure(1, weight=1)
ttk.Label(parent, text="// CHRONIK", style="TimelineHeader.TLabel").grid(
row=0, column=0, sticky="w", pady=(0, 8)
)
self.timeline = ttk.Treeview(
parent, columns=("time", "summary"), show="headings", style="Timeline.Treeview"
)
self.timeline.heading("time", text="Zeit")
self.timeline.heading("summary", text="Ereignis")
self.timeline.column("time", width=135, stretch=False)
self.timeline.column("summary", width=320, stretch=True)
self.timeline.grid(row=1, column=0, sticky="nsew")
compose = ttk.Frame(parent)
compose.grid(row=2, column=0, sticky="ew", pady=(10, 0))
compose.columnconfigure(0, weight=1)
self.comment_var = tk.StringVar()
comment = ttk.Entry(compose, textvariable=self.comment_var)
comment.grid(row=0, column=0, sticky="ew", padx=(0, 6))
comment.bind("<Return>", lambda _event: self._add_comment())
ttk.Button(compose, text="Kommentar", command=self._add_comment).grid(row=0, column=1)
def refresh(self) -> None:
self.asset = self.repository.get_asset(self.asset_id)
self.title_var.set(self.asset.label or self.asset.asset_id)
holder = self._holder_label()
status = ASSET_STATUS_LABELS.get(self.asset.status, self.asset.status.upper())
self.subtitle_var.set(f"{self.asset.inventory_number or ''} · {status} · {holder}")
self.id_var.set(f"UUID: {self.asset.asset_id}")
warnings = self.repository.asset_hash_warnings(self.asset_id)
self.messages.set_messages(
[
TabMessage(
"warning",
"WARNUNG: " + " | ".join(warnings),
MessageAction("Überprüft, bestätigen", self._confirm_integrity_banner),
)
]
if warnings
else []
)
self.variables["asset_id"].set(self.asset.asset_id)
self.variables["label"].set(self.asset.label)
self.variables["category"].set(self.asset.category)
self.variables["inventory_number"].set(self.asset.inventory_number)
self.variables["serial_number"].set(self.asset.serial_number)
self.variables["deposit_amount_default"].set(self.asset.deposit_amount_default)
self.variables["status"].set(ASSET_STATUS_LABELS.get(self.asset.status, self.asset.status.upper()))
if self.notes_text is not None:
self.notes_text.delete("1.0", "end")
self.notes_text.insert("1.0", self.asset.notes)
self.holder_var.set(holder)
issued = bool(self.asset.current_holder_member_id)
self.holder_label.configure(cursor="hand2" if issued else "")
self.issue_button.configure(state="normal" if self.asset.status == "available" else "disabled")
self.return_button.configure(state="normal" if issued else "disabled")
self.status_box.configure(state="disabled" if issued else "readonly")
deposit_state = "readonly" if issued else "normal"
self.deposit_entry.configure(state=deposit_state)
self._refresh_events()
self._refresh_finance()
def _holder_label(self) -> str:
if not self.asset.current_holder_member_id:
return ""
try:
member = self.repository.get_member(self.asset.current_holder_member_id)
except RepositoryError:
return self.asset.current_holder_member_id
return f"{member.member_number or member.member_id} · {member.display_name}"
def _refresh_events(self) -> None:
self.timeline.delete(*self.timeline.get_children())
try:
events = self.repository.get_asset_events(self.asset_id)
except RepositoryError as exc:
messagebox.showerror("Chronik beschädigt", str(exc), parent=self)
return
for event in reversed(events):
self.timeline.insert("", "end", values=(_format_timestamp(event), _event_label(event)))
def _refresh_finance(self) -> None:
self.asset_claims.delete(*self.asset_claims.get_children())
linked_claims: list[tuple[str, str, dict]] = []
for member in self.repository.list_members():
try:
data = self.repository.get_contributions(member.member_id)
except RepositoryError:
continue
for claim in data.claims:
origin = claim.get("origin") or {}
if isinstance(origin, dict) and str(origin.get("asset_id", "")) == self.asset_id:
linked_claims.append((member.member_id, member.display_name, claim))
for member_id, member_name, claim in linked_claims:
claim_id = str(claim.get("claim_id", ""))
self.asset_claims.insert(
"",
"end",
iid=f"{member_id}:{claim_id}",
values=(
str(claim.get("title", "")),
str(claim.get("due_date", "")),
str(claim.get("amount", "")),
member_name,
),
)
self.finance_summary_var.set(
"Forderungen für dieses Asset. Negative Betraege dokumentieren Gutschriften/Rueckzahlungen."
)
def _save(self) -> None:
warnings = self.repository.asset_hash_warnings(self.asset_id)
if warnings:
self._confirm_integrity_and_then(self._save_confirmed)
return
self._save_confirmed()
def _save_confirmed(self) -> None:
self.asset.label = self.variables["label"].get().strip()
self.asset.category = self.variables["category"].get().strip()
self.asset.inventory_number = self.variables["inventory_number"].get().strip()
self.asset.serial_number = self.variables["serial_number"].get().strip()
self.asset.notes = self.notes_text.get("1.0", "end-1c").strip() if self.notes_text else ""
if not self.asset.current_holder_member_id:
self.asset.deposit_amount_default = self.variables["deposit_amount_default"].get().strip()
self.asset.status = _asset_status_key(self.variables["status"].get())
try:
self.repository.save_asset(self.asset)
self.repository.refresh_asset_record_hashes(self.asset_id)
except RepositoryError as exc:
messagebox.showerror("Asset konnte nicht gespeichert werden", str(exc), parent=self)
return
self.refresh()
self.on_changed()
def _confirm_integrity_banner(self) -> None:
self._confirm_integrity_and_then(self._refresh_hashes_only)
def _refresh_hashes_only(self) -> None:
self.repository.refresh_asset_record_hashes(self.asset_id)
self.refresh()
self.on_changed()
def _confirm_integrity_and_then(self, callback: Callable[[], None]) -> None:
warnings = self.repository.asset_hash_warnings(self.asset_id)
if not warnings:
callback()
return
IntegrityWarningDialog(
self,
title="Externe Änderungen bestätigen",
warnings=warnings,
on_confirm=callback,
)
def _add_comment(self) -> None:
text = self.comment_var.get().strip()
if not text:
return
self.repository.append_asset_event(
self.asset_id,
event_type="board_comment",
summary=text,
actor_type="user",
actor_name="Vorstand",
)
self.comment_var.set("")
self._refresh_events()
def _open_holder_member(self) -> None:
if self.asset.current_holder_member_id:
self.on_open_member(self.asset.current_holder_member_id)
def _create_deposit_claim(self) -> None:
self._open_claim_dialog(
preset_title=f"Kaution: {self.asset.label}",
preset_amount=self.asset.deposit_amount_default or "0.00",
preset_description=f"Kaution für Asset {self.asset.label}",
claim_type="asset_deposit",
)
def _create_refund_claim(self) -> None:
amount = self.asset.deposit_amount_default or "0.00"
if not str(amount).startswith("-"):
amount = f"-{amount}"
self._open_claim_dialog(
preset_title=f"Kautionsrückzahlung: {self.asset.label}",
preset_amount=amount,
preset_description=f"Gutschrift / Rückzahlung der Kaution für Asset {self.asset.label}",
claim_type="asset_refund",
)
def _create_loss_claim(self) -> None:
self._open_claim_dialog(
preset_title=f"Verlust: {self.asset.label}",
preset_amount=self.asset.deposit_amount_default or "0.00",
preset_description=f"Forderung wegen Verlust von Asset {self.asset.label}",
claim_type="asset_loss",
)
def _create_repair_claim(self) -> None:
self._open_claim_dialog(
preset_title=f"Reparatur: {self.asset.label}",
preset_amount="0.00",
preset_description=f"Reparaturkosten für Asset {self.asset.label}",
claim_type="asset_repair",
)
def _open_claim_dialog(
self,
*,
preset_title: str,
preset_amount: str,
preset_description: str,
claim_type: str,
) -> None:
member_id = self.asset.current_holder_member_id
if not member_id:
messagebox.showerror(
"Forderung nicht möglich",
"Dieses Asset ist aktuell keinem Mitglied zugeordnet.",
parent=self,
)
return
AssetClaimDialog(
self,
self.repository,
self.asset_id,
member_id,
preset_title=preset_title,
preset_amount=preset_amount,
preset_description=preset_description,
claim_type=claim_type,
on_created=lambda claim_id: self._claim_created(member_id, claim_id),
)
def _claim_created(self, member_id: str, claim_id: str) -> None:
self.refresh()
self.on_changed()
self.on_open_claim(member_id, claim_id)
def _open_selected_asset_claim(self) -> None:
selected = self.asset_claims.selection()
if not selected:
return
member_id, claim_id = selected[0].split(":", 1)
self.on_open_claim(member_id, claim_id)
def _format_timestamp(event: Event) -> str:
try:
return datetime.fromisoformat(event.timestamp).strftime("%d.%m.%Y %H:%M")
except ValueError:
return event.timestamp[:16]
def _event_label(event: Event) -> str:
if event.actor_type == "system":
return f"[AUTO] {event.summary}"
return event.summary
def _asset_status_key(label: str) -> str:
for key, value in ASSET_STATUS_LABELS.items():
if value == label:
return key
return label.casefold().strip()
+173 -1
View File
@@ -12,8 +12,10 @@ from ccma.domain.contributions import (
allocated_total, allocated_total,
claim_balance, claim_balance,
claim_items, claim_items,
claim_settled_total,
claim_status, claim_status,
claim_total, claim_total,
credit_allocated_total,
decimal_value, decimal_value,
money_text, money_text,
payment_allocated_total, payment_allocated_total,
@@ -125,6 +127,8 @@ class ClaimTab(ttk.Frame):
self.ledger.tag_configure("position", background="#234d70", foreground="#ffffff") self.ledger.tag_configure("position", background="#234d70", foreground="#ffffff")
self.ledger.tag_configure("payment-group", background="#237a3b", foreground="#ffffff") self.ledger.tag_configure("payment-group", background="#237a3b", foreground="#ffffff")
self.ledger.tag_configure("payment", background="#285b3b", foreground="#ffffff") self.ledger.tag_configure("payment", background="#285b3b", foreground="#ffffff")
self.ledger.tag_configure("credit-group", background="#8b3d88", foreground="#ffffff")
self.ledger.tag_configure("credit", background="#5e2f5b", foreground="#ffffff")
self.ledger.tag_configure("reminder-group", background="#b85f00", foreground="#ffffff") self.ledger.tag_configure("reminder-group", background="#b85f00", foreground="#ffffff")
self.ledger.tag_configure("reminder", background="#70451f", foreground="#ffffff") self.ledger.tag_configure("reminder", background="#70451f", foreground="#ffffff")
self.ledger.bind("<<TreeviewSelect>>", lambda _event: self._update_reminder_buttons()) self.ledger.bind("<<TreeviewSelect>>", lambda _event: self._update_reminder_buttons())
@@ -137,6 +141,10 @@ class ClaimTab(ttk.Frame):
side="left", padx=(0, 8) side="left", padx=(0, 8)
) )
ttk.Button(buttons, text="Zahlung erfassen", command=self._record_payment).pack(side="left") ttk.Button(buttons, text="Zahlung erfassen", command=self._record_payment).pack(side="left")
ttk.Button(buttons, text="Vorhandene Gutschrift zuordnen", command=self._allocate_credit).pack(
side="left", padx=(8, 8)
)
ttk.Button(buttons, text="Gutschrift erfassen", command=self._record_credit).pack(side="left")
ttk.Separator(buttons, orient="vertical").pack(side="left", fill="y", padx=10) ttk.Separator(buttons, orient="vertical").pack(side="left", fill="y", padx=10)
self.discard_reminder_button = ttk.Button( self.discard_reminder_button = ttk.Button(
buttons, text="Entwurf verwerfen", command=self._discard_reminder, state="disabled" buttons, text="Entwurf verwerfen", command=self._discard_reminder, state="disabled"
@@ -159,7 +167,7 @@ class ClaimTab(ttk.Frame):
messagebox.showerror("Forderung konnte nicht geladen werden", str(exc), parent=self) messagebox.showerror("Forderung konnte nicht geladen werden", str(exc), parent=self)
return return
total = claim_total(self.claim) total = claim_total(self.claim)
paid = allocated_total(self.data, self.claim_id) paid = claim_settled_total(self.data, self.claim)
balance = claim_balance(self.data, self.claim) balance = claim_balance(self.data, self.claim)
status = claim_status(self.data, self.claim) status = claim_status(self.data, self.claim)
self.title_var.set(str(self.claim.get("title") or "Forderung")) self.title_var.set(str(self.claim.get("title") or "Forderung"))
@@ -227,6 +235,8 @@ class ClaimTab(ttk.Frame):
) )
for allocation in allocations: for allocation in allocations:
payment = payment_by_id.get(str(allocation.get("payment_id", "")), {}) payment = payment_by_id.get(str(allocation.get("payment_id", "")), {})
if not payment:
continue
payment_total = str(payment.get("amount", "")) payment_total = str(payment.get("amount", ""))
gnucash_id = str(payment.get("gnucash_transaction_id", "")) gnucash_id = str(payment.get("gnucash_transaction_id", ""))
self.ledger.insert( self.ledger.insert(
@@ -246,6 +256,57 @@ class ClaimTab(ttk.Frame):
tags=("payment",), tags=("payment",),
) )
credits_by_id = {str(item.get("credit_id")): item for item in self.data.credits}
credit_allocations = [
allocation
for allocation in self.data.allocations
if str(allocation.get("claim_id", "")) == self.claim_id and str(allocation.get("credit_id", ""))
]
allocated_credit_total = money_text(
sum(
(decimal_value(item.get("amount", "0")) for item in credit_allocations),
Decimal("0"),
)
)
credit_group = self.ledger.insert(
"",
"end",
iid="group:credits",
text=f"Gutschriften ({len(credit_allocations)})",
values=(
"",
"",
"",
"",
f"{allocated_credit_total} EUR",
"",
"",
),
tags=("credit-group",),
open=True,
)
for allocation in credit_allocations:
credit = credits_by_id.get(str(allocation.get("credit_id", "")), {})
if not credit:
continue
credit_total = str(credit.get("amount", ""))
self.ledger.insert(
credit_group,
"end",
iid=f"credit-allocation:{allocation.get('allocation_id', '')}",
text="Gutschrift",
values=(
format_date_for_display(str(credit.get("date", ""))),
credit.get("reference", ""),
"",
"",
f"{allocation.get('amount', '')} EUR",
f"Gutschrift gesamt: {credit_total} EUR",
"",
),
tags=("credit",),
)
reminders = [ reminders = [
reminder reminder
for reminder in self.data.reminders for reminder in self.data.reminders
@@ -309,6 +370,26 @@ class ClaimTab(ttk.Frame):
self._changed, self._changed,
) )
def _record_credit(self) -> None:
CreditDialog(
self,
self.repository,
self.member_id,
self.claim_id,
claim_balance(self.data, self.claim),
self._changed,
)
def _allocate_credit(self) -> None:
AllocateCreditDialog(
self,
self.repository,
self.member_id,
self.claim_id,
claim_balance(self.data, self.claim),
self._changed,
)
def _add_reminder(self) -> None: def _add_reminder(self) -> None:
ReminderDialog(self, self.repository, self.member_id, self.claim_id, self._changed) ReminderDialog(self, self.repository, self.member_id, self.claim_id, self._changed)
@@ -508,6 +589,45 @@ class PaymentDialog(_Dialog):
self.on_saved() self.on_saved()
class CreditDialog(_Dialog):
def __init__(self, master, repository, member_id, claim_id, balance: Decimal, on_saved):
super().__init__(master, "Gutschrift erfassen", on_saved)
self.repository, self.member_id, self.claim_id = repository, member_id, claim_id
initial = money_text(max(-balance, Decimal("0")))
self.variables = {
"date": tk.StringVar(value=format_date_for_display(date.today().isoformat())),
"amount": tk.StringVar(value=initial),
"allocation": tk.StringVar(value=initial),
"reference": tk.StringVar(),
}
fields = (
(f"Gutschriftsdatum ({date_input_hint()})", "date"),
("Gutschriftsbetrag", "amount"),
("Dieser Gutschrift zuordnen", "allocation"),
("Referenz", "reference"),
)
for row, (label, key) in enumerate(fields):
ttk.Label(self.frame, text=label).grid(row=row, column=0, sticky="w", pady=5, padx=(0, 12))
ttk.Entry(self.frame, textvariable=self.variables[key], width=38).grid(row=row, column=1, pady=5)
self._buttons(len(fields), self._save)
def _save(self):
try:
self.repository.record_credit(
self.member_id,
self.claim_id,
credit_date=self.variables["date"].get(),
amount=self.variables["amount"].get(),
allocation_amount=self.variables["allocation"].get(),
reference=self.variables["reference"].get(),
)
except RepositoryError as exc:
messagebox.showerror("Gutschrift konnte nicht gespeichert werden", str(exc), parent=self)
return
self.destroy()
self.on_saved()
class AllocatePaymentDialog(_Dialog): class AllocatePaymentDialog(_Dialog):
def __init__(self, master, repository, member_id, claim_id, balance: Decimal, on_saved): def __init__(self, master, repository, member_id, claim_id, balance: Decimal, on_saved):
super().__init__(master, "Vorhandene Zahlung zuordnen", on_saved) super().__init__(master, "Vorhandene Zahlung zuordnen", on_saved)
@@ -560,6 +680,58 @@ class AllocatePaymentDialog(_Dialog):
self.on_saved() self.on_saved()
class AllocateCreditDialog(_Dialog):
def __init__(self, master, repository, member_id, claim_id, balance: Decimal, on_saved):
super().__init__(master, "Vorhandene Gutschrift zuordnen", on_saved)
self.repository, self.member_id, self.claim_id = repository, member_id, claim_id
data = repository.get_contributions(member_id)
self.credit_by_label = {}
for credit in data.credits:
credit_id = str(credit.get("credit_id", ""))
available = decimal_value(credit.get("amount", "0")) - credit_allocated_total(data, credit_id)
if available <= 0:
continue
label = (
f"{credit.get('date', '')} · {money_text(available)} EUR frei · "
f"{credit.get('reference', '')}"
)
self.credit_by_label[label] = (credit_id, available)
self.credit_var = tk.StringVar()
self.amount_var = tk.StringVar(value=money_text(max(-balance, Decimal("0"))))
ttk.Label(self.frame, text="Gutschrift").grid(row=0, column=0, sticky="w", pady=5, padx=(0, 12))
combo = ttk.Combobox(
self.frame,
textvariable=self.credit_var,
values=list(self.credit_by_label),
state="readonly",
width=60,
)
combo.grid(row=0, column=1, pady=5)
ttk.Label(self.frame, text="Betrag").grid(row=1, column=0, sticky="w", pady=5, padx=(0, 12))
ttk.Entry(self.frame, textvariable=self.amount_var).grid(row=1, column=1, sticky="ew", pady=5)
combo.bind("<<ComboboxSelected>>", lambda _event: self._select(balance))
self._buttons(2, self._save)
def _select(self, balance):
_credit_id, available = self.credit_by_label[self.credit_var.get()]
self.amount_var.set(money_text(min(available, max(-balance, Decimal("0")))))
def _save(self):
selected = self.credit_by_label.get(self.credit_var.get())
if not selected:
messagebox.showerror("Gutschrift auswählen", "Bitte eine Gutschrift auswählen.", parent=self)
return
try:
self.repository.allocate_credit(
self.member_id, self.claim_id, credit_id=selected[0], amount=self.amount_var.get()
)
except RepositoryError as exc:
messagebox.showerror("Zuordnung fehlgeschlagen", str(exc), parent=self)
return
self.destroy()
self.on_saved()
class ReminderDialog(_Dialog): class ReminderDialog(_Dialog):
def __init__(self, master, repository, member_id, claim_id, on_saved): def __init__(self, master, repository, member_id, claim_id, on_saved):
super().__init__(master, "Mahnung vorbereiten", on_saved) super().__init__(master, "Mahnung vorbereiten", on_saved)
+532 -3
View File
@@ -1,12 +1,28 @@
import tkinter as tk import tkinter as tk
from collections.abc import Callable from collections.abc import Callable
from datetime import date
from tkinter import messagebox, ttk from tkinter import messagebox, ttk
from ccma.domain.dates import age_label, date_input_hint from ccma.domain.dates import age_label, date_input_hint
from ccma.domain.models import Member from ccma.domain.models import Asset, Member
from ccma.storage.repository import MemberRepository, RepositoryError from ccma.storage.repository import MemberRepository, RepositoryError
def _activate_modal_window(window: tk.Toplevel, focus_widget: tk.Widget | None = None) -> None:
try:
window.update_idletasks()
window.deiconify()
window.lift()
window.focus_force()
if focus_widget is not None and focus_widget.winfo_exists():
focus_widget.focus_set()
window.grab_set()
window.attributes("-topmost", True)
window.after_idle(lambda: window.winfo_exists() and window.attributes("-topmost", False))
except tk.TclError:
return
class NewMemberDialog(tk.Toplevel): class NewMemberDialog(tk.Toplevel):
def __init__(self, master: tk.Misc, repository: MemberRepository, on_created: Callable[[Member], None]): def __init__(self, master: tk.Misc, repository: MemberRepository, on_created: Callable[[Member], None]):
super().__init__(master) super().__init__(master)
@@ -14,24 +30,36 @@ class NewMemberDialog(tk.Toplevel):
self.on_created = on_created self.on_created = on_created
self.title("Neue Mitgliederakte") self.title("Neue Mitgliederakte")
self.transient(master.winfo_toplevel()) self.transient(master.winfo_toplevel())
self.grab_set()
self.resizable(False, False) self.resizable(False, False)
self.number_policy = repository.get_member_number_policy() self.number_policy = repository.get_member_number_policy()
self.variables = { self.variables = {
name: tk.StringVar() name: tk.StringVar()
for name in ("first_name", "last_name", "email", "phone", "birth_date", "member_number") for name in (
"first_name",
"last_name",
"nickname",
"email",
"phone",
"birth_date",
"member_number",
)
} }
self._build_ui() self._build_ui()
self.bind("<Escape>", lambda _event: self.destroy()) self.bind("<Escape>", lambda _event: self.destroy())
self.bind("<Return>", lambda _event: self._create()) self.bind("<Return>", lambda _event: self._create())
self.after_idle(self._activate_modal)
self.after_idle(self._focus_first) self.after_idle(self._focus_first)
def _activate_modal(self) -> None:
_activate_modal_window(self, self.entries.get("first_name"))
def _build_ui(self) -> None: def _build_ui(self) -> None:
frame = ttk.Frame(self, padding=18) frame = ttk.Frame(self, padding=18)
frame.pack(fill="both", expand=True) frame.pack(fill="both", expand=True)
fields = [ fields = [
("Vorname *", "first_name"), ("Vorname *", "first_name"),
("Nachname *", "last_name"), ("Nachname *", "last_name"),
("Nickname", "nickname"),
("E-Mail-Adresse", "email"), ("E-Mail-Adresse", "email"),
("Telefonnummer", "phone"), ("Telefonnummer", "phone"),
(f"Geburtsdatum ({date_input_hint()})", "birth_date"), (f"Geburtsdatum ({date_input_hint()})", "birth_date"),
@@ -89,3 +117,504 @@ class NewMemberDialog(tk.Toplevel):
return return
self.destroy() self.destroy()
self.on_created(member) self.on_created(member)
class NewAssetDialog(tk.Toplevel):
def __init__(self, master: tk.Misc, repository: MemberRepository, on_created: Callable[[Asset], None]):
super().__init__(master)
self.repository = repository
self.on_created = on_created
self.title("Neues Asset")
self.transient(master.winfo_toplevel())
self.resizable(False, False)
self.variables = {
name: tk.StringVar()
for name in ("label", "category", "inventory_number", "serial_number", "deposit_amount_default")
}
self._build_ui()
self.bind("<Escape>", lambda _event: self.destroy())
self.bind("<Return>", lambda _event: self._create())
self.after_idle(self._activate_modal)
def _build_ui(self) -> None:
frame = ttk.Frame(self, padding=18)
frame.pack(fill="both", expand=True)
fields = [
("Bezeichnung *", "label"),
("Kategorie", "category"),
("Inventarnummer", "inventory_number"),
("Seriennummer", "serial_number"),
("Kaution (EUR)", "deposit_amount_default"),
]
self.entries: dict[str, ttk.Entry] = {}
for row, (label, key) in enumerate(fields):
ttk.Label(frame, text=label).grid(row=row, column=0, sticky="w", pady=5, padx=(0, 12))
entry = ttk.Entry(frame, textvariable=self.variables[key], width=38)
entry.grid(row=row, column=1, sticky="ew", pady=5)
self.entries[key] = entry
ttk.Label(frame, text="Interne Notiz").grid(
row=len(fields),
column=0,
sticky="nw",
pady=5,
padx=(0, 12),
)
self.notes_text = tk.Text(frame, width=38, height=5, wrap="word")
self.notes_text.grid(row=len(fields), column=1, sticky="ew", pady=5)
buttons = ttk.Frame(frame)
buttons.grid(row=len(fields) + 1, column=0, columnspan=2, sticky="e", pady=(16, 0))
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8))
ttk.Button(
buttons,
text="Asset anlegen",
style="Accent.TButton",
command=self._create,
).pack(side="left")
self.after_idle(lambda: self.entries["label"].focus_set())
def _activate_modal(self) -> None:
_activate_modal_window(self, self.entries.get("label"))
def _create(self) -> None:
try:
asset = self.repository.create_asset(
**{key: variable.get() for key, variable in self.variables.items()},
notes=self.notes_text.get("1.0", "end-1c"),
)
except RepositoryError as exc:
messagebox.showerror("Asset konnte nicht angelegt werden", str(exc), parent=self)
return
self.destroy()
self.on_created(asset)
class EditAssetDialog(tk.Toplevel):
def __init__(
self,
master: tk.Misc,
repository: MemberRepository,
asset_id: str,
on_saved: Callable[[Asset], None],
):
super().__init__(master)
self.repository = repository
self.asset_id = asset_id
self.on_saved = on_saved
self.asset = repository.get_asset(asset_id)
self.title("Asset bearbeiten")
self.transient(master.winfo_toplevel())
self.resizable(False, False)
self.variables = {
"label": tk.StringVar(value=self.asset.label),
"category": tk.StringVar(value=self.asset.category),
"inventory_number": tk.StringVar(value=self.asset.inventory_number),
"serial_number": tk.StringVar(value=self.asset.serial_number),
"deposit_amount_default": tk.StringVar(value=self.asset.deposit_amount_default),
"status": tk.StringVar(value=self.asset.status),
}
self._build_ui()
self.bind("<Escape>", lambda _event: self.destroy())
self.bind("<Return>", lambda _event: self._save())
self.after_idle(self._activate_modal)
def _build_ui(self) -> None:
frame = ttk.Frame(self, padding=18)
frame.pack(fill="both", expand=True)
issued = bool(self.asset.current_holder_member_id)
fields = [
("Bezeichnung *", "label"),
("Kategorie", "category"),
("Inventarnummer", "inventory_number"),
("Seriennummer", "serial_number"),
("Kaution (EUR)", "deposit_amount_default"),
]
self.entries: dict[str, ttk.Entry] = {}
for row, (label, key) in enumerate(fields):
ttk.Label(frame, text=label).grid(row=row, column=0, sticky="w", pady=5, padx=(0, 12))
state = "readonly" if key == "deposit_amount_default" and issued else "normal"
entry = ttk.Entry(frame, textvariable=self.variables[key], width=38, state=state)
entry.grid(row=row, column=1, sticky="ew", pady=5)
self.entries[key] = entry
ttk.Label(frame, text="Status").grid(row=len(fields), column=0, sticky="w", pady=5, padx=(0, 12))
status_values = [value for key, value in (
("available", "VERFUEGBAR"),
("lost", "VERLOREN"),
("retired", "AUSGEMUSTERT"),
)]
self.status_map = {
"VERFUEGBAR": "available",
"VERLOREN": "lost",
"AUSGEMUSTERT": "retired",
}
self.status_var = tk.StringVar(
value={
"available": "VERFUEGBAR",
"lost": "VERLOREN",
"retired": "AUSGEMUSTERT",
}.get(self.asset.status, "VERFUEGBAR")
)
self.status_box = ttk.Combobox(
frame,
textvariable=self.status_var,
values=status_values,
state="readonly" if not issued else "disabled",
width=35,
)
self.status_box.grid(row=len(fields), column=1, sticky="ew", pady=5)
note_row = len(fields) + 1
ttk.Label(frame, text="Interne Notiz").grid(row=note_row, column=0, sticky="nw", pady=5, padx=(0, 12))
self.notes_text = tk.Text(frame, width=38, height=5, wrap="word")
self.notes_text.grid(row=note_row, column=1, sticky="ew", pady=5)
self.notes_text.insert("1.0", self.asset.notes)
info_row = note_row + 1
info_text = (
"Kaution kann nur geändert werden, wenn das Asset nicht ausgegeben ist."
if issued
else "Status kann hier auf verfuegbar, verloren oder ausgemustert gesetzt werden."
)
ttk.Label(frame, text=info_text, style="Mono.TLabel").grid(
row=info_row, column=0, columnspan=2, sticky="w", pady=(4, 0)
)
buttons = ttk.Frame(frame)
buttons.grid(row=info_row + 1, column=0, columnspan=2, sticky="e", pady=(16, 0))
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8))
ttk.Button(buttons, text="Speichern", style="Accent.TButton", command=self._save).pack(side="left")
self.after_idle(lambda: self.entries["label"].focus_set())
def _activate_modal(self) -> None:
_activate_modal_window(self, self.entries.get("label"))
def _save(self) -> None:
self.asset.label = self.variables["label"].get()
self.asset.category = self.variables["category"].get()
self.asset.inventory_number = self.variables["inventory_number"].get()
self.asset.serial_number = self.variables["serial_number"].get()
self.asset.notes = self.notes_text.get("1.0", "end-1c")
if not self.asset.current_holder_member_id:
self.asset.deposit_amount_default = self.variables["deposit_amount_default"].get()
self.asset.status = self.status_map.get(self.status_var.get(), self.asset.status)
try:
self.repository.save_asset(self.asset)
except RepositoryError as exc:
messagebox.showerror("Asset konnte nicht gespeichert werden", str(exc), parent=self)
return
self.destroy()
self.on_saved(self.asset)
class IssueAssetDialog(tk.Toplevel):
def __init__(
self,
master: tk.Misc,
repository: MemberRepository,
asset_id: str,
on_assigned: Callable[[Asset], None],
*,
preselected_member_id: str = "",
):
super().__init__(master)
self.repository = repository
self.asset_id = asset_id
self.on_assigned = on_assigned
self.preselected_member_id = preselected_member_id
self.title("Asset ausgeben")
self.transient(master.winfo_toplevel())
self.resizable(True, True)
self.search_var = tk.StringVar()
self.members = self.repository.list_members()
self._build_ui()
self.bind("<Escape>", lambda _event: self.destroy())
self.bind("<Return>", lambda _event: self._assign())
self.after_idle(self._activate_modal)
def _build_ui(self) -> None:
frame = ttk.Frame(self, padding=18)
frame.pack(fill="both", expand=True)
frame.columnconfigure(1, weight=1)
frame.rowconfigure(3, weight=1)
asset = self.repository.get_asset(self.asset_id)
ttk.Label(frame, text="Asset").grid(row=0, column=0, sticky="w", pady=5, padx=(0, 12))
ttk.Label(frame, text=asset.label, style="TimelineHeader.TLabel").grid(
row=0,
column=1,
sticky="w",
pady=5,
)
ttk.Label(frame, text="Suche").grid(row=1, column=0, sticky="w", pady=5, padx=(0, 12))
self.search_entry = ttk.Entry(frame, textvariable=self.search_var, width=42)
self.search_entry.grid(row=1, column=1, sticky="ew", pady=5)
self.search_var.trace_add("write", lambda *_args: self._render_members())
ttk.Label(frame, text="Mitglied").grid(row=2, column=0, sticky="nw", pady=5, padx=(0, 12))
self.member_tree = ttk.Treeview(
frame,
columns=("number", "name", "email"),
show="headings",
height=10,
)
for key, title, width in (
("number", "Nummer", 120),
("name", "Mitglied", 220),
("email", "E-Mail", 220),
):
self.member_tree.heading(key, text=title)
self.member_tree.column(key, width=width, anchor="w")
self.member_tree.grid(row=3, column=0, columnspan=2, sticky="nsew", pady=5)
self.member_tree.bind("<Double-1>", lambda _event: self._assign())
self.member_tree.bind("<Return>", lambda _event: self._assign())
self.member_tree.bind("<<TreeviewSelect>>", lambda _event: self._sync_selected_member_label())
self.selected_member_var = tk.StringVar(value="Kein Mitglied ausgewählt.")
ttk.Label(frame, textvariable=self.selected_member_var, style="Mono.TLabel").grid(
row=4, column=0, columnspan=2, sticky="w", pady=(4, 0)
)
buttons = ttk.Frame(frame)
buttons.grid(row=5, column=0, columnspan=2, sticky="e", pady=(16, 0))
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8))
ttk.Button(buttons, text="Ausgeben", style="Accent.TButton", command=self._assign).pack(side="left")
self._render_members()
self.after_idle(self.search_entry.focus_set)
def _activate_modal(self) -> None:
_activate_modal_window(self, self.search_entry)
def _assign(self) -> None:
selected = self.member_tree.selection()
member_id = selected[0] if selected else ""
if not member_id:
messagebox.showerror("Ausgabe fehlgeschlagen", "Bitte ein Mitglied auswählen.", parent=self)
return
try:
asset = self.repository.assign_asset(self.asset_id, member_id)
except RepositoryError as exc:
messagebox.showerror("Ausgabe fehlgeschlagen", str(exc), parent=self)
return
self.destroy()
self.on_assigned(asset)
def _render_members(self) -> None:
self.member_tree.delete(*self.member_tree.get_children())
query = self.search_var.get().strip().casefold()
filtered = [
member
for member in self.members
if not query or query in self._member_search_text(member)
]
for member in filtered:
self.member_tree.insert(
"",
"end",
iid=member.member_id,
values=(member.member_number, member.display_name, member.email),
)
selected_id = self.preselected_member_id if self.preselected_member_id else ""
if filtered:
target_id = (
selected_id
if selected_id and any(member.member_id == selected_id for member in filtered)
else filtered[0].member_id
)
self.member_tree.selection_set(target_id)
self.member_tree.focus(target_id)
self.member_tree.see(target_id)
self._sync_selected_member_label()
def _sync_selected_member_label(self) -> None:
selected = self.member_tree.selection()
if not selected:
self.selected_member_var.set("Kein Mitglied ausgewählt.")
return
member = next((item for item in self.members if item.member_id == selected[0]), None)
if member is None:
self.selected_member_var.set("Kein Mitglied ausgewählt.")
return
self.selected_member_var.set(f"Ausgewählt: {self._member_label(member)}")
@staticmethod
def _member_search_text(member: Member) -> str:
return " ".join(
value.casefold()
for value in (
member.member_number,
member.first_name,
member.last_name,
member.nickname,
member.display_name,
member.email,
)
if value
)
@staticmethod
def _member_label(member: Member) -> str:
prefix = member.member_number or member.member_id
name = member.display_name or member.member_id
return f"{prefix} · {name}"
class AssetClaimDialog(tk.Toplevel):
def __init__(
self,
master: tk.Misc,
repository: MemberRepository,
asset_id: str,
member_id: str,
*,
preset_title: str,
preset_amount: str,
preset_description: str,
claim_type: str,
on_created: Callable[[str], None],
):
super().__init__(master)
self.repository = repository
self.asset_id = asset_id
self.member_id = member_id
self.claim_type = claim_type
self.on_created = on_created
self.title("Forderung aus Asset anlegen")
self.transient(master.winfo_toplevel())
self.resizable(False, False)
self.variables = {
"title": tk.StringVar(value=preset_title),
"amount": tk.StringVar(value=preset_amount),
"due_date": tk.StringVar(value=date.today().isoformat()),
}
self.preset_description = preset_description
self._build_ui()
self.bind("<Escape>", lambda _event: self.destroy())
self.bind("<Return>", lambda _event: self._create())
self.after_idle(self._activate_modal)
def _build_ui(self) -> None:
frame = ttk.Frame(self, padding=18)
frame.pack(fill="both", expand=True)
asset = self.repository.get_asset(self.asset_id)
member = self.repository.get_member(self.member_id)
ttk.Label(frame, text="Asset").grid(row=0, column=0, sticky="w", pady=5, padx=(0, 12))
ttk.Label(frame, text=asset.label, style="TimelineHeader.TLabel").grid(
row=0,
column=1,
sticky="w",
pady=5,
)
ttk.Label(frame, text="Mitglied").grid(row=1, column=0, sticky="w", pady=5, padx=(0, 12))
ttk.Label(frame, text=f"{member.member_number or member.member_id} · {member.display_name}").grid(
row=1, column=1, sticky="w", pady=5
)
row_offset = 2
for index, (label, key) in enumerate(
(
("Titel *", "title"),
("Betrag (EUR) *", "amount"),
(f"Fällig am ({date_input_hint()}) *", "due_date"),
)
):
ttk.Label(frame, text=label).grid(
row=row_offset + index,
column=0,
sticky="w",
pady=5,
padx=(0, 12),
)
ttk.Entry(frame, textvariable=self.variables[key], width=42).grid(
row=row_offset + index, column=1, sticky="ew", pady=5
)
ttk.Label(frame, text="Beschreibung").grid(
row=row_offset + 3,
column=0,
sticky="nw",
pady=5,
padx=(0, 12),
)
self.description_text = tk.Text(frame, width=42, height=5, wrap="word")
self.description_text.grid(row=row_offset + 3, column=1, sticky="ew", pady=5)
self.description_text.insert("1.0", self.preset_description)
info = (
"Negative Betraege werden als Gutschrift dokumentiert. "
"Die Auszahlung selbst wird im aktuellen CCMA-Stand nicht als eigener Workflow modelliert."
)
ttk.Label(frame, text=info, style="Mono.TLabel").grid(
row=row_offset + 4, column=0, columnspan=2, sticky="w", pady=(4, 0)
)
buttons = ttk.Frame(frame)
buttons.grid(row=row_offset + 5, column=0, columnspan=2, sticky="e", pady=(16, 0))
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8))
ttk.Button(
buttons,
text="Forderung anlegen",
style="Accent.TButton",
command=self._create,
).pack(side="left")
self.after_idle(lambda: frame.focus_set())
def _activate_modal(self) -> None:
_activate_modal_window(self)
def _create(self) -> None:
try:
result = self.repository.create_manual_claim(
self.member_id,
title=self.variables["title"].get(),
amount=self.variables["amount"].get(),
due_date=self.variables["due_date"].get(),
description=self.description_text.get("1.0", "end-1c"),
claim_type=self.claim_type,
references={"asset_id": self.asset_id},
)
except RepositoryError as exc:
messagebox.showerror("Forderung konnte nicht angelegt werden", str(exc), parent=self)
return
self.destroy()
self.on_created(str(result["claim"]["claim_id"]))
class IntegrityWarningDialog(tk.Toplevel):
def __init__(
self,
master: tk.Misc,
*,
title: str,
warnings: list[str],
on_confirm: Callable[[], None],
):
super().__init__(master)
self.on_confirm = on_confirm
self.title(title)
self.transient(master.winfo_toplevel())
self.resizable(False, False)
self.warnings = warnings
self._build_ui()
self.bind("<Escape>", lambda _event: self.destroy())
self.after_idle(self._activate_modal)
def _build_ui(self) -> None:
frame = ttk.Frame(self, padding=18)
frame.pack(fill="both", expand=True)
message = (
"ACHTUNG: Die zugehörigen JSON-Dateien wurden vermutlich extern geändert.\n\n"
"Haben Sie alle Daten geprüft und soll der Hash jetzt aktualisiert werden?"
)
ttk.Label(frame, text=message, justify="left").grid(row=0, column=0, sticky="w")
ttk.Label(
frame,
text="\n".join(f"{item}" for item in self.warnings),
style="Warning.TLabel",
justify="left",
).grid(
row=1, column=0, sticky="w", pady=(12, 0)
)
buttons = ttk.Frame(frame)
buttons.grid(row=2, column=0, sticky="e", pady=(18, 0))
ttk.Button(buttons, text="Nein", command=self.destroy).pack(side="left", padx=(0, 8))
ttk.Button(
buttons,
text="Ja, bestätigen",
style="Accent.TButton",
command=self._confirm,
).pack(side="left")
def _activate_modal(self) -> None:
_activate_modal_window(self)
def _confirm(self) -> None:
self.destroy()
self.on_confirm()
+155 -5
View File
@@ -5,16 +5,17 @@ from tkinter import messagebox, ttk
from ccma import __version__ from ccma import __version__
from ccma.config import AppConfig from ccma.config import AppConfig
from ccma.domain.models import HousekeeperFinding, Member from ccma.domain.models import Asset, HousekeeperFinding, Member
from ccma.services.housekeeper import Housekeeper from ccma.services.housekeeper import Housekeeper
from ccma.storage.repository import MemberRepository, RepositoryError from ccma.storage.repository import MemberRepository, RepositoryError
from ccma.ui.asset_tab import AssetTab
from ccma.ui.claim_tab import ClaimTab from ccma.ui.claim_tab import ClaimTab
from ccma.ui.dialogs import NewMemberDialog from ccma.ui.dialogs import EditAssetDialog, IssueAssetDialog, NewAssetDialog, NewMemberDialog
from ccma.ui.icons import IconStore from ccma.ui.icons import IconStore
from ccma.ui.member_tab import MemberTab from ccma.ui.member_tab import MemberTab
from ccma.ui.options_dialog import OptionsDialog from ccma.ui.options_dialog import OptionsDialog
from ccma.ui.theme import load_theme from ccma.ui.theme import load_theme
from ccma.ui.work_tabs import DashboardTab, HousekeeperTab, MembersTab, SearchResultsTab from ccma.ui.work_tabs import AssetsTab, DashboardTab, HousekeeperTab, MembersTab, SearchResultsTab
class TabManager: class TabManager:
@@ -175,6 +176,17 @@ class MainWindow(ttk.Frame):
) )
members_button.image = members_icon members_button.image = members_icon
members_button.pack(side="left", padx=(0, 6)) members_button.pack(side="left", padx=(0, 6))
assets_icon = self.icons.get("key-variant", 24) or self.icons.get("package-variant-closed", 24)
assets_button = ttk.Button(
actions,
text="Inventar",
image=assets_icon,
compound="top",
width=14,
command=self.open_assets,
)
assets_button.image = assets_icon
assets_button.pack(side="left", padx=(0, 6))
new_icon = self.icons.get("account-plus", 24) new_icon = self.icons.get("account-plus", 24)
new_button = ttk.Button( new_button = ttk.Button(
actions, actions,
@@ -278,6 +290,9 @@ class MainWindow(ttk.Frame):
on_close=lambda: self.tabs.close(key), on_close=lambda: self.tabs.close(key),
on_changed=self.refresh_overview, on_changed=self.refresh_overview,
on_open_claim=self.open_claim, on_open_claim=self.open_claim,
on_open_assets=self.open_assets,
on_open_asset=self.open_asset,
on_return_asset=self.return_asset,
) )
self.tabs.add( self.tabs.add(
key, key,
@@ -312,6 +327,34 @@ class MainWindow(ttk.Frame):
icon_name="receipt", icon_name="receipt",
) )
def open_asset(self, asset_id: str) -> None:
key = f"asset:{asset_id}"
if self.tabs.focus(key):
return
try:
asset = self.repository.get_asset(asset_id)
except RepositoryError as exc:
messagebox.showerror("Asset konnte nicht geöffnet werden", str(exc), parent=self)
return
tab = AssetTab(
self.notebook,
self.repository,
asset_id,
on_close=lambda: self.tabs.close(key),
on_changed=self.refresh_overview,
on_open_member=self.open_member,
on_issue_asset=self.issue_asset,
on_return_asset=self.return_asset,
on_open_claim=self.open_claim,
)
self.tabs.add(
key,
tab,
asset.label or asset.asset_id,
image=self.icons.get("key-variant", 16) or self.icons.get("package-variant-closed", 16),
icon_name="key-variant",
)
def _claim_changed(self, member_id: str) -> None: def _claim_changed(self, member_id: str) -> None:
member_tab = self.tabs.tabs.get(f"member:{member_id}") member_tab = self.tabs.tabs.get(f"member:{member_id}")
if isinstance(member_tab, MemberTab) and member_tab.winfo_exists(): if isinstance(member_tab, MemberTab) and member_tab.winfo_exists():
@@ -336,14 +379,91 @@ class MainWindow(ttk.Frame):
icon_name="account-group", icon_name="account-group",
) )
def open_assets(self) -> None:
key = "assets"
if self.tabs.focus(key):
return
tab = AssetsTab(
self.notebook,
self.repository.list_assets(),
self._member_reference_label,
self.new_asset,
self.open_asset,
self.edit_asset,
self.issue_asset,
self.return_asset,
lambda: self.tabs.close(key),
)
self.tabs.add(
key,
tab,
"Inventar",
image=self.icons.get("key-variant", 16) or self.icons.get("package-variant-closed", 16),
icon_name="key-variant",
)
def new_member(self) -> None: def new_member(self) -> None:
NewMemberDialog(self, self.repository, self._member_created) NewMemberDialog(self, self.repository, self._member_created)
def new_asset(self) -> None:
NewAssetDialog(self, self.repository, self._asset_created)
def _member_created(self, member: Member) -> None: def _member_created(self, member: Member) -> None:
self.refresh_overview() self.refresh_overview()
self.open_member(member.member_id) self.open_member(member.member_id)
self.status_var.set(f"Mitgliederakte für {member.display_name} angelegt.") self.status_var.set(f"Mitgliederakte für {member.display_name} angelegt.")
def _asset_created(self, asset: Asset) -> None:
self.refresh_overview()
self.open_assets()
self.status_var.set(f"Asset angelegt: {asset.label}")
def issue_asset(self, asset_id: str, preselected_member_id: str = "") -> None:
try:
IssueAssetDialog(
self,
self.repository,
asset_id,
self._asset_changed,
preselected_member_id=preselected_member_id,
)
except (RepositoryError, tk.TclError) as exc:
messagebox.showerror("Asset konnte nicht ausgegeben werden", str(exc), parent=self)
def edit_asset(self, asset_id: str) -> None:
try:
EditAssetDialog(
self,
self.repository,
asset_id,
self._asset_changed,
)
except (RepositoryError, tk.TclError) as exc:
messagebox.showerror("Asset konnte nicht bearbeitet werden", str(exc), parent=self)
def return_asset(self, asset_id: str) -> None:
try:
asset = self.repository.get_asset(asset_id)
except RepositoryError as exc:
messagebox.showerror("Asset konnte nicht geladen werden", str(exc), parent=self)
return
if not messagebox.askyesno(
"Asset zurücknehmen",
f"{asset.label}\n\nDieses Asset wirklich als zurückgegeben markieren?",
parent=self,
):
return
try:
asset = self.repository.return_asset(asset_id)
except RepositoryError as exc:
messagebox.showerror("Rückgabe fehlgeschlagen", str(exc), parent=self)
return
self._asset_changed(asset)
def _asset_changed(self, asset: Asset) -> None:
self.refresh_overview()
self.status_var.set(f"Asset aktualisiert: {asset.label}")
def open_housekeeper(self) -> None: def open_housekeeper(self) -> None:
key = "housekeeper" key = "housekeeper"
if self.tabs.focus(key): if self.tabs.focus(key):
@@ -351,7 +471,7 @@ class MainWindow(ttk.Frame):
tab = HousekeeperTab( tab = HousekeeperTab(
self.notebook, self.notebook,
self.findings, self.findings,
self.open_member, self._open_housekeeper_target,
self.run_housekeeper, self.run_housekeeper,
self.delete_housekeeper_task, self.delete_housekeeper_task,
lambda: self.tabs.close(key), lambda: self.tabs.close(key),
@@ -383,6 +503,14 @@ class MainWindow(ttk.Frame):
members_tab = self.tabs.tabs.get("members") members_tab = self.tabs.tabs.get("members")
if isinstance(members_tab, MembersTab) and members_tab.winfo_exists(): if isinstance(members_tab, MembersTab) and members_tab.winfo_exists():
members_tab.refresh(self.repository.list_members()) members_tab.refresh(self.repository.list_members())
assets_tab = self.tabs.tabs.get("assets")
if isinstance(assets_tab, AssetsTab) and assets_tab.winfo_exists():
assets_tab.refresh(self.repository.list_assets())
for key, widget in self.tabs.tabs.items():
if key.startswith("member:") and isinstance(widget, MemberTab) and widget.winfo_exists():
widget.refresh()
if key.startswith("asset:") and isinstance(widget, AssetTab) and widget.winfo_exists():
widget.refresh()
if self.housekeeper_button and self.housekeeper_button.winfo_exists(): if self.housekeeper_button and self.housekeeper_button.winfo_exists():
self.housekeeper_button.configure(text=f"Hausmeister ({len(self.findings)})") self.housekeeper_button.configure(text=f"Hausmeister ({len(self.findings)})")
@@ -417,8 +545,30 @@ class MainWindow(ttk.Frame):
self.tabs.refresh_icons(self.icons) self.tabs.refresh_icons(self.icons)
def _show_validation_warning(self) -> None: def _show_validation_warning(self) -> None:
display_errors = [
item for item in self.validation_errors if "Hash fehlt oder stimmt nicht" not in item
]
if not display_errors:
return
messagebox.showwarning( messagebox.showwarning(
"Datenprüfung", "Datenprüfung",
"Der Store enthält ungültige Akten:\n\n" + "\n".join(self.validation_errors[:12]), "Der Store enthält ungültige Akten:\n\n" + "\n".join(display_errors[:12]),
parent=self, parent=self,
) )
def _open_housekeeper_target(self, finding: HousekeeperFinding) -> None:
if finding.target_type == "asset" and finding.asset_id:
self.open_asset(finding.asset_id)
return
if finding.member_id:
self.open_member(finding.member_id)
def _member_reference_label(self, member_id: str) -> str:
if not member_id:
return ""
try:
member = self.repository.get_member(member_id)
except RepositoryError:
return member_id
prefix = member.member_number or member.member_id
return f"{prefix} · {member.display_name or member.member_id}"
+278 -43
View File
@@ -8,12 +8,35 @@ from tkinter import messagebox, ttk
from ccma.domain.contributions import CLAIM_STATUS_LABELS, claim_status, claim_total, money_text from ccma.domain.contributions import CLAIM_STATUS_LABELS, claim_status, claim_total, money_text
from ccma.domain.dates import age_label, date_input_hint, format_date_for_display from ccma.domain.dates import age_label, date_input_hint, format_date_for_display
from ccma.domain.models import ASSET_STATUS_LABELS, Event
from ccma.domain.models import MEMBERSHIP_STATUS_LABELS as STATUS_LABELS from ccma.domain.models import MEMBERSHIP_STATUS_LABELS as STATUS_LABELS
from ccma.domain.models import Event
from ccma.storage.repository import MemberRepository, RepositoryError from ccma.storage.repository import MemberRepository, RepositoryError
from ccma.ui.dialogs import IntegrityWarningDialog
from ccma.ui.document_dialog import DocumentTemplateDialog from ccma.ui.document_dialog import DocumentTemplateDialog
from ccma.ui.file_open import open_path from ccma.ui.file_open import open_path
from ccma.ui.labels import display_label, storage_key from ccma.ui.labels import display_label, storage_key
from ccma.ui.messages import MessageAction, MessageBannerList, TabMessage
from ccma.ui.scrolling import ScrollableFrame
CLAIM_TABLE_COLUMNS = (
("title", "Forderung", 220),
("due", "Fällig", 100),
("amount", "Betrag", 90),
("status", "Status", 110),
)
def _claim_sort_value(data, claim: dict, column: str) -> str:
if column == "title":
return str(claim.get("title", ""))
if column == "due":
return str(claim.get("due_date", ""))
if column == "amount":
return f"{claim_total(claim):012.2f}"
if column == "status":
status = claim_status(data, claim)
return CLAIM_STATUS_LABELS.get(status, status.upper())
return ""
class MemberTab(ttk.Frame): class MemberTab(ttk.Frame):
@@ -25,6 +48,9 @@ class MemberTab(ttk.Frame):
on_close: Callable[[], None], on_close: Callable[[], None],
on_changed: Callable[[], None], on_changed: Callable[[], None],
on_open_claim: Callable[[str, str], None], on_open_claim: Callable[[str, str], None],
on_open_assets: Callable[[], None],
on_open_asset: Callable[[str], None],
on_return_asset: Callable[[str], None],
): ):
super().__init__(master, padding=12) super().__init__(master, padding=12)
self.repository = repository self.repository = repository
@@ -32,31 +58,51 @@ class MemberTab(ttk.Frame):
self.on_close = on_close self.on_close = on_close
self.on_changed = on_changed self.on_changed = on_changed
self.on_open_claim = on_open_claim self.on_open_claim = on_open_claim
self.on_open_assets = on_open_assets
self.on_open_asset = on_open_asset
self.on_return_asset = on_return_asset
self.member = repository.get_member(member_id) self.member = repository.get_member(member_id)
self.variables: dict[str, tk.Variable] = {} self.variables: dict[str, tk.Variable] = {}
self._field_sections: dict[str, str] = {}
self._dirty_sections: set[str] = set()
self._form_tabs: dict[str, str] = {}
self._form_tab_titles: dict[str, str] = {}
self._loading = False
self.notes_text: tk.Text | None = None
self._build_ui() self._build_ui()
self.refresh() self.refresh()
def _build_ui(self) -> None: def _build_ui(self) -> None:
self.columnconfigure(0, weight=1) self.columnconfigure(0, weight=1)
self.rowconfigure(1, weight=1) self.rowconfigure(2, weight=1)
header = ttk.Frame(self) header = ttk.Frame(self)
header.grid(row=0, column=0, sticky="ew", pady=(0, 10)) header.grid(row=0, column=0, sticky="ew", pady=(0, 10))
header.columnconfigure(0, weight=1) header.columnconfigure(0, weight=1)
header.columnconfigure(1, weight=0)
self.title_var = tk.StringVar() self.title_var = tk.StringVar()
self.status_var = tk.StringVar() self.status_var = tk.StringVar()
ttk.Label(header, textvariable=self.title_var, style="TabTitle.TLabel").grid( self.id_var = tk.StringVar()
title_column = ttk.Frame(header)
title_column.grid(row=0, column=0, sticky="ew")
title_column.columnconfigure(0, weight=1)
ttk.Label(title_column, textvariable=self.title_var, style="TabTitle.TLabel").grid(
row=0, column=0, sticky="w" row=0, column=0, sticky="w"
) )
ttk.Label(header, textvariable=self.status_var, style="Mono.TLabel").grid( ttk.Label(title_column, textvariable=self.status_var, style="Mono.TLabel").grid(
row=1, column=0, sticky="w", pady=(3, 0) row=1, column=0, sticky="w", pady=(3, 0)
) )
ttk.Button(header, text="Tab schließen", command=self.on_close).grid( ttk.Label(title_column, textvariable=self.id_var, style="Mono.TLabel").grid(
row=0, column=1, rowspan=2, sticky="e" row=2, column=0, sticky="w", pady=(3, 0)
) )
ttk.Button(header, text="Tab schließen", command=self._close).grid(
row=0, column=1, sticky="ne", padx=(12, 0)
)
self.messages = MessageBannerList(self)
self.messages.grid(row=1, column=0, sticky="ew", pady=(0, 10))
self.messages.grid_remove()
self.pane = ttk.Panedwindow(self, orient="horizontal") self.pane = ttk.Panedwindow(self, orient="horizontal")
self.pane.grid(row=1, column=0, sticky="nsew") self.pane.grid(row=2, column=0, sticky="nsew")
self.details_pane = ttk.Frame(self.pane, padding=(0, 0, 10, 0)) self.details_pane = ttk.Frame(self.pane, padding=(0, 0, 10, 0))
self.timeline_pane = ttk.Frame(self.pane, padding=(10, 0, 0, 0)) self.timeline_pane = ttk.Frame(self.pane, padding=(10, 0, 0, 0))
self.pane.add(self.details_pane, weight=2) self.pane.add(self.details_pane, weight=2)
@@ -79,24 +125,38 @@ class MemberTab(ttk.Frame):
def _build_details(self, parent: ttk.Frame) -> None: def _build_details(self, parent: ttk.Frame) -> None:
parent.columnconfigure(0, weight=1) parent.columnconfigure(0, weight=1)
parent.rowconfigure(0, weight=1) parent.rowconfigure(1, weight=1)
ttk.Label(parent, text="// MITGLIED", style="TimelineHeader.TLabel").grid(
row=0, column=0, sticky="w", pady=(0, 8)
)
notebook = ttk.Notebook(parent) notebook = ttk.Notebook(parent)
notebook.grid(row=0, column=0, sticky="nsew") self.details_notebook = notebook
data_tab = ttk.Frame(notebook, padding=16) notebook.grid(row=1, column=0, sticky="nsew")
address_tab = ttk.Frame(notebook, padding=16) data_tab = self._create_form_tab(notebook, "data", "Stammdaten")
banking_tab = ttk.Frame(notebook, padding=16) address_tab = self._create_form_tab(notebook, "address", "Anschrift")
banking_tab = self._create_form_tab(notebook, "banking", "Bank / SEPA")
actions = ttk.Frame(parent)
actions.grid(row=2, column=0, sticky="ew", pady=(10, 0))
actions.columnconfigure(0, weight=1)
ttk.Button(
actions,
text="Mitgliedsdaten speichern",
style="Accent.TButton",
command=self._save,
).grid(row=0, column=0, sticky="e")
contribution_tab = ttk.Frame(notebook, padding=16) contribution_tab = ttk.Frame(notebook, padding=16)
assets_tab = ttk.Frame(notebook, padding=16)
documents_tab = ttk.Frame(notebook, padding=16) documents_tab = ttk.Frame(notebook, padding=16)
notebook.add(data_tab, text="Stammdaten")
notebook.add(address_tab, text="Anschrift")
notebook.add(banking_tab, text="Bank / SEPA")
notebook.add(contribution_tab, text="Forderungen") notebook.add(contribution_tab, text="Forderungen")
notebook.add(assets_tab, text="Assets")
notebook.add(documents_tab, text="Dokumente") notebook.add(documents_tab, text="Dokumente")
fields = [ fields = [
("UUID / Ordner-ID", "member_id"),
("Mitgliedsnummer", "member_number"), ("Mitgliedsnummer", "member_number"),
("Vorname", "first_name"), ("Vorname", "first_name"),
("Nachname", "last_name"), ("Nachname", "last_name"),
("Nickname", "nickname"),
("E-Mail-Adresse", "email"), ("E-Mail-Adresse", "email"),
("Telefonnummer", "phone"), ("Telefonnummer", "phone"),
(f"Geburtsdatum ({date_input_hint()})", "birth_date"), (f"Geburtsdatum ({date_input_hint()})", "birth_date"),
@@ -105,7 +165,7 @@ class MemberTab(ttk.Frame):
] ]
for row, (label, key) in enumerate(fields): for row, (label, key) in enumerate(fields):
variable = tk.StringVar() variable = tk.StringVar()
self.variables[key] = variable self._add_variable(key, variable, "data")
ttk.Label(data_tab, text=label).grid(row=row, column=0, sticky="w", pady=5, padx=(0, 12)) ttk.Label(data_tab, text=label).grid(row=row, column=0, sticky="w", pady=5, padx=(0, 12))
if key == "birth_date": if key == "birth_date":
birth_row = ttk.Frame(data_tab) birth_row = ttk.Frame(data_tab)
@@ -120,10 +180,11 @@ class MemberTab(ttk.Frame):
"write", lambda *_args, source=variable: self.age_var.set(age_label(source.get())) "write", lambda *_args, source=variable: self.age_var.set(age_label(source.get()))
) )
else: else:
ttk.Entry(data_tab, textvariable=variable, width=42).grid( entry_state = "readonly" if key in {"member_id", "member_number"} else "normal"
ttk.Entry(data_tab, textvariable=variable, width=42, state=entry_state).grid(
row=row, column=1, sticky="ew", pady=5 row=row, column=1, sticky="ew", pady=5
) )
self.variables["status"] = tk.StringVar() self._add_variable("status", tk.StringVar(), "data")
ttk.Label(data_tab, text="Status").grid(row=len(fields), column=0, sticky="w", pady=5, padx=(0, 12)) ttk.Label(data_tab, text="Status").grid(row=len(fields), column=0, sticky="w", pady=5, padx=(0, 12))
ttk.Combobox( ttk.Combobox(
data_tab, data_tab,
@@ -132,17 +193,13 @@ class MemberTab(ttk.Frame):
state="readonly", state="readonly",
width=39, width=39,
).grid(row=len(fields), column=1, sticky="ew", pady=5) ).grid(row=len(fields), column=1, sticky="ew", pady=5)
self.variables["notes"] = tk.StringVar()
ttk.Label(data_tab, text="Interne Notiz").grid( ttk.Label(data_tab, text="Interne Notiz").grid(
row=len(fields) + 1, column=0, sticky="nw", pady=5, padx=(0, 12) row=len(fields) + 1, column=0, sticky="nw", pady=5, padx=(0, 12)
) )
ttk.Entry(data_tab, textvariable=self.variables["notes"]).grid( self.notes_text = tk.Text(data_tab, width=42, height=6, wrap="word")
row=len(fields) + 1, column=1, sticky="ew", pady=5 self.notes_text.grid(row=len(fields) + 1, column=1, sticky="ew", pady=5)
) self.notes_text.bind("<<Modified>>", lambda _event: self._mark_dirty_from_text("data"), add="+")
data_tab.columnconfigure(1, weight=1) data_tab.columnconfigure(1, weight=1)
ttk.Button(data_tab, text="Änderungen speichern", style="Accent.TButton", command=self._save).grid(
row=len(fields) + 2, column=1, sticky="e", pady=(18, 0)
)
address_fields = ( address_fields = (
("Straße und Hausnummer", "street"), ("Straße und Hausnummer", "street"),
@@ -153,14 +210,11 @@ class MemberTab(ttk.Frame):
) )
address_tab.columnconfigure(1, weight=1) address_tab.columnconfigure(1, weight=1)
for row, (label, key) in enumerate(address_fields): for row, (label, key) in enumerate(address_fields):
self.variables[key] = tk.StringVar() self._add_variable(key, tk.StringVar(), "address")
ttk.Label(address_tab, text=label).grid(row=row, column=0, sticky="w", padx=(0, 12), pady=5) ttk.Label(address_tab, text=label).grid(row=row, column=0, sticky="w", padx=(0, 12), pady=5)
ttk.Entry(address_tab, textvariable=self.variables[key], width=42).grid( ttk.Entry(address_tab, textvariable=self.variables[key], width=42).grid(
row=row, column=1, sticky="ew", pady=5 row=row, column=1, sticky="ew", pady=5
) )
ttk.Button(
address_tab, text="Anschrift speichern", style="Accent.TButton", command=self._save
).grid(row=len(address_fields), column=1, sticky="e", pady=(18, 0))
banking_fields = ( banking_fields = (
("Kontoinhaber", "account_holder"), ("Kontoinhaber", "account_holder"),
@@ -172,22 +226,18 @@ class MemberTab(ttk.Frame):
) )
banking_tab.columnconfigure(1, weight=1) banking_tab.columnconfigure(1, weight=1)
for row, (label, key) in enumerate(banking_fields): for row, (label, key) in enumerate(banking_fields):
self.variables[key] = tk.StringVar() self._add_variable(key, tk.StringVar(), "banking")
ttk.Label(banking_tab, text=label).grid(row=row, column=0, sticky="w", padx=(0, 12), pady=5) ttk.Label(banking_tab, text=label).grid(row=row, column=0, sticky="w", padx=(0, 12), pady=5)
ttk.Entry(banking_tab, textvariable=self.variables[key], width=42).grid( ttk.Entry(banking_tab, textvariable=self.variables[key], width=42).grid(
row=row, column=1, sticky="ew", pady=5 row=row, column=1, sticky="ew", pady=5
) )
self.variables["mandate_active"] = tk.BooleanVar() self._add_variable("mandate_active", tk.BooleanVar(), "banking")
ttk.Checkbutton( ttk.Checkbutton(
banking_tab, banking_tab,
text="SEPA-Lastschriftmandat ist aktiv", text="SEPA-Lastschriftmandat ist aktiv",
variable=self.variables["mandate_active"], variable=self.variables["mandate_active"],
style="Switch", style="Switch",
).grid(row=len(banking_fields), column=0, columnspan=2, sticky="w", pady=(12, 5)) ).grid(row=len(banking_fields), column=0, columnspan=2, sticky="w", pady=(12, 5))
ttk.Button(
banking_tab, text="Bankdaten speichern", style="Accent.TButton", command=self._save
).grid(row=len(banking_fields) + 1, column=1, sticky="e", pady=(18, 0))
contribution_tab.columnconfigure(0, weight=1) contribution_tab.columnconfigure(0, weight=1)
contribution_tab.rowconfigure(1, weight=1) contribution_tab.rowconfigure(1, weight=1)
self.contribution_summary = tk.StringVar() self.contribution_summary = tk.StringVar()
@@ -197,18 +247,53 @@ class MemberTab(ttk.Frame):
self.claims = ttk.Treeview( self.claims = ttk.Treeview(
contribution_tab, columns=("title", "due", "amount", "status"), show="headings" contribution_tab, columns=("title", "due", "amount", "status"), show="headings"
) )
for key, title, width in ( self.claim_sort_column = "due"
("title", "Forderung", 220), self.claim_sort_descending = False
("due", "Fällig", 100), for key, title, width in CLAIM_TABLE_COLUMNS:
("amount", "Betrag", 90), self.claims.heading(key, text=title, command=lambda column=key: self._toggle_claim_sort(column))
("status", "Status", 110),
):
self.claims.heading(key, text=title)
self.claims.column(key, width=width, anchor="w") self.claims.column(key, width=width, anchor="w")
self.claims.grid(row=1, column=0, sticky="nsew") self.claims.grid(row=1, column=0, sticky="nsew")
self.claims.bind("<Double-1>", lambda _event: self._open_selected_claim()) self.claims.bind("<Double-1>", lambda _event: self._open_selected_claim())
self.claims.bind("<Return>", lambda _event: self._open_selected_claim()) self.claims.bind("<Return>", lambda _event: self._open_selected_claim())
assets_tab.columnconfigure(0, weight=1)
assets_tab.rowconfigure(1, weight=1)
self.assets_summary = tk.StringVar()
ttk.Label(assets_tab, textvariable=self.assets_summary, style="Mono.TLabel").grid(
row=0, column=0, sticky="w", pady=(0, 10)
)
self.assets_tree = ttk.Treeview(
assets_tab,
columns=("label", "category", "inventory_number", "status"),
show="headings",
)
for key, title, width in (
("label", "Bezeichnung", 240),
("category", "Kategorie", 140),
("inventory_number", "Inventarnummer", 140),
("status", "Status", 140),
):
self.assets_tree.heading(key, text=title)
self.assets_tree.column(key, width=width, anchor="w")
self.assets_tree.grid(row=1, column=0, sticky="nsew")
self.assets_tree.bind("<Double-1>", lambda _event: self._open_selected_asset())
self.assets_tree.bind("<Return>", lambda _event: self._open_selected_asset())
asset_actions = ttk.Frame(assets_tab)
asset_actions.grid(row=2, column=0, sticky="e", pady=(10, 0))
ttk.Button(asset_actions, text="Inventar öffnen", command=self.on_open_assets).pack(
side="left", padx=(0, 8)
)
ttk.Button(asset_actions, text="Asset öffnen", command=self._open_selected_asset).pack(
side="left", padx=(0, 8)
)
ttk.Button(
asset_actions,
text="Ausgewähltes Asset zurücknehmen",
command=self._return_selected_asset,
).pack(
side="left",
)
documents_tab.columnconfigure(0, weight=1) documents_tab.columnconfigure(0, weight=1)
documents_tab.rowconfigure(1, weight=1) documents_tab.rowconfigure(1, weight=1)
document_buttons = ttk.Frame(documents_tab) document_buttons = ttk.Frame(documents_tab)
@@ -247,6 +332,53 @@ class MemberTab(ttk.Frame):
self.documents.bind("<Return>", lambda _event: self._open_selected_document()) self.documents.bind("<Return>", lambda _event: self._open_selected_document())
self.document_paths: dict[str, Path] = {} self.document_paths: dict[str, Path] = {}
def _create_form_tab(self, notebook: ttk.Notebook, section: str, title: str) -> ttk.Frame:
tab = ttk.Frame(notebook)
tab.columnconfigure(0, weight=1)
tab.rowconfigure(0, weight=1)
scroller = ScrollableFrame(tab, padding=16)
scroller.grid(row=0, column=0, sticky="nsew")
notebook.add(tab, text=title)
self._form_tabs[section] = str(tab)
self._form_tab_titles[section] = title
return scroller.content
def _add_variable(self, key: str, variable: tk.Variable, section: str) -> None:
self.variables[key] = variable
self._field_sections[key] = section
variable.trace_add("write", lambda *_args, target=section: self._mark_dirty(target))
def _mark_dirty(self, section: str) -> None:
if self._loading:
return
self._dirty_sections.add(section)
self._refresh_dirty_tabs()
def _mark_dirty_from_text(self, section: str) -> None:
if self.notes_text is None or not self.notes_text.edit_modified():
return
self.notes_text.edit_modified(False)
self._mark_dirty(section)
def _refresh_dirty_tabs(self) -> None:
for section, tab_id in self._form_tabs.items():
title = self._form_tab_titles[section]
suffix = " *" if section in self._dirty_sections else ""
self.details_notebook.tab(tab_id, text=f"{title}{suffix}")
def _clear_dirty(self) -> None:
self._dirty_sections.clear()
self._refresh_dirty_tabs()
def _close(self) -> None:
if self._dirty_sections and not messagebox.askokcancel(
"Ungespeicherte Änderungen",
"Es gibt ungespeicherte Änderungen an den Mitgliedsdaten. Tab trotzdem schließen?",
parent=self,
):
return
self.on_close()
def _build_timeline(self, parent: ttk.Frame) -> None: def _build_timeline(self, parent: ttk.Frame) -> None:
parent.columnconfigure(0, weight=1) parent.columnconfigure(0, weight=1)
parent.rowconfigure(1, weight=1) parent.rowconfigure(1, weight=1)
@@ -271,9 +403,23 @@ class MemberTab(ttk.Frame):
ttk.Button(compose, text="Kommentar", command=self._add_comment).grid(row=0, column=1) ttk.Button(compose, text="Kommentar", command=self._add_comment).grid(row=0, column=1)
def refresh(self) -> None: def refresh(self) -> None:
self._loading = True
self.member = self.repository.get_member(self.member_id) self.member = self.repository.get_member(self.member_id)
self.title_var.set(f"{self.member.member_number or ''} · {self.member.display_name}") self.title_var.set(f"{self.member.member_number or ''} · {self.member.display_name}")
self.status_var.set(STATUS_LABELS.get(self.member.status, self.member.status.upper())) self.status_var.set(STATUS_LABELS.get(self.member.status, self.member.status.upper()))
self.id_var.set(f"UUID: {self.member.member_id}")
warnings = self.repository.member_hash_warnings(self.member_id)
self.messages.set_messages(
[
TabMessage(
"warning",
"WARNUNG: " + " | ".join(warnings),
MessageAction("Überprüft, bestätigen", self._confirm_integrity_banner),
)
]
if warnings
else []
)
date_fields = { date_fields = {
"birth_date", "birth_date",
"accepted_at", "accepted_at",
@@ -287,8 +433,15 @@ class MemberTab(ttk.Frame):
variable.set(display_label(STATUS_LABELS, str(value))) variable.set(display_label(STATUS_LABELS, str(value)))
else: else:
variable.set(format_date_for_display(value) if key in date_fields else value) variable.set(format_date_for_display(value) if key in date_fields else value)
if self.notes_text is not None:
self.notes_text.delete("1.0", "end")
self.notes_text.insert("1.0", self.member.notes)
self.notes_text.edit_modified(False)
self._loading = False
self._clear_dirty()
self._refresh_events() self._refresh_events()
self._refresh_contributions() self._refresh_contributions()
self._refresh_assets()
self._refresh_documents() self._refresh_documents()
def _refresh_events(self) -> None: def _refresh_events(self) -> None:
@@ -308,7 +461,13 @@ class MemberTab(ttk.Frame):
except RepositoryError as exc: except RepositoryError as exc:
self.contribution_summary.set(f"FEHLER: {exc}") self.contribution_summary.set(f"FEHLER: {exc}")
return return
for index, claim in enumerate(data.claims): claims = sorted(
data.claims,
key=lambda claim: _claim_sort_value(data, claim, self.claim_sort_column).casefold(),
reverse=self.claim_sort_descending,
)
self._update_claim_headings()
for index, claim in enumerate(claims):
claim_id = str(claim.get("claim_id") or f"missing-id-{index}") claim_id = str(claim.get("claim_id") or f"missing-id-{index}")
status = claim_status(data, claim) status = claim_status(data, claim)
self.claims.insert( self.claims.insert(
@@ -324,6 +483,25 @@ class MemberTab(ttk.Frame):
) )
self.contribution_summary.set(f"{len(data.claims)} Forderungen · {len(data.payments)} Zahlungen") self.contribution_summary.set(f"{len(data.claims)} Forderungen · {len(data.payments)} Zahlungen")
def _toggle_claim_sort(self, column: str) -> None:
if self.claim_sort_column == column:
self.claim_sort_descending = not self.claim_sort_descending
else:
self.claim_sort_column = column
self.claim_sort_descending = False
self._refresh_contributions()
def _update_claim_headings(self) -> None:
for key, title, _width in CLAIM_TABLE_COLUMNS:
suffix = ""
if key == self.claim_sort_column:
suffix = " v" if self.claim_sort_descending else " ^"
self.claims.heading(
key,
text=f"{title}{suffix}",
command=lambda column=key: self._toggle_claim_sort(column),
)
def _open_selected_claim(self) -> None: def _open_selected_claim(self) -> None:
selected = self.claims.selection() selected = self.claims.selection()
if selected and not selected[0].startswith("missing-id-"): if selected and not selected[0].startswith("missing-id-"):
@@ -356,21 +534,68 @@ class MemberTab(ttk.Frame):
), ),
) )
def _refresh_assets(self) -> None:
self.assets_tree.delete(*self.assets_tree.get_children())
assets = self.repository.list_member_assets(self.member_id)
self.assets_summary.set(f"{len(assets)} ausgegebene Assets")
for asset in assets:
self.assets_tree.insert(
"",
"end",
iid=asset.asset_id,
values=(
asset.label,
asset.category,
asset.inventory_number,
ASSET_STATUS_LABELS.get(asset.status, asset.status),
),
)
def _save(self) -> None: def _save(self) -> None:
warnings = self.repository.member_hash_warnings(self.member_id)
if warnings:
self._confirm_integrity_and_then(self._save_confirmed)
return
self._save_confirmed()
def _save_confirmed(self) -> None:
for key, variable in self.variables.items(): for key, variable in self.variables.items():
raw_value = variable.get() raw_value = variable.get()
value = raw_value.strip() if isinstance(raw_value, str) else raw_value value = raw_value.strip() if isinstance(raw_value, str) else raw_value
if key == "status": if key == "status":
value = storage_key(STATUS_LABELS, value) value = storage_key(STATUS_LABELS, value)
setattr(self.member, key, value) setattr(self.member, key, value)
if self.notes_text is not None:
self.member.notes = self.notes_text.get("1.0", "end-1c").strip()
try: try:
self.repository.save_member(self.member) self.repository.save_member(self.member)
self.repository.refresh_member_record_hashes(self.member_id)
except RepositoryError as exc: except RepositoryError as exc:
messagebox.showerror("Speichern fehlgeschlagen", str(exc), parent=self) messagebox.showerror("Speichern fehlgeschlagen", str(exc), parent=self)
return return
self.refresh() self.refresh()
self.on_changed() self.on_changed()
def _confirm_integrity_banner(self) -> None:
self._confirm_integrity_and_then(self._refresh_hashes_only)
def _refresh_hashes_only(self) -> None:
self.repository.refresh_member_record_hashes(self.member_id)
self.refresh()
self.on_changed()
def _confirm_integrity_and_then(self, callback: Callable[[], None]) -> None:
warnings = self.repository.member_hash_warnings(self.member_id)
if not warnings:
callback()
return
IntegrityWarningDialog(
self,
title="Externe Änderungen bestätigen",
warnings=warnings,
on_confirm=callback,
)
def _add_comment(self) -> None: def _add_comment(self) -> None:
text = self.comment_var.get().strip() text = self.comment_var.get().strip()
if not text: if not text:
@@ -389,6 +614,16 @@ class MemberTab(ttk.Frame):
path = self.repository.members_root / self.member_id / "files" path = self.repository.members_root / self.member_id / "files"
self._open_path(path) self._open_path(path)
def _return_selected_asset(self) -> None:
selected = self.assets_tree.selection()
if selected:
self.on_return_asset(selected[0])
def _open_selected_asset(self) -> None:
selected = self.assets_tree.selection()
if selected:
self.on_open_asset(selected[0])
def _open_selected_document(self) -> None: def _open_selected_document(self) -> None:
selected = self.documents.selection() selected = self.documents.selection()
if selected and selected[0] in self.document_paths: if selected and selected[0] in self.document_paths:
+79
View File
@@ -0,0 +1,79 @@
from __future__ import annotations
import tkinter as tk
from collections.abc import Callable, Iterable
from dataclasses import dataclass
from tkinter import ttk
MessageType = str
@dataclass(frozen=True)
class MessageAction:
label: str
callback: Callable[[], None]
@dataclass(frozen=True)
class TabMessage:
message_type: MessageType
text: str
action: MessageAction | None = None
_TYPE_STYLES = {
"error": "Error",
"notification": "Notification",
"warning": "Warning",
"info": "Info",
}
class MessageBannerList(ttk.Frame):
def __init__(self, master: tk.Misc):
super().__init__(master)
self.columnconfigure(0, weight=1)
self._messages: list[TabMessage] = []
def set_messages(self, messages: Iterable[TabMessage]) -> None:
self._messages = [message for message in messages if message.text.strip()]
self._render()
if self._messages:
self.grid()
else:
self.grid_remove()
def _render(self) -> None:
for child in self.winfo_children():
child.destroy()
for row_index, message in enumerate(self._messages):
style_name = _message_style_name(message.message_type)
banner = ttk.Frame(self, style=f"Message{style_name}Border.TFrame", padding=1)
banner.grid(row=row_index, column=0, sticky="ew", pady=(0, 6))
banner.columnconfigure(0, weight=1)
body = ttk.Frame(banner, style=f"Message{style_name}.TFrame", padding=(10, 8))
body.grid(row=0, column=0, sticky="ew")
body.columnconfigure(0, weight=1)
label = ttk.Label(
body,
text=message.text,
style=f"Message{style_name}.TLabel",
wraplength=900,
justify="left",
)
label.grid(row=0, column=0, sticky="ew")
body.bind(
"<Configure>",
lambda event, target=label: target.configure(wraplength=max(240, event.width - 180)),
add="+",
)
if message.action is not None:
ttk.Button(
body,
text=message.action.label,
command=message.action.callback,
).grid(row=0, column=1, sticky="e", padx=(12, 0))
def _message_style_name(message_type: MessageType) -> str:
return _TYPE_STYLES.get(message_type.casefold(), "Info")
+26
View File
@@ -6,6 +6,7 @@ from pathlib import Path
from tkinter import filedialog, messagebox, ttk from tkinter import filedialog, messagebox, ttk
from ccma.config import AppConfig from ccma.config import AppConfig
from ccma.domain.models import HOUSEKEEPER_MEMBER_FIELD_LABELS
from ccma.services.intervals import ( from ccma.services.intervals import (
IntervalValidationError, IntervalValidationError,
normalize_anniversary_intervals, normalize_anniversary_intervals,
@@ -38,6 +39,11 @@ class OptionsDialog(tk.Toplevel):
self.anniversary_before_var = tk.StringVar(value=str(config.anniversary_days_before)) self.anniversary_before_var = tk.StringVar(value=str(config.anniversary_days_before))
self.anniversary_after_var = tk.StringVar(value=str(config.anniversary_days_after)) self.anniversary_after_var = tk.StringVar(value=str(config.anniversary_days_after))
self.anniversary_intervals_var = tk.StringVar(value=config.anniversary_intervals) self.anniversary_intervals_var = tk.StringVar(value=config.anniversary_intervals)
self.retroactive_claims_var = tk.BooleanVar(value=config.retroactive_claims)
self.optional_member_field_vars = {
field: tk.BooleanVar(value=field in config.optional_member_fields)
for field in HOUSEKEEPER_MEMBER_FIELD_LABELS
}
number_policy = repository.get_member_number_policy() number_policy = repository.get_member_number_policy()
self.manual_numbers_var = tk.BooleanVar(value=number_policy["mode"] == "manual") self.manual_numbers_var = tk.BooleanVar(value=number_policy["mode"] == "manual")
self.number_pattern_var = tk.StringVar(value=number_policy["pattern"]) self.number_pattern_var = tk.StringVar(value=number_policy["pattern"])
@@ -238,6 +244,22 @@ class OptionsDialog(tk.Toplevel):
text="Komma oder Semikolon; ohne Einheit = Jahre. Beispiel: 30D;2M;1;10Y.", text="Komma oder Semikolon; ohne Einheit = Jahre. Beispiel: 30D;2M;1;10Y.",
style="Muted.TLabel", style="Muted.TLabel",
).grid(row=5, column=1, sticky="w") ).grid(row=5, column=1, sticky="w")
ttk.Checkbutton(
parent,
text="Beitragsforderungen rückwirkend seit Beitritt anlegen",
variable=self.retroactive_claims_var,
style="Switch",
).grid(row=6, column=0, columnspan=3, sticky="w", pady=(18, 0))
optional_fields = ttk.LabelFrame(parent, text="Bei leeren Mitgliedsfeldern nicht meckern", padding=12)
optional_fields.grid(row=7, column=0, columnspan=3, sticky="ew", pady=(18, 0))
for column in range(2):
optional_fields.columnconfigure(column, weight=1)
for index, (field, label) in enumerate(HOUSEKEEPER_MEMBER_FIELD_LABELS.items()):
ttk.Checkbutton(
optional_fields,
text=label,
variable=self.optional_member_field_vars[field],
).grid(row=index // 2, column=index % 2, sticky="w", padx=(0, 16), pady=4)
def _build_member_numbers(self, parent: ttk.Frame) -> None: def _build_member_numbers(self, parent: ttk.Frame) -> None:
parent.columnconfigure(1, weight=1) parent.columnconfigure(1, weight=1)
@@ -381,6 +403,10 @@ class OptionsDialog(tk.Toplevel):
self.config_obj.anniversary_days_before = anniversary_before self.config_obj.anniversary_days_before = anniversary_before
self.config_obj.anniversary_days_after = anniversary_after self.config_obj.anniversary_days_after = anniversary_after
self.config_obj.anniversary_intervals = anniversary_intervals self.config_obj.anniversary_intervals = anniversary_intervals
self.config_obj.retroactive_claims = self.retroactive_claims_var.get()
self.config_obj.optional_member_fields = tuple(
field for field, variable in self.optional_member_field_vars.items() if variable.get()
)
try: try:
self.config_obj.save() self.config_obj.save()
self.repository.save_member_number_policy(mode=number_mode, pattern=number_pattern) self.repository.save_member_number_policy(mode=number_mode, pattern=number_pattern)
+68
View File
@@ -0,0 +1,68 @@
from __future__ import annotations
import tkinter as tk
from tkinter import ttk
class ScrollableFrame(ttk.Frame):
def __init__(self, master: tk.Misc, *, padding: int | tuple[int, ...] = 0):
super().__init__(master)
self.columnconfigure(0, weight=1)
self.rowconfigure(0, weight=1)
self.canvas = tk.Canvas(self, highlightthickness=0, borderwidth=0)
self.scrollbar = ttk.Scrollbar(self, orient="vertical", command=self.canvas.yview)
self.content = ttk.Frame(self.canvas, padding=padding)
self._content_window = self.canvas.create_window((0, 0), window=self.content, anchor="nw")
self.canvas.configure(yscrollcommand=self.scrollbar.set)
self.canvas.grid(row=0, column=0, sticky="nsew")
self.content.bind("<Configure>", self._update_scrollregion, add="+")
self.canvas.bind("<Configure>", self._fit_content_width, add="+")
self.bind("<Enter>", self._bind_mousewheel, add="+")
self.bind("<Leave>", self._unbind_mousewheel, add="+")
self.content.bind("<Enter>", self._bind_mousewheel, add="+")
self.content.bind("<Leave>", self._unbind_mousewheel, add="+")
self.after_idle(self._apply_canvas_background)
def _apply_canvas_background(self) -> None:
background = ttk.Style(self).lookup("TFrame", "background")
if background:
self.canvas.configure(background=background)
def _update_scrollregion(self, _event: tk.Event | None = None) -> None:
self.canvas.configure(scrollregion=self.canvas.bbox("all"))
self._sync_scrollbar()
def _fit_content_width(self, event: tk.Event) -> None:
self.canvas.itemconfigure(self._content_window, width=event.width)
self._sync_scrollbar()
def _sync_scrollbar(self) -> None:
bounds = self.canvas.bbox("all")
if not bounds:
self.scrollbar.grid_remove()
return
content_height = bounds[3] - bounds[1]
canvas_height = self.canvas.winfo_height()
if canvas_height > 1 and content_height > canvas_height:
self.scrollbar.grid(row=0, column=1, sticky="ns")
else:
self.scrollbar.grid_remove()
def _bind_mousewheel(self, _event: tk.Event | None = None) -> None:
self.canvas.bind_all("<MouseWheel>", self._on_mousewheel, add="+")
self.canvas.bind_all("<Button-4>", self._on_mousewheel, add="+")
self.canvas.bind_all("<Button-5>", self._on_mousewheel, add="+")
def _unbind_mousewheel(self, _event: tk.Event | None = None) -> None:
self.canvas.unbind_all("<MouseWheel>")
self.canvas.unbind_all("<Button-4>")
self.canvas.unbind_all("<Button-5>")
def _on_mousewheel(self, event: tk.Event) -> None:
if getattr(event, "num", None) == 4:
delta = -1
elif getattr(event, "num", None) == 5:
delta = 1
else:
delta = -1 * int(getattr(event, "delta", 0) / 120)
self.canvas.yview_scroll(delta, "units")
+10
View File
@@ -0,0 +1,10 @@
from __future__ import annotations
import tkinter as tk
from tkinter import ttk
def titled_frame(parent: tk.Misc, title: str) -> ttk.LabelFrame:
frame = ttk.LabelFrame(parent, padding=(12, 10))
frame.configure(labelwidget=ttk.Label(frame, text=title, style="TimelineHeader.TLabel"))
return frame
+34
View File
@@ -47,6 +47,28 @@ def _configure_ccma_styles(style: ttk.Style, variant: str) -> None:
accent = "#00d084" if dark else "#087f5b" accent = "#00d084" if dark else "#087f5b"
warning = "#ffb454" warning = "#ffb454"
danger = "#ff6b6b" danger = "#ff6b6b"
message_styles = {
"Error": (
"#5c2528" if dark else "#d04242",
"#3a1719" if dark else "#fde8e8",
"#ffd7d7" if dark else "#8a1f1f",
),
"Warning": (
"#6c4b19" if dark else "#d69b19",
"#3a2a12" if dark else "#fff4cc",
"#ffe2a8" if dark else "#7a4f00",
),
"Info": (
"#204f79" if dark else "#5b9bd8",
"#132c45" if dark else "#e5f1ff",
"#cde6ff" if dark else "#174a7c",
),
"Notification": (
"#1f5a3a" if dark else "#57ad75",
"#123222" if dark else "#e4f7ec",
"#c9f2dc" if dark else "#1f6b3d",
),
}
style.configure("Ribbon.TFrame", padding=(12, 9)) style.configure("Ribbon.TFrame", padding=(12, 9))
style.configure("AppTitle.TLabel", font=("TkDefaultFont", 14, "bold")) style.configure("AppTitle.TLabel", font=("TkDefaultFont", 14, "bold"))
style.configure("TabTitle.TLabel", font=("TkDefaultFont", 15, "bold")) style.configure("TabTitle.TLabel", font=("TkDefaultFont", 15, "bold"))
@@ -75,3 +97,15 @@ def _configure_ccma_styles(style: ttk.Style, variant: str) -> None:
fieldbackground=background, fieldbackground=background,
foreground=foreground, foreground=foreground,
) )
for name, (message_border, message_background, message_foreground) in message_styles.items():
style.configure(f"Message{name}Border.TFrame", background=message_border)
style.configure(
f"Message{name}.TFrame",
background=message_background,
)
style.configure(
f"Message{name}.TLabel",
background=message_background,
foreground=message_foreground,
font=("TkDefaultFont", 10),
)
+370 -22
View File
@@ -4,7 +4,97 @@ from collections.abc import Callable
from tkinter import messagebox, ttk from tkinter import messagebox, ttk
from ccma.domain.dates import format_date_for_display from ccma.domain.dates import format_date_for_display
from ccma.domain.models import MEMBERSHIP_STATUS_LABELS, HousekeeperFinding, Member from ccma.domain.models import (
ASSET_STATUS_LABELS,
MEMBERSHIP_STATUS_LABELS,
Asset,
HousekeeperFinding,
Member,
)
from ccma.ui.labels import storage_key
from ccma.ui.sections import titled_frame
MEMBER_TABLE_COLUMNS = (
("number", "Nummer", 110),
("first_name", "Vorname", 160),
("last_name", "Nachname", 180),
("nickname", "Nickname", 160),
("email", "E-Mail-Adresse", 270),
("birth", "Geburtsdatum", 120),
("status", "Status", 170),
)
STATUS_FILTER_ALL = "Alle"
ASSET_FILTER_ALL = "Alle"
ASSET_TABLE_COLUMNS = (
("label", "Bezeichnung", 260),
("category", "Kategorie", 140),
("inventory_number", "Inventarnummer", 140),
("status", "Status", 140),
("holder", "Mitglied", 240),
)
def _member_table_value(member: Member, column: str) -> str:
if column == "number":
return member.member_number
if column == "first_name":
return member.first_name
if column == "last_name":
return member.last_name
if column == "nickname":
return member.nickname
if column == "email":
return member.email
if column == "birth":
return member.birth_date
if column == "status":
return MEMBERSHIP_STATUS_LABELS.get(member.status, member.status)
return ""
def _filter_members(members: list[Member], status_filter: str) -> list[Member]:
if status_filter == "all":
return list(members)
return [member for member in members if member.status == status_filter]
def _sort_members(members: list[Member], column: str, descending: bool) -> list[Member]:
return sorted(
members,
key=lambda member: _member_table_value(member, column).casefold(),
reverse=descending,
)
def _selected_status_filter(label: str) -> str:
if label == STATUS_FILTER_ALL:
return "all"
return storage_key(MEMBERSHIP_STATUS_LABELS, label)
def _filter_label_frame(parent: tk.Misc) -> ttk.LabelFrame:
return titled_frame(parent, "// FILTER")
def _asset_table_value(asset: Asset, column: str, holder_label: str) -> str:
if column == "label":
return asset.label
if column == "category":
return asset.category
if column == "inventory_number":
return asset.inventory_number
if column == "status":
return ASSET_STATUS_LABELS.get(asset.status, asset.status)
if column == "holder":
return holder_label
return ""
def _filter_assets(assets: list[Asset], status_filter: str) -> list[Asset]:
if status_filter == "all":
return list(assets)
return [asset for asset in assets if asset.status == status_filter]
class DashboardTab(ttk.Frame): class DashboardTab(ttk.Frame):
@@ -82,11 +172,17 @@ class SearchResultsTab(ttk.Frame):
ttk.Label(header, text=f"{len(self.members)} Treffer", style="Mono.TLabel").grid( ttk.Label(header, text=f"{len(self.members)} Treffer", style="Mono.TLabel").grid(
row=1, column=0, sticky="w" row=1, column=0, sticky="w"
) )
ttk.Button(header, text="Tab schließen", command=self.on_close).grid(row=0, column=1, rowspan=2) ttk.Button(header, text="Tab schließen", command=self.on_close).grid(row=0, column=3, rowspan=2)
tree = ttk.Treeview(self, columns=("number", "name", "email", "birth", "status"), show="headings") tree = ttk.Treeview(
self,
columns=("number", "first_name", "last_name", "nickname", "email", "birth", "status"),
show="headings",
)
for key, title, width in ( for key, title, width in (
("number", "Nummer", 90), ("number", "Nummer", 90),
("name", "Name", 220), ("first_name", "Vorname", 150),
("last_name", "Nachname", 170),
("nickname", "Nickname", 150),
("email", "E-Mail-Adresse", 260), ("email", "E-Mail-Adresse", 260),
("birth", "Geburtsdatum", 110), ("birth", "Geburtsdatum", 110),
("status", "Status", 160), ("status", "Status", 160),
@@ -101,7 +197,9 @@ class SearchResultsTab(ttk.Frame):
iid=member.member_id, iid=member.member_id,
values=( values=(
member.member_number, member.member_number,
member.display_name, member.first_name,
member.last_name,
member.nickname,
member.email, member.email,
format_date_for_display(member.birth_date), format_date_for_display(member.birth_date),
MEMBERSHIP_STATUS_LABELS.get(member.status, member.status), MEMBERSHIP_STATUS_LABELS.get(member.status, member.status),
@@ -132,7 +230,7 @@ class MembersTab(ttk.Frame):
def _build_ui(self) -> None: def _build_ui(self) -> None:
self.columnconfigure(0, weight=1) self.columnconfigure(0, weight=1)
self.rowconfigure(1, weight=1) self.rowconfigure(2, weight=1)
header = ttk.Frame(self) header = ttk.Frame(self)
header.grid(row=0, column=0, sticky="ew", pady=(0, 10)) header.grid(row=0, column=0, sticky="ew", pady=(0, 10))
header.columnconfigure(0, weight=1) header.columnconfigure(0, weight=1)
@@ -140,60 +238,300 @@ class MembersTab(ttk.Frame):
self.count_var = tk.StringVar() self.count_var = tk.StringVar()
ttk.Label(header, textvariable=self.count_var, style="Mono.TLabel").grid(row=1, column=0, sticky="w") ttk.Label(header, textvariable=self.count_var, style="Mono.TLabel").grid(row=1, column=0, sticky="w")
ttk.Button(header, text="Tab schließen", command=self.on_close).grid(row=0, column=1, rowspan=2) ttk.Button(header, text="Tab schließen", command=self.on_close).grid(row=0, column=1, rowspan=2)
filters = _filter_label_frame(self)
filters.grid(row=1, column=0, sticky="ew", pady=(0, 10))
filter_row = ttk.Frame(filters)
filter_row.grid(row=0, column=0, sticky="w")
self.tree = ttk.Treeview( self.tree = ttk.Treeview(
self, columns=("number", "name", "email", "birth", "status"), show="headings" self,
columns=("number", "first_name", "last_name", "nickname", "email", "birth", "status"),
show="headings",
) )
for key, title, width in ( self.sort_column = "last_name"
("number", "Nummer", 110), self.sort_descending = False
("name", "Name", 230), self.status_filter_var = tk.StringVar(value=STATUS_FILTER_ALL)
("email", "E-Mail-Adresse", 270), ttk.Label(filter_row, text="Status").grid(row=0, column=0, sticky="w", padx=(0, 8))
("birth", "Geburtsdatum", 120), self.status_filter = ttk.Combobox(
("status", "Status", 170), filter_row,
): textvariable=self.status_filter_var,
self.tree.heading(key, text=title) state="readonly",
values=[STATUS_FILTER_ALL, *MEMBERSHIP_STATUS_LABELS.values()],
width=28,
)
self.status_filter.grid(row=0, column=1, sticky="w")
self.status_filter.bind("<<ComboboxSelected>>", lambda _event: self._render_members())
for key, title, width in MEMBER_TABLE_COLUMNS:
self.tree.heading(key, text=title, command=lambda column=key: self._toggle_sort(column))
self.tree.column(key, width=width, anchor="w") self.tree.column(key, width=width, anchor="w")
self.tree.grid(row=1, column=0, sticky="nsew") self.tree.grid(row=2, column=0, sticky="nsew")
self.tree.bind("<Double-1>", lambda _event: self._open_selected()) self.tree.bind("<Double-1>", lambda _event: self._open_selected())
self.tree.bind("<Return>", lambda _event: self._open_selected()) self.tree.bind("<Return>", lambda _event: self._open_selected())
self.refresh(self.members) self.refresh(self.members)
def refresh(self, members: list[Member]) -> None: def refresh(self, members: list[Member]) -> None:
self.members = members self.members = members
self._render_members()
def _render_members(self) -> None:
self.tree.delete(*self.tree.get_children()) self.tree.delete(*self.tree.get_children())
self.count_var.set(f"{len(members)} Mitglieder") status_filter = _selected_status_filter(self.status_filter_var.get())
for member in members: filtered_members = _filter_members(self.members, status_filter)
sorted_members = _sort_members(filtered_members, self.sort_column, self.sort_descending)
if len(filtered_members) == len(self.members):
self.count_var.set(f"{len(filtered_members)} Mitglieder")
else:
self.count_var.set(f"{len(filtered_members)} / {len(self.members)} Mitglieder")
self._update_tree_headings()
for member in sorted_members:
self.tree.insert( self.tree.insert(
"", "",
"end", "end",
iid=member.member_id, iid=member.member_id,
values=( values=(
member.member_number, member.member_number,
member.display_name, member.first_name,
member.last_name,
member.nickname,
member.email, member.email,
format_date_for_display(member.birth_date), format_date_for_display(member.birth_date),
MEMBERSHIP_STATUS_LABELS.get(member.status, member.status), MEMBERSHIP_STATUS_LABELS.get(member.status, member.status),
), ),
) )
def _toggle_sort(self, column: str) -> None:
if self.sort_column == column:
self.sort_descending = not self.sort_descending
else:
self.sort_column = column
self.sort_descending = False
self._render_members()
def _update_tree_headings(self) -> None:
for key, title, _width in MEMBER_TABLE_COLUMNS:
suffix = ""
if key == self.sort_column:
suffix = " v" if self.sort_descending else " ^"
self.tree.heading(
key,
text=f"{title}{suffix}",
command=lambda column=key: self._toggle_sort(column),
)
def _open_selected(self) -> None: def _open_selected(self) -> None:
selected = self.tree.selection() selected = self.tree.selection()
if selected: if selected:
self.on_open(selected[0]) self.on_open(selected[0])
class AssetsTab(ttk.Frame):
def __init__(
self,
master: tk.Misc,
assets: list[Asset],
resolve_holder_label: Callable[[str], str],
on_new: Callable[[], None],
on_open: Callable[[str], None],
on_edit: Callable[[str], None],
on_issue: Callable[[str], None],
on_return: Callable[[str], None],
on_close: Callable[[], None],
):
super().__init__(master, padding=12)
self.assets = assets
self.resolve_holder_label = resolve_holder_label
self.on_new = on_new
self.on_open = on_open
self.on_edit = on_edit
self.on_issue = on_issue
self.on_return = on_return
self.on_close = on_close
self.sort_column = "label"
self.sort_descending = False
self.status_filter_var = tk.StringVar(value=ASSET_FILTER_ALL)
self._build_ui()
def _build_ui(self) -> None:
self.columnconfigure(0, weight=1)
self.rowconfigure(2, weight=1)
header = ttk.Frame(self)
header.grid(row=0, column=0, sticky="ew", pady=(0, 10))
header.columnconfigure(0, weight=1)
ttk.Label(header, text="INVENTAR", style="TabTitle.TLabel").grid(row=0, column=0, sticky="w")
self.count_var = tk.StringVar()
ttk.Label(header, textvariable=self.count_var, style="Mono.TLabel").grid(row=1, column=0, sticky="w")
button_row = ttk.Frame(header)
button_row.grid(row=0, column=1, rowspan=2, sticky="e")
ttk.Button(button_row, text="Neues Asset", command=self.on_new).pack(side="left", padx=(0, 8))
ttk.Button(button_row, text="Tab schließen", command=self.on_close).pack(side="left")
filters = _filter_label_frame(self)
filters.grid(row=1, column=0, sticky="ew", pady=(0, 10))
filter_row = ttk.Frame(filters)
filter_row.grid(row=0, column=0, sticky="w")
ttk.Label(filter_row, text="Status").grid(row=0, column=0, sticky="w", padx=(0, 8))
self.status_filter = ttk.Combobox(
filter_row,
textvariable=self.status_filter_var,
state="readonly",
values=[ASSET_FILTER_ALL, *ASSET_STATUS_LABELS.values()],
width=22,
)
self.status_filter.grid(row=0, column=1, sticky="w")
self.status_filter.bind("<<ComboboxSelected>>", lambda _event: self._render_assets())
self.tree = ttk.Treeview(
self,
columns=tuple(key for key, _title, _width in ASSET_TABLE_COLUMNS),
show="headings",
)
for key, title, width in ASSET_TABLE_COLUMNS:
self.tree.heading(key, text=title, command=lambda column=key: self._toggle_sort(column))
self.tree.column(key, width=width, anchor="w")
self.tree.grid(row=2, column=0, sticky="nsew")
self.tree.bind("<Double-1>", self._open_selected)
self.tree.bind("<<TreeviewSelect>>", lambda _event: self._update_actions())
actions = ttk.Frame(self)
actions.grid(row=3, column=0, sticky="e", pady=(10, 0))
self.edit_button = ttk.Button(
actions,
text="Bearbeiten",
command=self._edit_selected,
state="disabled",
)
self.edit_button.pack(side="left", padx=(0, 8))
self.open_button = ttk.Button(actions, text="Öffnen", command=self._open_selected, state="disabled")
self.open_button.pack(side="left", padx=(0, 8))
self.issue_button = ttk.Button(
actions,
text="Ausgeben",
command=self._issue_selected,
state="disabled",
)
self.issue_button.pack(side="left", padx=(0, 8))
self.return_button = ttk.Button(
actions,
text="Zurücknehmen",
command=self._return_selected,
state="disabled",
)
self.return_button.pack(side="left")
self.refresh(self.assets)
def refresh(self, assets: list[Asset]) -> None:
self.assets = assets
self._render_assets()
def _render_assets(self) -> None:
self.tree.delete(*self.tree.get_children())
status_filter = _selected_asset_filter(self.status_filter_var.get())
filtered_assets = _filter_assets(self.assets, status_filter)
sorted_assets = sorted(
filtered_assets,
key=lambda asset: _asset_table_value(
asset,
self.sort_column,
self.resolve_holder_label(asset.current_holder_member_id),
).casefold(),
reverse=self.sort_descending,
)
if len(filtered_assets) == len(self.assets):
self.count_var.set(f"{len(filtered_assets)} Assets")
else:
self.count_var.set(f"{len(filtered_assets)} / {len(self.assets)} Assets")
self._update_tree_headings()
for asset in sorted_assets:
self.tree.insert(
"",
"end",
iid=asset.asset_id,
values=(
asset.label,
asset.category,
asset.inventory_number,
ASSET_STATUS_LABELS.get(asset.status, asset.status),
self.resolve_holder_label(asset.current_holder_member_id),
),
)
self._update_actions()
def _toggle_sort(self, column: str) -> None:
if self.sort_column == column:
self.sort_descending = not self.sort_descending
else:
self.sort_column = column
self.sort_descending = False
self._render_assets()
def _update_tree_headings(self) -> None:
for key, title, _width in ASSET_TABLE_COLUMNS:
suffix = ""
if key == self.sort_column:
suffix = " v" if self.sort_descending else " ^"
self.tree.heading(
key,
text=f"{title}{suffix}",
command=lambda column=key: self._toggle_sort(column),
)
def _selected_asset_id(self) -> str:
selected = self.tree.selection()
return selected[0] if selected else ""
def _selected_asset(self) -> Asset | None:
selected_asset_id = self._selected_asset_id()
return next((asset for asset in self.assets if asset.asset_id == selected_asset_id), None)
def _update_actions(self) -> None:
asset = self._selected_asset()
if asset is None:
self.edit_button.configure(state="disabled")
self.open_button.configure(state="disabled")
self.issue_button.configure(state="disabled")
self.return_button.configure(state="disabled")
return
self.edit_button.configure(state="normal")
self.open_button.configure(state="normal")
self.issue_button.configure(state="normal" if asset.status == "available" else "disabled")
self.return_button.configure(state="normal" if asset.current_holder_member_id else "disabled")
def _open_selected(self, _event: tk.Event | None = None) -> None:
asset_id = self._selected_asset_id()
if not asset_id:
asset_id = self.tree.focus()
if asset_id:
self.on_open(asset_id)
def _edit_selected(self, _event: tk.Event | None = None) -> None:
asset_id = self._selected_asset_id()
if not asset_id:
asset_id = self.tree.focus()
if asset_id:
self.on_edit(asset_id)
def _issue_selected(self, _event: tk.Event | None = None) -> None:
asset_id = self._selected_asset_id()
if not asset_id:
asset_id = self.tree.focus()
if asset_id:
self.on_issue(asset_id)
def _return_selected(self) -> None:
asset_id = self._selected_asset_id()
if asset_id:
self.on_return(asset_id)
class HousekeeperTab(ttk.Frame): class HousekeeperTab(ttk.Frame):
def __init__( def __init__(
self, self,
master: tk.Misc, master: tk.Misc,
findings: list[HousekeeperFinding], findings: list[HousekeeperFinding],
on_open_member: Callable[[str], None], on_open_target: Callable[[HousekeeperFinding], None],
on_refresh: Callable[[], list[HousekeeperFinding]], on_refresh: Callable[[], list[HousekeeperFinding]],
on_delete: Callable[[str], list[HousekeeperFinding]], on_delete: Callable[[str], list[HousekeeperFinding]],
on_close: Callable[[], None], on_close: Callable[[], None],
): ):
super().__init__(master, padding=12) super().__init__(master, padding=12)
self.findings = findings self.findings = findings
self.on_open_member = on_open_member self.on_open_target = on_open_target
self.on_refresh = on_refresh self.on_refresh = on_refresh
self.on_delete = on_delete self.on_delete = on_delete
self.on_close = on_close self.on_close = on_close
@@ -303,11 +641,15 @@ class HousekeeperTab(ttk.Frame):
def _open_selected(self) -> None: def _open_selected(self) -> None:
selected = self.tree.selection() selected = self.tree.selection()
if selected: if selected:
self.on_open_member(self.findings[int(selected[0])].member_id) self.on_open_target(self.findings[int(selected[0])])
def _finding_details(finding: HousekeeperFinding) -> str: def _finding_details(finding: HousekeeperFinding) -> str:
lines = [f"{finding.severity.upper()} · {finding.code}", finding.title] lines = [f"{finding.severity.upper()} · {finding.code}", finding.title]
if finding.target_type == "asset" and finding.asset_id:
lines.append(f"Asset: {finding.asset_id}")
elif finding.member_id:
lines.append(f"Mitglied: {finding.member_id}")
if finding.key: if finding.key:
lines.append(f"Key: {finding.key}") lines.append(f"Key: {finding.key}")
if finding.due_date: if finding.due_date:
@@ -315,3 +657,9 @@ def _finding_details(finding: HousekeeperFinding) -> str:
if finding.detail: if finding.detail:
lines.extend(("", finding.detail)) lines.extend(("", finding.detail))
return "\n".join(lines) return "\n".join(lines)
def _selected_asset_filter(label: str) -> str:
if label == ASSET_FILTER_ALL:
return "all"
return storage_key(ASSET_STATUS_LABELS, label)
+3 -3
View File
@@ -1,9 +1,9 @@
from ccma import __version__
from ccma.ui.changelog_view import load_changelog from ccma.ui.changelog_view import load_changelog
def test_changelog_contains_current_version() -> None: def test_changelog_contains_release_entry() -> None:
entries = load_changelog() entries = load_changelog()
assert entries assert entries
assert entries[0]["version"] == __version__ assert entries[0]["version"]
assert entries[0]["date"]
assert entries[0]["changes"] assert entries[0]["changes"]
+4
View File
@@ -16,6 +16,8 @@ def test_paths_and_automation_settings_round_trip(tmp_path, monkeypatch) -> None
anniversary_days_before=21, anniversary_days_before=21,
anniversary_days_after=5, anniversary_days_after=5,
anniversary_intervals="30D;2M;1Y;10Y", anniversary_intervals="30D;2M;1Y;10Y",
retroactive_claims=True,
optional_member_fields=("nickname", "email", "phone"),
window_geometry="1200x800-1800+40", window_geometry="1200x800-1800+40",
window_state="maximized", window_state="maximized",
monitor_bounds=(-1920, 0, 1920, 1080), monitor_bounds=(-1920, 0, 1920, 1080),
@@ -28,6 +30,8 @@ def test_paths_and_automation_settings_round_trip(tmp_path, monkeypatch) -> None
assert raw["schema_version"] == 1 assert raw["schema_version"] == 1
assert raw["monitor_bounds"] == [-1920, 0, 1920, 1080] assert raw["monitor_bounds"] == [-1920, 0, 1920, 1080]
assert raw["splash_minimum_seconds"] == 0 assert raw["splash_minimum_seconds"] == 0
assert raw["retroactive_claims"] is True
assert raw["optional_member_fields"] == ["nickname", "email", "phone"]
def test_splash_minimum_defaults_to_five_and_is_clamped(tmp_path, monkeypatch) -> None: def test_splash_minimum_defaults_to_five_and_is_clamped(tmp_path, monkeypatch) -> None:
+21
View File
@@ -3,8 +3,10 @@ from decimal import Decimal
import pytest import pytest
from ccma.domain.contributions import ( from ccma.domain.contributions import (
allocated_total,
claim_balance, claim_balance,
claim_items, claim_items,
claim_settled_total,
claim_status, claim_status,
claim_total, claim_total,
payment_allocated_total, payment_allocated_total,
@@ -102,6 +104,25 @@ def test_payment_can_be_split_across_multiple_claims(tmp_path) -> None:
) )
def test_credit_claim_settlement_is_displayed_as_positive_amount() -> None:
claim = {"claim_id": "claim-1", "title": "Kautionsrückzahlung", "amount": "-25.00"}
data = ContributionData(
claims=[claim],
credits=[{"credit_id": "credit-1", "amount": "25.00"}],
allocations=[
{
"allocation_id": "allocation-1",
"claim_id": "claim-1",
"credit_id": "credit-1",
"amount": "25.00",
}
],
)
assert allocated_total(data, "claim-1") == Decimal("-25.00")
assert claim_settled_total(data, claim) == Decimal("25.00")
def test_reminder_fee_increases_claim_and_is_audited(tmp_path) -> None: def test_reminder_fee_increases_claim_and_is_audited(tmp_path) -> None:
repository, member = _repository_with_claim(tmp_path) repository, member = _repository_with_claim(tmp_path)
+22
View File
@@ -58,6 +58,28 @@ def test_document_and_claim_creation_time_placeholders() -> None:
assert values["claim.created_at"] == "21.06.2026 14:35" assert values["claim.created_at"] == "21.06.2026 14:35"
def test_credit_claim_paid_placeholder_is_positive() -> None:
member = Member("member-1", "CCMA-1", "Ada", "Lovelace")
claim = {"claim_id": "claim-1", "title": "Kautionsrückzahlung", "amount": "-25.00"}
data = ContributionData(
claims=[claim],
credits=[{"credit_id": "credit-1", "amount": "25.00"}],
allocations=[
{
"allocation_id": "allocation-1",
"claim_id": "claim-1",
"credit_id": "credit-1",
"amount": "25.00",
}
],
)
values, _repeats = _template_values(member, data=data, claim=claim)
assert values["claim.paid"] == "25.00 EUR"
assert values["claim.balance"] == "0.00 EUR"
def test_claim_item_loop_clones_formatted_table_row() -> None: def test_claim_item_loop_clones_formatted_table_row() -> None:
source = b"""<office:document source = b"""<office:document
xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
+25
View File
@@ -113,3 +113,28 @@ def test_housekeeper_reports_invalid_member_dates(tmp_path) -> None:
assert len(invalid) == 1 assert len(invalid) == 1
assert invalid[0].member_id == member.member_id assert invalid[0].member_id == member.member_id
assert "Geburtsdatum" in invalid[0].detail assert "Geburtsdatum" in invalid[0].detail
def test_housekeeper_can_treat_selected_member_fields_as_optional(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Optional", last_name="Fields")
member.status = "active"
repository.save_member(member)
settings = HousekeeperSettings.from_values(
birthday_days_before=0,
birthday_days_after=0,
anniversary_days_before=0,
anniversary_days_after=0,
anniversary_intervals="1Y",
optional_member_fields=("nickname", "email", "phone", "birth_date"),
)
findings = Housekeeper(repository, settings).run(today=date(2026, 6, 21))
codes = {finding.code for finding in findings}
assert "missing_birth_date" not in codes
assert "missing_member_field:nickname" not in codes
assert "missing_member_field:email" not in codes
assert "missing_member_field:phone" not in codes
assert "missing_member_field:street" in codes
+193 -1
View File
@@ -1,7 +1,10 @@
import json import json
from decimal import Decimal
import pytest import pytest
from ccma.domain.contributions import claim_balance
from ccma.services.housekeeper import Housekeeper
from ccma.storage.repository import ( from ccma.storage.repository import (
MemberRepository, MemberRepository,
RepositoryError, RepositoryError,
@@ -18,6 +21,7 @@ def test_repository_creates_transparent_member_record(tmp_path) -> None:
member = repository.create_member( member = repository.create_member(
first_name="Ada", first_name="Ada",
last_name="Lovelace", last_name="Lovelace",
nickname="Enchantress",
email="ada@example.org", email="ada@example.org",
birth_date="1990-12-10", birth_date="1990-12-10",
member_number="0042", member_number="0042",
@@ -32,7 +36,9 @@ def test_repository_creates_transparent_member_record(tmp_path) -> None:
raw = json.loads((member_dir / "member.json").read_text(encoding="utf-8")) raw = json.loads((member_dir / "member.json").read_text(encoding="utf-8"))
assert raw["person"]["first_name"] == "Ada" assert raw["person"]["first_name"] == "Ada"
assert raw["person"]["nickname"] == "Enchantress"
assert raw["schema_version"] == 1 assert raw["schema_version"] == 1
assert raw["content_hash"]
def test_search_matches_name_email_number_and_german_birth_date(tmp_path) -> None: def test_search_matches_name_email_number_and_german_birth_date(tmp_path) -> None:
@@ -41,12 +47,13 @@ def test_search_matches_name_email_number_and_german_birth_date(tmp_path) -> Non
member = repository.create_member( member = repository.create_member(
first_name="Jörg", first_name="Jörg",
last_name="Müller", last_name="Müller",
nickname="Jogi",
email="joerg.mueller@example.org", email="joerg.mueller@example.org",
birth_date="1990-04-23", birth_date="1990-04-23",
member_number="C3-007", member_number="C3-007",
) )
for query in ("Jorg Muller", "mueller@example.org", "C3-007", "23.04.1990"): for query in ("Jorg Muller", "Jogi", "mueller@example.org", "C3-007", "23.04.1990"):
assert [result.member_id for result in repository.search(query)] == [member.member_id] assert [result.member_id for result in repository.search(query)] == [member.member_id]
@@ -138,6 +145,7 @@ def test_automatic_member_numbers_are_sequential_and_preview_does_not_consume(tm
first = repository.create_member(first_name="First", last_name="Member") first = repository.create_member(first_name="First", last_name="Member")
second = repository.create_member(first_name="Second", last_name="Member") second = repository.create_member(first_name="Second", last_name="Member")
assert first.member_number == "CCMA-0001" assert first.member_number == "CCMA-0001"
assert second.member_number == "CCMA-0002" assert second.member_number == "CCMA-0002"
assert repository.preview_member_number() == "CCMA-0003" assert repository.preview_member_number() == "CCMA-0003"
@@ -234,3 +242,187 @@ def test_organization_sender_data_is_stored_centrally(tmp_path) -> None:
organization = repository.get_configuration()["organization"] organization = repository.get_configuration()["organization"]
assert organization["street"] == "Testweg 1" assert organization["street"] == "Testweg 1"
assert organization["iban"] == "DE89370400440532013000" assert organization["iban"] == "DE89370400440532013000"
def test_repository_creates_asset_record_and_events(tmp_path) -> None:
repository = MemberRepository(tmp_path / "store")
repository.initialize()
asset = repository.create_asset(
label="Clubraumschlüssel A12",
category="key",
inventory_number="KEY-A12",
deposit_amount_default="25",
)
asset_dir = repository.assets_root / asset.asset_id
assert (asset_dir / "asset.json").is_file()
assert (asset_dir / "events.jsonl").is_file()
assert (asset_dir / "files").is_dir()
loaded = repository.get_asset(asset.asset_id)
assert loaded.label == "Clubraumschlüssel A12"
assert loaded.deposit_amount_default == "25.00"
def test_asset_can_be_assigned_and_returned_to_single_member(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Ada", last_name="Lovelace")
other = repository.create_member(first_name="Grace", last_name="Hopper")
asset = repository.create_asset(label="Transponder 01")
repository.assign_asset(asset.asset_id, member.member_id)
assigned = repository.get_asset(asset.asset_id)
assert assigned.status == "issued"
assert assigned.current_holder_member_id == member.member_id
assert [item.asset_id for item in repository.list_member_assets(member.member_id)] == [asset.asset_id]
with pytest.raises(RepositoryError, match="bereits einem Mitglied zugeordnet"):
repository.assign_asset(asset.asset_id, other.member_id)
repository.return_asset(asset.asset_id)
returned = repository.get_asset(asset.asset_id)
assert returned.status == "available"
assert returned.current_holder_member_id == ""
assert repository.list_member_assets(member.member_id) == []
def test_asset_assignment_is_audited_on_asset_and_member(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Key", last_name="Holder", member_number="0042")
asset = repository.create_asset(label="Clubraumschlüssel")
repository.assign_asset(asset.asset_id, member.member_id)
repository.return_asset(asset.asset_id)
asset_events = [event.event_type for event in repository.get_asset_events(asset.asset_id)]
member_events = [event.event_type for event in repository.get_events(member.member_id)]
assert asset_events == ["asset_created", "asset_issued", "asset_returned"]
assert "asset_assigned" in member_events
assert "asset_returned" in member_events
def test_asset_deposit_cannot_change_while_issued(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Ada", last_name="Lovelace")
asset = repository.create_asset(label="Clubraumschlüssel", deposit_amount_default="25")
repository.assign_asset(asset.asset_id, member.member_id)
issued = repository.get_asset(asset.asset_id)
issued.deposit_amount_default = "35"
with pytest.raises(RepositoryError, match="Kaution kann nur geändert werden"):
repository.save_asset(issued)
def test_asset_deposit_can_change_when_not_issued(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
asset = repository.create_asset(label="Clubraumschlüssel", deposit_amount_default="25")
asset.deposit_amount_default = "35"
repository.save_asset(asset)
updated = repository.get_asset(asset.asset_id)
assert updated.deposit_amount_default == "35.00"
def test_manual_asset_claim_is_linked_to_member_and_asset(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Ada", last_name="Lovelace")
asset = repository.create_asset(label="Clubraumschlüssel")
repository.assign_asset(asset.asset_id, member.member_id)
result = repository.create_manual_claim(
member.member_id,
title="Kaution Clubraumschlüssel",
amount="25.00",
due_date="2026-06-26",
description="Kaution für Schlüssel",
claim_type="asset_deposit",
references={"asset_id": asset.asset_id},
)
claim = result["claim"]
loaded_claim = repository.get_contributions(member.member_id).claims[0]
assert claim["claim_id"] == loaded_claim["claim_id"]
assert loaded_claim["origin"]["asset_id"] == asset.asset_id
assert repository.get_asset_events(asset.asset_id)[-1].event_type == "asset_claim_created"
def test_negative_claim_can_be_settled_with_credit(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Ada", last_name="Lovelace")
asset = repository.create_asset(label="Clubraumschlüssel")
repository.assign_asset(asset.asset_id, member.member_id)
claim = repository.create_manual_claim(
member.member_id,
title="Kautionsrückzahlung",
amount="-25.00",
due_date="2026-06-26",
claim_type="asset_refund",
references={"asset_id": asset.asset_id},
)["claim"]
repository.record_credit(
member.member_id,
str(claim["claim_id"]),
credit_date="2026-06-26",
amount="25.00",
allocation_amount="25.00",
reference="Bar ausgezahlt",
)
data, loaded_claim = repository.get_claim(member.member_id, str(claim["claim_id"]))
assert claim_balance(data, loaded_claim) == Decimal("0.00")
assert data.credits[0]["amount"] == "25.00"
def test_member_hash_warning_does_not_block_reading(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Ada", last_name="Lovelace")
path = repository.members_root / member.member_id / "member.json"
raw = json.loads(path.read_text(encoding="utf-8"))
raw["person"]["first_name"] = "Eve"
path.write_text(json.dumps(raw, ensure_ascii=False, indent=2), encoding="utf-8")
loaded = repository.get_member(member.member_id)
warnings = repository.member_hash_warnings(member.member_id)
assert loaded.first_name == "Eve"
assert warnings
assert "Hash fehlt oder stimmt nicht" in warnings[0]
def test_refresh_member_record_hashes_clears_hash_warning(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Ada", last_name="Lovelace")
path = repository.members_root / member.member_id / "member.json"
raw = json.loads(path.read_text(encoding="utf-8"))
raw["person"]["first_name"] = "Eve"
path.write_text(json.dumps(raw, ensure_ascii=False, indent=2), encoding="utf-8")
assert repository.member_hash_warnings(member.member_id)
repository.refresh_member_record_hashes(member.member_id)
assert repository.member_hash_warnings(member.member_id) == []
def test_housekeeper_reports_json_hash_mismatch(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Ada", last_name="Lovelace")
path = repository.members_root / member.member_id / "member.json"
raw = json.loads(path.read_text(encoding="utf-8"))
raw["person"]["last_name"] = "Example"
path.write_text(json.dumps(raw, ensure_ascii=False, indent=2), encoding="utf-8")
findings = Housekeeper(repository).run()
assert any(
finding.code == "json_hash_mismatch" and finding.member_id == member.member_id
for finding in findings
)
+79 -1
View File
@@ -91,6 +91,84 @@ def test_housekeeper_claim_actions_are_idempotent(tmp_path) -> None:
assert state["last_completed_run"] == "2026-04-15:000002" assert state["last_completed_run"] == "2026-04-15:000002"
def test_housekeeper_creates_membership_claims_retroactively_since_entry(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Retro", last_name="Claims", birth_date="1990-01-01")
member.status = "active"
member.accepted_at = "2024-04-15"
member.membership_started_at = "2024-04-15"
repository.save_member(member)
settings = housekeeper_module.HousekeeperSettings.from_values(
birthday_days_before=0,
birthday_days_after=0,
anniversary_days_before=0,
anniversary_days_after=0,
anniversary_intervals="1Y",
retroactive_claims=True,
)
Housekeeper(repository, settings).run(today=date(2026, 6, 21))
claims = repository.get_contributions(member.member_id).claims
claims_by_key = {claim["claim_key"]: claim for claim in claims}
assert set(claims_by_key) == {
"admission-fee",
"membership-fee:2024:annual",
"membership-fee:2025:annual",
"membership-fee:2026:annual",
}
assert claims_by_key["membership-fee:2024:annual"]["amount"] == "112.50"
def test_housekeeper_uses_pre_2022_contribution_amounts_for_legacy_years(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Legacy", last_name="Rates", birth_date="1990-01-01")
member.status = "active"
member.accepted_at = "2021-04-15"
member.membership_started_at = "2021-04-15"
repository.save_member(member)
settings = housekeeper_module.HousekeeperSettings.from_values(
birthday_days_before=0,
birthday_days_after=0,
anniversary_days_before=0,
anniversary_days_after=0,
anniversary_intervals="1Y",
retroactive_claims=True,
)
Housekeeper(repository, settings).run(today=date(2022, 6, 21))
claims = repository.get_contributions(member.member_id).claims
claims_by_key = {claim["claim_key"]: claim for claim in claims}
assert claims_by_key["membership-fee:2021:annual"]["amount"] == "90.00"
assert claims_by_key["membership-fee:2022:annual"]["amount"] == "150.00"
def test_housekeeper_does_not_create_retroactive_membership_claims_by_default(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Current", last_name="Only", birth_date="1990-01-01")
member.status = "active"
member.accepted_at = "2024-04-15"
member.membership_started_at = "2024-04-15"
repository.save_member(member)
Housekeeper(repository).run(today=date(2026, 6, 21))
claim_keys = {claim["claim_key"] for claim in repository.get_contributions(member.member_id).claims}
assert claim_keys == {
"admission-fee",
"membership-fee:2026:annual",
}
def test_housekeeper_resolves_tasks_not_seen_in_current_run(tmp_path) -> None: def test_housekeeper_resolves_tasks_not_seen_in_current_run(tmp_path) -> None:
repository = MemberRepository(tmp_path) repository = MemberRepository(tmp_path)
repository.initialize() repository.initialize()
@@ -102,7 +180,7 @@ def test_housekeeper_resolves_tasks_not_seen_in_current_run(tmp_path) -> None:
repository.save_member(member) repository.save_member(member)
housekeeper.run(today=date(2026, 6, 21)) housekeeper.run(today=date(2026, 6, 21))
state = json.loads((repository.root / "housekeeper.json").read_text(encoding="utf-8")) state = json.loads((repository.root / "housekeeper.json").read_text(encoding="utf-8"))
task = next(item for item in state["items"] if item["key"].endswith(":missing")) task = next(item for item in state["items"] if item["key"].endswith(":missing:birth_date"))
assert task["status"] == "resolved" assert task["status"] == "resolved"
assert task["first_seen_run"] == "2026-06-21:000001" assert task["first_seen_run"] == "2026-06-21:000001"
+87 -2
View File
@@ -1,5 +1,6 @@
def test_ui_modules_import_without_creating_root_window() -> None: def test_ui_modules_import_without_creating_root_window() -> None:
import ccma.app # noqa: F401 import ccma.app # noqa: F401
import ccma.ui.asset_tab # noqa: F401
import ccma.ui.claim_tab # noqa: F401 import ccma.ui.claim_tab # noqa: F401
import ccma.ui.main_window # noqa: F401 import ccma.ui.main_window # noqa: F401
import ccma.ui.member_tab # noqa: F401 import ccma.ui.member_tab # noqa: F401
@@ -68,16 +69,16 @@ def test_housekeeper_details_are_multiline() -> None:
finding = HousekeeperFinding( finding = HousekeeperFinding(
severity="error", severity="error",
member_id="member-1",
code="invalid_member_record", code="invalid_member_record",
title="Mitgliederakte beschädigt", title="Mitgliederakte beschädigt",
detail="Die JSON-Datei ist leer und wird nicht automatisch überschrieben.", detail="Die JSON-Datei ist leer und wird nicht automatisch überschrieben.",
member_id="member-1",
due_date=date(2026, 7, 31), due_date=date(2026, 7, 31),
) )
rendered = _finding_details(finding) rendered = _finding_details(finding)
assert rendered.splitlines()[0] == "ERROR · invalid_member_record" assert rendered.splitlines()[0] == "ERROR · invalid_member_record"
assert "Mitgliederakte beschädigt\nFällig:" in rendered assert "Mitgliederakte beschädigt\nMitglied: member-1\nFällig:" in rendered
assert rendered.endswith("nicht automatisch überschrieben.") assert rendered.endswith("nicht automatisch überschrieben.")
@@ -96,3 +97,87 @@ def test_german_ui_labels_round_trip_to_english_storage_keys() -> None:
assert storage_key(CLAIM_ITEM_TYPE_LABELS, "Dienstleistung") == "service" assert storage_key(CLAIM_ITEM_TYPE_LABELS, "Dienstleistung") == "service"
assert display_label(MEMBERSHIP_STATUS_LABELS, "active") == "AKTIV" assert display_label(MEMBERSHIP_STATUS_LABELS, "active") == "AKTIV"
assert storage_key(MEMBERSHIP_STATUS_LABELS, "EHRENMITGLIED") == "honorary" assert storage_key(MEMBERSHIP_STATUS_LABELS, "EHRENMITGLIED") == "honorary"
def test_member_table_filter_only_keeps_selected_status() -> None:
from ccma.domain.models import Member
from ccma.ui.work_tabs import _filter_members, _selected_status_filter
members = [
Member("1", "0001", "Ada", "Lovelace", status="active"),
Member("2", "0002", "Grace", "Hopper", status="application"),
Member("3", "0003", "Linus", "Example", status="active"),
]
assert _selected_status_filter("Alle") == "all"
assert _selected_status_filter("AKTIV") == "active"
assert [member.member_id for member in _filter_members(members, "active")] == ["1", "3"]
assert [member.member_id for member in _filter_members(members, "all")] == ["1", "2", "3"]
def test_member_table_sort_uses_display_values() -> None:
from ccma.domain.models import Member
from ccma.ui.work_tabs import _sort_members
members = [
Member("1", "0002", "Grace", "Hopper", status="application"),
Member("2", "0001", "Ada", "Lovelace", status="active"),
Member("3", "0003", "Linus", "Example", status="honorary"),
]
assert [member.member_id for member in _sort_members(members, "number", False)] == ["2", "1", "3"]
assert [member.member_id for member in _sort_members(members, "first_name", False)] == ["2", "1", "3"]
assert [member.member_id for member in _sort_members(members, "last_name", False)] == ["3", "1", "2"]
assert [member.member_id for member in _sort_members(members, "status", False)] == ["2", "1", "3"]
def test_asset_table_filter_and_sort_use_status_and_holder_label() -> None:
from ccma.domain.models import Asset
from ccma.ui.work_tabs import _asset_table_value, _filter_assets, _selected_asset_filter
assets = [
Asset("1", "Clubraumschlüssel", status="issued", current_holder_member_id="member-1"),
Asset("2", "Beamer", status="available"),
]
assert _selected_asset_filter("Alle") == "all"
assert _selected_asset_filter("AUSGEGEBEN") == "issued"
assert [asset.asset_id for asset in _filter_assets(assets, "available")] == ["2"]
assert _asset_table_value(assets[0], "holder", "0001 · Ada") == "0001 · Ada"
def test_claim_table_sort_uses_due_date_by_raw_value() -> None:
from ccma.domain.models import ContributionData
from ccma.ui.member_tab import _claim_sort_value
data = ContributionData()
older = {"title": "Alt", "due_date": "2024-01-31", "amount": "75.00"}
newer = {"title": "Neu", "due_date": "2025-07-31", "amount": "50.00"}
assert _claim_sort_value(data, older, "due") < _claim_sort_value(data, newer, "due")
def test_negative_claims_are_labeled_as_credit() -> None:
from ccma.domain.contributions import claim_status
from ccma.domain.models import ContributionData
data = ContributionData()
claim = {"claim_id": "claim-1", "title": "Rueckzahlung", "amount": "-25.00"}
assert claim_status(data, claim) == "credit"
def test_housekeeper_details_include_asset_target() -> None:
from ccma.domain.models import HousekeeperFinding
from ccma.ui.work_tabs import _finding_details
finding = HousekeeperFinding(
severity="warning",
code="json_hash_mismatch",
title="Assetakte extern geändert",
detail="asset.json: Hash fehlt oder stimmt nicht.",
asset_id="asset-1",
target_type="asset",
)
rendered = _finding_details(finding)
assert "Asset: asset-1" in rendered
+1 -1
View File
@@ -5,4 +5,4 @@ from ccma import __version__
def test_ui_version_matches_version_file() -> None: def test_ui_version_matches_version_file() -> None:
expected = (Path(__file__).resolve().parents[1] / "VERSION").read_text(encoding="utf-8").strip() expected = (Path(__file__).resolve().parents[1] / "VERSION").read_text(encoding="utf-8").strip()
assert __version__ == expected == "0.0.1-dev0" assert __version__ == expected == "0.0.1-dev1"