Skip to content

Commit b48e366

Browse files
authored
Check people quota before import (#381)
1 parent 687e29e commit b48e366

File tree

6 files changed

+123
-31
lines changed

6 files changed

+123
-31
lines changed

gramps_webapi/api/media.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
get_object_keys_size,
3939
upload_file_s3,
4040
)
41-
from .util import get_db_handle, get_tree_from_jwt
41+
from .util import abort_with_message, get_db_handle, get_tree_from_jwt
4242

4343

4444
PREFIX_S3 = "s3://"
@@ -332,9 +332,10 @@ def update_usage_media() -> int:
332332
return usage_media
333333

334334

335-
def check_quota_media(to_add: int) -> None:
335+
def check_quota_media(to_add: int, tree: Optional[str] = None) -> None:
336336
"""Check whether the quota allows adding `to_add` bytes and abort if not."""
337-
tree = get_tree_from_jwt()
337+
if not tree:
338+
tree = get_tree_from_jwt()
338339
usage_dict = get_tree_usage(tree)
339340
if not usage_dict or usage_dict.get("usage_media") is None:
340341
update_usage_media()
@@ -344,4 +345,4 @@ def check_quota_media(to_add: int) -> None:
344345
if quota is None:
345346
return
346347
if usage + to_add > quota:
347-
abort(405)
348+
abort_with_message(405, "Not allowed by media quota")

gramps_webapi/api/resources/importers.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,13 @@
2525
from http import HTTPStatus
2626
from typing import Any, Dict
2727

28-
from flask import Response, abort, current_app, request
28+
from flask import Response, current_app, request
2929
from webargs import fields
3030

3131
from ...auth.const import PERM_IMPORT_FILE
3232
from ..auth import require_permissions
3333
from ..tasks import AsyncResult, import_file, make_task_response, run_task
34-
from ..util import get_db_handle, get_tree_from_jwt, use_args
34+
from ..util import abort_with_message, get_db_handle, get_tree_from_jwt, use_args
3535
from . import ProtectedResource
3636
from .emit import GrampsJSONEncoder
3737
from .util import get_importers
@@ -56,7 +56,7 @@ def get(self, args: Dict[str, Any], extension: str) -> Response:
5656
get_db_handle() # needed to load plugins
5757
importers = get_importers(extension)
5858
if not importers:
59-
abort(404)
59+
abort_with_message(404, f"Importer for extension {extension} not found")
6060
return self.response(200, importers[0])
6161

6262

