feat: initialize CCMA member administration

This commit is contained in:
Marcel Peterkau
2026-06-21 16:46:15 +02:00
parent 4c6a1191ee
commit dfd5b1192b
184 changed files with 5051 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
from ccma import __version__
from ccma.ui.changelog_view import load_changelog
def test_changelog_contains_current_version() -> None:
entries = load_changelog()
assert entries
assert entries[0]["version"] == __version__
assert entries[0]["changes"]
+37
View File
@@ -0,0 +1,37 @@
import json
from ccma.config import AppConfig, load_config
def test_paths_and_automation_settings_round_trip(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("CCMA_CONFIG_DIR", str(tmp_path / "config"))
expected = AppConfig(
store_path=str(tmp_path / "members"),
gnucash_path=str(tmp_path / "club.gnucash"),
theme_mode="light",
run_housekeeper_on_startup=False,
birthday_days_before=10,
birthday_days_after=3,
anniversary_days_before=21,
anniversary_days_after=5,
anniversary_intervals="30D;2M;1Y;10Y",
window_geometry="1200x800-1800+40",
window_state="maximized",
monitor_bounds=(-1920, 0, 1920, 1080),
)
expected.save()
loaded = load_config()
assert loaded == expected
raw = json.loads(expected.path.read_text(encoding="utf-8"))
assert raw["schema_version"] == 1
assert raw["monitor_bounds"] == [-1920, 0, 1920, 1080]
def test_legacy_c3ma_environment_variables_are_still_read(tmp_path, monkeypatch) -> None:
monkeypatch.delenv("CCMA_CONFIG_DIR", raising=False)
monkeypatch.delenv("CCMA_STORE", raising=False)
monkeypatch.setenv("C3MA_CONFIG_DIR", str(tmp_path / "legacy-config"))
monkeypatch.setenv("C3MA_STORE", str(tmp_path / "legacy-store"))
assert load_config().store_path == str(tmp_path / "legacy-store")
+63
View File
@@ -0,0 +1,63 @@
from datetime import date
import pytest
from ccma.domain.dates import (
DateValidationError,
age_label,
calculate_age,
format_date_for_display,
normalize_date_input,
parse_date_input,
parse_iso_date,
validate_birth_date,
validate_member_dates,
)
def test_iso_dates_are_strict_and_real() -> None:
assert parse_iso_date("2024-02-29", "Datum") == date(2024, 2, 29)
for value in ("29.02.2024", "2024-2-29", "2023-02-29", "irgendwas"):
with pytest.raises(DateValidationError):
parse_iso_date(value, "Datum")
def test_date_input_accepts_german_and_iso_formats() -> None:
expected = date(2024, 2, 29)
assert parse_date_input("29.02.2024", "Datum") == expected
assert parse_date_input("2024-02-29", "Datum") == expected
assert normalize_date_input("29.02.2024", "Datum") == "2024-02-29"
def test_date_display_uses_system_pattern(monkeypatch) -> None:
monkeypatch.setattr("ccma.domain.dates.system_date_pattern", lambda: "%d.%m.%Y")
assert format_date_for_display("2024-02-29") == "29.02.2024"
monkeypatch.setattr("ccma.domain.dates.system_date_pattern", lambda: "%Y-%m-%d")
assert format_date_for_display("2024-02-29") == "2024-02-29"
def test_birth_date_checks_future_and_plausibility() -> None:
today = date(2026, 6, 21)
assert validate_birth_date("2000-06-22", today=today) == date(2000, 6, 22)
with pytest.raises(DateValidationError, match="Zukunft"):
validate_birth_date("2026-06-22", today=today)
with pytest.raises(DateValidationError, match="120"):
validate_birth_date("1900-01-01", today=today)
def test_member_dates_must_be_chronological() -> None:
with pytest.raises(DateValidationError, match="Aufnahmebeschluss"):
validate_member_dates(
birth_date="2000-01-01",
accepted_at="2020-01-02",
membership_started_at="2020-01-01",
today=date(2026, 6, 21),
)
def test_age_calculation_and_label() -> None:
today = date(2026, 6, 21)
assert calculate_age(date(2000, 6, 21), today) == 26
assert calculate_age(date(2000, 6, 22), today) == 25
assert age_label("2000-06-21", today=today) == "Alter: 26 Jahre"
assert age_label("nein", today=today) == "UNGÜLTIGES DATUM"
+93
View File
@@ -0,0 +1,93 @@
from datetime import date
from ccma.domain.models import ContributionData
from ccma.services.housekeeper import Housekeeper, HousekeeperSettings
from ccma.storage.repository import MemberRepository
def test_housekeeper_reports_initial_payment_and_open_claims(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Test", last_name="Person")
member.status = "accepted_pending_payment"
member.accepted_at = "2026-01-01"
repository.save_member(member)
repository.save_contributions(
member.member_id,
ContributionData(
claims=[
{
"claim_id": "claim-1",
"title": "Mitgliedsbeitrag 2026",
"amount": "150.00",
"due_date": "2026-01-31",
"status": "open",
}
]
),
)
findings = Housekeeper(repository).run(today=date(2026, 2, 10))
assert {finding.code for finding in findings} == {"initial_payment_overdue", "claim_overdue"}
def test_housekeeper_reports_birthdays_before_today_and_after(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
dates = ("1990-06-20", "1990-06-21", "1990-06-22")
for index, birth_date in enumerate(dates):
member = repository.create_member(
first_name=f"Birthday{index}",
last_name="Member",
birth_date=birth_date,
)
member.status = "active"
repository.save_member(member)
settings = HousekeeperSettings.from_values(
birthday_days_before=2,
birthday_days_after=2,
anniversary_days_before=0,
anniversary_days_after=0,
anniversary_intervals="1Y",
)
findings = [
finding
for finding in Housekeeper(repository, settings).run(today=date(2026, 6, 21))
if finding.code == "birthday"
]
assert {finding.title for finding in findings} == {
"Birthday0 Member hatte vor 1 Tag Geburtstag",
"Birthday1 Member hat heute Geburtstag",
"Birthday2 Member hat in 1 Tag Geburtstag",
}
def test_housekeeper_reports_day_month_and_year_anniversaries(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
starts = ("2026-05-22", "2026-04-21", "2025-06-21", "2016-06-22")
for index, started_at in enumerate(starts):
member = repository.create_member(first_name=f"Anniversary{index}", last_name="Member")
member.status = "active"
member.membership_started_at = started_at
repository.save_member(member)
settings = HousekeeperSettings.from_values(
birthday_days_before=0,
birthday_days_after=0,
anniversary_days_before=2,
anniversary_days_after=2,
anniversary_intervals="30D;2M;1Y;10Y",
)
findings = [
finding
for finding in Housekeeper(repository, settings).run(today=date(2026, 6, 21))
if finding.code == "membership_anniversary"
]
assert {finding.title for finding in findings} == {
"Anniversary0 Member hat heute 30-Tage-Mitgliedsjubiläum",
"Anniversary1 Member hat heute 2-Monats-Mitgliedsjubiläum",
"Anniversary2 Member hat heute 1-jähriges Mitgliedsjubiläum",
"Anniversary3 Member hat in 1 Tag 10-jähriges Mitgliedsjubiläum",
}
+27
View File
@@ -0,0 +1,27 @@
from datetime import date
import pytest
from ccma.services.intervals import (
IntervalValidationError,
normalize_anniversary_intervals,
parse_anniversary_intervals,
)
def test_intervals_accept_commas_semicolons_and_units() -> None:
intervals = parse_anniversary_intervals("30d, 2M;1;10Y;10y")
assert [interval.token for interval in intervals] == ["30D", "2M", "1Y", "10Y"]
assert normalize_anniversary_intervals("30d, 2M;1Y") == "30D;2M;1Y"
def test_month_and_year_intervals_use_calendar_arithmetic() -> None:
intervals = parse_anniversary_intervals("1M;1Y")
assert intervals[0].target_date(date(2024, 1, 31)) == date(2024, 2, 29)
assert intervals[1].target_date(date(2024, 2, 29)) == date(2025, 2, 28)
@pytest.mark.parametrize("value", ["", "D", "0D", "-1Y", "101Y"])
def test_invalid_intervals_are_rejected(value) -> None:
with pytest.raises(IntervalValidationError):
parse_anniversary_intervals(value)
+13
View File
@@ -0,0 +1,13 @@
from ccma.ui.monitors import MonitorBounds, centered_geometry, ensure_visible_geometry, parse_geometry
def test_centered_geometry_supports_monitor_left_of_primary() -> None:
monitor = MonitorBounds(-1920, 0, 1920, 1080)
geometry = centered_geometry(620, 330, monitor)
assert parse_geometry(geometry) == (620, 330, -1270, 375)
def test_saved_geometry_is_clamped_to_selected_monitor() -> None:
monitor = MonitorBounds(1920, 0, 1920, 1080)
geometry = ensure_visible_geometry("1500x860-1600+100", monitor)
assert parse_geometry(geometry) == (1500, 860, 1920, 100)
+143
View File
@@ -0,0 +1,143 @@
import json
import pytest
from ccma.storage.repository import (
MemberRepository,
RepositoryError,
format_member_number,
validate_member_number_pattern,
)
def test_repository_creates_transparent_member_record(tmp_path) -> None:
repository = MemberRepository(tmp_path / "store")
repository.initialize()
member = repository.create_member(
first_name="Ada",
last_name="Lovelace",
email="ada@example.org",
birth_date="1990-12-10",
member_number="0042",
)
member_dir = repository.members_root / member.member_id
assert (member_dir / "member.json").is_file()
assert (member_dir / "contributions.json").is_file()
assert (member_dir / "events.jsonl").is_file()
assert (member_dir / "files").is_dir()
assert repository.validate() == []
raw = json.loads((member_dir / "member.json").read_text(encoding="utf-8"))
assert raw["person"]["first_name"] == "Ada"
assert raw["schema_version"] == 1
def test_search_matches_name_email_number_and_german_birth_date(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(
first_name="Jörg",
last_name="Müller",
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"):
assert [result.member_id for result in repository.search(query)] == [member.member_id]
def test_events_are_appended_and_changes_do_not_leak_values(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Alice", last_name="Example", email="old@example.org")
member.email = "new@example.org"
repository.save_member(member)
repository.append_event(
member.member_id,
event_type="board_comment",
summary="Telefonisch erreicht",
actor_type="user",
actor_name="Vorstand",
)
events = repository.get_events(member.member_id)
assert [event.event_type for event in events] == [
"member_created",
"member_data_changed",
"board_comment",
]
assert "E-Mail-Adresse" in events[1].summary
assert "old@example.org" not in events[1].summary
assert "new@example.org" not in events[1].summary
def test_status_change_audit_contains_old_and_new_status(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Status", last_name="Test")
member.status = "active"
repository.save_member(member)
event = repository.get_events(member.member_id)[-1]
assert event.summary == "Mitgliedsdaten geändert: Status von ANTRAG zu AKTIV"
def test_repository_accepts_local_date_input_and_rejects_invalid_dates(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Local", last_name="Date", birth_date="31.12.2000")
assert member.birth_date == "2000-12-31"
assert repository.get_member(member.member_id).birth_date == "2000-12-31"
with pytest.raises(RepositoryError, match="gültiges Datum"):
repository.create_member(first_name="Invalid", last_name="Date", birth_date="31.02.2000")
def test_member_path_rejects_traversal(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
with pytest.raises(RepositoryError):
repository.get_member("../outside")
def test_automatic_member_numbers_are_sequential_and_preview_does_not_consume(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
assert repository.preview_member_number() == "CCMA-0001"
assert repository.preview_member_number() == "CCMA-0001"
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"
def test_custom_pattern_and_manual_mode(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
repository.save_member_number_policy(mode="automatic", pattern="MA-{year}-{number:03d}")
automatic = repository.create_member(first_name="Auto", last_name="Member")
assert automatic.member_number.startswith("MA-")
assert automatic.member_number.endswith("-001")
repository.save_member_number_policy(mode="manual", pattern="MA-{number:03d}")
with pytest.raises(RepositoryError, match="erforderlich"):
repository.create_member(first_name="Missing", last_name="Number")
manual = repository.create_member(first_name="Manual", last_name="Member", member_number="SPECIAL-7")
assert manual.member_number == "SPECIAL-7"
with pytest.raises(RepositoryError, match="bereits vergeben"):
repository.create_member(first_name="Duplicate", last_name="Member", member_number="special-7")
@pytest.mark.parametrize("pattern", ["", "CCMA-{year}", "{unknown}-{number}", "{number!r}"])
def test_invalid_member_number_patterns_are_rejected(pattern) -> None:
with pytest.raises(RepositoryError):
validate_member_number_pattern(pattern)
def test_member_number_formatter_supports_padding_and_year() -> None:
assert format_member_number("CCMA-{year}-{number:05d}", 42, year=2026) == "CCMA-2026-00042"
+40
View File
@@ -0,0 +1,40 @@
def test_ui_modules_import_without_creating_root_window() -> None:
import ccma.app # noqa: F401
import ccma.ui.main_window # noqa: F401
import ccma.ui.member_tab # noqa: F401
import ccma.ui.splash # noqa: F401
def test_splash_position_centers_on_pointer_and_stays_on_screen() -> None:
from ccma.ui.splash import centered_position
assert centered_position(
width=620,
height=330,
pointer_x=2500,
pointer_y=600,
screen_x=0,
screen_y=0,
screen_width=3840,
screen_height=1080,
) == (2190, 435)
assert centered_position(
width=620,
height=330,
pointer_x=10,
pointer_y=10,
screen_x=0,
screen_y=0,
screen_width=1920,
screen_height=1080,
) == (0, 0)
def test_event_labels_hide_board_actor_but_keep_automatic_marker() -> None:
from ccma.domain.models import Event
from ccma.ui.member_tab import _event_label
user_event = Event("1", "2026-01-01T00:00:00+01:00", "comment", "Kommentar", "user", "Vorstand")
system_event = Event("2", "2026-01-01T00:00:00+01:00", "automatic", "Automatisch")
assert _event_label(user_event) == "Kommentar"
assert _event_label(system_event) == "[AUTO] Automatisch"
+8
View File
@@ -0,0 +1,8 @@
from pathlib import Path
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"