Skip to content

Commit 6558eb3

Browse files
Add endpoint to undo a transaction from the history (#701)
* Add endpoint to undo a transaction from the history * Update apispec * Add undo GET endpoint * Update tests/test_endpoints/test_history.py Co-authored-by: Copilot <[email protected]> * Add type hint --------- Co-authored-by: Copilot <[email protected]>
1 parent 167b815 commit 6558eb3

File tree

4 files changed

+661
-6
lines changed

4 files changed

+661
-6
lines changed

gramps_webapi/api/__init__.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,11 @@
5151
from .resources.families import FamiliesResource, FamilyResource
5252
from .resources.file import MediaFileResource
5353
from .resources.filters import FilterResource, FiltersResource, FiltersResources
54-
from .resources.history import TransactionHistoryResource, TransactionsHistoryResource
54+
from .resources.history import (
55+
TransactionHistoryResource,
56+
TransactionUndoResource,
57+
TransactionsHistoryResource,
58+
)
5559
from .resources.holidays import HolidayResource, HolidaysResource
5660
from .resources.import_media import MediaUploadZipResource
5761
from .resources.importers import (
@@ -152,6 +156,11 @@ def register_endpt(resource: Type[Resource], url: str, name: str):
152156
"/transactions/history/<int:transaction_id>",
153157
"transaction_history",
154158
)
159+
register_endpt(
160+
TransactionUndoResource,
161+
"/transactions/history/<int:transaction_id>/undo",
162+
"transaction_undo",
163+
)
155164
# Token
156165
register_endpt(TokenResource, "/token/", "token")
157166
register_endpt(TokenRefreshResource, "/token/refresh/", "token_refresh")
@@ -162,7 +171,11 @@ def register_endpt(resource: Type[Resource], url: str, name: str):
162171
register_endpt(OIDCConfigResource, "/oidc/config/", "oidcconfigresource")
163172
register_endpt(OIDCTokenExchangeResource, "/oidc/tokens/", "oidctokenexchangeresource")
164173
register_endpt(OIDCLogoutResource, "/oidc/logout/", "oidclogoutresource")
165-
register_endpt(OIDCBackchannelLogoutResource, "/oidc/backchannel-logout/", "oidcbackchannellogoutresource")
174+
register_endpt(
175+
OIDCBackchannelLogoutResource,
176+
"/oidc/backchannel-logout/",
177+
"oidcbackchannellogoutresource",
178+
)
166179
# People
167180
register_endpt(
168181
PersonTimelineResource, "/people/<string:handle>/timeline", "person-timeline"

gramps_webapi/api/resources/history.py

Lines changed: 188 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,32 @@
2323
from typing import Dict
2424

2525
from flask import Response, current_app
26+
from flask_jwt_extended import get_jwt_identity
27+
from gramps.gen.db import REFERENCE_KEY
2628
from gramps.gen.db.dbconst import TXNADD, TXNDEL, TXNUPD
2729
from webargs import fields, validate
2830

2931
from ...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
3133
from ...const import TREE_MULTI
34+
from ...types import ResponseReturnValue
3235
from ..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+
)
3450
from . import ProtectedResource
51+
from .util import reverse_transaction
3552

3653
trans_code = {"delete": TXNDEL, "add": TXNADD, "update": TXNUPD}
3754

@@ -83,7 +100,7 @@ def get(self, args: Dict) -> Response:
83100

84101

85102
class 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+
113297
def get_user_dict() -> Dict[str, Dict[str, str]]:
114298
"""Get a dictionary with user IDs to user names."""
115299
tree = get_tree_from_jwt()

gramps_webapi/data/apispec.yaml

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5146,6 +5146,114 @@ paths:
51465146
422:
51475147
description: "Unprocessable Entity: Invalid or bad parameter provided."
51485148

5149+
/transactions/history/{transaction_id}/undo:
5150+
get:
5151+
tags:
5152+
- transactions
5153+
summary: "Check if a specific database transaction can be undone without conflicts."
5154+
operationId: checkUndoTransaction
5155+
security:
5156+
- Bearer: []
5157+
parameters:
5158+
- name: transaction_id
5159+
in: path
5160+
required: true
5161+
type: integer
5162+
description: "The ID of the transaction to check for undo feasibility."
5163+
responses:
5164+
200:
5165+
description: "OK: Undo feasibility check completed successfully."
5166+
schema:
5167+
type: object
5168+
properties:
5169+
transaction_id:
5170+
type: integer
5171+
description: "The ID of the transaction that was checked."
5172+
can_undo_without_force:
5173+
type: boolean
5174+
description: "Whether the transaction can be undone without using the force parameter."
5175+
total_changes:
5176+
type: integer
5177+
description: "Total number of changes in the transaction."
5178+
conflicts_count:
5179+
type: integer
5180+
description: "Number of conflicts that would prevent undo without force."
5181+
conflicts:
5182+
type: array
5183+
description: "List of specific conflicts found during the check."
5184+
items:
5185+
type: object
5186+
properties:
5187+
change_index:
5188+
type: integer
5189+
description: "Index of the conflicting change within the transaction."
5190+
object_class:
5191+
type: string
5192+
description: "Class of the conflicting object (e.g., 'Person', 'Family')."
5193+
handle:
5194+
type: string
5195+
description: "Handle of the conflicting object."
5196+
conflict_type:
5197+
type: string
5198+
description: "Type of conflict (e.g., 'object_changed', 'object_exists')."
5199+
description:
5200+
type: string
5201+
description: "Human-readable description of the conflict."
5202+
400:
5203+
description: "Bad Request: Malformed request could not be parsed."
5204+
401:
5205+
description: "Unauthorized: Missing authorization header."
5206+
403:
5207+
description: "Forbidden: Insufficient permissions to check undo transactions."
5208+
404:
5209+
description: "Not Found: Transaction with the specified ID was not found."
5210+
422:
5211+
description: "Unprocessable Entity: Invalid or bad parameter provided."
5212+
post:
5213+
tags:
5214+
- transactions
5215+
summary: "Undo a specific database transaction using background processing."
5216+
operationId: undoTransaction
5217+
security:
5218+
- Bearer: []
5219+
parameters:
5220+
- name: transaction_id
5221+
in: path
5222+
required: true
5223+
type: integer
5224+
description: "The ID of the transaction to undo."
5225+
- name: force
5226+
in: query
5227+
required: false
5228+
type: boolean
5229+
description: "If true, force undoing the transaction even if objects have been modified since the original transaction."
5230+
responses:
5231+
200:
5232+
description: "OK: Transaction undo completed successfully."
5233+
schema:
5234+
type: array
5235+
items:
5236+
$ref: "#/definitions/Transaction"
5237+
202:
5238+
description: "Accepted: Transaction undo will be applied in the background."
5239+
schema:
5240+
type: object
5241+
properties:
5242+
task:
5243+
$ref: "#/definitions/TaskReference"
5244+
400:
5245+
description: "Bad Request: Malformed request could not be parsed."
5246+
401:
5247+
description: "Unauthorized: Missing authorization header."
5248+
403:
5249+
description: "Forbidden: Insufficient permissions to undo transactions."
5250+
404:
5251+
description: "Not Found: Transaction with the specified ID was not found."
5252+
409:
5253+
description: "Conflict: Objects have changed since the original transaction and force parameter was not used."
5254+
422:
5255+
description: "Unprocessable Entity: Invalid or bad parameter provided."
5256+
51495257

51505258

51515259
##############################################################################

0 commit comments

Comments
 (0)