Skip to content

Commit 70a1f48

Browse files
authored
Make reports load much faster (#457)
1 parent e6a264d commit 70a1f48

File tree

4 files changed

+211
-25
lines changed

4 files changed

+211
-25
lines changed

gramps_webapi/api/report.py

Lines changed: 171 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -21,17 +21,34 @@
2121
"""Functions for running Gramps reports."""
2222

2323
import os
24+
import sys
2425
import uuid
2526
from pathlib import Path
2627
from typing import Dict, Optional
2728

2829
from flask import abort, current_app
2930
from gramps.cli.plug import CommandLineReport
3031
from gramps.cli.user import User
32+
from gramps.gen.const import GRAMPS_LOCALE as glocale
3133
from gramps.gen.db.base import DbReadBase
34+
from gramps.gen.display.name import displayer as name_displayer
3235
from gramps.gen.filters import reload_custom_filters
3336
from gramps.gen.plug import BasePluginManager
3437
from gramps.gen.plug.docgen import PaperStyle
38+
from gramps.gen.plug.menu import (
39+
BooleanOption,
40+
DestinationOption,
41+
EnumeratedListOption,
42+
FamilyOption,
43+
MediaOption,
44+
NoteOption,
45+
NumberOption,
46+
Option,
47+
PersonListOption,
48+
PersonOption,
49+
StringOption,
50+
TextOption,
51+
)
3552
from gramps.gen.plug.report import (
3653
CATEGORY_BOOK,
3754
CATEGORY_DRAW,
@@ -45,40 +62,54 @@
4562
from ..const import MIME_TYPES, REPORT_DEFAULTS, REPORT_FILTERS
4663
from .util import abort_with_message
4764

65+
_ = glocale.translation.gettext
66+
4867
_EXTENSION_MAP = {".gvpdf": ".pdf", ".gspdf": ".pdf"}
4968

5069

5170
def get_report_profile(
52-
db_handle: DbReadBase, plugin_manager: BasePluginManager, report_data
71+
db_handle: DbReadBase,
72+
plugin_manager: BasePluginManager,
73+
report_data,
74+
include_options_help: bool = True,
5375
):
5476
"""Extract and return report attributes and options."""
5577
module = plugin_manager.load_plugin(report_data)
5678
option_class = getattr(module, report_data.optionclass)
57-
report = CommandLineReport(
58-
db_handle, report_data.name, report_data.category, option_class, {}
79+
report = ModifiedCommandLineReport(
80+
db_handle,
81+
report_data.name,
82+
report_data.category,
83+
option_class,
84+
{},
85+
include_options_help=include_options_help,
5986
)
60-
options_help = report.options_help
61-
if REPORT_FILTERS:
62-
for report_type in REPORT_FILTERS:
63-
for item in options_help["off"][2]:
64-
if item[: len(report_type)] == report_type:
65-
del options_help["off"][2][options_help["off"][2].index(item)]
66-
break
67-
return {
87+
result = {
6888
"authors": report_data.authors,
6989
"authors_email": report_data.authors_email,
7090
"category": report_data.category,
7191
"description": report_data.description,
7292
"id": report_data.id,
7393
"name": report_data.name,
7494
"options_dict": report.options_dict,
75-
"options_help": options_help,
7695
"report_modes": report_data.report_modes,
7796
"version": report_data.version,
7897
}
98+
if include_options_help:
99+
options_help = report.options_help
100+
if REPORT_FILTERS:
101+
for report_type in REPORT_FILTERS:
102+
for item in options_help["off"][2]:
103+
if item[: len(report_type)] == report_type:
104+
del options_help["off"][2][options_help["off"][2].index(item)]
105+
break
106+
result["options_help"] = options_help
107+
return result
79108

80109

81-
def get_reports(db_handle: DbReadBase, report_id: str = None):
110+
def get_reports(
111+
db_handle: DbReadBase, report_id: str = None, include_options_help: bool = True
112+
):
82113
"""Extract and return report attributes and options."""
83114
reload_custom_filters()
84115
plugin_manager = BasePluginManager.get_instance()
@@ -88,7 +119,12 @@ def get_reports(db_handle: DbReadBase, report_id: str = None):
88119
continue
89120
if report_data.category not in REPORT_DEFAULTS:
90121
continue
91-
report = get_report_profile(db_handle, plugin_manager, report_data)
122+
report = get_report_profile(
123+
db_handle,
124+
plugin_manager,
125+
report_data,
126+
include_options_help=include_options_help,
127+
)
92128
reports.append(report)
93129
return reports
94130

@@ -103,6 +139,124 @@ def check_report_id_exists(report_id: str) -> bool:
103139
return False
104140

105141

142+
class ModifiedCommandLineReport(CommandLineReport):
143+
"""Patched version of gramps.cli.plug.CommandLineReport.
144+
145+
Avoids calling get_person_from_handle (individual database
146+
calls) on every person in the database."""
147+
148+
def __init__(self, *args, **kwargs):
149+
"""Initialize report."""
150+
self._name_dict = {}
151+
self.include_options_help = kwargs.pop("include_options_help", True)
152+
super().__init__(*args, **kwargs)
153+
154+
def _get_name_dict(self):
155+
"""Get a dictionary with all names in the database and cache it."""
156+
if not self._name_dict:
157+
self._name_dict = {
158+
person.handle: {
159+
"gramps_id": person.gramps_id,
160+
"name": name_displayer.display(person),
161+
}
162+
for person in self.database.iter_people()
163+
}
164+
return self._name_dict
165+
166+
def init_report_options_help(self):
167+
"""
168+
Initialize help for the options that are defined by each report.
169+
(And also any docgen options, if defined by the docgen.)
170+
"""
171+
if not self.include_options_help:
172+
return
173+
if not hasattr(self.option_class, "menu"):
174+
return
175+
menu = self.option_class.menu
176+
177+
for name in menu.get_all_option_names():
178+
option = menu.get_option_by_name(name)
179+
self.options_help[name] = ["", option.get_help()]
180+
181+
if isinstance(option, PersonOption):
182+
name_dict = self._get_name_dict()
183+
handles = self.database.get_person_handles(True)
184+
id_list = [
185+
f"{name_dict[handle]['gramps_id']}\t{name_dict[handle]['name']}"
186+
for handle in handles
187+
]
188+
self.options_help[name].append(id_list)
189+
elif isinstance(option, FamilyOption):
190+
id_list = []
191+
name_dict = self._get_name_dict()
192+
for family in self.database.iter_families():
193+
mname = ""
194+
fname = ""
195+
mhandle = family.get_mother_handle()
196+
if mhandle:
197+
mname = name_dict.get(mhandle, {}).get("name", "")
198+
fhandle = family.get_father_handle()
199+
if fhandle:
200+
fname = name_dict.get(fhandle, {}).get("name", "")
201+
# Translators: needed for French, Hebrew and Arabic
202+
text = _(f"{family.gramps_id}:\t{fname}, {mname}")
203+
id_list.append(text)
204+
self.options_help[name].append(id_list)
205+
elif isinstance(option, NoteOption):
206+
id_list = []
207+
for note in self.database.iter_notes():
208+
id_list.append(note.get_gramps_id())
209+
self.options_help[name].append(id_list)
210+
elif isinstance(option, MediaOption):
211+
id_list = []
212+
for mobject in self.database.iter_media():
213+
id_list.append(mobject.get_gramps_id())
214+
self.options_help[name].append(id_list)
215+
elif isinstance(option, PersonListOption):
216+
self.options_help[name].append("")
217+
elif isinstance(option, NumberOption):
218+
self.options_help[name].append("A number")
219+
elif isinstance(option, BooleanOption):
220+
self.options_help[name].append(["False", "True"])
221+
elif isinstance(option, DestinationOption):
222+
self.options_help[name].append("A file system path")
223+
elif isinstance(option, StringOption):
224+
self.options_help[name].append("Any text")
225+
elif isinstance(option, TextOption):
226+
self.options_help[name].append(
227+
"A list of text values. Each entry in the list "
228+
"represents one line of text."
229+
)
230+
elif isinstance(option, EnumeratedListOption):
231+
ilist = []
232+
for value, description in option.get_items():
233+
tabs = "\t"
234+
try:
235+
tabs = "\t\t" if len(value) < 10 else "\t"
236+
except TypeError: # Value is a number, use just one tab.
237+
pass
238+
val = "%s%s%s" % (value, tabs, description)
239+
ilist.append(val)
240+
self.options_help[name].append(ilist)
241+
elif isinstance(option, Option):
242+
self.options_help[name].append(option.get_help())
243+
else:
244+
print(_("Unknown option: %s") % option, file=sys.stderr)
245+
print(
246+
_(" Valid options are:")
247+
+ _(", ").join(list(self.options_dict.keys())), # Arabic OK
248+
file=sys.stderr,
249+
)
250+
print(
251+
_(
252+
" Use '%(donottranslate)s' to see description "
253+
"and acceptable values"
254+
)
255+
% {"donottranslate": "show=option"},
256+
file=sys.stderr,
257+
)
258+
259+
106260
def cl_report_new(
107261
database,
108262
name,
@@ -116,7 +270,9 @@ def cl_report_new(
116270
117271
Derived from gramps.cli.plug.cl_report.
118272
"""
119-
clr = CommandLineReport(database, name, category, options_class, options_str_dict)
273+
clr = ModifiedCommandLineReport(
274+
database, name, category, options_class, options_str_dict
275+
)
120276
if category in [CATEGORY_TEXT, CATEGORY_DRAW, CATEGORY_BOOK]:
121277
if clr.doc_options:
122278
clr.option_class.handler.doc = clr.format(

gramps_webapi/api/resources/reports.py

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,7 @@
3434
from ..auth import has_permissions
3535
from ..report import check_report_id_exists, get_reports, run_report
3636
from ..tasks import AsyncResult, generate_report, make_task_response, run_task
37-
from ..util import (
38-
get_buffer_for_file,
39-
get_db_handle,
40-
get_tree_from_jwt,
41-
use_args,
42-
)
37+
from ..util import get_buffer_for_file, get_db_handle, get_tree_from_jwt, use_args
4338
from . import ProtectedResource
4439
from .emit import GrampsJSONEncoder
4540
from .util import check_fix_default_person
@@ -48,24 +43,30 @@
4843
class ReportsResource(ProtectedResource, GrampsJSONEncoder):
4944
"""Reports resource."""
5045

51-
@use_args({}, location="query")
46+
@use_args({"include_help": fields.Boolean(load_default=False)}, location="query")
5247
def get(self, args: Dict) -> Response:
5348
"""Get all available report attributes."""
5449
if has_permissions({PERM_EDIT_OBJ}):
5550
check_fix_default_person(get_db_handle(readonly=False))
56-
reports = get_reports(get_db_handle())
51+
reports = get_reports(
52+
get_db_handle(), include_options_help=args["include_help"]
53+
)
5754
return self.response(200, reports)
5855

5956

6057
class ReportResource(ProtectedResource, GrampsJSONEncoder):
6158
"""Report resource."""
6259

63-
@use_args({}, location="query")
60+
@use_args({"include_help": fields.Boolean(load_default=True)}, location="query")
6461
def get(self, args: Dict, report_id: str) -> Response:
6562
"""Get specific report attributes."""
6663
if has_permissions({PERM_EDIT_OBJ}):
6764
check_fix_default_person(get_db_handle(readonly=False))
68-
reports = get_reports(get_db_handle(), report_id=report_id)
65+
reports = get_reports(
66+
get_db_handle(),
67+
report_id=report_id,
68+
include_options_help=args["include_help"],
69+
)
6970
if not reports:
7071
abort(404)
7172
return self.response(200, reports[0])

gramps_webapi/data/apispec.yaml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5924,6 +5924,13 @@ paths:
59245924
operationId: getAllReports
59255925
security:
59265926
- Bearer: []
5927+
parameters:
5928+
- name: include_help
5929+
in: query
5930+
required: false
5931+
type: boolean
5932+
default: false
5933+
description: "Indicates whether the report options help should be included."
59275934
responses:
59285935
200:
59295936
description: "OK: Successful operation."
@@ -5951,6 +5958,12 @@ paths:
59515958
required: true
59525959
type: string
59535960
description: "The report identifier."
5961+
- name: include_help
5962+
in: query
5963+
required: false
5964+
type: boolean
5965+
default: true
5966+
description: "Indicates whether the report options help should be included."
59545967
responses:
59555968
200:
59565969
description: "OK: Successful operation."

tests/test_endpoints/test_reports.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,14 @@ def test_get_reports_validate_semantics(self):
5858
"""Test invalid parameters and values."""
5959
check_invalid_semantics(self, TEST_URL + "?test=1")
6060

61+
def test_get_reports_validate_semantics_without_help(self):
62+
"""Test invalid parameters and values."""
63+
check_invalid_semantics(self, TEST_URL + "?include_help")
64+
65+
def test_get_reports_without_help(self):
66+
"""Test invalid parameters and values."""
67+
check_success(self, TEST_URL + "?include_help=0")
68+
6169

6270
class TestReportsReportId(unittest.TestCase):
6371
"""Test cases for the /api/reports/{report_id} endpoint."""
@@ -83,6 +91,14 @@ def test_get_reports_report_id_validate_semantics(self):
8391
"""Test invalid parameters and values."""
8492
check_invalid_semantics(self, TEST_URL + "ancestor_report?test=1")
8593

94+
def test_get_report_id_validate_semantics_without_help(self):
95+
"""Test invalid parameters and values."""
96+
check_invalid_semantics(self, TEST_URL + "ancestor_report?include_help")
97+
98+
def test_get_report_id_without_help(self):
99+
"""Test invalid parameters and values."""
100+
check_success(self, TEST_URL + "ancestor_report?include_help=0")
101+
86102

87103
class TestReportsReportIdFile(unittest.TestCase):
88104
"""Test cases for the /api/reports/{report_id}/file endpoint."""

0 commit comments

Comments
 (0)