@@ -81,9 +81,13 @@ def post(self, args: Dict, extension: str) -> Response:
8181
file_path = os.path.join(export_path, file_name)
8282
with open(file_path, "w+b") as ftmp:
8383
ftmp.write(request_stream.read())
84+
if os.path.getsize(file_path) == 0:
85+
abort_with_message(400, "Imported file is empty")
8486
importers = get_importers(extension.lower())
8587
if not importers:
86-
abort(HTTPStatus.NOT_FOUND)
88+
abort_with_message(
89+
HTTPStatus.NOT_FOUND, f"Importer for extension {extension} not found"
90+
)
8791
tree = get_tree_from_jwt()
8892
task = run_task(
8993
import_file,

gramps_webapi/api/resources/util.py

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,12 @@
2929

3030
import gramps
3131
import jsonschema
32-
from flask import abort, current_app
32+
from flask import current_app
3333
from gramps.gen.const import GRAMPS_LOCALE as glocale
3434
from gramps.gen.db import KEY_TO_CLASS_MAP, DbTxn
3535
from gramps.gen.db.base import DbReadBase, DbWriteBase
3636
from gramps.gen.db.dbconst import TXNADD, TXNDEL, TXNUPD
37+
from gramps.gen.db.utils import import_as_dict
3738
from gramps.gen.display.name import NameDisplay
3839
from gramps.gen.display.place import PlaceDisplay
3940
from gramps.gen.errors import HandleError
@@ -72,7 +73,7 @@
7273
from ...const import DISABLED_IMPORTERS, SEX_FEMALE, SEX_MALE, SEX_UNKNOWN
7374
from ...types import FilenameOrPath, Handle, TransactionJson
7475
from ..media import get_media_handler
75-
from ..util import get_db_handle, get_tree_from_jwt
76+
from ..util import abort_with_message, get_db_handle, get_tree_from_jwt
7677

7778
pd = PlaceDisplay()
7879
_ = glocale.translation.gettext
@@ -797,7 +798,7 @@ def add_object(
797798
"""
798799
if db_handle.readonly:
799800
# adding objects is forbidden on a read-only db!
800-
abort(HTTPStatus.FORBIDDEN)
801+
abort_with_message(HTTPStatus.FORBIDDEN, "Forbidden: database is read-only")
801802
obj_class = obj.__class__.__name__.lower()
802803
if fail_if_exists:
803804
if has_handle(db_handle, obj):
@@ -988,7 +989,7 @@ def update_object(
988989
"""
989990
if db_handle.readonly:
990991
# updating objects is forbidden on a read-only db!
991-
abort(HTTPStatus.FORBIDDEN)
992+
abort_with_message(HTTPStatus.FORBIDDEN, "Forbidden: database is read-only")
992993
obj_class = obj.__class__.__name__.lower()
993994
if not has_handle(db_handle, obj):
994995
raise ValueError("Cannot be used for new objects.")
@@ -1185,5 +1186,26 @@ def run_import(
11851186
if delete:
11861187
os.remove(file_name)
11871188
if not result:
1188-
abort(500)
1189+
abort_with_message(500, "Import failed")
11891190
return
1191+
1192+
1193+
def dry_run_import(
1194+
file_name: FilenameOrPath,
1195+
) -> Optional[Dict[str, int]]:
1196+
"""Import a file into an in-memory database and returns object counts."""
1197+
db_handle: DbReadBase = import_as_dict(filename=file_name, user=User())
1198+
if db_handle is None:
1199+
return None
1200+
return {
1201+
"people": db_handle.get_number_of_people(),
1202+
"families": db_handle.get_number_of_families(),
1203+
"sources": db_handle.get_number_of_sources(),
1204+
"citations": db_handle.get_number_of_citations(),
1205+
"events": db_handle.get_number_of_events(),
1206+
"media": db_handle.get_number_of_media(),
1207+
"places": db_handle.get_number_of_places(),
1208+
"repositories": db_handle.get_number_of_repositories(),
1209+
"notes": db_handle.get_number_of_notes(),
1210+
"tags": db_handle.get_number_of_tags(),
1211+
}

gramps_webapi/api/tasks.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,9 @@
3333
from .export import prepare_options, run_export
3434
from .media import get_media_handler
3535
from .report import run_report
36-
from .resources.util import run_import
36+
from .resources.util import dry_run_import, run_import
3737
from .util import (
38+
check_quota_people,
3839
get_config,
3940
get_db_manager,
4041
get_db_outside_request,
@@ -126,6 +127,10 @@ def search_reindex_incremental(tree) -> None:
126127
@shared_task()
127128
def import_file(tree: str, file_name: str, extension: str, delete: bool = True):
128129
"""Import a file."""
130+
object_counts = dry_run_import(file_name=file_name)
131+
if object_counts is None:
132+
raise ValueError(f"Failed importing {file_name}")
133+
check_quota_people(to_add=object_counts["people"], tree=tree)
129134
db_manager = get_db_manager(tree)
130135
db_handle = db_manager.get_db().db
131136
run_import(

gramps_webapi/api/util.py

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
import hashlib
2323
import io
24+
import json
2425
import os
2526
import smtplib
2627
import socket
@@ -29,7 +30,7 @@
2930
from http import HTTPStatus
3031
from typing import BinaryIO, List, Optional, Sequence, Tuple
3132

32-
from flask import abort, current_app, g, jsonify, make_response, request
33+
from flask import Response, abort, current_app, g, jsonify, make_response, request
3334
from flask_jwt_extended import get_jwt
3435
from gramps.cli.clidbman import NAME_FILE, CLIDbManager
3536
from gramps.gen.config import config
@@ -58,8 +59,16 @@ class Parser(FlaskParser):
5859

5960
def handle_error(self, error, req, schema, *, error_status_code, error_headers):
6061
status_code = error_status_code or self.DEFAULT_VALIDATION_STATUS
62+
pretty_message = "".join([c for c in str(error.messages) if c not in "{}[]()'"])
63+
payload = {
64+
"error": {
65+
"code": status_code,
66+
"message": pretty_message,
67+
"messages": error.messages,
68+
}
69+
}
6170
abort(
62-
make_response(jsonify(error.messages), status_code),
71+
make_response(jsonify(payload), status_code),
6372
exc=error,
6473
messages=error.messages,
6574
schema=schema,
@@ -135,7 +144,9 @@ def get_db_outside_request(tree: str, view_private: bool, readonly: bool) -> DbR
135144
if not view_private:
136145
if not readonly:
137146
# requesting write access on a private proxy DB is impossible & forbidden!
138-
abort(HTTPStatus.FORBIDDEN)
147+
abort_with_message(
148+
HTTPStatus.FORBIDDEN, "Cannot write to a private proxy database"
149+
)
139150
# if we're not authorized to view private records,
140151
# return a proxy DB instead of the real one
141152
return ModifiedPrivateProxyDb(dbstate.db)
@@ -168,7 +179,9 @@ def get_db_handle(readonly: bool = True) -> DbReadBase:
168179
if not view_private:
169180
if not readonly:
170181
# requesting write access on a private proxy DB is impossible & forbidden!
171-
abort(HTTPStatus.FORBIDDEN)
182+
abort_with_message(
183+
HTTPStatus.FORBIDDEN, "Cannot write to a private proxy database"
184+
)
172185
return ModifiedPrivateProxyDb(g.db)
173186

174187
if not readonly and "db_write" not in g:
@@ -218,7 +231,7 @@ def get_buffer_for_file(filename: str, delete=True, not_found=False) -> BinaryIO
218231
except FileNotFoundError:
219232
if not_found:
220233
raise FileNotFoundError
221-
abort(500)
234+
abort_with_message(500, "File not found")
222235
if delete:
223236
os.remove(filename)
224237
return buffer
@@ -282,7 +295,7 @@ def make_cache_key_thumbnails(*args, **kwargs):
282295
try:
283296
obj = db_handle.get_media_from_handle(handle)
284297
except HandleError:
285-
abort(404)
298+
abort_with_message(404, f"Handle {handle} not found")
286299
# checksum in the DB
287300
checksum = obj.checksum
288301

@@ -319,7 +332,7 @@ def get_tree_id(guid: str) -> str:
319332
if not tree_id:
320333
if current_app.config["TREE"] == TREE_MULTI:
321334
# multi-tree support enabled but user has no tree ID: forbidden!
322-
abort(403)
335+
abort_with_message(403, "Forbidden")
323336
# needed for backwards compatibility: single-tree mode but user without tree ID
324337
dbmgr = WebDbManager(name=current_app.config["TREE"], create_if_missing=False)
325338
tree_id = dbmgr.dirname
@@ -343,25 +356,42 @@ def tree_exists(tree_id: str) -> bool:
343356
return True
344357

345358

346-
def update_usage_people() -> int:
359+
def update_usage_people(tree: Optional[str] = None) -> int:
347360
"""Update the usage of people."""
348-
tree = get_tree_from_jwt()
349-
db_handle = get_db_handle()
361+
if not tree:
362+
tree = get_tree_from_jwt()
363+
db_handle = get_db_outside_request(
364+
tree=tree,
365+
view_private=True,
366+
readonly=True,
367+
)
350368
usage_people = db_handle.get_number_of_people()
351369
set_tree_usage(tree, usage_people=usage_people)
352370
return usage_people
353371

354372

355-
def check_quota_people(to_add: int) -> None:
373+
def check_quota_people(to_add: int, tree: Optional[str] = None) -> None:
356374
"""Check whether the quota allows adding `to_add` people and abort if not."""
357-
tree = get_tree_from_jwt()
375+
if not tree:
376+
tree = get_tree_from_jwt()
358377
usage_dict = get_tree_usage(tree)
359378
if not usage_dict or usage_dict.get("usage_people") is None:
360-
update_usage_people()
379+
update_usage_people(tree=tree)
361380
usage_dict = get_tree_usage(tree)
362381
usage = usage_dict["usage_people"]
363382
quota = usage_dict.get("quota_people")
364383
if quota is None:
365384
return
366385
if usage + to_add > quota:
367-
abort(405)
386+
abort_with_message(405, "Not allowed by people quota")
387+
388+
389+
def abort_with_message(status: int, message: str):
390+
"""Abort with a JSON response."""
391+
payload = {"error": {"code": status, "message": message}}
392+
response = Response(
393+
response=json.dumps(payload),
394+
status=status,
395+
mimetype="application/json",
396+
)
397+
abort(response)

tests/test_endpoints/test_importers.py

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,15 @@
2121
"""Tests for the /api/importers endpoints."""
2222

2323
import io
24+
import os
2425
import unittest
2526
from unittest.mock import patch
2627

2728
from gramps.cli.clidbman import CLIDbManager
2829
from gramps.gen.dbstate import DbState
2930

3031
from gramps_webapi.app import create_app
31-
from gramps_webapi.auth import user_db, add_user
32+
from gramps_webapi.auth import add_user, set_tree_quota, user_db
3233
from gramps_webapi.auth.const import ROLE_EDITOR, ROLE_OWNER
3334
from gramps_webapi.const import ENV_CONFIG_FILE, TEST_EMPTY_GRAMPS_AUTH_CONFIG
3435

@@ -100,7 +101,7 @@ def setUpClass(cls):
100101
"""Test class setup."""
101102
cls.name = "empty"
102103
cls.dbman = CLIDbManager(DbState())
103-
_, _name = cls.dbman.create_new_db_cli(cls.name, dbid="sqlite")
104+
cls.dbpath, _name = cls.dbman.create_new_db_cli(cls.name, dbid="sqlite")
104105
cls.dbman.create_new_db_cli(cls.name, dbid="sqlite")
105106
with patch.dict(
106107
"os.environ",
@@ -112,6 +113,7 @@ def setUpClass(cls):
112113
cls.test_app = create_app()
113114
cls.test_app.config["TESTING"] = True
114115
cls.client = cls.test_app.test_client()
116+
cls.tree = os.path.basename(cls.dbpath)
115117
with cls.test_app.app_context():
116118
user_db.create_all()
117119
for role in TEST_USERS:
@@ -148,10 +150,13 @@ def test_importers_no_data(self):
148150
data=None,
149151
headers=headers,
150152
)
151-
assert rv.status_code == 500
153+
assert rv.status_code == 400
154+
assert "error" in rv.json
155+
assert "empty" in rv.json["error"]["message"]
152156

153157
def test_importers_example_data(self):
154158
"""Test importing example.gramps."""
159+
os.remove(os.path.join(self.dbpath, "sqlite.db"))
155160
example_db = ExampleDbInMemory()
156161
file_obj = io.BytesIO()
157162
with open(example_db.path, "rb") as f:
@@ -184,3 +189,28 @@ def test_importers_example_data(self):
184189
# everything doubled
185190
rv = check_success(self, f"{BASE_URL}/people/")
186191
assert len(rv) == 2 * 2157
192+
193+
def test_importers_example_data_quota(self):
194+
"""Test importing example.gramps with a quota."""
195+
os.remove(os.path.join(self.dbpath, "sqlite.db"))
196+
with self.test_app.app_context():
197+
set_tree_quota(self.tree, quota_people=2000)
198+
example_db = ExampleDbInMemory()
199+
file_obj = io.BytesIO()
200+
with open(example_db.path, "rb") as f:
201+
file_obj.write(f.read())
202+
file_obj.seek(0)
203+
headers = fetch_header(self.client, role=ROLE_OWNER)
204+
# database has no people
205+
rv = check_success(self, f"{BASE_URL}/people/")
206+
assert len(rv) == 0
207+
rv = self.client.post(
208+
f"{TEST_URL}gramps/file",
209+
data=file_obj,
210+
headers=headers,
211+
)
212+
assert rv.status_code == 405
213+
rv = check_success(self, f"{BASE_URL}/people/")
214+
assert len(rv) == 0
215+
with self.test_app.app_context():
216+
set_tree_quota(self.tree, quota_people=None)

0 commit comments

Comments
 (0)