|
51 | 51 | from gramps.gen.lib import PrimaryObject |
52 | 52 | from ..dbguielement import DbGUIElement |
53 | 53 | from ..uimanager import ActionGroup |
| 54 | +from ..savecascade import children_to_resolve |
54 | 55 |
|
55 | 56 |
|
56 | 57 | class EditPrimary(ManagedWindow, DbGUIElement, metaclass=abc.ABCMeta): |
@@ -81,6 +82,10 @@ def __init__( |
81 | 82 | self.db = state.db |
82 | 83 | self.callback = callback |
83 | 84 | self.ok_button = None |
| 85 | + # The editor's confirm handler, captured by define_ok_button and driven |
| 86 | + # by _save_with_dependent_children. Defaults to save so the shared save |
| 87 | + # guard is safe even before/without define_ok_button (#7924). |
| 88 | + self._ok_function = self.save |
84 | 89 | self.get_from_handle = get_from_handle |
85 | 90 | self.get_from_gramps_id = get_from_gramps_id |
86 | 91 | self.contexteventbox = None |
@@ -177,9 +182,128 @@ def object_is_empty(self): |
177 | 182 |
|
178 | 183 | def define_ok_button(self, button, function): |
179 | 184 | self.ok_button = button |
180 | | - button.connect("clicked", function) |
| 185 | + # Do not wire the editor's confirm handler straight to the OK button. |
| 186 | + # Route it -- and every other save door (see close()) -- through |
| 187 | + # _save_with_dependent_children first: when this editor is confirmed |
| 188 | + # while a child primary editor it spawned is still open holding unsaved |
| 189 | + # data whose completion callback must land a reference on this editor's |
| 190 | + # working object, that child is resolved BEFORE this editor reads its |
| 191 | + # references and commits. See Mantis #7924. |
| 192 | + self._ok_function = function |
| 193 | + button.connect("clicked", self._save_with_dependent_children) |
181 | 194 | button.set_sensitive(not self.db.readonly) |
182 | 195 |
|
| 196 | + def _save_with_dependent_children(self, *args): |
| 197 | + """Confirm handler shared by every save door: resolve open dependent |
| 198 | + child editors, then save. |
| 199 | +
|
| 200 | + A primary editor is non-modal, so it can be confirmed while a child |
| 201 | + primary editor it spawned -- e.g. an EditPerson opened as "add a new |
| 202 | + person as the mother" from EditFamily -- is still open with unsaved |
| 203 | + data. That child's completion callback, which writes the child's |
| 204 | + handle onto *this* editor's working object (EditFamily.new_mother_added), |
| 205 | + only runs when the *child* saves. If this editor commits first, it |
| 206 | + persists an object graph with that reference silently dropped and the |
| 207 | + child's data orphaned -- the #7924 defect. |
| 208 | +
|
| 209 | + So drive each open dependent child's own save-guard first. If any child |
| 210 | + is left unresolved (the user chose "keep editing" on its "Save Changes?" |
| 211 | + prompt, or its save aborted on a validation error), abort this editor's |
| 212 | + save entirely -- it stays open and nothing is committed, so no partial |
| 213 | + graph is ever persisted. |
| 214 | +
|
| 215 | + This is wired to BOTH the OK button (define_ok_button) and the |
| 216 | + close/Cancel/window-X save path (close()'s SaveDialog), so the child is |
| 217 | + resolved no matter which door commits the parent (Mantis #7924). |
| 218 | + """ |
| 219 | + if not self._resolve_dependent_children(): |
| 220 | + return |
| 221 | + self._ok_function(*args) |
| 222 | + |
| 223 | + def _resolve_dependent_children(self): |
| 224 | + """Resolve open child primary editors holding unsaved data before commit. |
| 225 | +
|
| 226 | + Returns True if the save may proceed -- every dependent child editor |
| 227 | + was resolved (saved, or discarded by the user), or there were none. |
| 228 | + Returns False if a child was left unresolved, in which case the parent |
| 229 | + save must abort. |
| 230 | +
|
| 231 | + Children are resolved deepest-first (see |
| 232 | + :func:`~gramps.gui.savecascade.children_to_resolve`) so that, in a |
| 233 | + nested chain, each level's completion callback has already landed on |
| 234 | + its own parent before that parent reads its references. |
| 235 | + """ |
| 236 | + if self.uistate is None: |
| 237 | + return True |
| 238 | + try: |
| 239 | + item = self.uistate.gwm.get_item_from_track(self.track) |
| 240 | + except (IndexError, KeyError): |
| 241 | + return True |
| 242 | + for child in children_to_resolve(item, self._is_unresolved_dependent_child): |
| 243 | + if not child._resolve_before_parent_commit(): |
| 244 | + return False |
| 245 | + return True |
| 246 | + |
| 247 | + def _is_unresolved_dependent_child(self, window): |
| 248 | + """True if ``window`` is an open child primary editor with a pending |
| 249 | + reference this editor's commit would drop. |
| 250 | +
|
| 251 | + Only a primary editor opened WITH a completion callback (``callback`` |
| 252 | + is not None -- e.g. EditFamily.new_mother_added) writes a handle back |
| 253 | + onto the spawning editor's object, so only such a child must be |
| 254 | + resolved before this editor commits. A child opened to edit an |
| 255 | + *existing* object carries no callback (EditFamily.edit_person passes |
| 256 | + none) -- committing this editor drops nothing, so it is left alone and |
| 257 | + the common case is unchanged (#7924 over-trigger guard). Selectors and |
| 258 | + secondary/reference windows are excluded by the EditPrimary check. |
| 259 | + """ |
| 260 | + return ( |
| 261 | + window is not self |
| 262 | + and isinstance(window, EditPrimary) |
| 263 | + and getattr(window, "opened", False) |
| 264 | + and getattr(window, "callback", None) is not None |
| 265 | + and window.data_has_changed() |
| 266 | + ) |
| 267 | + |
| 268 | + def _resolve_before_parent_commit(self): |
| 269 | + """Drive this child editor's save-guard as part of a parent's commit. |
| 270 | +
|
| 271 | + Called on a child primary editor when its parent is confirmed while |
| 272 | + this child is still open with unsaved data. It reuses the very same |
| 273 | + "Save Changes?" guard the direct-close path shows (:meth:`close`) -- |
| 274 | + Save runs this editor's own save (its completion callback lands our |
| 275 | + reference on the parent's object), "Close without saving" discards our |
| 276 | + edits, Cancel keeps this editor open. |
| 277 | +
|
| 278 | + Whether the parent save may proceed is read from *this* editor's own |
| 279 | + window state AFTER the attempt, never assumed up front: a successful |
| 280 | + save (or an explicit discard) closes this editor (``self.opened`` |
| 281 | + becomes False); a Cancel ("keep editing") or a validation-aborted save |
| 282 | + leaves it open. So: |
| 283 | +
|
| 284 | + * closed -> return True (resolved; the parent may commit); |
| 285 | + * open -> return False (unresolved; the parent must abort). |
| 286 | + """ |
| 287 | + if config.get("interface.dont-ask"): |
| 288 | + # The user opted out of the "Save Changes?" prompt. Honour that as |
| 289 | + # "save without asking" -- a silent save of this child, NOT as |
| 290 | + # skipping its save (which would drop its reference and revive the |
| 291 | + # #7924 defect for dont-ask users). A validation error still leaves |
| 292 | + # the editor open, handled by the check below. |
| 293 | + self.save() |
| 294 | + else: |
| 295 | + SaveDialog( |
| 296 | + _("Save Changes?"), |
| 297 | + _( |
| 298 | + "If you close without saving, the changes you " |
| 299 | + "have made will be lost" |
| 300 | + ), |
| 301 | + self._do_close, |
| 302 | + self.save, |
| 303 | + parent=self.window, |
| 304 | + ) |
| 305 | + return not self.opened |
| 306 | + |
183 | 307 | def define_cancel_button(self, button): |
184 | 308 | button.connect("clicked", self.close) |
185 | 309 |
|
@@ -252,7 +376,12 @@ def close(self, *obj): |
252 | 376 | "have made will be lost" |
253 | 377 | ), |
254 | 378 | self._do_close, |
255 | | - self.save, |
| 379 | + # Save via the shared guard, not self.save directly: closing a |
| 380 | + # parent editor with the window-X or Cancel and choosing Save |
| 381 | + # must ALSO resolve an open dependent child first, or the parent |
| 382 | + # commits with the child's reference dropped -- the #7924 defect |
| 383 | + # via a second save door (see _save_with_dependent_children). |
| 384 | + self._save_with_dependent_children, |
256 | 385 | parent=self.window, |
257 | 386 | ) |
258 | 387 | return True |
|
0 commit comments