mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-07-01 03:04:52 +02:00
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:
@@ -1,16 +1,17 @@
|
||||
[
|
||||
{
|
||||
"version": "0.0.1-dev0",
|
||||
"date": "2026-06-21",
|
||||
"version": "1.0.0",
|
||||
"date": "2026-06-27",
|
||||
"changes": [
|
||||
"Erste Entwicklungsversion der dateibasierten CCMA-Mitgliederverwaltung.",
|
||||
"Mitgliederakten mit Stammdaten, Anschrift, Bank-/SEPA-Daten, Dokumentordner und nachvollziehbarer Chronik.",
|
||||
"Dashboard, Freitextsuche, Mitgliederliste und parallele Arbeits-Tabs in einer deutschen Light-/Dark-Oberfläche.",
|
||||
"Konfigurierbare Mitgliedsnummern, validierte Datums- und Bankdaten sowie zentrale Vereins- und Absenderangaben.",
|
||||
"Regelbasierter Hausmeister für Datenprüfung, Geburtstage, Jubiläen, Forderungen und anstehende Aufgaben.",
|
||||
"Forderungsmanagement mit Positionen, Teilzahlungen, GnuCash-Referenzen, Gebühren und mehrstufigem Mahnworkflow.",
|
||||
"OpenDocument-Templates für Mitglieder, Forderungen und Mahnungen mit Platzhaltern, Tabellenzeilen und lokaler PDF-Erzeugung.",
|
||||
"Transparente JSON-Speicherung, atomare Schreibvorgänge und portable Mitgliedsordner für Backup und DSGVO-Auskunft."
|
||||
"First Release der CCMA-Mitgliederverwaltung für lokale, dateibasierte Vereinsverwaltung.",
|
||||
"Mitglieder können mit Stammdaten, Anschrift, Kontaktangaben, Status, Bank-/SEPA-Daten und internen Notizen verwaltet werden.",
|
||||
"Mitgliederlisten, Suche und parallele Arbeits-Tabs helfen beim schnellen Finden und Bearbeiten von Akten.",
|
||||
"Assets und Inventar können angelegt, Mitgliedern zugeordnet, zurückgenommen und mit Kautionen oder Forderungen verbunden werden.",
|
||||
"Forderungen, Zahlungen, Gutschriften, Mahnungen und GnuCash-Referenzen können pro Mitglied nachvollziehbar gepflegt werden.",
|
||||
"Dokumente lassen sich aus OpenDocument-Vorlagen erzeugen und als PDF in den jeweiligen Akten ablegen.",
|
||||
"Chroniken halten wichtige Ereignisse, Kommentare und automatisch erzeugte Vorgänge nachvollziehbar fest.",
|
||||
"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."
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -311,17 +311,17 @@ namespace eval ttk::theme::forest-dark {
|
||||
active $I(check-unsel-hover) \
|
||||
] -width 26 -sticky w
|
||||
|
||||
# Switch
|
||||
ttk::style element create Switch.indicator image \
|
||||
[list $I(off-accent) \
|
||||
{selected disabled} $I(on-basic) \
|
||||
disabled $I(off-basic) \
|
||||
{pressed selected} $I(on-accent) \
|
||||
{active selected} $I(on-hover) \
|
||||
selected $I(on-accent) \
|
||||
{pressed !selected} $I(off-accent) \
|
||||
active $I(off-hover) \
|
||||
] -width 46 -sticky w
|
||||
# Switch
|
||||
ttk::style element create Switch.indicator image \
|
||||
[list $I(off-basic) \
|
||||
{selected disabled} $I(on-basic) \
|
||||
disabled $I(off-basic) \
|
||||
{pressed selected} $I(on-accent) \
|
||||
{active selected} $I(on-hover) \
|
||||
selected $I(on-accent) \
|
||||
{pressed !selected} $I(off-hover) \
|
||||
active $I(off-hover) \
|
||||
] -width 46 -sticky w
|
||||
|
||||
# ToggleButton
|
||||
ttk::style configure ToggleButton -padding {8 4 8 4} -width -10 -anchor center
|
||||
|
||||
@@ -311,17 +311,17 @@ namespace eval ttk::theme::forest-light {
|
||||
active $I(check-unsel-hover) \
|
||||
] -width 26 -sticky w
|
||||
|
||||
# Switch
|
||||
ttk::style element create Switch.indicator image \
|
||||
[list $I(off-accent) \
|
||||
{selected disabled} $I(on-basic) \
|
||||
disabled $I(off-basic) \
|
||||
{pressed selected} $I(on-accent) \
|
||||
{active selected} $I(on-hover) \
|
||||
selected $I(on-accent) \
|
||||
{pressed !selected} $I(off-accent) \
|
||||
active $I(off-hover) \
|
||||
] -width 46 -sticky w
|
||||
# Switch
|
||||
ttk::style element create Switch.indicator image \
|
||||
[list $I(off-basic) \
|
||||
{selected disabled} $I(on-basic) \
|
||||
disabled $I(off-basic) \
|
||||
{pressed selected} $I(on-accent) \
|
||||
{active selected} $I(on-hover) \
|
||||
selected $I(on-accent) \
|
||||
{pressed !selected} $I(off-hover) \
|
||||
active $I(off-hover) \
|
||||
] -width 46 -sticky w
|
||||
|
||||
# ToggleButton
|
||||
ttk::style configure ToggleButton -padding {8 4 8 4} -width -10 -anchor center -foregound $colors(-fg)
|
||||
|
||||
@@ -7,6 +7,7 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
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
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -25,6 +26,8 @@ class AppConfig:
|
||||
anniversary_days_before: int = 14
|
||||
anniversary_days_after: int = 7
|
||||
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_state: str = "normal"
|
||||
monitor_bounds: tuple[int, int, int, int] | None = None
|
||||
@@ -48,6 +51,8 @@ class AppConfig:
|
||||
"anniversary_days_before": self.anniversary_days_before,
|
||||
"anniversary_days_after": self.anniversary_days_after,
|
||||
"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_state": self.window_state,
|
||||
"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_after=self.anniversary_days_after,
|
||||
anniversary_intervals=self.anniversary_intervals,
|
||||
retroactive_claims=self.retroactive_claims,
|
||||
optional_member_fields=self.optional_member_fields,
|
||||
)
|
||||
except IntervalValidationError:
|
||||
return HousekeeperSettings()
|
||||
@@ -101,6 +108,10 @@ def load_config() -> AppConfig:
|
||||
anniversary_days_before=int(data.get("anniversary_days_before", 14)),
|
||||
anniversary_days_after=int(data.get("anniversary_days_after", 7)),
|
||||
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_state=str(data.get("window_state", "normal")),
|
||||
monitor_bounds=monitor_bounds,
|
||||
|
||||
@@ -14,6 +14,7 @@ CLAIM_STATUS_LABELS = {
|
||||
"paid": "BEZAHLT",
|
||||
"overpaid": "ÜBERZAHLT",
|
||||
"overdue": "ÜBERFÄLLIG",
|
||||
"credit": "GUTSCHRIFT",
|
||||
"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"))
|
||||
|
||||
|
||||
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:
|
||||
return sum(
|
||||
(
|
||||
decimal_value(allocation.get("amount", "0"))
|
||||
allocation_effect(data, allocation)
|
||||
for allocation in data.allocations
|
||||
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:
|
||||
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:
|
||||
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)
|
||||
paid = allocated_total(data, str(claim.get("claim_id", "")))
|
||||
balance = total - paid
|
||||
if total < 0:
|
||||
return "credit"
|
||||
if balance < 0:
|
||||
return "overpaid"
|
||||
if balance == 0:
|
||||
|
||||
@@ -20,6 +20,37 @@ MEMBERSHIP_STATUS_LABELS = {
|
||||
"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)
|
||||
class Member:
|
||||
@@ -27,6 +58,7 @@ class Member:
|
||||
member_number: str
|
||||
first_name: str
|
||||
last_name: str
|
||||
nickname: str = ""
|
||||
email: str = ""
|
||||
phone: str = ""
|
||||
birth_date: str = ""
|
||||
@@ -65,6 +97,7 @@ class Member:
|
||||
"person": {
|
||||
"first_name": self.first_name,
|
||||
"last_name": self.last_name,
|
||||
"nickname": self.nickname,
|
||||
"birth_date": self.birth_date,
|
||||
"email": self.email,
|
||||
"phone": self.phone,
|
||||
@@ -113,6 +146,7 @@ class Member:
|
||||
member_number=str(data.get("member_number", "")),
|
||||
first_name=str(person.get("first_name", "")),
|
||||
last_name=str(person.get("last_name", "")),
|
||||
nickname=str(person.get("nickname", "")),
|
||||
email=str(person.get("email", "")),
|
||||
phone=str(person.get("phone", "")),
|
||||
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)
|
||||
class Event:
|
||||
event_id: str
|
||||
@@ -182,6 +265,7 @@ class Event:
|
||||
class ContributionData:
|
||||
claims: 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)
|
||||
reminders: list[dict[str, Any]] = field(default_factory=list)
|
||||
schema_version: int = 1
|
||||
@@ -191,6 +275,7 @@ class ContributionData:
|
||||
"schema_version": self.schema_version,
|
||||
"claims": self.claims,
|
||||
"payments": self.payments,
|
||||
"credits": self.credits,
|
||||
"allocations": self.allocations,
|
||||
"reminders": self.reminders,
|
||||
}
|
||||
@@ -201,6 +286,7 @@ class ContributionData:
|
||||
schema_version=int(data.get("schema_version", 1)),
|
||||
claims=list(data.get("claims") or []),
|
||||
payments=list(data.get("payments") or []),
|
||||
credits=list(data.get("credits") or []),
|
||||
allocations=list(data.get("allocations") or []),
|
||||
reminders=list(data.get("reminders") or []),
|
||||
)
|
||||
@@ -209,10 +295,12 @@ class ContributionData:
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class HousekeeperFinding:
|
||||
severity: str
|
||||
member_id: str
|
||||
code: str
|
||||
title: str
|
||||
detail: str
|
||||
member_id: str = ""
|
||||
asset_id: str = ""
|
||||
target_type: str = "member"
|
||||
due_date: date | None = None
|
||||
key: str = ""
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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
|
||||
|
||||
RULE_ID = "birthdate-check"
|
||||
@@ -7,18 +8,30 @@ ORDER = 10
|
||||
|
||||
def evaluate(context: RuleContext):
|
||||
member = context.member
|
||||
if not member.birth_date.strip():
|
||||
return [
|
||||
actions = []
|
||||
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(
|
||||
rule_id=RULE_ID,
|
||||
member=member,
|
||||
key_suffix="missing",
|
||||
key_suffix=f"missing:{field}",
|
||||
severity="warning",
|
||||
title=f"{member.display_name}: Geburtsdatum fehlt",
|
||||
detail="Das Geburtsdatum muss in der Mitgliederakte ergänzt werden.",
|
||||
code="missing_birth_date",
|
||||
title=f"{member.display_name}: {label} fehlt",
|
||||
detail=detail,
|
||||
code=code,
|
||||
)
|
||||
]
|
||||
)
|
||||
try:
|
||||
validate_member_dates(
|
||||
birth_date=member.birth_date,
|
||||
@@ -27,7 +40,7 @@ def evaluate(context: RuleContext):
|
||||
today=context.today,
|
||||
)
|
||||
except DateValidationError as exc:
|
||||
return [
|
||||
actions.append(
|
||||
task(
|
||||
rule_id=RULE_ID,
|
||||
member=member,
|
||||
@@ -37,5 +50,5 @@ def evaluate(context: RuleContext):
|
||||
detail=str(exc),
|
||||
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))
|
||||
return actions
|
||||
|
||||
@@ -77,6 +82,8 @@ def _membership_claims(context: RuleContext, started_at: date, accepted_at: date
|
||||
actions = []
|
||||
monthly_amount = annual_amount / Decimal(12)
|
||||
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)
|
||||
months = max(0, last_month - charged_from + 1)
|
||||
if months == 0:
|
||||
|
||||
@@ -15,9 +15,9 @@ from xml.etree import ElementTree
|
||||
|
||||
from ccma.domain.contributions import (
|
||||
CLAIM_STATUS_LABELS,
|
||||
allocated_total,
|
||||
claim_balance,
|
||||
claim_items,
|
||||
claim_settled_total,
|
||||
claim_status,
|
||||
claim_total,
|
||||
money_text,
|
||||
@@ -203,6 +203,7 @@ def _template_values(
|
||||
"member.number": member.member_number,
|
||||
"member.first_name": member.first_name,
|
||||
"member.last_name": member.last_name,
|
||||
"member.nickname": member.nickname,
|
||||
"member.full_name": member.display_name,
|
||||
"member.email": member.email,
|
||||
"member.phone": member.phone,
|
||||
@@ -256,7 +257,7 @@ def _template_values(
|
||||
),
|
||||
"claim.created_at": _display_timestamp(str(claim.get("created_at", ""))),
|
||||
"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.status": CLAIM_STATUS_LABELS.get(status, status),
|
||||
"claim.items": "; ".join(item_lines),
|
||||
|
||||
@@ -12,7 +12,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
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.loader import LoadedRule, load_rules
|
||||
from ccma.services.intervals import AnniversaryInterval, parse_anniversary_intervals
|
||||
@@ -29,6 +29,8 @@ class HousekeeperSettings:
|
||||
anniversary_intervals: tuple[AnniversaryInterval, ...] = field(
|
||||
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
|
||||
def from_values(
|
||||
@@ -39,6 +41,8 @@ class HousekeeperSettings:
|
||||
anniversary_days_before: int,
|
||||
anniversary_days_after: int,
|
||||
anniversary_intervals: str,
|
||||
retroactive_claims: bool = False,
|
||||
optional_member_fields: tuple[str, ...] = (),
|
||||
) -> HousekeeperSettings:
|
||||
return cls(
|
||||
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_after=min(365, max(0, anniversary_days_after)),
|
||||
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)
|
||||
successful_scopes: set[tuple[str, str]] = set()
|
||||
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_asset_items(items, asset_ids)
|
||||
|
||||
rules = load_rules(self.repository.root)
|
||||
repository_config = self.repository.get_configuration()
|
||||
@@ -87,6 +95,15 @@ class Housekeeper:
|
||||
successful_scopes.add(("member-record-check", member_id))
|
||||
continue
|
||||
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:
|
||||
scope = (rule.rule_id, member.member_id)
|
||||
try:
|
||||
@@ -124,6 +141,17 @@ class Housekeeper:
|
||||
else:
|
||||
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)
|
||||
working.update(
|
||||
{
|
||||
@@ -192,6 +220,58 @@ class Housekeeper:
|
||||
)
|
||||
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(
|
||||
self,
|
||||
items: dict[str, dict[str, Any]],
|
||||
@@ -337,7 +417,8 @@ class Housekeeper:
|
||||
for item in items.values():
|
||||
if item.get("action") != "task" or item.get("status") != "open":
|
||||
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:
|
||||
continue
|
||||
item["status"] = "resolved"
|
||||
@@ -390,10 +471,12 @@ def _open_findings(items: list[dict[str, Any]]) -> list[HousekeeperFinding]:
|
||||
findings.append(
|
||||
HousekeeperFinding(
|
||||
severity=str(item.get("severity", "info")),
|
||||
member_id=str(item.get("member_id", "")),
|
||||
code=str(item.get("code", item.get("rule_id", "housekeeper"))),
|
||||
title=str(item.get("title", item.get("key", "Hausmeister"))),
|
||||
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,
|
||||
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]
|
||||
|
||||
|
||||
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:
|
||||
try:
|
||||
delay = float(value)
|
||||
|
||||
@@ -1,17 +1,60 @@
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
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:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
|
||||
temporary = Path(temporary_name)
|
||||
payload = attach_json_content_hash(data)
|
||||
try:
|
||||
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.flush()
|
||||
os.fsync(handle.fileno())
|
||||
|
||||
+545
-20
@@ -13,14 +13,22 @@ from uuid import uuid4
|
||||
from ccma.domain.contributions import (
|
||||
claim_balance,
|
||||
claim_total,
|
||||
credit_allocated_total,
|
||||
decimal_value,
|
||||
materialize_claim_items,
|
||||
money_text,
|
||||
payment_allocated_total,
|
||||
)
|
||||
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.storage.atomic import read_json, write_json_atomic
|
||||
from ccma.domain.models import (
|
||||
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):
|
||||
@@ -29,6 +37,39 @@ class RepositoryError(RuntimeError):
|
||||
|
||||
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 = {
|
||||
"schema_version": 1,
|
||||
@@ -73,23 +114,7 @@ DEFAULT_CONFIGURATION = {
|
||||
},
|
||||
],
|
||||
},
|
||||
"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",
|
||||
}
|
||||
],
|
||||
"contribution_rules": DEFAULT_CONTRIBUTION_RULES,
|
||||
}
|
||||
|
||||
|
||||
@@ -97,9 +122,11 @@ class MemberRepository:
|
||||
def __init__(self, root: Path | str):
|
||||
self.root = Path(root).expanduser().resolve()
|
||||
self.members_root = self.root / "members"
|
||||
self.assets_root = self.root / "assets"
|
||||
|
||||
def initialize(self) -> None:
|
||||
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)
|
||||
templates_root = self.root / "templates"
|
||||
templates_root.mkdir(parents=True, exist_ok=True)
|
||||
@@ -122,6 +149,10 @@ class MemberRepository:
|
||||
errors: list[str] = []
|
||||
try:
|
||||
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:
|
||||
errors.append("repository.json: nicht unterstützte schema_version")
|
||||
policy = config.get("member_number_policy") or {}
|
||||
@@ -135,6 +166,10 @@ class MemberRepository:
|
||||
for member_dir in self._member_directories():
|
||||
try:
|
||||
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(
|
||||
birth_date=member.birth_date,
|
||||
accepted_at=member.accepted_at,
|
||||
@@ -160,6 +195,35 @@ class MemberRepository:
|
||||
DateValidationError,
|
||||
) as 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
|
||||
|
||||
def list_members(self) -> list[Member]:
|
||||
@@ -209,6 +273,7 @@ class MemberRepository:
|
||||
*,
|
||||
first_name: str,
|
||||
last_name: str,
|
||||
nickname: str = "",
|
||||
email: str = "",
|
||||
phone: str = "",
|
||||
birth_date: str = "",
|
||||
@@ -238,6 +303,7 @@ class MemberRepository:
|
||||
member_number=selected_number,
|
||||
first_name=first_name.strip(),
|
||||
last_name=last_name.strip(),
|
||||
nickname=nickname.strip(),
|
||||
email=email.strip(),
|
||||
phone=phone.strip(),
|
||||
birth_date=birth_date,
|
||||
@@ -294,6 +360,301 @@ class MemberRepository:
|
||||
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:
|
||||
path = self._member_path(member_id) / "contributions.json"
|
||||
if not path.exists():
|
||||
@@ -302,7 +663,7 @@ class MemberRepository:
|
||||
raw = read_json(path)
|
||||
if not isinstance(raw, dict):
|
||||
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):
|
||||
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]):
|
||||
@@ -457,6 +818,92 @@ class MemberRepository:
|
||||
)
|
||||
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(
|
||||
self,
|
||||
member_id: str,
|
||||
@@ -752,6 +1199,7 @@ class MemberRepository:
|
||||
member.member_number,
|
||||
member.first_name,
|
||||
member.last_name,
|
||||
member.nickname,
|
||||
member.display_name,
|
||||
member.email,
|
||||
member.phone,
|
||||
@@ -773,6 +1221,9 @@ class MemberRepository:
|
||||
def member_count(self) -> int:
|
||||
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:
|
||||
try:
|
||||
configuration = read_json(self.root / "repository.json")
|
||||
@@ -782,6 +1233,49 @@ class MemberRepository:
|
||||
raise RepositoryError("repository.json enthält keine gültige Konfiguration.")
|
||||
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]:
|
||||
try:
|
||||
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(".")
|
||||
)
|
||||
|
||||
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:
|
||||
if not member_id or Path(member_id).name != member_id or member_id in {".", ".."}:
|
||||
raise RepositoryError("Ungültige Mitglieds-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:
|
||||
config = read_json(self.root / "repository.json")
|
||||
member_number, next_value = self._next_available_member_number(config, pattern)
|
||||
@@ -880,6 +1386,7 @@ class MemberRepository:
|
||||
"member_number": "Mitgliedsnummer",
|
||||
"first_name": "Vorname",
|
||||
"last_name": "Nachname",
|
||||
"nickname": "Nickname",
|
||||
"email": "E-Mail-Adresse",
|
||||
"phone": "Telefonnummer",
|
||||
"birth_date": "Geburtsdatum",
|
||||
@@ -915,6 +1422,24 @@ class MemberRepository:
|
||||
changes.append(label)
|
||||
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:
|
||||
return "".join(value.split()).upper()
|
||||
|
||||
@@ -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
@@ -12,8 +12,10 @@ from ccma.domain.contributions import (
|
||||
allocated_total,
|
||||
claim_balance,
|
||||
claim_items,
|
||||
claim_settled_total,
|
||||
claim_status,
|
||||
claim_total,
|
||||
credit_allocated_total,
|
||||
decimal_value,
|
||||
money_text,
|
||||
payment_allocated_total,
|
||||
@@ -125,6 +127,8 @@ class ClaimTab(ttk.Frame):
|
||||
self.ledger.tag_configure("position", background="#234d70", 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("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", background="#70451f", foreground="#ffffff")
|
||||
self.ledger.bind("<<TreeviewSelect>>", lambda _event: self._update_reminder_buttons())
|
||||
@@ -137,6 +141,10 @@ class ClaimTab(ttk.Frame):
|
||||
side="left", padx=(0, 8)
|
||||
)
|
||||
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)
|
||||
self.discard_reminder_button = ttk.Button(
|
||||
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)
|
||||
return
|
||||
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)
|
||||
status = claim_status(self.data, self.claim)
|
||||
self.title_var.set(str(self.claim.get("title") or "Forderung"))
|
||||
@@ -227,6 +235,8 @@ class ClaimTab(ttk.Frame):
|
||||
)
|
||||
for allocation in allocations:
|
||||
payment = payment_by_id.get(str(allocation.get("payment_id", "")), {})
|
||||
if not payment:
|
||||
continue
|
||||
payment_total = str(payment.get("amount", ""))
|
||||
gnucash_id = str(payment.get("gnucash_transaction_id", ""))
|
||||
self.ledger.insert(
|
||||
@@ -246,6 +256,57 @@ class ClaimTab(ttk.Frame):
|
||||
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 = [
|
||||
reminder
|
||||
for reminder in self.data.reminders
|
||||
@@ -309,6 +370,26 @@ class ClaimTab(ttk.Frame):
|
||||
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:
|
||||
ReminderDialog(self, self.repository, self.member_id, self.claim_id, self._changed)
|
||||
|
||||
@@ -508,6 +589,45 @@ class PaymentDialog(_Dialog):
|
||||
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):
|
||||
def __init__(self, master, repository, member_id, claim_id, balance: Decimal, on_saved):
|
||||
super().__init__(master, "Vorhandene Zahlung zuordnen", on_saved)
|
||||
@@ -560,6 +680,58 @@ class AllocatePaymentDialog(_Dialog):
|
||||
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):
|
||||
def __init__(self, master, repository, member_id, claim_id, on_saved):
|
||||
super().__init__(master, "Mahnung vorbereiten", on_saved)
|
||||
|
||||
+532
-3
@@ -1,12 +1,28 @@
|
||||
import tkinter as tk
|
||||
from collections.abc import Callable
|
||||
from datetime import date
|
||||
from tkinter import messagebox, ttk
|
||||
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
def __init__(self, master: tk.Misc, repository: MemberRepository, on_created: Callable[[Member], None]):
|
||||
super().__init__(master)
|
||||
@@ -14,24 +30,36 @@ class NewMemberDialog(tk.Toplevel):
|
||||
self.on_created = on_created
|
||||
self.title("Neue Mitgliederakte")
|
||||
self.transient(master.winfo_toplevel())
|
||||
self.grab_set()
|
||||
self.resizable(False, False)
|
||||
self.number_policy = repository.get_member_number_policy()
|
||||
self.variables = {
|
||||
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.bind("<Escape>", lambda _event: self.destroy())
|
||||
self.bind("<Return>", lambda _event: self._create())
|
||||
self.after_idle(self._activate_modal)
|
||||
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:
|
||||
frame = ttk.Frame(self, padding=18)
|
||||
frame.pack(fill="both", expand=True)
|
||||
fields = [
|
||||
("Vorname *", "first_name"),
|
||||
("Nachname *", "last_name"),
|
||||
("Nickname", "nickname"),
|
||||
("E-Mail-Adresse", "email"),
|
||||
("Telefonnummer", "phone"),
|
||||
(f"Geburtsdatum ({date_input_hint()})", "birth_date"),
|
||||
@@ -89,3 +117,504 @@ class NewMemberDialog(tk.Toplevel):
|
||||
return
|
||||
self.destroy()
|
||||
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
@@ -5,16 +5,17 @@ from tkinter import messagebox, ttk
|
||||
|
||||
from ccma import __version__
|
||||
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.storage.repository import MemberRepository, RepositoryError
|
||||
from ccma.ui.asset_tab import AssetTab
|
||||
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.member_tab import MemberTab
|
||||
from ccma.ui.options_dialog import OptionsDialog
|
||||
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:
|
||||
@@ -175,6 +176,17 @@ class MainWindow(ttk.Frame):
|
||||
)
|
||||
members_button.image = members_icon
|
||||
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_button = ttk.Button(
|
||||
actions,
|
||||
@@ -278,6 +290,9 @@ class MainWindow(ttk.Frame):
|
||||
on_close=lambda: self.tabs.close(key),
|
||||
on_changed=self.refresh_overview,
|
||||
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(
|
||||
key,
|
||||
@@ -312,6 +327,34 @@ class MainWindow(ttk.Frame):
|
||||
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:
|
||||
member_tab = self.tabs.tabs.get(f"member:{member_id}")
|
||||
if isinstance(member_tab, MemberTab) and member_tab.winfo_exists():
|
||||
@@ -336,14 +379,91 @@ class MainWindow(ttk.Frame):
|
||||
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:
|
||||
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:
|
||||
self.refresh_overview()
|
||||
self.open_member(member.member_id)
|
||||
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:
|
||||
key = "housekeeper"
|
||||
if self.tabs.focus(key):
|
||||
@@ -351,7 +471,7 @@ class MainWindow(ttk.Frame):
|
||||
tab = HousekeeperTab(
|
||||
self.notebook,
|
||||
self.findings,
|
||||
self.open_member,
|
||||
self._open_housekeeper_target,
|
||||
self.run_housekeeper,
|
||||
self.delete_housekeeper_task,
|
||||
lambda: self.tabs.close(key),
|
||||
@@ -383,6 +503,14 @@ class MainWindow(ttk.Frame):
|
||||
members_tab = self.tabs.tabs.get("members")
|
||||
if isinstance(members_tab, MembersTab) and members_tab.winfo_exists():
|
||||
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():
|
||||
self.housekeeper_button.configure(text=f"Hausmeister ({len(self.findings)})")
|
||||
|
||||
@@ -417,8 +545,30 @@ class MainWindow(ttk.Frame):
|
||||
self.tabs.refresh_icons(self.icons)
|
||||
|
||||
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(
|
||||
"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,
|
||||
)
|
||||
|
||||
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
@@ -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.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 Event
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||
from ccma.ui.dialogs import IntegrityWarningDialog
|
||||
from ccma.ui.document_dialog import DocumentTemplateDialog
|
||||
from ccma.ui.file_open import open_path
|
||||
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):
|
||||
@@ -25,6 +48,9 @@ class MemberTab(ttk.Frame):
|
||||
on_close: Callable[[], None],
|
||||
on_changed: Callable[[], 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)
|
||||
self.repository = repository
|
||||
@@ -32,31 +58,51 @@ class MemberTab(ttk.Frame):
|
||||
self.on_close = on_close
|
||||
self.on_changed = on_changed
|
||||
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.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.refresh()
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
self.columnconfigure(0, weight=1)
|
||||
self.rowconfigure(1, 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.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"
|
||||
)
|
||||
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)
|
||||
)
|
||||
ttk.Button(header, text="Tab schließen", command=self.on_close).grid(
|
||||
row=0, column=1, rowspan=2, sticky="e"
|
||||
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._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=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.timeline_pane = ttk.Frame(self.pane, padding=(10, 0, 0, 0))
|
||||
self.pane.add(self.details_pane, weight=2)
|
||||
@@ -79,24 +125,38 @@ class MemberTab(ttk.Frame):
|
||||
|
||||
def _build_details(self, parent: ttk.Frame) -> None:
|
||||
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.grid(row=0, column=0, sticky="nsew")
|
||||
data_tab = ttk.Frame(notebook, padding=16)
|
||||
address_tab = ttk.Frame(notebook, padding=16)
|
||||
banking_tab = ttk.Frame(notebook, padding=16)
|
||||
self.details_notebook = notebook
|
||||
notebook.grid(row=1, column=0, sticky="nsew")
|
||||
data_tab = self._create_form_tab(notebook, "data", "Stammdaten")
|
||||
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)
|
||||
assets_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(assets_tab, text="Assets")
|
||||
notebook.add(documents_tab, text="Dokumente")
|
||||
|
||||
fields = [
|
||||
("UUID / Ordner-ID", "member_id"),
|
||||
("Mitgliedsnummer", "member_number"),
|
||||
("Vorname", "first_name"),
|
||||
("Nachname", "last_name"),
|
||||
("Nickname", "nickname"),
|
||||
("E-Mail-Adresse", "email"),
|
||||
("Telefonnummer", "phone"),
|
||||
(f"Geburtsdatum ({date_input_hint()})", "birth_date"),
|
||||
@@ -105,7 +165,7 @@ class MemberTab(ttk.Frame):
|
||||
]
|
||||
for row, (label, key) in enumerate(fields):
|
||||
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))
|
||||
if key == "birth_date":
|
||||
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()))
|
||||
)
|
||||
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
|
||||
)
|
||||
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.Combobox(
|
||||
data_tab,
|
||||
@@ -132,17 +193,13 @@ class MemberTab(ttk.Frame):
|
||||
state="readonly",
|
||||
width=39,
|
||||
).grid(row=len(fields), column=1, sticky="ew", pady=5)
|
||||
self.variables["notes"] = tk.StringVar()
|
||||
ttk.Label(data_tab, text="Interne Notiz").grid(
|
||||
row=len(fields) + 1, column=0, sticky="nw", pady=5, padx=(0, 12)
|
||||
)
|
||||
ttk.Entry(data_tab, textvariable=self.variables["notes"]).grid(
|
||||
row=len(fields) + 1, column=1, sticky="ew", pady=5
|
||||
)
|
||||
self.notes_text = tk.Text(data_tab, width=42, height=6, wrap="word")
|
||||
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)
|
||||
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 = (
|
||||
("Straße und Hausnummer", "street"),
|
||||
@@ -153,14 +210,11 @@ class MemberTab(ttk.Frame):
|
||||
)
|
||||
address_tab.columnconfigure(1, weight=1)
|
||||
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.Entry(address_tab, textvariable=self.variables[key], width=42).grid(
|
||||
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 = (
|
||||
("Kontoinhaber", "account_holder"),
|
||||
@@ -172,22 +226,18 @@ class MemberTab(ttk.Frame):
|
||||
)
|
||||
banking_tab.columnconfigure(1, weight=1)
|
||||
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.Entry(banking_tab, textvariable=self.variables[key], width=42).grid(
|
||||
row=row, column=1, sticky="ew", pady=5
|
||||
)
|
||||
self.variables["mandate_active"] = tk.BooleanVar()
|
||||
self._add_variable("mandate_active", tk.BooleanVar(), "banking")
|
||||
ttk.Checkbutton(
|
||||
banking_tab,
|
||||
text="SEPA-Lastschriftmandat ist aktiv",
|
||||
variable=self.variables["mandate_active"],
|
||||
style="Switch",
|
||||
).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.rowconfigure(1, weight=1)
|
||||
self.contribution_summary = tk.StringVar()
|
||||
@@ -197,18 +247,53 @@ class MemberTab(ttk.Frame):
|
||||
self.claims = ttk.Treeview(
|
||||
contribution_tab, columns=("title", "due", "amount", "status"), show="headings"
|
||||
)
|
||||
for key, title, width in (
|
||||
("title", "Forderung", 220),
|
||||
("due", "Fällig", 100),
|
||||
("amount", "Betrag", 90),
|
||||
("status", "Status", 110),
|
||||
):
|
||||
self.claims.heading(key, text=title)
|
||||
self.claim_sort_column = "due"
|
||||
self.claim_sort_descending = False
|
||||
for key, title, width in CLAIM_TABLE_COLUMNS:
|
||||
self.claims.heading(key, text=title, command=lambda column=key: self._toggle_claim_sort(column))
|
||||
self.claims.column(key, width=width, anchor="w")
|
||||
self.claims.grid(row=1, column=0, sticky="nsew")
|
||||
self.claims.bind("<Double-1>", 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.rowconfigure(1, weight=1)
|
||||
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.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:
|
||||
parent.columnconfigure(0, 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)
|
||||
|
||||
def refresh(self) -> None:
|
||||
self._loading = True
|
||||
self.member = self.repository.get_member(self.member_id)
|
||||
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.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 = {
|
||||
"birth_date",
|
||||
"accepted_at",
|
||||
@@ -287,8 +433,15 @@ class MemberTab(ttk.Frame):
|
||||
variable.set(display_label(STATUS_LABELS, str(value)))
|
||||
else:
|
||||
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_contributions()
|
||||
self._refresh_assets()
|
||||
self._refresh_documents()
|
||||
|
||||
def _refresh_events(self) -> None:
|
||||
@@ -308,7 +461,13 @@ class MemberTab(ttk.Frame):
|
||||
except RepositoryError as exc:
|
||||
self.contribution_summary.set(f"FEHLER: {exc}")
|
||||
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}")
|
||||
status = claim_status(data, claim)
|
||||
self.claims.insert(
|
||||
@@ -324,6 +483,25 @@ class MemberTab(ttk.Frame):
|
||||
)
|
||||
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:
|
||||
selected = self.claims.selection()
|
||||
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:
|
||||
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():
|
||||
raw_value = variable.get()
|
||||
value = raw_value.strip() if isinstance(raw_value, str) else raw_value
|
||||
if key == "status":
|
||||
value = storage_key(STATUS_LABELS, 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:
|
||||
self.repository.save_member(self.member)
|
||||
self.repository.refresh_member_record_hashes(self.member_id)
|
||||
except RepositoryError as exc:
|
||||
messagebox.showerror("Speichern fehlgeschlagen", 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_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:
|
||||
text = self.comment_var.get().strip()
|
||||
if not text:
|
||||
@@ -389,6 +614,16 @@ class MemberTab(ttk.Frame):
|
||||
path = self.repository.members_root / self.member_id / "files"
|
||||
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:
|
||||
selected = self.documents.selection()
|
||||
if selected and selected[0] in self.document_paths:
|
||||
|
||||
@@ -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")
|
||||
@@ -6,6 +6,7 @@ from pathlib import Path
|
||||
from tkinter import filedialog, messagebox, ttk
|
||||
|
||||
from ccma.config import AppConfig
|
||||
from ccma.domain.models import HOUSEKEEPER_MEMBER_FIELD_LABELS
|
||||
from ccma.services.intervals import (
|
||||
IntervalValidationError,
|
||||
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_after_var = tk.StringVar(value=str(config.anniversary_days_after))
|
||||
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()
|
||||
self.manual_numbers_var = tk.BooleanVar(value=number_policy["mode"] == "manual")
|
||||
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.",
|
||||
style="Muted.TLabel",
|
||||
).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:
|
||||
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_after = anniversary_after
|
||||
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:
|
||||
self.config_obj.save()
|
||||
self.repository.save_member_number_policy(mode=number_mode, pattern=number_pattern)
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -47,6 +47,28 @@ def _configure_ccma_styles(style: ttk.Style, variant: str) -> None:
|
||||
accent = "#00d084" if dark else "#087f5b"
|
||||
warning = "#ffb454"
|
||||
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("AppTitle.TLabel", font=("TkDefaultFont", 14, "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,
|
||||
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
@@ -4,7 +4,97 @@ from collections.abc import Callable
|
||||
from tkinter import messagebox, ttk
|
||||
|
||||
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):
|
||||
@@ -82,11 +172,17 @@ class SearchResultsTab(ttk.Frame):
|
||||
ttk.Label(header, text=f"{len(self.members)} Treffer", 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)
|
||||
tree = ttk.Treeview(self, columns=("number", "name", "email", "birth", "status"), show="headings")
|
||||
ttk.Button(header, text="Tab schließen", command=self.on_close).grid(row=0, column=3, rowspan=2)
|
||||
tree = ttk.Treeview(
|
||||
self,
|
||||
columns=("number", "first_name", "last_name", "nickname", "email", "birth", "status"),
|
||||
show="headings",
|
||||
)
|
||||
for key, title, width in (
|
||||
("number", "Nummer", 90),
|
||||
("name", "Name", 220),
|
||||
("first_name", "Vorname", 150),
|
||||
("last_name", "Nachname", 170),
|
||||
("nickname", "Nickname", 150),
|
||||
("email", "E-Mail-Adresse", 260),
|
||||
("birth", "Geburtsdatum", 110),
|
||||
("status", "Status", 160),
|
||||
@@ -101,7 +197,9 @@ class SearchResultsTab(ttk.Frame):
|
||||
iid=member.member_id,
|
||||
values=(
|
||||
member.member_number,
|
||||
member.display_name,
|
||||
member.first_name,
|
||||
member.last_name,
|
||||
member.nickname,
|
||||
member.email,
|
||||
format_date_for_display(member.birth_date),
|
||||
MEMBERSHIP_STATUS_LABELS.get(member.status, member.status),
|
||||
@@ -132,7 +230,7 @@ class MembersTab(ttk.Frame):
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
self.columnconfigure(0, weight=1)
|
||||
self.rowconfigure(1, 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)
|
||||
@@ -140,60 +238,300 @@ class MembersTab(ttk.Frame):
|
||||
self.count_var = tk.StringVar()
|
||||
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)
|
||||
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, 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 (
|
||||
("number", "Nummer", 110),
|
||||
("name", "Name", 230),
|
||||
("email", "E-Mail-Adresse", 270),
|
||||
("birth", "Geburtsdatum", 120),
|
||||
("status", "Status", 170),
|
||||
):
|
||||
self.tree.heading(key, text=title)
|
||||
self.sort_column = "last_name"
|
||||
self.sort_descending = False
|
||||
self.status_filter_var = tk.StringVar(value=STATUS_FILTER_ALL)
|
||||
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=[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.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("<Return>", lambda _event: self._open_selected())
|
||||
self.refresh(self.members)
|
||||
|
||||
def refresh(self, members: list[Member]) -> None:
|
||||
self.members = members
|
||||
self._render_members()
|
||||
|
||||
def _render_members(self) -> None:
|
||||
self.tree.delete(*self.tree.get_children())
|
||||
self.count_var.set(f"{len(members)} Mitglieder")
|
||||
for member in members:
|
||||
status_filter = _selected_status_filter(self.status_filter_var.get())
|
||||
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(
|
||||
"",
|
||||
"end",
|
||||
iid=member.member_id,
|
||||
values=(
|
||||
member.member_number,
|
||||
member.display_name,
|
||||
member.first_name,
|
||||
member.last_name,
|
||||
member.nickname,
|
||||
member.email,
|
||||
format_date_for_display(member.birth_date),
|
||||
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:
|
||||
selected = self.tree.selection()
|
||||
if selected:
|
||||
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):
|
||||
def __init__(
|
||||
self,
|
||||
master: tk.Misc,
|
||||
findings: list[HousekeeperFinding],
|
||||
on_open_member: Callable[[str], None],
|
||||
on_open_target: Callable[[HousekeeperFinding], None],
|
||||
on_refresh: Callable[[], list[HousekeeperFinding]],
|
||||
on_delete: Callable[[str], list[HousekeeperFinding]],
|
||||
on_close: Callable[[], None],
|
||||
):
|
||||
super().__init__(master, padding=12)
|
||||
self.findings = findings
|
||||
self.on_open_member = on_open_member
|
||||
self.on_open_target = on_open_target
|
||||
self.on_refresh = on_refresh
|
||||
self.on_delete = on_delete
|
||||
self.on_close = on_close
|
||||
@@ -303,11 +641,15 @@ class HousekeeperTab(ttk.Frame):
|
||||
def _open_selected(self) -> None:
|
||||
selected = self.tree.selection()
|
||||
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:
|
||||
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:
|
||||
lines.append(f"Key: {finding.key}")
|
||||
if finding.due_date:
|
||||
@@ -315,3 +657,9 @@ def _finding_details(finding: HousekeeperFinding) -> str:
|
||||
if finding.detail:
|
||||
lines.extend(("", finding.detail))
|
||||
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)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
from ccma import __version__
|
||||
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()
|
||||
assert entries
|
||||
assert entries[0]["version"] == __version__
|
||||
assert entries[0]["version"]
|
||||
assert entries[0]["date"]
|
||||
assert entries[0]["changes"]
|
||||
|
||||
@@ -16,6 +16,8 @@ def test_paths_and_automation_settings_round_trip(tmp_path, monkeypatch) -> None
|
||||
anniversary_days_before=21,
|
||||
anniversary_days_after=5,
|
||||
anniversary_intervals="30D;2M;1Y;10Y",
|
||||
retroactive_claims=True,
|
||||
optional_member_fields=("nickname", "email", "phone"),
|
||||
window_geometry="1200x800-1800+40",
|
||||
window_state="maximized",
|
||||
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["monitor_bounds"] == [-1920, 0, 1920, 1080]
|
||||
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:
|
||||
|
||||
@@ -3,8 +3,10 @@ from decimal import Decimal
|
||||
import pytest
|
||||
|
||||
from ccma.domain.contributions import (
|
||||
allocated_total,
|
||||
claim_balance,
|
||||
claim_items,
|
||||
claim_settled_total,
|
||||
claim_status,
|
||||
claim_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:
|
||||
repository, member = _repository_with_claim(tmp_path)
|
||||
|
||||
|
||||
@@ -58,6 +58,28 @@ def test_document_and_claim_creation_time_placeholders() -> None:
|
||||
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:
|
||||
source = b"""<office:document
|
||||
xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
|
||||
|
||||
@@ -113,3 +113,28 @@ def test_housekeeper_reports_invalid_member_dates(tmp_path) -> None:
|
||||
assert len(invalid) == 1
|
||||
assert invalid[0].member_id == member.member_id
|
||||
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
@@ -1,7 +1,10 @@
|
||||
import json
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from ccma.domain.contributions import claim_balance
|
||||
from ccma.services.housekeeper import Housekeeper
|
||||
from ccma.storage.repository import (
|
||||
MemberRepository,
|
||||
RepositoryError,
|
||||
@@ -18,6 +21,7 @@ def test_repository_creates_transparent_member_record(tmp_path) -> None:
|
||||
member = repository.create_member(
|
||||
first_name="Ada",
|
||||
last_name="Lovelace",
|
||||
nickname="Enchantress",
|
||||
email="ada@example.org",
|
||||
birth_date="1990-12-10",
|
||||
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"))
|
||||
assert raw["person"]["first_name"] == "Ada"
|
||||
assert raw["person"]["nickname"] == "Enchantress"
|
||||
assert raw["schema_version"] == 1
|
||||
assert raw["content_hash"]
|
||||
|
||||
|
||||
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(
|
||||
first_name="Jörg",
|
||||
last_name="Müller",
|
||||
nickname="Jogi",
|
||||
email="joerg.mueller@example.org",
|
||||
birth_date="1990-04-23",
|
||||
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]
|
||||
|
||||
|
||||
@@ -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")
|
||||
second = repository.create_member(first_name="Second", last_name="Member")
|
||||
|
||||
|
||||
assert first.member_number == "CCMA-0001"
|
||||
assert second.member_number == "CCMA-0002"
|
||||
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"]
|
||||
assert organization["street"] == "Testweg 1"
|
||||
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
@@ -91,6 +91,84 @@ def test_housekeeper_claim_actions_are_idempotent(tmp_path) -> None:
|
||||
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:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
@@ -102,7 +180,7 @@ def test_housekeeper_resolves_tasks_not_seen_in_current_run(tmp_path) -> None:
|
||||
repository.save_member(member)
|
||||
housekeeper.run(today=date(2026, 6, 21))
|
||||
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["first_seen_run"] == "2026-06-21:000001"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
def test_ui_modules_import_without_creating_root_window() -> None:
|
||||
import ccma.app # noqa: F401
|
||||
import ccma.ui.asset_tab # noqa: F401
|
||||
import ccma.ui.claim_tab # noqa: F401
|
||||
import ccma.ui.main_window # noqa: F401
|
||||
import ccma.ui.member_tab # noqa: F401
|
||||
@@ -68,16 +69,16 @@ def test_housekeeper_details_are_multiline() -> None:
|
||||
|
||||
finding = HousekeeperFinding(
|
||||
severity="error",
|
||||
member_id="member-1",
|
||||
code="invalid_member_record",
|
||||
title="Mitgliederakte beschädigt",
|
||||
detail="Die JSON-Datei ist leer und wird nicht automatisch überschrieben.",
|
||||
member_id="member-1",
|
||||
due_date=date(2026, 7, 31),
|
||||
)
|
||||
|
||||
rendered = _finding_details(finding)
|
||||
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.")
|
||||
|
||||
|
||||
@@ -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 display_label(MEMBERSHIP_STATUS_LABELS, "active") == "AKTIV"
|
||||
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
|
||||
|
||||
@@ -5,4 +5,4 @@ from ccma import __version__
|
||||
|
||||
def test_ui_version_matches_version_file() -> None:
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user