mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-09-06 03:20:49 +02:00
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:
co-authored by
Claude Opus 5
parent
1972dbabb3
commit
be042949a2
@@ -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()
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user