Skip to content

Commit

Permalink
dbio.project: fix clearing/deleting
Browse files Browse the repository at this point in the history
  • Loading branch information
RayPlante committed Nov 22, 2023
1 parent d5774bc commit f5327e5
Show file tree
Hide file tree
Showing 4 changed files with 208 additions and 26 deletions.
24 changes: 18 additions & 6 deletions python/nistoar/midas/dbio/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,14 @@ def create_record(self, name, data=None, meta=None) -> ProjectRecord:
self.log.info("Created %s record %s (%s) for %s", self.dbcli.project, prec.id, prec.name, self.who)
return prec

def delete_record(self, id) -> ProjectRecord:
"""
delete the draft record. This may leave a stub record in place if, for example, the record
has been published previously.
"""
# TODO: handling previously published records
raise NotImplementedError()

def _get_id_shoulder(self, user: PubAgent):
"""
return an ID shoulder that is appropriate for the given user agent
Expand Down Expand Up @@ -231,7 +239,7 @@ def update_data(self, id, newdata, part=None, message="", _prec=None):
"""
merge the given data into the currently save data content for the record with the given identifier.
:param str id: the identifier for the record whose data should be updated.
:param str newdata: the data to save as the new content.
:param str|dict|list newdata: the data to save as the new content.
:param str part: the slash-delimited pointer to an internal data property. If provided,
the given ``newdata`` is a value that should be set to the property pointed
to by ``part``.
Expand Down Expand Up @@ -498,13 +506,16 @@ def _save_data(self, indata: Mapping, prec: ProjectRecord,
def _validate_data(self, data):
pass

def clear_data(self, id: str, part: str=None, message: str=None, prec=None):
def clear_data(self, id: str, part: str=None, message: str=None, prec=None) -> bool:
"""
remove the stored data content of the record and reset it to its defaults.
remove the stored data content of the record and reset it to its defaults. Note that
no change is recorded if the requested data does not exist yet.
:param str id: the identifier for the record whose data should be cleared.
:param stt part: the slash-delimited pointer to an internal data property. If provided,
only that property will be cleared (either removed or set to an initial
default).
:return: True the data was properly cleared; return False if ``part`` was specified but does not
yet exist in the data.
:param ProjectRecord prec: the previously fetched and possibly updated record corresponding to `id`.
If this is not provided, the record will by fetched anew based on the `id`.
:raises ObjectNotFound: if no record with the given ID exists or the `part` parameter points to
Expand All @@ -518,7 +529,7 @@ def clear_data(self, id: str, part: str=None, message: str=None, prec=None):
set_state = True
prec = self.dbcli.get_record_for(id, ACLs.WRITE) # may raise ObjectNotFound/NotAuthorized

if _prec.status.state not in [status.EDIT, status.READY]:
if prec.status.state not in [status.EDIT, status.READY]:
raise NotEditable(id)

initdata = self._new_data_for(prec.id, prec.meta)
Expand All @@ -541,7 +552,7 @@ def clear_data(self, id: str, part: str=None, message: str=None, prec=None):
elif prop not in data:
data[prop] = {}
elif prop not in data:
break
return False
elif not steps:
del data[prop]
break
Expand Down Expand Up @@ -576,7 +587,8 @@ def clear_data(self, id: str, part: str=None, message: str=None, prec=None):
finally:
self._record_action(provact)
self.log.info("Cleared out data for %s record %s (%s) for %s",
self.dbcli.project, _prec.id, _prec.name, self.who)
self.dbcli.project, prec.id, prec.name, self.who)
return True


def update_status_message(self, id: str, message: str, _prec=None) -> status.RecordStatus:
Expand Down
85 changes: 74 additions & 11 deletions python/nistoar/midas/dbio/wsgi/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,39 @@
A web service interface to various MIDAS project records.
A _project record_ is a persistable record that is compliant with the MIDAS Common Database project
data model, where examples of "project record" types include DMP records and data publication drafts.
data model, where examples of a "project record" types include DMP records and data publication drafts.
The :py:class:`MIDASProjectApp` encapsulates the handling of requests to create and manipulate project
records. If desired, this class can be specialized for a particular project type, and the easiest way
to do that is by sub-classing the :py:class:`~nistoar.midas.dbio.wsgi.project.ProjectRecordBroker` and
passing that class to the :py:class:`MIDASProjectApp` constructor. This is because the
:py:class:`~nistoar.midas.dbio.wsgi.project.ProjectRecordBroker` class isolates the business logic for
retrieving and manipulating project records.
records. If desired, this class can be specialized for a particular project type; as an example, see
:py:mod:`nistoar.midas.dap.service.mds3`.
This implementation uses the simple :py:mod:`nistoar-internal WSGI
framework<nistoar.pdr.publish.service.wsgi>` to handle the specific web service endpoints. The
:py:class:`MIDASProjectApp` is the router for the Project collection endpoint: it analyzes the relative
URL path and delegates the handling to a more specific handler class. In particular, these endpoints
are handled accordingly:
``/`` -- :py:class:`ProjectSelectionHandler`
responds to to project search queries to find project records matching search criteria (GET)
as well as accepts requests to create new records (POST).
``/{projid}`` -- :py:class:`ProjectHandler`
returns the full project record (GET) or deletes it (DELETE).
``/{projid}/name`` -- :py:class:`ProjectNameHandler`
returns (GET) or updates (PUT) the user-supplied name of the record.
``/{projid}/data[/...]`` -- :py:class:`ProjectDataHandler`
returns (GET), updates (PUT, PATCH), or clears (DELETE) the data content of the record. This
implementation supports updating individual parts of the data object via PUT, PATCH, DELETE
based on the path relative to ``data``. Subclasses (e.g. with the
:py:mod:`DAP specialization<nistoar.midas.dap.service.mds3>`) may also support POST for certain
array-type properties within ``data``.
``/{projid}/acls[/...]`` -- :py:class:`ProjectACLsHandler`
returns (GET) or updates (PUT, PATCH, POST, DELETE) access control lists for the record.
``/{projid}/*`` -- :py:class`ProjectInfoHandler`
returns other non-editable parts of the record via GET (including the ``meta`` property).
"""
from logging import Logger
from collections import OrderedDict
Expand Down Expand Up @@ -86,7 +112,7 @@ def __init__(self, service: ProjectService, subapp: SubApp, wsgienv: dict, start
raise ValueError("Missing ProjectRecord id")

def do_OPTIONS(self, path):
return self.send_options(["GET"])
return self.send_options(["GET", "DELETE"])

def do_GET(self, path, ashead=False):
try:
Expand All @@ -98,7 +124,22 @@ def do_GET(self, path, ashead=False):
self._id, ashead=ashead)

return self.send_json(prec.to_dict(), ashead=ashead)


def do_DELETE(self, path):
try:
prec = self.svc.get_record(self._id)
out = prec.to_dict()
self.svc.delete_record(self._id)
except dbio.NotAuthorized as ex:
return self.send_unauthorized()
except dbio.ObjectNotFound as ex:
return self.send_error_resp(404, "ID not found", "Record with requested identifier not found",
self._id)
except NotImplementedError as ex:
return self.send_error(501, "Not Implemented")

return self.send_json(out, "Deleted")


class ProjectInfoHandler(ProjectRecordHandler):
"""
Expand Down Expand Up @@ -261,7 +302,7 @@ def __init__(self, service: ProjectService, subapp: SubApp, wsgienv: dict, start
raise ValueError("Missing ProjectRecord id")

def do_OPTIONS(self, path):
return self.send_options(["GET", "PUT", "PATCH"])
return self.send_options(["GET", "PUT", "PATCH", "DELETE"])

def do_GET(self, path, ashead=False):
"""
Expand All @@ -283,6 +324,28 @@ def do_GET(self, path, ashead=False):
"Record with requested identifier not found", self._id, ashead=ashead)
return self.send_json(out, ashead=ashead)

