Skip to content

Commit 5d337d3

Browse files
authored
Optionally process transactions in background (#593)
* Refactor transaction endpoint into standalone function * Move process transaction to tasks * Optionally process in background
1 parent a378a43 commit 5d337d3

File tree

3 files changed

+182
-139
lines changed

3 files changed

+182
-139
lines changed
Lines changed: 23 additions & 120 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-2023 David Straub
4+
# Copyright (C) 2021-2024 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,35 +20,19 @@
2020
"""Raw database transaction API resource."""
2121

2222
import json
23-
from typing import Dict
2423

2524
from flask import Response, request
26-
from gramps.gen.db import DbTxn
27-
from gramps.gen.db.base import DbReadBase
25+
from flask_jwt_extended import get_jwt_identity
2826
from gramps.gen.db.dbconst import TXNADD, TXNDEL, TXNUPD
29-
from gramps.gen.errors import HandleError
30-
from gramps.gen.lib.serialize import from_json, to_json
31-
from gramps.gen.merge.diff import diff_items
3227
from webargs import fields
3328

3429
from ...auth.const import PERM_ADD_OBJ, PERM_DEL_OBJ, PERM_EDIT_OBJ
30+
from ...types import ResponseReturnValue
3531
from ..auth import require_permissions
36-
from ..search import (
37-
SearchIndexer,
38-
get_search_indexer,
39-
SemanticSearchIndexer,
40-
get_semantic_search_indexer,
41-
)
42-
from ..util import (
43-
abort_with_message,
44-
check_quota_people,
45-
get_db_handle,
46-
get_tree_from_jwt_or_fail,
47-
update_usage_people,
48-
use_args,
49-
)
32+
from ..tasks import AsyncResult, make_task_response, process_transactions, run_task
33+
from ..util import abort_with_message, use_args, get_tree_from_jwt_or_fail
5034
from . import ProtectedResource
51-
from .util import app_has_semantic_search, reverse_transaction, transaction_to_json
35+
from .util import reverse_transaction
5236

5337
trans_code = {"delete": TXNDEL, "add": TXNADD, "update": TXNUPD}
5438

@@ -60,10 +44,11 @@ class TransactionsResource(ProtectedResource):
6044
{
6145
"undo": fields.Boolean(load_default=False),
6246
"force": fields.Boolean(load_default=False),
47+
"background": fields.Boolean(load_default=False),
6348
},
6449
location="query",
6550
)
66-
def post(self, args) -> Response:
51+
def post(self, args) -> ResponseReturnValue:
6752
"""Post the transaction."""
6853
require_permissions([PERM_ADD_OBJ, PERM_EDIT_OBJ, PERM_DEL_OBJ])
6954
payload = request.json
@@ -72,108 +57,26 @@ def post(self, args) -> Response:
7257
is_undo = args["undo"]
7358
if is_undo:
7459
payload = reverse_transaction(payload)
75-
db_handle = get_db_handle(readonly=False)
76-
num_people_deleted = sum(
77-
item["type"] == "delete" and item["_class"] == "Person" for item in payload
78-
)
79-
num_people_added = sum(
80-
item["type"] == "add" and item["_class"] == "Person" for item in payload
81-
)
82-
num_people_new = num_people_added - num_people_deleted
83-
check_quota_people(to_add=num_people_new)
84-
with DbTxn("Raw transaction", db_handle) as trans:
85-
for item in payload:
86-
try:
87-
class_name = item["_class"]
88-
trans_type = item["type"]
89-
handle = item["handle"]
90-
old_data = item["old"]
91-
if not args["force"] and not self.old_unchanged(
92-
db_handle, class_name, handle, old_data
93-
):
94-
if num_people_added or num_people_deleted:
95-
update_usage_people()
96-
abort_with_message(409, "Object has changed")
97-
new_data = item["new"]
98-
if new_data:
99-
new_obj = from_json(json.dumps(new_data))
100-
if trans_type == "delete":
101-
self.handle_delete(trans, class_name, handle)
102-
if (
103-
class_name == "Person"
104-
and handle == db_handle.get_default_handle()
105-
):
106-
db_handle.set_default_person_handle(None)
107-
elif trans_type == "add":
108-
self.handle_add(trans, class_name, new_obj)
109-
elif trans_type == "update":
110-
self.handle_commit(trans, class_name, new_obj)
111-
else:
112-
if num_people_added or num_people_deleted:
113-
update_usage_people()
114-
abort_with_message(400, "Unexpected transaction type")
115-
except (KeyError, UnicodeDecodeError, json.JSONDecodeError, TypeError):
116-
if num_people_added or num_people_deleted:
117-
update_usage_people()
118-
abort_with_message(400, "Error while processing transaction")
119-
trans_dict = transaction_to_json(trans)
120-
if num_people_new:
121-
update_usage_people()
122-
# update search index
12360
tree = get_tree_from_jwt_or_fail()
124-
indexer: SearchIndexer = get_search_indexer(tree)
125-
for _trans_dict in trans_dict:
126-
handle = _trans_dict["handle"]
127-
class_name = _trans_dict["_class"]
128-
if _trans_dict["type"] == "delete":
129-
indexer.delete_object(handle, class_name)
130-
else:
131-
indexer.add_or_update_object(handle, db_handle, class_name)
132-
if app_has_semantic_search():
133-
semantic_indexer: SemanticSearchIndexer = get_semantic_search_indexer(tree)
134-
for _trans_dict in trans_dict:
135-
handle = _trans_dict["handle"]
136-
class_name = _trans_dict["_class"]
137-
if _trans_dict["type"] == "delete":
138-
semantic_indexer.delete_object(handle, class_name)
139-
else:
140-
semantic_indexer.add_or_update_object(handle, db_handle, class_name)
61+
user_id = get_jwt_identity()
62+
if args["background"]:
63+
task = run_task(
64+
process_transactions,
65+
tree=tree,
66+
user_id=user_id,
67+
payload=payload,
68+
force=args["force"],
69+
)
70+
if isinstance(task, AsyncResult):
71+
return make_task_response(task)
72+
return task, 200
73+
trans_dict = process_transactions(
74+
tree=tree, user_id=user_id, payload=payload, force=args["force"]
75+
)
14176
res = Response(
14277
response=json.dumps(trans_dict),
14378
status=200,
14479
mimetype="application/json",
14580
)
14681
res.headers.add("X-Total-Count", str(len(trans_dict)))
14782
return res
148-
149-
def handle_delete(self, trans: DbTxn, class_name: str, handle: str) -> None:
150-
"""Handle a delete action."""
151-
del_func = trans.db.method("remove_%s", class_name)
152-
del_func(handle, trans)
153-
154-
def handle_commit(self, trans: DbTxn, class_name: str, obj) -> None:
155-
"""Handle an update action."""
156-
com_func = trans.db.method("commit_%s", class_name)
157-
com_func(obj, trans)
158-
159-
def handle_add(self, trans: DbTxn, class_name: str, obj) -> None:
160-
"""Handle an add action."""
161-
if class_name != "Tag" and not obj.gramps_id:
162-
abort_with_message(400, "Gramps ID missing")
163-
self.handle_commit(trans, class_name, obj)
164-
165-
def old_unchanged(
166-
self, db: DbReadBase, class_name: str, handle: str, old_data: Dict
167-
) -> bool:
168-
"""Check if the "old" object is still unchanged."""
169-
handle_func = db.method("get_%s_from_handle", class_name)
170-
try:
171-
obj = handle_func(handle)
172-
except HandleError:
173-
if old_data is None:
174-
return True
175-
return False
176-
obj_dict = json.loads(to_json(obj))
177-
if diff_items(class_name, old_data, obj_dict):
178-
return False
179-
return True

