Keep CCMA usable when the member store is mounted read-only

The encrypted volume holding the member data can be mounted without write
access, but starting against such a store failed: the housekeeper takes a lock
file before doing anything, so its startup pass died with a PermissionError and
took the whole start with it.

The store is now probed with an actual write once at startup -- permissions,
mount options and filesystem state all matter, and only an attempt covers them
together -- and a read-only store opens as a read-only session. The housekeeper
is skipped rather than attempted, every write inside the repository goes through
one guard that reports ReadOnlyStoreError (a RepositoryError, so the dialogs
already handle it) instead of letting an OS error surface, and the services that
archive into the member file check before they start sending or rendering.

The session says so permanently: a warning banner above the tabs, "NUR LESEN" in
the window title and status bar, and refused actions explaining why. Program
settings still save -- they live in the user's config directory -- while the
store-backed ones are skipped with a notice. "Erneut prüfen" picks up a volume
that was remounted writable without restarting.

A store that was never initialized still fails, but says that creating one needs
write access.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Marcel Peterkau
2026-08-28 21:10:31 +02:00
co-authored by Claude Opus 5
parent 1972dbabb3
commit be042949a2
14 changed files with 403 additions and 47 deletions
+18
View File
@@ -183,6 +183,24 @@ marker may share the last cell. CCMA removes both markers and clones the whole
row, including its formatting, once per item. With no items, the template row
is removed. A loop that is not closed in the same row is rejected.
## Read-only stores
The member store may be mounted read-only -- the encrypted volume holding the
member data does not have to be writable to look something up. CCMA detects this
at startup by probing the store with an actual write, skips the housekeeper (every
one of its passes writes) and opens in a read-only session: a permanent warning
banner above the tabs, a "NUR LESEN" marker in the window title and status bar,
and every write refused with one clear message instead of an operating system
error.
Program settings still save normally -- they live in the user's config directory,
not in the store. Settings that belong to the store (club data, member numbers,
reminders, e-mail) are skipped with a notice. When the volume is remounted with
write access, "Erneut prüfen" in the banner picks that up without a restart.
A store that was never initialized cannot be opened read-only: creating it needs
write access, and CCMA says so instead of failing obscurely.
## Housekeeper rules
The housekeeper runs every rule for every member. Built-in Python rules live in
+2
View File
@@ -78,6 +78,8 @@ class CCMAApp(tk.Tk):
def _startup_complete(self, result: StartupResult) -> None:
self.deiconify()
if result.repository.read_only:
self.title(f"CCMA · v{__version__} · NUR LESEN (Store schreibgeschützt)")
main = MainWindow(
self,
result.repository,
+2 -1
View File
@@ -38,7 +38,8 @@
"Eine überfällige Lastschrift, für die bereits eine Rücklastschrift-Mahnung versandt wurde und deren Zahlungsfrist noch läuft, wird vom Hausmeister nicht mehr weiterhin als überfällig gemeldet, sondern als niedrig priorisierter Hinweis, dass die Klärung noch läuft.",
"Im SEPA-Export lassen sich einzelne Lastschriften per Mehrfachauswahl von der CSV-/XML-/Info-Mail-Erstellung ausschließen (z. B. während eine Rücklastschrift geklärt wird), statt immer alle offenen Lastschriften einziehen zu müssen; standardmäßig sind alle ausgewählt.",
"Mahnungs- und SEPA-Info-Mails müssen nicht mehr zwingend als lokale Datei gespeichert und von Hand in Thunderbird importiert werden: unter Optionen → E-Mail-Versand kann ein Postfach mit SMTP- und IMAP-Zugangsdaten hinterlegt werden, wahlweise mit direktem Versand, Ablage als Entwurf im Postfach oder einer Rückfrage bei jeder Erzeugung; die bisherige lokale Ablage bleibt als Standard erhalten.",
"Direkt versandte Mahnungen und SEPA-Info-Mails können zusätzlich automatisch in einen Gesendet-Ordner des Postfachs abgelegt werden. Für den Entwürfe- und den Gesendet-Ordner lässt sich die tatsächliche Ordnerliste live vom Server abrufen und per Dropdown auswählen; ein noch nicht vorhandener, frei eingegebener Ordner wird bei Bedarf automatisch angelegt."
"Direkt versandte Mahnungen und SEPA-Info-Mails können zusätzlich automatisch in einen Gesendet-Ordner des Postfachs abgelegt werden. Für den Entwürfe- und den Gesendet-Ordner lässt sich die tatsächliche Ordnerliste live vom Server abrufen und per Dropdown auswählen; ein noch nicht vorhandener, frei eingegebener Ordner wird bei Bedarf automatisch angelegt.",
"CCMA startet jetzt auch, wenn der Mitglieder-Store schreibgeschützt eingebunden ist (z. B. ein nur lesend gemountetes Crypt-Laufwerk): Die Daten lassen sich wie gewohnt ansehen, der Hausmeister wird übersprungen und alle schreibenden Aktionen werden mit einer verständlichen Meldung abgelehnt statt mit einem Systemfehler. Ein dauerhaft sichtbarer Warnhinweis über den Tabs sowie Markierungen in Fenstertitel und Statusleiste weisen auf den Nur-Lese-Betrieb hin; wird der Store wieder mit Schreibrechten eingebunden, genügt „Erneut prüfen“ ohne Neustart."
]
},
{
+1
View File
@@ -98,6 +98,7 @@ class DocumentService:
if not template_path.is_file() or template_path.suffix.casefold() not in SUPPORTED_TEMPLATE_SUFFIXES:
raise DocumentError("Das ausgewählte OpenDocument-Template ist nicht verfügbar.")
self.repository.assert_writable()
member = self.repository.get_member(member_id)
data = None
claim = None
+4
View File
@@ -77,6 +77,9 @@ class Housekeeper:
overrides the configured setting for just this run without changing it
permanently -- e.g. to recompute one member's full claim history on demand
after a contribution change, without retroactively billing everyone else."""
# The housekeeper exists to write: it refreshes tasks, creates claims and
# updates member records. On a read-only store it must not even take its lock.
self.repository.assert_writable()
current_date = today or date.today()
delay = _non_negative_delay(member_delay)
effective_settings = (
@@ -189,6 +192,7 @@ class Housekeeper:
return _open_findings(working["items"])
def delete_task(self, key: str) -> list[HousekeeperFinding]:
self.repository.assert_writable()
selected_key = key.strip()
if not selected_key:
raise RepositoryError("Der Task hat keinen gültigen Key.")
+1
View File
@@ -133,6 +133,7 @@ def generate_and_send_reminder_mail(
) -> GeneratedReminderMail:
if delivery_mode not in {"local", "send", "drafts"}:
raise RepositoryError(f"Unbekannter Versandmodus: {delivery_mode}")
repository.assert_writable()
member = repository.get_member(member_id)
data, claim = repository.get_claim(member_id, claim_id)
reminder = repository._find_reminder(data, claim_id, reminder_id)
+1
View File
@@ -109,6 +109,7 @@ def generate_debit_mails(
sender_email: str,
signature: str,
) -> tuple[list[GeneratedDebitMail], list[str]]:
repository.assert_writable()
output: Path | None = None
email_settings: dict | None = None
smtp_ctx = nullcontext(None)
+80 -17
View File
@@ -53,6 +53,12 @@ class RepositoryError(RuntimeError):
pass
class ReadOnlyStoreError(RepositoryError):
"""Raised instead of a bare PermissionError when the store is mounted read-only:
the encrypted volume holding the member data can be mounted without write access,
and CCMA stays usable for reading in that case."""
DEFAULT_MEMBER_NUMBER_PATTERN = "CCMA-{number:04d}"
DEFAULT_CONTRIBUTION_RULES = [
@@ -167,8 +173,60 @@ class MemberRepository:
self.root = Path(root).expanduser().resolve()
self.members_root = self.root / "members"
self.assets_root = self.root / "assets"
self._read_only: bool | None = None
@property
def read_only(self) -> bool:
"""Whether the store can be written to at all. Probed once and cached: the
answer only changes when the volume is remounted, which needs a restart (or
an explicit refresh_read_only()) anyway."""
if self._read_only is None:
self._read_only = self.detect_read_only()
return self._read_only
def refresh_read_only(self) -> bool:
self._read_only = self.detect_read_only()
return self._read_only
def detect_read_only(self) -> bool:
# Probe by actually writing: permissions, mount options and filesystem state
# all matter, and only an attempt covers them together. A store directory that
# does not exist yet counts as writable when its nearest existing parent is --
# that is an ordinary first start, not a read-only volume.
target = self.root
while not target.exists() and target != target.parent:
target = target.parent
probe = target / f".ccma-write-probe-{uuid4().hex}"
try:
probe.touch()
probe.unlink()
except OSError:
return True
return False
def assert_writable(self) -> None:
if self.read_only:
raise ReadOnlyStoreError(
"Der Mitglieder-Store ist schreibgeschützt eingebunden. Änderungen sind "
"erst wieder möglich, wenn der Store mit Schreibrechten eingebunden ist."
)
def _write_json(self, path: Path, data) -> None:
self.assert_writable()
write_json_atomic(path, data)
def initialize(self) -> None:
# A read-only store cannot be set up -- but it also does not need to be, as
# long as it was initialized while it was still writable. Opening one that was
# never initialized is the only case that has to fail here.
if self.read_only:
if not (self.root / "repository.json").is_file():
raise ReadOnlyStoreError(
f"Der Mitglieder-Store {self.root} ist schreibgeschützt eingebunden und "
"enthält keine repository.json. Ein neuer Store kann nur mit "
"Schreibrechten angelegt werden."
)
return
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)
@@ -185,7 +243,7 @@ class MemberRepository:
shutil.copyfile(source, destination)
config_path = self.root / "repository.json"
if not config_path.exists():
write_json_atomic(config_path, DEFAULT_CONFIGURATION)
self._write_json(config_path, DEFAULT_CONFIGURATION)
def validate(self) -> list[str]:
errors: list[str] = []
@@ -376,6 +434,7 @@ class MemberRepository:
selected_number = self._allocate_member_number(
policy["pattern"], policy["allocation_strategy"]
)
self.assert_writable()
member_id = str(uuid4())
directory = self._member_path(member_id)
directory.mkdir(parents=True, exist_ok=False)
@@ -391,8 +450,8 @@ class MemberRepository:
birth_date=birth_date,
application_date=application_date,
)
write_json_atomic(directory / "member.json", member.to_dict())
write_json_atomic(directory / "contributions.json", ContributionData().to_dict())
self._write_json(directory / "member.json", member.to_dict())
self._write_json(directory / "contributions.json", ContributionData().to_dict())
self.append_event(
member_id,
event_type="member_created",
@@ -440,7 +499,7 @@ class MemberRepository:
self._assert_member_number_available(member.member_number, exclude_member_id=member.member_id)
changes = self._summarize_changes(existing, member)
member.updated_at = datetime.now().astimezone().isoformat(timespec="seconds")
write_json_atomic(self._member_path(member.member_id) / "member.json", member.to_dict())
self._write_json(self._member_path(member.member_id) / "member.json", member.to_dict())
if changes:
self.append_event(
member.member_id,
@@ -501,6 +560,7 @@ class MemberRepository:
if deposit_amount < 0:
raise RepositoryError("Die Kaution darf nicht negativ sein.")
self._validate_asset_relationships(owner_type, owner_member_id, owner_name, custody_type, "")
self.assert_writable()
asset_id = str(uuid4())
directory = self._asset_path(asset_id)
directory.mkdir(parents=True, exist_ok=False)
@@ -522,7 +582,7 @@ class MemberRepository:
condition=condition.strip(),
estimated_value=estimated_value.strip(),
)
write_json_atomic(directory / "asset.json", asset.to_dict())
self._write_json(directory / "asset.json", asset.to_dict())
self.append_asset_event(
asset.asset_id,
event_type="asset_created",
@@ -585,7 +645,7 @@ class MemberRepository:
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())
self._write_json(self._asset_path(asset.asset_id) / "asset.json", asset.to_dict())
if changes:
self.append_asset_event(
asset.asset_id,
@@ -625,7 +685,7 @@ class MemberRepository:
asset.custody_type = "member"
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._write_json(self._asset_path(asset.asset_id) / "asset.json", asset.to_dict())
self.append_asset_event(
asset.asset_id,
event_type="asset_issued",
@@ -653,7 +713,7 @@ class MemberRepository:
asset.custody_type = "club"
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._write_json(self._asset_path(asset.asset_id) / "asset.json", asset.to_dict())
self.append_asset_event(
asset.asset_id,
event_type="asset_returned",
@@ -812,6 +872,7 @@ class MemberRepository:
references=references or {},
data=data or {},
)
self.assert_writable()
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:
@@ -867,7 +928,7 @@ class MemberRepository:
def save_contributions(self, member_id: str, data: ContributionData) -> None:
self.get_member(member_id)
write_json_atomic(self._member_path(member_id) / "contributions.json", data.to_dict())
self._write_json(self._member_path(member_id) / "contributions.json", data.to_dict())
def get_claim(self, member_id: str, claim_id: str) -> tuple[ContributionData, dict]:
data = self.get_contributions(member_id)
@@ -2390,6 +2451,7 @@ class MemberRepository:
references=references or {},
data=data or {},
)
self.assert_writable()
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:
@@ -2496,12 +2558,12 @@ class MemberRepository:
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())
self._write_json(self._member_path(member_id) / "member.json", member.to_dict())
self._write_json(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())
self._write_json(self._asset_path(asset_id) / "asset.json", asset.to_dict())
def get_member_number_policy(self) -> dict[str, str]:
try:
@@ -2538,7 +2600,7 @@ class MemberRepository:
"allocation_strategy": allocation_strategy,
}
config.setdefault("member_number_sequences", {})
write_json_atomic(self.root / "repository.json", config)
self._write_json(self.root / "repository.json", config)
def get_reminder_policy(self) -> dict:
config = self.get_configuration()
@@ -2626,7 +2688,7 @@ class MemberRepository:
"levels": normalized_levels,
"standard_fee_items": normalized_items,
}
write_json_atomic(self.root / "repository.json", config)
self._write_json(self.root / "repository.json", config)
def get_email_settings(self) -> dict:
config = self.get_configuration()
@@ -2720,7 +2782,7 @@ class MemberRepository:
"imap_sent_enabled": bool(imap_sent_enabled),
"imap_sent_folder": imap_sent_folder.strip() or "INBOX.Sent",
}
write_json_atomic(self.root / "repository.json", config)
self._write_json(self.root / "repository.json", config)
def save_organization(self, values: dict[str, str]) -> None:
organization = {key: str(value).strip() for key, value in values.items()}
@@ -2732,7 +2794,7 @@ class MemberRepository:
raise RepositoryError("Der Vereinsname ist erforderlich.")
config = self.get_configuration()
config["organization"] = organization
write_json_atomic(self.root / "repository.json", config)
self._write_json(self.root / "repository.json", config)
def preview_member_number(
self, pattern: str | None = None, allocation_strategy: str | None = None
@@ -2778,7 +2840,7 @@ class MemberRepository:
sequences = {}
config["member_number_sequences"] = sequences
sequences[pattern] = next_value
write_json_atomic(self.root / "repository.json", config)
self._write_json(self.root / "repository.json", config)
return member_number
def _next_available_member_number(
@@ -2804,6 +2866,7 @@ class MemberRepository:
@contextmanager
def _member_number_lock(self):
self.assert_writable()
lock_path = self.root / ".member-number.lock"
lock_path.parent.mkdir(parents=True, exist_ok=True)
with lock_path.open("a+", encoding="utf-8") as handle:
+5 -1
View File
@@ -404,7 +404,11 @@ class AssetTab(ttk.Frame):
self._confirm_integrity_and_then(self._refresh_hashes_only)
def _refresh_hashes_only(self) -> None:
self.repository.refresh_asset_record_hashes(self.asset_id)
try:
self.repository.refresh_asset_record_hashes(self.asset_id)
except RepositoryError as exc:
messagebox.showerror("Prüfsummen konnten nicht erneuert werden", str(exc), parent=self)
return
self.refresh()
self.on_changed()
+67 -5
View File
@@ -13,6 +13,7 @@ from ccma.ui.claim_tab import ClaimTab
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.messages import MessageAction, MessageBannerList, TabMessage
from ccma.ui.options_dialog import OptionsDialog
from ccma.ui.sepa_dialog import SepaExportDialog
from ccma.ui.theme import load_theme
@@ -98,10 +99,15 @@ class MainWindow(ttk.Frame):
def _build_ui(self) -> None:
self.columnconfigure(0, weight=1)
self.rowconfigure(1, weight=1)
self.rowconfigure(2, weight=1)
self._build_ribbon()
# The read-only notice sits above the tabs rather than inside one, so it stays
# visible no matter which member, asset or housekeeper tab is in front.
self.messages = MessageBannerList(self)
self.messages.grid(row=1, column=0, sticky="ew", pady=(10, 0))
self._refresh_store_messages()
self.notebook = ttk.Notebook(self)
self.notebook.grid(row=1, column=0, sticky="nsew", pady=(10, 0))
self.notebook.grid(row=2, column=0, sticky="nsew", pady=(10, 0))
self.tabs = TabManager(self.notebook)
self.dashboard = DashboardTab(
self.notebook,
@@ -117,17 +123,63 @@ class MainWindow(ttk.Frame):
icon_name="view-dashboard",
)
status = ttk.Frame(self, padding=(6, 5))
status.grid(row=2, column=0, sticky="ew")
status.grid(row=3, column=0, sticky="ew")
status.columnconfigure(0, weight=1)
ttk.Label(status, textvariable=self.status_var, style="Status.TLabel").grid(
row=0, column=0, sticky="w"
)
store_label = f"STORE {self.repository.root}"
if self.repository.read_only:
store_label = f"{store_label} · NUR LESEN"
ttk.Label(
status,
text=f"STORE {self.repository.root} · VERSION {__version__}",
text=f"{store_label} · VERSION {__version__}",
style="Status.TLabel",
).grid(row=0, column=1, sticky="e")
def _refresh_store_messages(self) -> None:
messages = []
if self.repository.read_only:
messages.append(
TabMessage(
"warning",
"ACHTUNG: Der Mitglieder-Store ist schreibgeschützt eingebunden. "
"Alle Daten sind nur lesbar Änderungen, Dokumente, E-Mails und der "
"Hausmeister sind deaktiviert. Zum Bearbeiten den Store mit "
"Schreibrechten einbinden und CCMA neu starten.",
MessageAction("Erneut prüfen", self._recheck_store_access),
)
)
self.messages.set_messages(messages)
def _recheck_store_access(self) -> None:
"""Lets the board re-mount the volume without restarting first -- if it came
back writable, the notice disappears and the housekeeper works again."""
if self.repository.refresh_read_only():
self.status_var.set("Der Store ist weiterhin schreibgeschützt.")
self._refresh_store_messages()
return
self._refresh_store_messages()
self._refresh_ribbon_icons()
self.status_var.set("Der Store ist wieder beschreibbar.")
messagebox.showinfo(
"Store beschreibbar",
"Der Mitglieder-Store ist jetzt mit Schreibrechten eingebunden. "
"Der Hausmeister wurde beim Start übersprungen und kann nun ausgeführt werden.",
parent=self,
)
def _refuse_read_only(self, action: str) -> bool:
if not self.repository.read_only:
return False
messagebox.showwarning(
"Store ist schreibgeschützt",
f"{action} ist nicht möglich, solange der Mitglieder-Store nur lesend "
"eingebunden ist.",
parent=self,
)
return True
def _build_ribbon(self) -> None:
ribbon = ttk.Frame(self, style="Ribbon.TFrame")
self.ribbon = ribbon
@@ -417,9 +469,13 @@ class MainWindow(ttk.Frame):
SepaExportDialog(self, self.repository)
def new_member(self) -> None:
if self._refuse_read_only("Das Anlegen eines Mitglieds"):
return
NewMemberDialog(self, self.repository, self._member_created)
def new_asset(self) -> None:
if self._refuse_read_only("Das Anlegen eines Assets"):
return
NewAssetDialog(self, self.repository, self._asset_created)
def _member_created(self, member: Member) -> None:
@@ -499,6 +555,8 @@ class MainWindow(ttk.Frame):
)
def run_housekeeper(self) -> list[HousekeeperFinding]:
if self._refuse_read_only("Ein Hausmeisterlauf"):
return self.findings
self.findings = Housekeeper(self.repository, self.config.housekeeper_settings()).run()
self.refresh_overview(run_housekeeper=False)
self.status_var.set(f"Hausmeisterlauf beendet: {len(self.findings)} Vorgänge.")
@@ -507,6 +565,8 @@ class MainWindow(ttk.Frame):
def run_housekeeper_for_member(
self, member_id: str, retroactive_claims: bool = False
) -> list[HousekeeperFinding]:
if self._refuse_read_only("Ein Hausmeisterlauf"):
return self.findings
self.findings = Housekeeper(self.repository, self.config.housekeeper_settings()).run(
member_id=member_id, retroactive_claims=retroactive_claims or None
)
@@ -515,13 +575,15 @@ class MainWindow(ttk.Frame):
return self.findings
def delete_housekeeper_task(self, key: str) -> list[HousekeeperFinding]:
if self._refuse_read_only("Das Löschen eines Hausmeister-Tasks"):
return self.findings
self.findings = Housekeeper(self.repository, self.config.housekeeper_settings()).delete_task(key)
self.refresh_overview(run_housekeeper=False)
self.status_var.set("Hausmeister-Task gelöscht.")
return self.findings
def refresh_overview(self, *, run_housekeeper: bool = True) -> None:
if run_housekeeper:
if run_housekeeper and not self.repository.read_only:
self.findings = Housekeeper(self.repository, self.config.housekeeper_settings()).run()
self.dashboard.update_data(self.repository.member_count(), self.findings)
members_tab = self.tabs.tabs.get("members")
+16 -8
View File
@@ -1122,7 +1122,11 @@ class MemberTab(ttk.Frame):
self._confirm_integrity_and_then(self._refresh_hashes_only)
def _refresh_hashes_only(self) -> None:
self.repository.refresh_member_record_hashes(self.member_id)
try:
self.repository.refresh_member_record_hashes(self.member_id)
except RepositoryError as exc:
messagebox.showerror("Prüfsummen konnten nicht erneuert werden", str(exc), parent=self)
return
self.refresh()
self.on_changed()
@@ -1142,13 +1146,17 @@ class MemberTab(ttk.Frame):
text = self.comment_var.get().strip()
if not text:
return
self.repository.append_event(
self.member_id,
event_type="board_comment",
summary=text,
actor_type="user",
actor_name="Vorstand",
)
try:
self.repository.append_event(
self.member_id,
event_type="board_comment",
summary=text,
actor_type="user",
actor_name="Vorstand",
)
except RepositoryError as exc:
messagebox.showerror("Kommentar konnte nicht gespeichert werden", str(exc), parent=self)
return
self.comment_var.set("")
self._refresh_events()
+27 -14
View File
@@ -891,25 +891,38 @@ class OptionsDialog(tk.Toplevel):
self.config_obj.optional_member_fields = tuple(
field for field, variable in self.optional_member_field_vars.items() if variable.get()
)
# Program settings live in the user's config directory and stay writable even
# when the member store does not -- so they are saved either way, and only the
# store-backed settings are skipped (with a notice) on a read-only store.
store_read_only = self.repository.read_only
try:
self.config_obj.save()
self.repository.save_member_number_policy(
mode=number_mode,
pattern=number_pattern,
allocation_strategy=self.number_strategy_var.get(),
)
self.repository.save_organization(
{key: variable.get() for key, variable in self.organization_vars.items()}
)
self.repository.save_reminder_policy(
grace_days_after_due=grace_days_after_due,
levels=self.levels,
standard_fee_items=self.standard_items,
)
self.repository.save_email_settings(**email_settings)
if not store_read_only:
self.repository.save_member_number_policy(
mode=number_mode,
pattern=number_pattern,
allocation_strategy=self.number_strategy_var.get(),
)
self.repository.save_organization(
{key: variable.get() for key, variable in self.organization_vars.items()}
)
self.repository.save_reminder_policy(
grace_days_after_due=grace_days_after_due,
levels=self.levels,
standard_fee_items=self.standard_items,
)
self.repository.save_email_settings(**email_settings)
except (OSError, RepositoryError) as exc:
messagebox.showerror("Optionen konnten nicht gespeichert werden", str(exc), parent=self)
return
if store_read_only:
messagebox.showwarning(
"Store ist schreibgeschützt",
"Die Programmeinstellungen wurden gespeichert. Vereinsdaten, "
"Mitgliedsnummern, Mahnungen und E-Mail-Versand konnten nicht gespeichert "
"werden, weil der Mitglieder-Store nur lesend eingebunden ist.",
parent=self,
)
if self.on_saved:
self.on_saved(store_changed)
self.destroy()
+8 -1
View File
@@ -121,7 +121,14 @@ class SplashScreen(tk.Toplevel):
self.repository.list_members()
member_count = len(self.repository.list_member_ids())
findings = []
if self.run_housekeeper:
# A read-only store makes the housekeeper impossible, not just
# unwanted: every one of its passes writes. Skipping it keeps the
# rest of the startup -- and the whole read-only session -- working.
if self.repository.read_only:
self._messages.put(
("status", "Store ist schreibgeschützt Hausmeister übersprungen …")
)
elif self.run_housekeeper:
self._messages.put(("status", "Starte Hausmeister …"))
findings = Housekeeper(self.repository, self.housekeeper_settings).run(
member_delay=_member_delay_for_splash(
+171
View File
@@ -0,0 +1,171 @@
import os
import stat
from datetime import date
import pytest
from ccma.domain.models import ContributionData
from ccma.services.documents import DocumentService
from ccma.services.housekeeper import Housekeeper
from ccma.services.reminder_mail import generate_and_send_reminder_mail
from ccma.storage.repository import MemberRepository, ReadOnlyStoreError
# Root ignores the permission bits this test relies on, so the read-only mount it
# stands in for cannot be simulated there.
pytestmark = pytest.mark.skipif(
hasattr(os, "geteuid") and os.geteuid() == 0, reason="root bypasses file permissions"
)
def _set_writable(root, writable: bool) -> None:
mode_add = stat.S_IWUSR
for path in sorted(root.rglob("*"), reverse=True) + [root]:
current = stat.S_IMODE(path.stat().st_mode)
path.chmod(current | mode_add if writable else current & ~mode_add)
@pytest.fixture
def read_only_store(tmp_path):
root = tmp_path / "store"
repository = MemberRepository(root)
repository.initialize()
member = repository.create_member(
first_name="Ada", last_name="Lovelace", birth_date="1990-01-01"
)
repository.save_contributions(
member.member_id,
ContributionData(
claims=[
{
"claim_id": "claim-1",
"claim_key": "overdue",
"title": "Offene Forderung",
"amount": "100.00",
"due_date": "2026-01-31",
"status": "open",
}
]
),
)
_set_writable(root, False)
yield MemberRepository(root), member
_set_writable(root, True)
def test_a_writable_store_is_not_reported_as_read_only(tmp_path):
repository = MemberRepository(tmp_path / "store")
repository.initialize()
assert repository.read_only is False
def test_store_directory_that_does_not_exist_yet_counts_as_writable(tmp_path):
assert MemberRepository(tmp_path / "not" / "created" / "yet").read_only is False
def test_read_only_store_opens_and_stays_readable(read_only_store):
repository, member = read_only_store
assert repository.read_only is True
repository.initialize()
assert repository.validate() == []
assert [item.member_id for item in repository.list_members()] == [member.member_id]
assert repository.get_member(member.member_id).first_name == "Ada"
assert repository.get_contributions(member.member_id).claims
assert repository.get_events(member.member_id)
assert repository.get_configuration()["organization"]["name"]
def test_every_write_is_refused_with_one_clear_message(read_only_store):
repository, member = read_only_store
stored = repository.get_member(member.member_id)
for action in (
lambda: repository.save_member(stored),
lambda: repository.create_member(first_name="Grace", last_name="Hopper"),
lambda: repository.create_manual_claim(
member.member_id, title="Beitrag", amount="10.00", due_date="2026-09-30"
),
lambda: repository.save_contributions(member.member_id, ContributionData()),
lambda: repository.append_event(
member.member_id, event_type="comment", summary="Test", actor_type="user"
),
lambda: repository.save_organization({"name": "Test", "iban": "", "bic": ""}),
lambda: repository.create_asset(label="Beamer"),
):
with pytest.raises(ReadOnlyStoreError, match="schreibgeschützt"):
action()
def test_housekeeper_refuses_to_run_instead_of_failing_on_its_lock(read_only_store):
repository, _member = read_only_store
with pytest.raises(ReadOnlyStoreError, match="schreibgeschützt"):
Housekeeper(repository).run(today=date(2026, 2, 10))
with pytest.raises(ReadOnlyStoreError, match="schreibgeschützt"):
Housekeeper(repository).delete_task("some-key")
assert not (repository.root / ".housekeeper.lock").exists()
def test_documents_and_mails_refuse_before_touching_the_member_file(read_only_store, tmp_path):
repository, member = read_only_store
service = DocumentService(repository)
template = next(item for item in service.list_templates() if item.name == "Mitglied")
with pytest.raises(ReadOnlyStoreError, match="schreibgeschützt"):
service.generate(template, member.member_id, output_name="Test")
with pytest.raises(ReadOnlyStoreError, match="schreibgeschützt"):
generate_and_send_reminder_mail(
repository,
member.member_id,
"claim-1",
"reminder-1",
delivery_mode="local",
output_path=tmp_path / "Mahnung.eml",
sender_name="Verwaltung",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
def test_uninitialized_read_only_store_reports_why_it_cannot_be_opened(tmp_path):
root = tmp_path / "empty-store"
root.mkdir()
root.chmod(stat.S_IRUSR | stat.S_IXUSR)
try:
with pytest.raises(ReadOnlyStoreError, match="repository.json"):
MemberRepository(root).initialize()
finally:
root.chmod(stat.S_IRWXU)
def test_remounting_writable_is_picked_up_without_a_restart(read_only_store):
repository, _member = read_only_store
assert repository.read_only is True
_set_writable(repository.root, True)
assert repository.read_only is True, "the cached answer must stay put until refreshed"
assert repository.refresh_read_only() is False
repository.append_event(
_member.member_id,
event_type="comment",
summary="Wieder beschreibbar",
actor_type="user",
)
def test_startup_sequence_completes_without_the_housekeeper(read_only_store):
"""Mirrors what the splash screen's worker thread does -- this is the sequence
that used to abort the whole start with a PermissionError."""
repository, member = read_only_store
repository.initialize()
errors = repository.validate()
members = repository.list_members()
findings = [] if repository.read_only else Housekeeper(repository).run()
assert errors == []
assert [item.member_id for item in members] == [member.member_id]
assert findings == []
assert repository.member_count() == 1