Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add endpoint for creating objects (fixes #178) #188

Merged
merged 17 commits into from
Jul 26, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions gramps_webapi/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
UsersResource,
UserTriggerResetPasswordResource,
)
from .resources.objects import CreateObjectsResource
from .util import make_cache_key_thumbnails, use_args

api_blueprint = Blueprint("api", __name__, url_prefix=API_PREFIX)
Expand All @@ -89,6 +90,8 @@ def register_endpt(resource: Type[Resource], url: str, name: str):
api_blueprint.add_url_rule(url, view_func=resource.as_view(name))


# Objects
register_endpt(CreateObjectsResource, "/objects/", "objects")
# Token
register_endpt(TokenResource, "/token/", "token")
register_endpt(TokenRefreshResource, "/token/refresh/", "token_refresh")
Expand Down
46 changes: 42 additions & 4 deletions gramps_webapi/api/resources/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,28 +19,36 @@

"""Base for Gramps object API resources."""

import json
from abc import abstractmethod
from typing import Dict, List
from typing import Dict, List, Sequence

from flask import Response, abort
from flask import Response, abort, request
from gramps.gen.const import GRAMPS_LOCALE as glocale
from gramps.gen.db import DbTxn
from gramps.gen.db.base import DbReadBase
from gramps.gen.errors import HandleError
from gramps.gen.lib.primaryobj import BasicPrimaryObject as GrampsObject
from gramps.gen.lib.serialize import from_json
from gramps.gen.utils.grampslocale import GrampsLocale
from webargs import fields, validate

from ...auth.const import PERM_ADD_OBJ, PERM_EDIT_OBJ
from ..auth import require_permissions
from ..util import get_db_handle, get_locale_for_language, use_args
from . import ProtectedResource, Resource
from .emit import GrampsJSONEncoder
from .filters import apply_filter
from .match import match_dates
from .sort import sort_objects
from .util import (
add_object,
get_backlinks,
get_extended_attributes,
get_reference_profile_for_object,
get_soundex,
update_object,
validate_object_dict,
)


Expand Down Expand Up @@ -98,8 +106,13 @@ def match_dates(self, handles: List[str], date: str):

@property
def db_handle(self) -> DbReadBase:
"""Get the database instance."""
return get_db_handle()
"""Get the readonly database instance."""
return get_db_handle(readonly=True)

@property
def db_handle_writable(self) -> DbReadBase:
"""Get the writable database instance."""
return get_db_handle(readonly=False)

def get_object_from_gramps_id(self, gramps_id: str) -> GrampsObject:
"""Get the object given a Gramps ID."""
Expand All @@ -115,6 +128,17 @@ def get_object_from_handle(self, handle: str) -> GrampsObject:
)
return query_method(handle)

def _parse_object(self) -> GrampsObject:
"""Parse the object."""
obj_dict = request.json
if "_class" not in obj_dict:
obj_dict["_class"] = self.gramps_class_name
elif obj_dict["_class"] != self.gramps_class_name:
abort(400)
if not validate_object_dict(obj_dict):
abort(400)
return from_json(json.dumps(obj_dict))


class GrampsObjectResource(GrampsObjectResourceHelper, Resource):
"""Resource for a single object."""
Expand Down Expand Up @@ -307,6 +331,20 @@ def get(self, args: Dict) -> Response:
total_items=total_items,
)

def post(self) -> Response:
"""Post a new object."""
require_permissions([PERM_ADD_OBJ])
obj = self._parse_object()
if not obj:
abort(400)
db_handle = self.db_handle_writable
with DbTxn("Add objects", db_handle) as trans:
try:
add_object(db_handle, obj, trans, fail_if_exists=True)
except ValueError:
abort(400)
return Response(status=201)


class GrampsObjectProtectedResource(GrampsObjectResource, ProtectedResource):
"""Resource for a single object, requiring authentication."""
Expand Down
79 changes: 79 additions & 0 deletions gramps_webapi/api/resources/objects.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
#
# Gramps Web API - A RESTful API for the Gramps genealogy program
#
# Copyright (C) 2021 David Straub
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
#

"""Object creation API resource."""

import json
from typing import Any, Dict, Sequence

import gramps
import jsonschema
from flask import Response, abort, request
from gramps.gen.db import DbTxn
from gramps.gen.db.base import DbWriteBase
from gramps.gen.lib import (
Citation,
Event,
Family,
Media,
Note,
Person,
Place,
Repository,
Source,
Tag,
)
from gramps.gen.lib.primaryobj import BasicPrimaryObject as GrampsObject
from gramps.gen.lib.serialize import from_json

from ...auth.const import PERM_ADD_OBJ
from ..auth import require_permissions
from ..util import get_db_handle
from . import ProtectedResource
from .util import add_object, validate_object_dict


class CreateObjectsResource(ProtectedResource):
"""Resource for creating multiple objects."""

def _parse_objects(self) -> Sequence[GrampsObject]:
"""Parse the objects."""
payload = request.json
objects = []
for obj_dict in payload:
if not validate_object_dict(obj_dict):
abort(400)
obj = from_json(json.dumps(obj_dict))
objects.append(obj)
return objects

def post(self) -> Response:
"""Post the objects."""
require_permissions([PERM_ADD_OBJ])
objects = self._parse_objects()
if not objects:
abort(400)
db_handle = get_db_handle(readonly=False)
with DbTxn("Add objects", db_handle) as trans:
for obj in objects:
try:
add_object(db_handle, obj, trans, fail_if_exists=True)
except ValueError:
abort(400)
return Response(status=201)
93 changes: 92 additions & 1 deletion gramps_webapi/api/resources/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,17 @@
#

