mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-09-11 21:54:18 +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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user