2323from typing import Dict
2424
2525from flask import Response , current_app
26+ from flask_jwt_extended import get_jwt_identity
27+ from gramps .gen .db import REFERENCE_KEY
2628from gramps .gen .db .dbconst import TXNADD , TXNDEL , TXNUPD
2729from webargs import fields , validate
2830
2931from ...auth import get_all_user_details
30- from ...auth .const import PERM_VIEW_PRIVATE
32+ from ...auth .const import PERM_ADD_OBJ , PERM_DEL_OBJ , PERM_EDIT_OBJ , PERM_VIEW_PRIVATE
3133from ...const import TREE_MULTI
34+ from ...types import ResponseReturnValue
3235from ..auth import require_permissions
33- from ..util import get_db_handle , get_tree_from_jwt , use_args
36+ from ..tasks import (
37+ AsyncResult ,
38+ make_task_response ,
39+ process_transactions ,
40+ run_task ,
41+ old_unchanged ,
42+ )
43+ from ..util import (
44+ abort_with_message ,
45+ get_db_handle ,
46+ get_tree_from_jwt ,
47+ get_tree_from_jwt_or_fail ,
48+ use_args ,
49+ )
3450from . import ProtectedResource
51+ from .util import reverse_transaction
3552
3653trans_code = {"delete" : TXNDEL , "add" : TXNADD , "update" : TXNUPD }
3754
@@ -83,7 +100,7 @@ def get(self, args: Dict) -> Response:
83100
84101
85102class TransactionHistoryResource (ProtectedResource ):
86- """Resource for database transaction history."""
103+ """Resource for viewing individual transaction history."""
87104
88105 @use_args (
89106 {
@@ -93,7 +110,7 @@ class TransactionHistoryResource(ProtectedResource):
93110 location = "query" ,
94111 )
95112 def get (self , args : Dict , transaction_id : int ) -> Response :
96- """Return a list of transactions ."""
113+ """Return a single transaction ."""
97114 require_permissions ([PERM_VIEW_PRIVATE ])
98115 db_handle = get_db_handle ()
99116 undodb = db_handle .undodb
@@ -110,6 +127,173 @@ def get(self, args: Dict, transaction_id: int) -> Response:
110127 return transaction
111128
112129
130+ class TransactionUndoResource (ProtectedResource ):
131+ """Resource for undoing transactions."""
132+
133+ def get (self , transaction_id : int ) -> ResponseReturnValue :
134+ """Check if a transaction can be undone without conflicts."""
135+ require_permissions ([PERM_VIEW_PRIVATE ])
136+
137+ # Get the transaction to check
138+ db_handle = get_db_handle ()
139+ undodb = db_handle .undodb
140+ try :
141+ transaction = undodb .get_transaction (
142+ transaction_id = transaction_id ,
143+ old_data = True ,
144+ new_data = True ,
145+ )
146+ except AttributeError :
147+ abort_with_message (404 , f"Transaction { transaction_id } not found" )
148+
149+ if not transaction :
150+ abort_with_message (404 , f"Transaction { transaction_id } not found" )
151+
152+ # Check each change in the transaction for conflicts
153+ conflicts : list [dict ] = []
154+ can_undo_without_force = True
155+
156+ for change in transaction ["changes" ]:
157+ # Skip reference entries as they are handled automatically by the database
158+ if str (change ["obj_class" ]) == str (REFERENCE_KEY ):
159+ continue
160+
161+ class_name = change ["obj_class" ]
162+ handle = change ["obj_handle" ]
163+ old_data = change .get ("old_data" )
164+
165+ if change ["trans_type" ] == TXNDEL :
166+ # Check if an object with this handle already exists (would be a conflict)
167+ handle_func = db_handle .method ("has_%s_handle" , class_name )
168+ if handle_func and handle_func (handle ):
169+ conflicts .append (
170+ {
171+ "change_index" : len (conflicts ),
172+ "object_class" : class_name ,
173+ "handle" : handle ,
174+ "conflict_type" : "object_exists" ,
175+ "description" : f"Cannot undo delete: object with handle { handle } already exists" ,
176+ }
177+ )
178+ can_undo_without_force = False
179+ else :
180+ try :
181+ if change ["trans_type" ] == TXNADD :
182+ # For add transactions, check if current object differs from what was added (new_data)
183+ new_data = change .get ("new_data" )
184+ unchanged = old_unchanged (
185+ db_handle , class_name , handle , new_data
186+ )
187+ else :
188+ # For update transactions, check if current object differs from pre-update state (old_data)
189+ unchanged = old_unchanged (
190+ db_handle , class_name , handle , old_data
191+ )
192+
193+ if not unchanged :
194+ conflicts .append (
195+ {
196+ "change_index" : len (conflicts ),
197+ "object_class" : class_name ,
198+ "handle" : handle ,
199+ "conflict_type" : "object_changed" ,
200+ "description" : f"Object { class_name } with handle { handle } has been modified since the original transaction" ,
201+ }
202+ )
203+ can_undo_without_force = False
204+ except Exception as e :
205+ if "No handle function found" in str (e ):
206+ # Skip objects we can't check (like references)
207+ continue
208+ conflicts .append (
209+ {
210+ "change_index" : len (conflicts ),
211+ "object_class" : class_name ,
212+ "handle" : handle ,
213+ "conflict_type" : "check_failed" ,
214+ "description" : f"Could not verify object state: { str (e )} " ,
215+ }
216+ )
217+ can_undo_without_force = False
218+
219+ result = {
220+ "transaction_id" : transaction_id ,
221+ "can_undo_without_force" : can_undo_without_force ,
222+ "total_changes" : len (
223+ [
224+ c
225+ for c in transaction ["changes" ]
226+ if str (c ["obj_class" ]) != str (REFERENCE_KEY )
227+ ]
228+ ),
229+ "conflicts_count" : len (conflicts ),
230+ "conflicts" : conflicts ,
231+ }
232+
233+ return result , 200
234+
235+ @use_args (
236+ {
237+ "force" : fields .Boolean (load_default = False ),
238+ },
239+ location = "query" ,
240+ )
241+ def post (self , args : Dict , transaction_id : int ) -> ResponseReturnValue :
242+ """Undo a transaction using background processing."""
243+ require_permissions ([PERM_ADD_OBJ , PERM_EDIT_OBJ , PERM_DEL_OBJ ])
244+
245+ # Get the transaction to undo
246+ db_handle = get_db_handle ()
247+ undodb = db_handle .undodb
248+ try :
249+ transaction = undodb .get_transaction (
250+ transaction_id = transaction_id ,
251+ old_data = True ,
252+ new_data = True ,
253+ )
254+ except AttributeError :
255+ # This happens when get_transaction returns None and we try to call _to_dict()
256+ abort_with_message (404 , f"Transaction { transaction_id } not found" )
257+
258+ if not transaction :
259+ abort_with_message (404 , f"Transaction { transaction_id } not found" )
260+
261+ # Convert transaction to the format expected by reverse_transaction
262+ # Skip reference entries as they are handled automatically by the database
263+ payload = []
264+ for change in transaction ["changes" ]:
265+ if str (change ["obj_class" ]) == str (REFERENCE_KEY ):
266+ continue # Skip reference entries
267+ item = {
268+ "type" : {TXNADD : "add" , TXNUPD : "update" , TXNDEL : "delete" }[
269+ change ["trans_type" ]
270+ ],
271+ "_class" : change ["obj_class" ],
272+ "handle" : change ["obj_handle" ],
273+ "old" : change .get ("old_data" ),
274+ "new" : change .get ("new_data" ),
275+ }
276+ payload .append (item )
277+
278+ # Reverse the transaction
279+ reversed_payload = reverse_transaction (payload )
280+
281+ tree = get_tree_from_jwt_or_fail ()
282+ user_id = get_jwt_identity ()
283+
284+ # Always use background processing for undo operations
285+ task = run_task (
286+ process_transactions ,
287+ tree = tree ,
288+ user_id = user_id ,
289+ payload = reversed_payload ,
290+ force = args ["force" ],
291+ )
292+ if isinstance (task , AsyncResult ):
293+ return make_task_response (task )
294+ return task , 200
295+
296+
113297def get_user_dict () -> Dict [str , Dict [str , str ]]:
114298 """Get a dictionary with user IDs to user names."""
115299 tree = get_tree_from_jwt ()
0 commit comments