gramps_webapi/api/tasks.py

Lines changed: 125 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
#
21
# Gramps Web API - A RESTful API for the Gramps genealogy program
32
#
4-
# Copyright (C) 2021-2023 David Straub
3+
# Copyright (C) 2021-2024 David Straub
54
#
65
# This program is free software; you can redistribute it and/or modify
76
# it under the terms of the GNU Affero General Public License as published by
@@ -20,6 +19,7 @@
2019

2120
from __future__ import annotations
2221

22+
import json
2323
import os
2424
import uuid
2525
from gettext import gettext as _
@@ -29,6 +29,11 @@
2929
from celery import shared_task, Task
3030
from celery.result import AsyncResult
3131
from flask import current_app
32+
from gramps.gen.db import DbTxn
33+
from gramps.gen.lib.serialize import from_json, to_json
34+
from gramps.gen.db.base import DbReadBase
35+
from gramps.gen.errors import HandleError
36+
from gramps.gen.merge.diff import diff_items
3237

3338
from gramps_webapi.api.search.indexer import SearchIndexer, SemanticSearchIndexer
3439

@@ -40,9 +45,15 @@
4045
from .media_importer import MediaImporter
4146
from .report import run_report
4247
from .resources.delete import delete_all_objects
43-
from .resources.util import dry_run_import, run_import
48+
from .resources.util import (
49+
app_has_semantic_search,
50+
dry_run_import,
51+
run_import,
52+
transaction_to_json,
53+
)
4454
from .search import get_search_indexer, get_semantic_search_indexer
4555
from .util import (
56+
abort_with_message,
4657
check_quota_people,
4758
close_db,
4859
get_config,
@@ -410,3 +421,114 @@ def delete_objects(
410421
self, title="Updating semantic search index..."
411422
),
412423
)
424+
425+
426+
@shared_task(bind=True)
427+
def process_transactions(
428+
self, tree: str, user_id: str, payload: list[dict], force: bool
429+
):
430+
"""Process a set of database transactions, updating search indices as needed."""
431+
num_people_deleted = sum(
432+
item["type"] == "delete" and item["_class"] == "Person" for item in payload
433+
)
434+
num_people_added = sum(
435+
item["type"] == "add" and item["_class"] == "Person" for item in payload
436+
)
437+
num_people_new = num_people_added - num_people_deleted
438+
check_quota_people(to_add=num_people_new, tree=tree, user_id=user_id)
439+
db_handle = get_db_outside_request(
440+
tree=tree, view_private=True, readonly=False, user_id=user_id
441+
)
442+
with DbTxn("Raw transaction", db_handle) as trans:
443+
for item in payload:
444+
try:
445+
class_name = item["_class"]
446+
trans_type = item["type"]
447+
handle = item["handle"]
448+
old_data = item["old"]
449+
if not force and not old_unchanged(
450+
db_handle, class_name, handle, old_data
451+
):
452+
if num_people_added or num_people_deleted:
453+
update_usage_people(tree=tree, user_id=user_id)
454+
abort_with_message(409, "Object has changed")
455+
new_data = item["new"]
456+
if new_data:
457+
new_obj = from_json(json.dumps(new_data))
458+
if trans_type == "delete":
459+
handle_delete(trans, class_name, handle)
460+
if (
461+
class_name == "Person"
462+
and handle == db_handle.get_default_handle()
463+
):
464+
db_handle.set_default_person_handle(None)
465+
elif trans_type == "add":
466+
handle_add(trans, class_name, new_obj)
467+
elif trans_type == "update":
468+
handle_commit(trans, class_name, new_obj)
469+
else:
470+
if num_people_added or num_people_deleted:
471+
update_usage_people(tree=tree, user_id=user_id)
472+
abort_with_message(400, "Unexpected transaction type")
473+
except (KeyError, UnicodeDecodeError, json.JSONDecodeError, TypeError):
474+
if num_people_added or num_people_deleted:
475+
update_usage_people(tree=tree, user_id=user_id)
476+
abort_with_message(400, "Error while processing transaction")
477+
trans_dict = transaction_to_json(trans)
478+
if num_people_new:
479+
update_usage_people(tree=tree, user_id=user_id)
480+
# update search index
481+
indexer: SearchIndexer = get_search_indexer(tree)
482+
for _trans_dict in trans_dict:
483+
handle = _trans_dict["handle"]
484+
class_name = _trans_dict["_class"]
485+
if _trans_dict["type"] == "delete":
486+
indexer.delete_object(handle, class_name)
487+
else:
488+
indexer.add_or_update_object(handle, db_handle, class_name)
489+
# update semantic search index
490+
if app_has_semantic_search():
491+
semantic_indexer: SemanticSearchIndexer = get_semantic_search_indexer(tree)
492+
for _trans_dict in trans_dict:
493+
handle = _trans_dict["handle"]
494+
class_name = _trans_dict["_class"]
495+
if _trans_dict["type"] == "delete":
496+
semantic_indexer.delete_object(handle, class_name)
497+
else:
498+
semantic_indexer.add_or_update_object(handle, db_handle, class_name)
499+
return trans_dict
500+
501+
502+
def handle_delete(trans: DbTxn, class_name: str, handle: str) -> None:
503+
"""Handle a delete action."""
504+
del_func = trans.db.method("remove_%s", class_name)
505+
del_func(handle, trans)
506+
507+
508+
def handle_commit(trans: DbTxn, class_name: str, obj) -> None:
509+
"""Handle an update action."""
510+
com_func = trans.db.method("commit_%s", class_name)
511+
com_func(obj, trans)
512+
513+
514+
def handle_add(trans: DbTxn, class_name: str, obj) -> None:
515+
"""Handle an add action."""
516+
if class_name != "Tag" and not obj.gramps_id:
517+
abort_with_message(400, "Gramps ID missing")
518+
handle_commit(trans, class_name, obj)
519+
520+
521+
def old_unchanged(db: DbReadBase, class_name: str, handle: str, old_data: Dict) -> bool:
522+
"""Check if the "old" object is still unchanged."""
523+
handle_func = db.method("get_%s_from_handle", class_name)
524+
assert handle_func is not None, "No handle function found"
525+
try:
526+
obj = handle_func(handle)
527+
except HandleError:
528+
if old_data is None:
529+
return True
530+
return False
531+
obj_dict = json.loads(to_json(obj))
532+
if diff_items(class_name, old_data, obj_dict):
533+
return False
534+
return True

0 commit comments

Comments
 (0)