def do_DELETE(self, path):
"""
respond to a DELETE request. This is used to clear the value of a particular property
within the project data or otherwise reset the project data to its initial defaults.
:param str path: a path to the portion of the data to clear
"""
try:
cleared = self.svc.clear_data(self._id, path)
except dbio.NotAuthorized as ex:
return self._send_unauthorized()
except dbio.PartNotAccessible as ex:
return self.send_error_resp(405, "Data part not deletable",
"Requested part of data cannot be deleted")
except dbio.ObjectNotFound as ex:
if ex.record_part:
return self.send_error_resp(404, "Data property not found",
"No data found at requested property", self._id, ashead=ashead)
return self.send_error_resp(404, "ID not found",
"Record with requested identifier not found", self._id, ashead=ashead)

return self.send_json(cleared, "Cleared", 201)

def do_PUT(self, path):
try:
newdata = self.get_json_body()
Expand Down Expand Up @@ -650,7 +713,7 @@ def do_DELETE(self, path):
try:
prec.acls.revoke_perm_from(parts[0], parts[1])
prec.save()
return self.send_ok()
return self.send_ok(message="ID removed")
except dbio.NotAuthorized as ex:
return self.send_unauthorized()

Expand Down Expand Up @@ -787,7 +850,7 @@ def _apply_action(self, action, message=None):

class MIDASProjectApp(SubApp):
"""
a base web app for an interface handling project record
a base web app for an interface handling project record.
"""
_selection_handler = ProjectSelectionHandler
_update_handler = ProjectHandler
Expand Down
26 changes: 26 additions & 0 deletions python/tests/nistoar/midas/dbio/test_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,32 @@ def test_update_replace_data(self):

self.assertEqual(len(self.project.dbcli._db.get(base.PROV_ACT_LOG, {}).get(prec.id,[])), 6)

def test_clear_data(self):
self.create_service()
prec = self.project.create_record("goob")
self.assertEqual(prec.data, {})

