Skip to content

Commit 687e29e

Browse files
authored
Implement transaction undo (#380)
1 parent 4b8b770 commit 687e29e

File tree

5 files changed

+144
-10
lines changed

5 files changed

+144
-10
lines changed

gramps_webapi/api/resources/transactions.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#
22
# Gramps Web API - A RESTful API for the Gramps genealogy program
33
#
4-
# Copyright (C) 2021 David Straub
4+
# Copyright (C) 2021-2023 David Straub
55
#
66
# This program is free software; you can redistribute it and/or modify
77
# it under the terms of the GNU Affero General Public License as published by
@@ -29,6 +29,7 @@
2929
from gramps.gen.errors import HandleError
3030
from gramps.gen.lib.serialize import from_json, to_json
3131
from gramps.gen.merge.diff import diff_items
32+
from webargs import fields
3233

3334
from ...auth.const import PERM_ADD_OBJ, PERM_DEL_OBJ, PERM_EDIT_OBJ
3435
from ..auth import require_permissions
@@ -38,27 +39,41 @@
3839
get_db_handle,
3940
get_search_indexer,
4041
get_tree_from_jwt,
42+
use_args,
4143
)
4244
from . import ProtectedResource
43-
from .util import transaction_to_json
45+
from .util import reverse_transaction, transaction_to_json
4446

4547
trans_code = {"delete": TXNDEL, "add": TXNADD, "update": TXNUPD}
4648

4749

4850
class TransactionsResource(ProtectedResource):
4951
"""Resource for raw database transactions."""
5052

