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