data = self.project.update_data(prec.id, {"color": "red", "pos": {"x": 23, "y": 12, "grid": "A"}})
self.assertEqual(data, {"color": "red", "pos": {"x": 23, "y": 12, "grid": "A"}})
prec = self.project.get_record(prec.id)
self.assertEqual(prec.data, {"color": "red", "pos": {"x": 23, "y": 12, "grid": "A"}})

self.assertIs(self.project.clear_data(prec.id, "color"), True)
prec = self.project.get_record(prec.id)
self.assertEqual(prec.data, {"pos": {"x": 23, "y": 12, "grid": "A"}})

self.assertIs(self.project.clear_data(prec.id, "color"), False)
self.assertIs(self.project.clear_data(prec.id, "gurn/goob/gomer"), False)

self.assertIs(self.project.clear_data(prec.id, "pos/y"), True)
prec = self.project.get_record(prec.id)
self.assertEqual(prec.data, {"pos": {"x": 23, "grid": "A"}})

self.assertIs(self.project.clear_data(prec.id), True)
prec = self.project.get_record(prec.id)
self.assertEqual(prec.data, {})


def test_finalize(self):
self.create_service()
prec = self.project.create_record("goob")
Expand Down
99 changes: 90 additions & 9 deletions python/tests/nistoar/midas/dbio/wsgi/test_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -266,15 +266,16 @@ def test_full_methnotallowed(self):
body = hdlr.handle()
self.assertIn("405 ", self.resp[0])

self.resp = []
path = "mdm1:0001"
req = {
'REQUEST_METHOD': 'DELETE',
'PATH_INFO': self.rootpath + path
}
hdlr = self.app.create_handler(req, self.start, path, nistr)
body = hdlr.handle()
self.assertIn("405 ", self.resp[0])
# DELETE is now allowed
# self.resp = []
# path = "mdm1:0001"
# req = {
# 'REQUEST_METHOD': 'DELETE',
# 'PATH_INFO': self.rootpath + path
# }
# hdlr = self.app.create_handler(req, self.start, path, nistr)
# body = hdlr.handle()
# self.assertIn("405 ", self.resp[0])

def test_create(self):
path = ""
Expand Down Expand Up @@ -305,6 +306,86 @@ def test_create(self):
self.assertEqual(resp['data'], {"color": "red"})
self.assertEqual(resp['meta'], {})

def test_delete(self):
path = ""
req = {
'REQUEST_METHOD': 'POST',
'PATH_INFO': self.rootpath + path
}
req['wsgi.input'] = StringIO(json.dumps({"name": "big", "owner": "nobody",
"data": {"color": "red", "pos": {"x": 0, "y": 1}}}))
hdlr = self.app.create_handler(req, self.start, path, nistr)
body = hdlr.handle()
self.assertIn("201 ", self.resp[0])
resp = self.body2dict(body)
self.assertEqual(resp['data'], {"color": "red", "pos": {"x": 0, "y": 1}})
recid = resp['id']

self.resp = []
path = recid+"/data/pos/x"
req = {
'REQUEST_METHOD': 'DELETE',
'PATH_INFO': self.rootpath + path
}
hdlr = self.app.create_handler(req, self.start, path, nistr)
body = hdlr.handle()
self.assertIn("201 ", self.resp[0])
resp = self.body2dict(body)
self.assertIs(resp, True)

self.resp = []
hdlr = self.app.create_handler(req, self.start, path, nistr)
body = hdlr.handle()
self.assertIn("201 ", self.resp[0])
resp = self.body2dict(body)
self.assertIs(resp, False)

self.resp = []
path = recid+"/data"
req = {
'REQUEST_METHOD': 'GET',
'PATH_INFO': self.rootpath + path
}
hdlr = self.app.create_handler(req, self.start, path, nistr)
body = hdlr.handle()
self.assertIn("200 ", self.resp[0])
resp = self.body2dict(body)
self.assertEqual(resp, {"color": "red", "pos": {"y": 1}})

self.resp = []
path = recid+"/data"
req = {
'REQUEST_METHOD': 'DELETE',
'PATH_INFO': self.rootpath + path
}
hdlr = self.app.create_handler(req, self.start, path, nistr)
body = hdlr.handle()
self.assertIn("201 ", self.resp[0])
resp = self.body2dict(body)
self.assertIs(resp, True)

self.resp = []
path = recid+"/data"
req = {
'REQUEST_METHOD': 'GET',
'PATH_INFO': self.rootpath + path
}
hdlr = self.app.create_handler(req, self.start, path, nistr)
body = hdlr.handle()
self.assertIn("200 ", self.resp[0])
resp = self.body2dict(body)
self.assertEqual(resp, {})

self.resp = []
path = recid
req = {
'REQUEST_METHOD': 'DELETE',
'PATH_INFO': self.rootpath + path
}
hdlr = self.app.create_handler(req, self.start, path, nistr)
body = hdlr.handle()
self.assertIn("501 ", self.resp[0])


def test_search(self):
path = ""
Expand Down

0 comments on commit f5327e5

Please sign in to comment.