51-
def post(self) -> Response:
53+
@use_args(
54+
{
55+
"undo": fields.Boolean(load_default=False),
56+
},
57+
location="query",
58+
)
59+
def post(self, args) -> Response:
5260
"""Post the transaction."""
5361
require_permissions([PERM_ADD_OBJ, PERM_EDIT_OBJ, PERM_DEL_OBJ])
5462
payload = request.json
5563
if not payload:
5664
abort(400) # disallow empty payload
65+
is_undo = args["undo"]
66+
if is_undo:
67+
payload = reverse_transaction(payload)
5768
db_handle = get_db_handle(readonly=False)
58-
new_people = sum(
69+
num_people_deleted = sum(
70+
item["type"] == "delete" and item["_class"] == "Person" for item in payload
71+
)
72+
num_people_added = sum(
5973
item["type"] == "add" and item["_class"] == "Person" for item in payload
6074
)
61-
check_quota_people(to_add=new_people)
75+
num_people_new = num_people_added - num_people_deleted
76+
check_quota_people(to_add=num_people_new)
6277
with DbTxn("Raw transaction", db_handle) as trans:
6378
for item in payload:
6479
try:

gramps_webapi/api/resources/util.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@
7070
from gramps.gen.utils.place import conv_lat_lon
7171

7272
from ...const import DISABLED_IMPORTERS, SEX_FEMALE, SEX_MALE, SEX_UNKNOWN
73-
from ...types import FilenameOrPath, Handle
73+
from ...types import FilenameOrPath, Handle, TransactionJson
7474
from ..media import get_media_handler
7575
from ..util import get_db_handle, get_tree_from_jwt
7676

@@ -1059,7 +1059,7 @@ def _fix_parent_handles(
10591059
db_handle.commit_person(person, trans)
10601060

10611061

1062-
def transaction_to_json(transaction: DbTxn) -> List[Dict[str, Any]]:
1062+
def transaction_to_json(transaction: DbTxn) -> TransactionJson:
10631063
"""Return a JSON representation of a database transaction."""
10641064
out = []
10651065
for recno in transaction.get_recnos(reverse=False):
@@ -1085,6 +1085,22 @@ def transaction_to_json(transaction: DbTxn) -> List[Dict[str, Any]]:
10851085
return out
10861086

10871087

1088+
def reverse_transaction(transaction_list: TransactionJson) -> TransactionJson:
1089+
"""Reverse a JSON representation of a database transaction."""
1090+
transaction_reversed = []
1091+
type_reversed = {"add": "delete", "delete": "add", "update": "update"}
1092+
for item in reversed(transaction_list):
1093+
item_reversed = {
1094+
"type": type_reversed[item["type"]],
1095+
"handle": item["handle"],
1096+
"_class": item["_class"],
1097+
"old": item["new"],
1098+
"new": item["old"],
1099+
}
1100+
transaction_reversed.append(item_reversed)
1101+
return transaction_reversed
1102+
1103+
10881104
def hash_object(obj: GrampsObject) -> str:
10891105
"""Generate a SHA256 hash for a Gramps object's data."""
10901106
data = to_json(obj).encode()

gramps_webapi/data/apispec.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4485,6 +4485,11 @@ paths:
44854485
consumes:
44864486
- application/json
44874487
parameters:
4488+
- name: undo
4489+
in: query
4490+
required: false
4491+
type: boolean
4492+
description: "If true, apply the inverse of the transaction."
44884493
- in: body
44894494
name: source
44904495
description: The database transaction

gramps_webapi/types.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#
22
# Gramps Web API - A RESTful API for the Gramps genealogy program
33
#
4-
# Copyright (C) 2020 David Straub
4+
# Copyright (C) 2020-2023 David Straub
55
#
66
# This program is free software; you can redistribute it and/or modify
77
# it under the terms of the GNU Affero General Public License as published by
@@ -20,8 +20,9 @@
2020
"""Custom types."""
2121

2222
from pathlib import Path
23-
from typing import NewType, Union
23+
from typing import Any, Dict, List, NewType, Union
2424

2525
Handle = NewType("Handle", str)
2626
GrampsId = NewType("GrampsId", str)
2727
FilenameOrPath = Union[str, Path]
28+
TransactionJson = List[Dict[str, Any]]

tests/test_endpoints/test_transactions.py

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#
22
# Gramps Web API - A RESTful API for the Gramps genealogy program
33
#
4-
# Copyright (C) 2020 David Straub
4+
# Copyright (C) 2020-2023 David Straub
55
#
66
# This program is free software; you can redistribute it and/or modify
77
# it under the terms of the GNU Affero General Public License as published by
@@ -109,6 +109,25 @@ def test_transaction_add_update_delete(self):
109109
obj_dict = rv.json
110110
self.assertEqual(obj_dict["handle"], handle)
111111
self.assertEqual(obj_dict["text"]["string"], "My first note.")
112+
# undo add
113+
rv = self.client.post(
114+
"/api/transactions/?undo=1", json=trans_dict, headers=headers
115+
)
116+
assert rv.status_code == 200
117+
trans_dict = rv.json
118+
self.assertEqual(len(trans_dict), 1)
119+
self.assertEqual(trans_dict[0]["handle"], handle)
120+
self.assertEqual(trans_dict[0]["type"], "delete")
121+
self.assertEqual(trans_dict[0]["_class"], "Note")
122+
rv = self.client.get(f"/api/notes/{handle}", headers=headers)
123+
self.assertEqual(rv.status_code, 404)
124+
# undo undo
125+
rv = self.client.post(
126+
"/api/transactions/?undo=1", json=trans_dict, headers=headers
127+
)
128+
assert rv.status_code == 200
129+
rv = self.client.get(f"/api/notes/{handle}", headers=headers)
130+
self.assertEqual(rv.status_code, 200)
112131
# update
113132
obj_new = deepcopy(obj)
114133
obj_new["gramps_id"] = "N2"
@@ -123,11 +142,27 @@ def test_transaction_add_update_delete(self):
123142
]
124143
rv = self.client.post("/api/transactions/", json=trans, headers=headers)
125144
self.assertEqual(rv.status_code, 200)
145+
trans_dict = rv.json
126146
rv = self.client.get(f"/api/notes/{handle}", headers=headers)
127147
self.assertEqual(rv.status_code, 200)
128148
obj_dict = rv.json
129149
self.assertEqual(obj_dict["handle"], handle)
130150
self.assertEqual(obj_dict["gramps_id"], "N2")
151+
# undo update
152+
rv = self.client.post(
153+
"/api/transactions/?undo=1", json=trans_dict, headers=headers
154+
)
155+
assert rv.status_code == 200
156+
trans_dict = rv.json
157+
rv = self.client.get(f"/api/notes/{handle}", headers=headers)
158+
self.assertEqual(rv.status_code, 200)
159+
obj_dict = rv.json
160+
self.assertEqual(obj_dict["handle"], handle)
161+
self.assertEqual(obj_dict["gramps_id"], "N1")
162+
# undo undo
163+
rv = self.client.post(
164+
"/api/transactions/?undo=1", json=trans_dict, headers=headers
165+
)
131166
# delete
132167
trans = [
133168
{
@@ -140,8 +175,70 @@ def test_transaction_add_update_delete(self):
140175
]
141176
rv = self.client.post("/api/transactions/", json=trans, headers=headers)
142177
self.assertEqual(rv.status_code, 200)
178+
trans_dict = rv.json
143179
rv = self.client.get(f"/api/notes/{handle}", headers=headers)
144180
self.assertEqual(rv.status_code, 404)
181+
# undo delete
182+
rv = self.client.post(
183+
"/api/transactions/?undo=1", json=trans_dict, headers=headers
184+
)
185+
self.assertEqual(rv.status_code, 200)
186+
trans_dict = rv.json
187+
rv = self.client.get(f"/api/notes/{handle}", headers=headers)
188+
self.assertEqual(rv.status_code, 200)
189+
# undo undo
190+
rv = self.client.post(
191+
"/api/transactions/?undo=1", json=trans_dict, headers=headers
192+
)
193+
194+
def test_family_undo(self):
195+
"""Undo update and subequent deletion of a single note."""
196+
father = {
197+
"_class": "Person",
198+
"handle": make_handle(),
199+
"gramps_id": "P1",
200+
}
201+
mother = {
202+
"_class": "Person",
203+
"handle": make_handle(),
204+
"gramps_id": "P2",
205+
}
206+
family = {
207+
"_class": "Family",
208+
"handle": make_handle(),
209+
"gramps_id": "F1",
210+
"father_handle": father["handle"],
211+
"mother_handle": mother["handle"],
212+
}
213+
headers = get_headers(self.client, "editor", "123")
214+
# add objects
215+
rv = self.client.post("/api/people/", json=father, headers=headers)
216+
assert rv.status_code == 201
217+
rv = self.client.post("/api/people/", json=mother, headers=headers)
218+
assert rv.status_code == 201
219+
rv = self.client.post("/api/families/", json=family, headers=headers)
220+
assert rv.status_code == 201
221+
trans_dict = rv.json
222+
rv = self.client.get(f"/api/people/{father['handle']}", headers=headers)
223+
assert rv.status_code == 200
224+
assert rv.json["family_list"] == [family["handle"]]
225+
rv = self.client.get(f"/api/people/{mother['handle']}", headers=headers)
226+
assert rv.status_code == 200
227+
assert rv.json["family_list"] == [family["handle"]]
228+
rv = self.client.get(f"/api/families/{family['handle']}", headers=headers)
229+
assert rv.status_code == 200
230+
# undo family post should delete the family and update the people
231+
rv = self.client.post(
232+
"/api/transactions/?undo=1", json=trans_dict, headers=headers
233+
)
234+
rv = self.client.get(f"/api/people/{father['handle']}", headers=headers)
235+
assert rv.status_code == 200
236+
assert rv.json["family_list"] == []
237+
rv = self.client.get(f"/api/people/{mother['handle']}", headers=headers)
238+
assert rv.status_code == 200
239+
assert rv.json["family_list"] == []
240+
rv = self.client.get(f"/api/families/{family['handle']}", headers=headers)
241+
assert rv.status_code == 404
145242

146243
def test_modify_two(self):
147244
"""Modify two objects simultaneously."""

0 commit comments

Comments
 (0)