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
+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()