"""Gramps utility functions."""


from http import HTTPStatus
from typing import Any, Dict, List, Optional, Tuple, Union

import gramps
import jsonschema
from flask import abort
from gramps.gen.const import GRAMPS_LOCALE as glocale
from gramps.gen.db.base import DbReadBase
from gramps.gen.db import DbTxn
from gramps.gen.db.base import DbReadBase, DbWriteBase
from gramps.gen.display.name import NameDisplay
from gramps.gen.display.place import PlaceDisplay
from gramps.gen.errors import HandleError
Expand All @@ -31,11 +38,14 @@
Event,
Family,
Media,
Note,
Person,
Place,
PlaceType,
Repository,
Source,
Span,
Tag,
)
from gramps.gen.lib.primaryobj import BasicPrimaryObject as GrampsObject
from gramps.gen.soundex import soundex
Expand Down Expand Up @@ -716,3 +726,84 @@ def get_rating(db_handle: DbReadBase, obj: GrampsObject) -> Tuple[int, int]:
if citation.confidence > confidence:
confidence = citation.confidence
return count, confidence


def has_handle(db_handle: DbWriteBase, obj: GrampsObject,) -> bool:
"""Check if an object with the same class and handle exists in the DB."""
obj_class = obj.__class__.__name__.lower()
method = db_handle.method("has_%s_handle", obj_class)
return method(obj.handle)


def has_gramps_id(db_handle: DbWriteBase, obj: GrampsObject,) -> bool:
"""Check if an object with the same class and handle exists in the DB."""
if not hasattr(obj, "gramps_id"): # needed for tags
return False
obj_class = obj.__class__.__name__.lower()
method = db_handle.method("has_%s_gramps_id", obj_class)
return method(obj.gramps_id)


def add_object(
db_handle: DbWriteBase,
obj: GrampsObject,
trans: DbTxn,
fail_if_exists: bool = False,
):
"""Commit a Gramps object to the database.

If `fail_if_exists` is true, raises a ValueError if an object of
the same type exists with the same handle or same Gramps ID.
"""
if db_handle.readonly:
# adding objects is forbidden on a read-only db!
abort(HTTPStatus.FORBIDDEN)
obj_class = obj.__class__.__name__.lower()
if fail_if_exists:
if has_handle(db_handle, obj):
raise ValueError("Handle already exists.")
if has_gramps_id(db_handle, obj):
raise ValueError("Gramps ID already exists.")
try:
add_method = db_handle.method("add_%s", obj_class)
return add_method(obj, trans)
except AttributeError:
raise ValueError("Database does not support writing.")


def validate_object_dict(obj_dict: Dict[str, Any]) -> bool:
"""Validate a dict representation of a Gramps object vs. its schema."""
try:
obj_cls = getattr(gramps.gen.lib, obj_dict["_class"])
except (KeyError, AttributeError):
return False
schema = obj_cls.get_schema()
try:
jsonschema.validate(obj_dict, schema)
except jsonschema.exceptions.ValidationError:
return False
return True


def update_object(
db_handle: DbWriteBase, obj: GrampsObject, trans: DbTxn,
):
"""Commit a modified Gramps object to the database.

Fails with a ValueError if the object with this handle does not
exist, or if another object of the same type exists with the
same Gramps ID.
"""
if db_handle.readonly:
# updating objects is forbidden on a read-only db!
abort(HTTPStatus.FORBIDDEN)
obj_class = obj.__class__.__name__.lower()
if not has_handle(db_handle, obj):
raise ValueError("Cannot be used for new objects.")
if has_gramps_id(db_handle, obj):
raise ValueError("Gramps ID already exists.")
try:
commit_method = db_handle.method("commit_%s", obj_class)
return commit_method(obj, trans)
except AttributeError:
raise ValueError("Database does not support writing.")
15 changes: 11 additions & 4 deletions gramps_webapi/api/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,13 @@

"""Utility functions."""

import io
import hashlib
import io
import os
import smtplib
import socket
from email.message import EmailMessage
from http import HTTPStatus
from typing import BinaryIO, Optional, Sequence

from flask import abort, current_app, g, request
Expand All @@ -38,6 +39,7 @@
from webargs.flaskparser import FlaskParser

from ..auth.const import PERM_VIEW_PRIVATE
from ..dbmanager import WebDbManager
from .auth import has_permissions


Expand Down Expand Up @@ -79,20 +81,25 @@ def set_name_group_mapping(self, name, group):
return self.db.set_name_group_mapping(name, group)


def get_db_handle() -> DbReadBase:
def get_db_handle(readonly: bool = True) -> DbReadBase:
"""Open the database and get the current instance.

Called before every request.

If a user is not authorized to view private records,
returns a proxy DB instance.

If `readonly` is false, locks the database during the request.
"""
if "dbstate" not in g:
# cache the DbState instance for the duration of
# the request
dbmgr = current_app.config["DB_MANAGER"]
g.dbstate = dbmgr.get_db()
dbmgr: WebDbManager = current_app.config["DB_MANAGER"]
g.dbstate = dbmgr.get_db(readonly=readonly)
if not has_permissions({PERM_VIEW_PRIVATE}):
if not readonly:
# requesting write access on a private proxy DB is impossible & forbidden!
abort(HTTPStatus.FORBIDDEN)
# if we're not authorized to view private records,
# return a proxy DB instead of the real one
return ModifiedPrivateProxyDb(g.dbstate.db)
Expand Down
Loading