diff --git a/src/ccma/rules/scripts/birthdate_check.py b/src/ccma/rules/scripts/birthdate_check.py index eccba3f..09674f4 100644 --- a/src/ccma/rules/scripts/birthdate_check.py +++ b/src/ccma/rules/scripts/birthdate_check.py @@ -1,5 +1,5 @@ -from ccma.domain.models import HOUSEKEEPER_MEMBER_FIELD_LABELS from ccma.domain.dates import DateValidationError, validate_member_dates +from ccma.domain.models import HOUSEKEEPER_MEMBER_FIELD_LABELS from ccma.rules.api import RuleContext, task RULE_ID = "birthdate-check" diff --git a/src/ccma/rules/scripts/contribution_claims.py b/src/ccma/rules/scripts/contribution_claims.py index 0920fe7..18590d3 100644 --- a/src/ccma/rules/scripts/contribution_claims.py +++ b/src/ccma/rules/scripts/contribution_claims.py @@ -44,7 +44,11 @@ def evaluate(context: RuleContext): ) ) - year_from = started_at.year if getattr(context.settings, "retroactive_claims", False) else context.today.year + year_from = ( + started_at.year + if getattr(context.settings, "retroactive_claims", False) + else context.today.year + ) for year in range(year_from, context.today.year + 2): actions.extend(_membership_claims(context, started_at, accepted_at, year)) return actions diff --git a/src/ccma/storage/atomic.py b/src/ccma/storage/atomic.py index 196b69f..18536e5 100644 --- a/src/ccma/storage/atomic.py +++ b/src/ccma/storage/atomic.py @@ -11,7 +11,11 @@ CONTENT_HASH_FIELD = "content_hash" def _hashable_copy(data: Any, *, hash_field: str = CONTENT_HASH_FIELD) -> Any: if isinstance(data, dict): - return {key: _hashable_copy(value, hash_field=hash_field) for key, value in data.items() if key != hash_field} + return { + key: _hashable_copy(value, hash_field=hash_field) + for key, value in data.items() + if key != hash_field + } if isinstance(data, list): return [_hashable_copy(item, hash_field=hash_field) for item in data] return data diff --git a/src/ccma/storage/repository.py b/src/ccma/storage/repository.py index c976197..5ae599a 100644 --- a/src/ccma/storage/repository.py +++ b/src/ccma/storage/repository.py @@ -20,7 +20,14 @@ from ccma.domain.contributions import ( payment_allocated_total, ) from ccma.domain.dates import DateValidationError, normalize_date_input, validate_member_dates -from ccma.domain.models import ASSET_STATUS_LABELS, MEMBERSHIP_STATUS_LABELS, Asset, ContributionData, Event, Member +from ccma.domain.models import ( + ASSET_STATUS_LABELS, + MEMBERSHIP_STATUS_LABELS, + Asset, + ContributionData, + Event, + Member, +) from ccma.storage.atomic import json_content_hash_matches, read_json, write_json_atomic @@ -143,7 +150,9 @@ class MemberRepository: try: config = read_json(self.root / "repository.json") if not json_content_hash_matches(config): - errors.append("repository.json: Hash fehlt oder stimmt nicht; Datei wurde vermutlich extern geändert.") + errors.append( + "repository.json: Hash fehlt oder stimmt nicht; Datei wurde vermutlich extern geändert." + ) if int(config.get("schema_version", 0)) != 1: errors.append("repository.json: nicht unterstützte schema_version") policy = config.get("member_number_policy") or {} @@ -157,7 +166,10 @@ class MemberRepository: for member_dir in self._member_directories(): try: member, _contributions = self.preflight_member_record(member_dir.name) - errors.extend(f"{member_dir.name}/{warning}" for warning in self.member_hash_warnings(member_dir.name)) + errors.extend( + f"{member_dir.name}/{warning}" + for warning in self.member_hash_warnings(member_dir.name) + ) validate_member_dates( birth_date=member.birth_date, accepted_at=member.accepted_at, @@ -186,12 +198,16 @@ class MemberRepository: for asset_dir in self._asset_directories(): try: asset = self.get_asset(asset_dir.name) - errors.extend(f"{asset_dir.name}/{warning}" for warning in self.asset_hash_warnings(asset_dir.name)) + errors.extend( + f"{asset_dir.name}/{warning}" + for warning in self.asset_hash_warnings(asset_dir.name) + ) if asset.asset_id != asset_dir.name: errors.append(f"{asset_dir.name}/asset.json: asset_id stimmt nicht mit Ordner überein") if asset.schema_version != 1: errors.append( - f"{asset_dir.name}/asset.json: nicht unterstützte schema_version {asset.schema_version}" + f"{asset_dir.name}/asset.json: " + f"nicht unterstützte schema_version {asset.schema_version}" ) if asset.status not in ASSET_STATUS_LABELS: errors.append(f"{asset_dir.name}/asset.json: ungültiger Asset-Status") @@ -425,7 +441,9 @@ class MemberRepository: existing.current_holder_member_id and money_text(deposit_amount) != str(existing.deposit_amount_default) ): - raise RepositoryError("Die Kaution kann nur geändert werden, wenn das Asset nicht ausgegeben ist.") + raise RepositoryError( + "Die Kaution kann nur geändert werden, wenn das Asset nicht ausgegeben ist." + ) asset.label = asset.label.strip() asset.category = asset.category.strip() asset.inventory_number = asset.inventory_number.strip() @@ -1220,14 +1238,17 @@ class MemberRepository: try: member_raw = read_json(self._member_path(member_id) / "member.json") if not json_content_hash_matches(member_raw): - warnings.append("member.json: Hash fehlt oder stimmt nicht; Datei wurde vermutlich extern geändert.") + warnings.append( + "member.json: Hash fehlt oder stimmt nicht; Datei wurde vermutlich extern geändert." + ) except (OSError, ValueError, TypeError, json.JSONDecodeError): pass try: contributions_raw = read_json(self._member_path(member_id) / "contributions.json") if not json_content_hash_matches(contributions_raw): warnings.append( - "contributions.json: Hash fehlt oder stimmt nicht; Datei wurde vermutlich extern geändert." + "contributions.json: Hash fehlt oder stimmt nicht; " + "Datei wurde vermutlich extern geändert." ) except (OSError, ValueError, TypeError, json.JSONDecodeError): pass @@ -1238,7 +1259,9 @@ class MemberRepository: try: asset_raw = read_json(self._asset_path(asset_id) / "asset.json") if not json_content_hash_matches(asset_raw): - warnings.append("asset.json: Hash fehlt oder stimmt nicht; Datei wurde vermutlich extern geändert.") + warnings.append( + "asset.json: Hash fehlt oder stimmt nicht; Datei wurde vermutlich extern geändert." + ) except (OSError, ValueError, TypeError, json.JSONDecodeError): pass return warnings diff --git a/src/ccma/ui/asset_tab.py b/src/ccma/ui/asset_tab.py index 0779f04..8b98abd 100644 --- a/src/ccma/ui/asset_tab.py +++ b/src/ccma/ui/asset_tab.py @@ -5,7 +5,7 @@ from collections.abc import Callable from datetime import datetime from tkinter import messagebox, ttk -from ccma.domain.models import ASSET_STATUS_LABELS, Asset, Event +from ccma.domain.models import ASSET_STATUS_LABELS, Event from ccma.storage.repository import MemberRepository, RepositoryError from ccma.ui.dialogs import AssetClaimDialog, IntegrityWarningDialog from ccma.ui.messages import MessageAction, MessageBannerList, TabMessage @@ -196,8 +196,16 @@ class AssetTab(ttk.Frame): ttk.Button(finance_actions, text="Verlustforderung", command=self._create_loss_claim).pack( side="left", padx=(0, 8) ) - ttk.Button(finance_actions, text="Reparaturforderung", command=self._create_repair_claim).pack(side="left") - self.asset_claims = ttk.Treeview(finance_tab, columns=("title", "due", "amount", "member"), show="headings") + ttk.Button( + finance_actions, + text="Reparaturforderung", + command=self._create_repair_claim, + ).pack(side="left") + self.asset_claims = ttk.Treeview( + finance_tab, + columns=("title", "due", "amount", "member"), + show="headings", + ) for key, title, width in ( ("title", "Forderung", 240), ("due", "Fällig", 110), @@ -429,7 +437,14 @@ class AssetTab(ttk.Frame): claim_type="asset_repair", ) - def _open_claim_dialog(self, *, preset_title: str, preset_amount: str, preset_description: str, claim_type: str) -> None: + def _open_claim_dialog( + self, + *, + preset_title: str, + preset_amount: str, + preset_description: str, + claim_type: str, + ) -> None: member_id = self.asset.current_holder_member_id if not member_id: messagebox.showerror( diff --git a/src/ccma/ui/claim_tab.py b/src/ccma/ui/claim_tab.py index 2117755..97e0263 100644 --- a/src/ccma/ui/claim_tab.py +++ b/src/ccma/ui/claim_tab.py @@ -261,12 +261,26 @@ class ClaimTab(ttk.Frame): for allocation in self.data.allocations if str(allocation.get("claim_id", "")) == self.claim_id and str(allocation.get("credit_id", "")) ] + allocated_credit_total = money_text( + sum( + (decimal_value(item.get("amount", "0")) for item in credit_allocations), + Decimal("0"), + ) + ) credit_group = self.ledger.insert( "", "end", iid="group:credits", text=f"Gutschriften ({len(credit_allocations)})", - values=("", "", "", "", f"{money_text(sum((decimal_value(item.get('amount', '0')) for item in credit_allocations), Decimal('0')))} EUR", "", ""), + values=( + "", + "", + "", + "", + f"{allocated_credit_total} EUR", + "", + "", + ), tags=("credit-group",), open=True, ) diff --git a/src/ccma/ui/dialogs.py b/src/ccma/ui/dialogs.py index 1146736..246ef7e 100644 --- a/src/ccma/ui/dialogs.py +++ b/src/ccma/ui/dialogs.py @@ -1,8 +1,7 @@ import tkinter as tk from collections.abc import Callable -from tkinter import messagebox, ttk - from datetime import date +from tkinter import messagebox, ttk from ccma.domain.dates import age_label, date_input_hint from ccma.domain.models import Asset, Member @@ -35,7 +34,15 @@ class NewMemberDialog(tk.Toplevel): self.number_policy = repository.get_member_number_policy() self.variables = { name: tk.StringVar() - for name in ("first_name", "last_name", "nickname", "email", "phone", "birth_date", "member_number") + for name in ( + "first_name", + "last_name", + "nickname", + "email", + "phone", + "birth_date", + "member_number", + ) } self._build_ui() self.bind("", lambda _event: self.destroy()) @@ -145,13 +152,24 @@ class NewAssetDialog(tk.Toplevel): entry = ttk.Entry(frame, textvariable=self.variables[key], width=38) entry.grid(row=row, column=1, sticky="ew", pady=5) self.entries[key] = entry - ttk.Label(frame, text="Interne Notiz").grid(row=len(fields), column=0, sticky="nw", pady=5, padx=(0, 12)) + ttk.Label(frame, text="Interne Notiz").grid( + row=len(fields), + column=0, + sticky="nw", + pady=5, + padx=(0, 12), + ) self.notes_text = tk.Text(frame, width=38, height=5, wrap="word") self.notes_text.grid(row=len(fields), column=1, sticky="ew", pady=5) buttons = ttk.Frame(frame) buttons.grid(row=len(fields) + 1, column=0, columnspan=2, sticky="e", pady=(16, 0)) ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8)) - ttk.Button(buttons, text="Asset anlegen", style="Accent.TButton", command=self._create).pack(side="left") + ttk.Button( + buttons, + text="Asset anlegen", + style="Accent.TButton", + command=self._create, + ).pack(side="left") self.after_idle(lambda: self.entries["label"].focus_set()) def _activate_modal(self) -> None: @@ -171,7 +189,13 @@ class NewAssetDialog(tk.Toplevel): class EditAssetDialog(tk.Toplevel): - def __init__(self, master: tk.Misc, repository: MemberRepository, asset_id: str, on_saved: Callable[[Asset], None]): + def __init__( + self, + master: tk.Misc, + repository: MemberRepository, + asset_id: str, + on_saved: Callable[[Asset], None], + ): super().__init__(master) self.repository = repository self.asset_id = asset_id @@ -310,7 +334,12 @@ class IssueAssetDialog(tk.Toplevel): frame.rowconfigure(3, weight=1) asset = self.repository.get_asset(self.asset_id) ttk.Label(frame, text="Asset").grid(row=0, column=0, sticky="w", pady=5, padx=(0, 12)) - ttk.Label(frame, text=asset.label, style="TimelineHeader.TLabel").grid(row=0, column=1, sticky="w", pady=5) + ttk.Label(frame, text=asset.label, style="TimelineHeader.TLabel").grid( + row=0, + column=1, + sticky="w", + pady=5, + ) ttk.Label(frame, text="Suche").grid(row=1, column=0, sticky="w", pady=5, padx=(0, 12)) self.search_entry = ttk.Entry(frame, textvariable=self.search_var, width=42) self.search_entry.grid(row=1, column=1, sticky="ew", pady=5) @@ -378,7 +407,11 @@ class IssueAssetDialog(tk.Toplevel): ) selected_id = self.preselected_member_id if self.preselected_member_id else "" if filtered: - target_id = selected_id if selected_id and any(member.member_id == selected_id for member in filtered) else filtered[0].member_id + target_id = ( + selected_id + if selected_id and any(member.member_id == selected_id for member in filtered) + else filtered[0].member_id + ) self.member_tree.selection_set(target_id) self.member_tree.focus(target_id) self.member_tree.see(target_id) @@ -457,7 +490,12 @@ class AssetClaimDialog(tk.Toplevel): asset = self.repository.get_asset(self.asset_id) member = self.repository.get_member(self.member_id) ttk.Label(frame, text="Asset").grid(row=0, column=0, sticky="w", pady=5, padx=(0, 12)) - ttk.Label(frame, text=asset.label, style="TimelineHeader.TLabel").grid(row=0, column=1, sticky="w", pady=5) + ttk.Label(frame, text=asset.label, style="TimelineHeader.TLabel").grid( + row=0, + column=1, + sticky="w", + pady=5, + ) ttk.Label(frame, text="Mitglied").grid(row=1, column=0, sticky="w", pady=5, padx=(0, 12)) ttk.Label(frame, text=f"{member.member_number or member.member_id} · {member.display_name}").grid( row=1, column=1, sticky="w", pady=5 @@ -470,11 +508,23 @@ class AssetClaimDialog(tk.Toplevel): (f"Fällig am ({date_input_hint()}) *", "due_date"), ) ): - ttk.Label(frame, text=label).grid(row=row_offset + index, column=0, sticky="w", pady=5, padx=(0, 12)) + ttk.Label(frame, text=label).grid( + row=row_offset + index, + column=0, + sticky="w", + pady=5, + padx=(0, 12), + ) ttk.Entry(frame, textvariable=self.variables[key], width=42).grid( row=row_offset + index, column=1, sticky="ew", pady=5 ) - ttk.Label(frame, text="Beschreibung").grid(row=row_offset + 3, column=0, sticky="nw", pady=5, padx=(0, 12)) + ttk.Label(frame, text="Beschreibung").grid( + row=row_offset + 3, + column=0, + sticky="nw", + pady=5, + padx=(0, 12), + ) self.description_text = tk.Text(frame, width=42, height=5, wrap="word") self.description_text.grid(row=row_offset + 3, column=1, sticky="ew", pady=5) self.description_text.insert("1.0", self.preset_description) @@ -488,7 +538,12 @@ class AssetClaimDialog(tk.Toplevel): buttons = ttk.Frame(frame) buttons.grid(row=row_offset + 5, column=0, columnspan=2, sticky="e", pady=(16, 0)) ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8)) - ttk.Button(buttons, text="Forderung anlegen", style="Accent.TButton", command=self._create).pack(side="left") + ttk.Button( + buttons, + text="Forderung anlegen", + style="Accent.TButton", + command=self._create, + ).pack(side="left") self.after_idle(lambda: frame.focus_set()) def _activate_modal(self) -> None: @@ -539,13 +594,23 @@ class IntegrityWarningDialog(tk.Toplevel): "Haben Sie alle Daten geprüft und soll der Hash jetzt aktualisiert werden?" ) ttk.Label(frame, text=message, justify="left").grid(row=0, column=0, sticky="w") - ttk.Label(frame, text="\n".join(f"• {item}" for item in self.warnings), style="Warning.TLabel", justify="left").grid( + ttk.Label( + frame, + text="\n".join(f"• {item}" for item in self.warnings), + style="Warning.TLabel", + justify="left", + ).grid( row=1, column=0, sticky="w", pady=(12, 0) ) buttons = ttk.Frame(frame) buttons.grid(row=2, column=0, sticky="e", pady=(18, 0)) ttk.Button(buttons, text="Nein", command=self.destroy).pack(side="left", padx=(0, 8)) - ttk.Button(buttons, text="Ja, bestätigen", style="Accent.TButton", command=self._confirm).pack(side="left") + ttk.Button( + buttons, + text="Ja, bestätigen", + style="Accent.TButton", + command=self._confirm, + ).pack(side="left") def _activate_modal(self) -> None: _activate_modal_window(self) diff --git a/src/ccma/ui/main_window.py b/src/ccma/ui/main_window.py index 9f827c3..51b2145 100644 --- a/src/ccma/ui/main_window.py +++ b/src/ccma/ui/main_window.py @@ -545,7 +545,9 @@ class MainWindow(ttk.Frame): self.tabs.refresh_icons(self.icons) def _show_validation_warning(self) -> None: - display_errors = [item for item in self.validation_errors if "Hash fehlt oder stimmt nicht" not in item] + display_errors = [ + item for item in self.validation_errors if "Hash fehlt oder stimmt nicht" not in item + ] if not display_errors: return messagebox.showwarning( diff --git a/src/ccma/ui/member_tab.py b/src/ccma/ui/member_tab.py index 2b9d38a..e1ad3f5 100644 --- a/src/ccma/ui/member_tab.py +++ b/src/ccma/ui/member_tab.py @@ -8,17 +8,16 @@ from tkinter import messagebox, ttk from ccma.domain.contributions import CLAIM_STATUS_LABELS, claim_status, claim_total, money_text from ccma.domain.dates import age_label, date_input_hint, format_date_for_display -from ccma.domain.models import ASSET_STATUS_LABELS, MEMBERSHIP_STATUS_LABELS as STATUS_LABELS -from ccma.domain.models import Event +from ccma.domain.models import ASSET_STATUS_LABELS, Event +from ccma.domain.models import MEMBERSHIP_STATUS_LABELS as STATUS_LABELS from ccma.storage.repository import MemberRepository, RepositoryError -from ccma.ui.document_dialog import DocumentTemplateDialog from ccma.ui.dialogs import IntegrityWarningDialog +from ccma.ui.document_dialog import DocumentTemplateDialog from ccma.ui.file_open import open_path from ccma.ui.labels import display_label, storage_key from ccma.ui.messages import MessageAction, MessageBannerList, TabMessage from ccma.ui.scrolling import ScrollableFrame - CLAIM_TABLE_COLUMNS = ( ("title", "Forderung", 220), ("due", "Fällig", 100), @@ -287,8 +286,12 @@ class MemberTab(ttk.Frame): ttk.Button(asset_actions, text="Asset öffnen", command=self._open_selected_asset).pack( side="left", padx=(0, 8) ) - ttk.Button(asset_actions, text="Ausgewähltes Asset zurücknehmen", command=self._return_selected_asset).pack( - side="left" + ttk.Button( + asset_actions, + text="Ausgewähltes Asset zurücknehmen", + command=self._return_selected_asset, + ).pack( + side="left", ) documents_tab.columnconfigure(0, weight=1) @@ -493,7 +496,11 @@ class MemberTab(ttk.Frame): suffix = "" if key == self.claim_sort_column: suffix = " v" if self.claim_sort_descending else " ^" - self.claims.heading(key, text=f"{title}{suffix}", command=lambda column=key: self._toggle_claim_sort(column)) + self.claims.heading( + key, + text=f"{title}{suffix}", + command=lambda column=key: self._toggle_claim_sort(column), + ) def _open_selected_claim(self) -> None: selected = self.claims.selection() diff --git a/src/ccma/ui/messages.py b/src/ccma/ui/messages.py index ee79dd1..15fc270 100644 --- a/src/ccma/ui/messages.py +++ b/src/ccma/ui/messages.py @@ -5,7 +5,6 @@ from collections.abc import Callable, Iterable from dataclasses import dataclass from tkinter import ttk - MessageType = str diff --git a/src/ccma/ui/work_tabs.py b/src/ccma/ui/work_tabs.py index d61eb40..26b2c1c 100644 --- a/src/ccma/ui/work_tabs.py +++ b/src/ccma/ui/work_tabs.py @@ -4,11 +4,16 @@ from collections.abc import Callable from tkinter import messagebox, ttk from ccma.domain.dates import format_date_for_display -from ccma.domain.models import ASSET_STATUS_LABELS, MEMBERSHIP_STATUS_LABELS, Asset, HousekeeperFinding, Member +from ccma.domain.models import ( + ASSET_STATUS_LABELS, + MEMBERSHIP_STATUS_LABELS, + Asset, + HousekeeperFinding, + Member, +) from ccma.ui.labels import storage_key from ccma.ui.sections import titled_frame - MEMBER_TABLE_COLUMNS = ( ("number", "Nummer", 110), ("first_name", "Vorname", 160), @@ -306,7 +311,11 @@ class MembersTab(ttk.Frame): suffix = "" if key == self.sort_column: suffix = " v" if self.sort_descending else " ^" - self.tree.heading(key, text=f"{title}{suffix}", command=lambda column=key: self._toggle_sort(column)) + self.tree.heading( + key, + text=f"{title}{suffix}", + command=lambda column=key: self._toggle_sort(column), + ) def _open_selected(self) -> None: selected = self.tree.selection() @@ -368,7 +377,11 @@ class AssetsTab(ttk.Frame): ) self.status_filter.grid(row=0, column=1, sticky="w") self.status_filter.bind("<>", lambda _event: self._render_assets()) - self.tree = ttk.Treeview(self, columns=tuple(key for key, _title, _width in ASSET_TABLE_COLUMNS), show="headings") + self.tree = ttk.Treeview( + self, + columns=tuple(key for key, _title, _width in ASSET_TABLE_COLUMNS), + show="headings", + ) for key, title, width in ASSET_TABLE_COLUMNS: self.tree.heading(key, text=title, command=lambda column=key: self._toggle_sort(column)) self.tree.column(key, width=width, anchor="w") @@ -377,13 +390,28 @@ class AssetsTab(ttk.Frame): self.tree.bind("<>", lambda _event: self._update_actions()) actions = ttk.Frame(self) actions.grid(row=3, column=0, sticky="e", pady=(10, 0)) - self.edit_button = ttk.Button(actions, text="Bearbeiten", command=self._edit_selected, state="disabled") + self.edit_button = ttk.Button( + actions, + text="Bearbeiten", + command=self._edit_selected, + state="disabled", + ) self.edit_button.pack(side="left", padx=(0, 8)) self.open_button = ttk.Button(actions, text="Öffnen", command=self._open_selected, state="disabled") self.open_button.pack(side="left", padx=(0, 8)) - self.issue_button = ttk.Button(actions, text="Ausgeben", command=self._issue_selected, state="disabled") + self.issue_button = ttk.Button( + actions, + text="Ausgeben", + command=self._issue_selected, + state="disabled", + ) self.issue_button.pack(side="left", padx=(0, 8)) - self.return_button = ttk.Button(actions, text="Zurücknehmen", command=self._return_selected, state="disabled") + self.return_button = ttk.Button( + actions, + text="Zurücknehmen", + command=self._return_selected, + state="disabled", + ) self.return_button.pack(side="left") self.refresh(self.assets) @@ -437,7 +465,11 @@ class AssetsTab(ttk.Frame): suffix = "" if key == self.sort_column: suffix = " v" if self.sort_descending else " ^" - self.tree.heading(key, text=f"{title}{suffix}", command=lambda column=key: self._toggle_sort(column)) + self.tree.heading( + key, + text=f"{title}{suffix}", + command=lambda column=key: self._toggle_sort(column), + ) def _selected_asset_id(self) -> str: selected = self.tree.selection() diff --git a/tests/test_repository.py b/tests/test_repository.py index 7fa593e..0aa46ed 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -422,4 +422,7 @@ def test_housekeeper_reports_json_hash_mismatch(tmp_path) -> None: findings = Housekeeper(repository).run() - assert any(finding.code == "json_hash_mismatch" and finding.member_id == member.member_id for finding in findings) + assert any( + finding.code == "json_hash_mismatch" and finding.member_id == member.member_id + for finding in findings + )