Skip to content

Commit 1b058f9

Browse files
authored
Implement check & repair endpoint (fixes #479) (#480)
* Implement check & repair endpoint (fixes #479) * Add license note to check.py including Gramps contributors * Try fixing test
1 parent 7bfe488 commit 1b058f9

File tree

7 files changed

+224
-3
lines changed

7 files changed

+224
-3
lines changed

gramps_webapi/api/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,9 @@
3030
from .media import get_media_handler
3131
from .resources.base import Resource
3232
from .resources.bookmarks import (
33+
BookmarkEditResource,
3334
BookmarkResource,
3435
BookmarksResource,
35-
BookmarkEditResource,
3636
)
3737
from .resources.citations import CitationResource, CitationsResource
3838
from .resources.config import ConfigResource, ConfigsResource
@@ -93,6 +93,7 @@
9393
from .resources.transactions import TransactionsResource
9494
from .resources.translations import TranslationResource, TranslationsResource
9595
from .resources.trees import (
96+
CheckTreeResource,
9697
DisableTreeResource,
9798
EnableTreeResource,
9899
TreeResource,
@@ -186,6 +187,7 @@ def register_endpt(resource: Type[Resource], url: str, name: str):
186187
register_endpt(TreesResource, "/trees/", "trees")
187188
register_endpt(DisableTreeResource, "/trees/<string:tree_id>/disable", "disable_tree")
188189
register_endpt(EnableTreeResource, "/trees/<string:tree_id>/enable", "enable_tree")
190+
register_endpt(CheckTreeResource, "/trees/<string:tree_id>/repair", "repair_tree")
189191
# Types
190192
register_endpt(CustomTypeResource, "/types/custom/<string:datatype>", "custom-type")
191193
register_endpt(CustomTypesResource, "/types/custom/", "custom-types")

gramps_webapi/api/check.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
#
2+
# Gramps - a GTK+/GNOME based genealogy program
3+
#
4+
# Copyright (C) 2000-2007 Donald N. Allingham
5+
# Copyright (C) 2008 Brian G. Matherly
6+
# Copyright (C) 2010 Jakim Friant
7+
# Copyright (C) 2011 Tim G L Lyons
8+
# Copyright (C) 2012 Michiel D. Nauta
9+
# Copyright (C) 2025 David Straub
10+
#
11+
# This program is free software; you can redistribute it and/or modify
12+
# it under the terms of the GNU General Public License as published by
13+
# the Free Software Foundation; either version 2 of the License, or
14+
# (at your option) any later version.
15+
#
16+
# This program is distributed in the hope that it will be useful,
17+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
18+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19+
# GNU General Public License for more details.
20+
#
21+
# You should have received a copy of the GNU General Public License
22+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
23+
24+
"""Check and repair a Gramps database."""
25+
26+
from gramps.gen.db import DbTxn, DbWriteBase
27+
from gramps.gen.dbstate import DbState
28+
from gramps.plugins.tool.check import CheckIntegrity
29+
30+
31+
def check_database(db_handle: DbWriteBase):
32+
with DbTxn("Check Integrity", db_handle, batch=True) as trans:
33+
db_handle.disable_signals()
34+
dbstate = DbState()
35+
dbstate.change_database(db_handle)
36+
checker = CheckIntegrity(dbstate, None, trans)
37+
38+
# start with empty objects, broken links can be corrected below
39+
# then. This is done before fixing encoding and missing photos,
40+
# since otherwise we will be trying to fix empty records which are
41+
# then going to be deleted.
42+
checker.cleanup_empty_objects()
43+
checker.fix_encoding()
44+
checker.fix_alt_place_names()
45+
checker.fix_ctrlchars_in_notes()
46+
# checker.cleanup_missing_photos(cli=1) # should not be done on Web API
47+
checker.cleanup_deleted_name_formats()
48+
49+
prev_total = -1
50+
total = 0
51+
52+
while prev_total != total:
53+
prev_total = total
54+
55+
checker.check_for_broken_family_links()
56+
checker.check_parent_relationships()
57+
checker.cleanup_empty_families(1)
58+
checker.cleanup_duplicate_spouses()
59+
60+
total = checker.family_errors()
61+
62+
checker.fix_duplicated_grampsid()
63+
checker.check_events()
64+
checker.check_person_references()
65+
checker.check_family_references()
66+
checker.check_place_references()
67+
checker.check_source_references()
68+
checker.check_citation_references()
69+
checker.check_media_references()
70+
checker.check_repo_references()
71+
checker.check_note_references()
72+
checker.check_tag_references()
73+
# checker.check_checksum() # should not be done on Web API
74+
checker.check_media_sourceref()
75+
# checker.check_note_links() # requires Gramps 5.2
76+
checker.check_backlinks()
77+
78+
# rebuilding reference maps needs to be done outside of a transaction
79+
# to avoid nesting transactions.
80+
if checker.bad_backlinks:
81+
checker.progress.set_pass("Rebuilding reference maps...", 6)
82+
db_handle.reindex_reference_map(checker.callback)
83+
84+
db_handle.enable_signals()
85+
db_handle.request_rebuild()
86+
87+
errs = checker.build_report()
88+
text = checker.text.getvalue()
89+
return {"num_errors": errs, "message": text}

gramps_webapi/api/export.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@
5555
"ExcludeAll": LivingProxyDb.MODE_EXCLUDE_ALL,
5656
}
5757

58-
mimetypes.init()
58+
# mimetypes.init()
5959

6060

6161
# ExportOptions derived from WriterOptionBox, review of the database

gramps_webapi/api/resources/trees.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
import uuid
2525
from typing import Dict, List, Optional
2626

27-
from flask import abort, current_app
27+
from flask import abort, current_app, jsonify
2828
from gramps.gen.config import config
2929
from webargs import fields
3030
from werkzeug.security import safe_join
@@ -36,11 +36,13 @@
3636
PERM_EDIT_OTHER_TREE,
3737
PERM_EDIT_TREE,
3838
PERM_EDIT_TREE_QUOTA,
39+
PERM_REPAIR_TREE,
3940
PERM_VIEW_OTHER_TREE,
4041
)
4142
from ...const import TREE_MULTI
4243
from ...dbmanager import WebDbManager
4344
from ..auth import has_permissions, require_permissions
45+
from ..tasks import AsyncResult, check_repair_database, make_task_response, run_task
4446
from ..util import abort_with_message, get_tree_from_jwt, list_trees, use_args
4547
from . import ProtectedResource
4648

@@ -224,3 +226,26 @@ class EnableTreeResource(DisableEnableTreeResource):
224226
def post(self, tree_id: str):
225227
"""Disable a tree."""
226228
return self._post_disable_enable_tree(tree_id=tree_id, disabled=False)
229+
230+
231+
class CheckTreeResource(ProtectedResource):
232+
"""Resource for checking & repairing a Gramps database."""
233+
234+
def post(self, tree_id: str):
235+
"""Check & repair a Gramps database (tree)."""
236+
require_permissions([PERM_REPAIR_TREE])
237+
user_tree_id = get_tree_from_jwt()
238+
if tree_id == "-":
239+
# own tree
240+
tree_id = user_tree_id
241+
else:
242+
validate_tree_id(tree_id)
243+
if tree_id != user_tree_id:
244+
abort_with_message(403, "Not allowed to repair other trees")
245+
task = run_task(
246+
check_repair_database,
247+
tree=tree_id,
248+
)
249+
if isinstance(task, AsyncResult):
250+
return make_task_response(task)
251+
return jsonify(task), 201

gramps_webapi/api/tasks.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from flask import current_app
3030

3131
from ..auth import get_owner_emails
32+
from .check import check_database
3233
from .emails import email_confirm_email, email_new_user, email_reset_pw
3334
from .export import prepare_options, run_export
3435
from .media import get_media_handler
@@ -232,3 +233,10 @@ def media_ocr(
232233
handle, db_handle=db_handle
233234
)
234235
return handler.get_ocr(lang=lang, output_format=output_format)
236+
237+
238+
@shared_task()
239+
def check_repair_database(tree: str):
240+
"""Check and repair a Gramps database (tree)"""
241+
db_handle = get_db_outside_request(tree=tree, view_private=True, readonly=False)
242+
return check_database(db_handle)

gramps_webapi/auth/const.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
PERM_TRIGGER_REINDEX = "TriggerReindex"
6363
PERM_EDIT_NAME_GROUP = "EditNameGroup"
6464
PERM_EDIT_TREE = "EditTree"
65+
PERM_REPAIR_TREE = "RepairTree"
6566

6667
PERMISSIONS = {}
6768

@@ -97,6 +98,7 @@
9798
PERM_IMPORT_FILE,
9899
PERM_TRIGGER_REINDEX,
99100
PERM_EDIT_TREE,
101+
PERM_REPAIR_TREE,
100102
}
101103

102104
PERMISSIONS[ROLE_ADMIN] = PERMISSIONS[ROLE_OWNER] | {
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
#
2+
# Gramps Web API - A RESTful API for the Gramps genealogy program
3+
#
4+
# Copyright (C) 2024 David Straub
5+
#
6+
# This program is free software; you can redistribute it and/or modify
7+
# it under the terms of the GNU Affero General Public License as published by
8+
# the Free Software Foundation; either version 3 of the License, or
9+
# (at your option) any later version.
10+
#
11+
# This program is distributed in the hope that it will be useful,
12+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
# GNU Affero General Public License for more details.
15+
#
16+
# You should have received a copy of the GNU Affero General Public License
17+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
18+
#
19+
20+
"""Tests for the database repair endpoint."""
21+
22+
import os
23+
import unittest
24+
from unittest.mock import patch
25+
26+
from gramps.cli.clidbman import CLIDbManager
27+
from gramps.gen.dbstate import DbState
28+
29+
from gramps_webapi.app import create_app
30+
from gramps_webapi.auth import add_user, user_db
31+
from gramps_webapi.auth.const import ROLE_GUEST, ROLE_OWNER
32+
from gramps_webapi.const import ENV_CONFIG_FILE, TEST_AUTH_CONFIG
33+
34+
35+
class TestRepair(unittest.TestCase):
36+
"""Test database repair."""
37+
38+
@classmethod
39+
def setUpClass(cls):
40+
cls.name = "Test Web API Repair"
41+
cls.dbman = CLIDbManager(DbState())
42+
dirpath, _ = cls.dbman.create_new_db_cli(cls.name, dbid="sqlite")
43+
tree = os.path.basename(dirpath)
44+
with patch.dict("os.environ", {ENV_CONFIG_FILE: TEST_AUTH_CONFIG}):
45+
cls.app = create_app()
46+
cls.app.config["TESTING"] = True
47+
cls.client = cls.app.test_client()
48+
with cls.app.app_context():
49+
user_db.create_all()
50+
add_user(name="user", password="123", role=ROLE_GUEST, tree=tree)
51+
add_user(name="owner", password="123", role=ROLE_OWNER, tree=tree)
52+
rv = cls.client.post(
53+
"/api/token/", json={"username": "owner", "password": "123"}
54+
)
55+
access_token = rv.json["access_token"]
56+
cls.headers = {"Authorization": f"Bearer {access_token}"}
57+
58+
@classmethod
59+
def tearDownClass(cls):
60+
cls.dbman.remove_database(cls.name)
61+
62+
def test_repair_empty_database(self):
63+
"""Test Repairing the empty database."""
64+
rv = self.client.post("/api/trees/-/repair", headers=self.headers)
65+
assert rv.status_code == 201
66+
assert rv.json["num_errors"] == 0
67+
assert rv.json["message"] == ""
68+
69+
def test_repair_empty_person(self):
70+
"""Test Repairing an empty person."""
71+
rv = self.client.post("/api/people/", json={}, headers=self.headers)
72+
assert rv.status_code == 201
73+
rv = self.client.get("/api/people/", headers=self.headers)
74+
assert rv.status_code == 200
75+
assert len(rv.json) == 1
76+
rv = self.client.post("/api/trees/-/repair", headers=self.headers)
77+
assert rv.status_code == 201
78+
assert rv.json["num_errors"] == 1
79+
rv = self.client.get("/api/people/", headers=self.headers)
80+
assert rv.status_code == 200
81+
assert len(rv.json) == 0
82+
83+
def test_repair_empty_event(self):
84+
"""Test Repairing an empty event."""
85+
rv = self.client.post("/api/events/", json={}, headers=self.headers)
86+
assert rv.status_code == 201
87+
rv = self.client.get("/api/events/", headers=self.headers)
88+
assert rv.status_code == 200
89+
assert len(rv.json) == 1
90+
rv = self.client.post("/api/trees/-/repair", headers=self.headers)
91+
assert rv.status_code == 201
92+
assert rv.json["num_errors"] == 1
93+
rv = self.client.get("/api/events/", headers=self.headers)
94+
assert rv.status_code == 200
95+
assert len(rv.json) == 0

0 commit comments

Comments
 (0)