Skip to content

Feature/extend item crud operations #42

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

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
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
153 changes: 140 additions & 13 deletions dspace_rest_client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -487,7 +487,7 @@ def search_objects(
size=20,
sort=None,
dso_type=None,
configuration='default',
configuration="default",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kshepherd How much do we care about having a clean git blame?

I would strongly prefer at the very least that mere formatting changes like this be in their own commit. (I assume this is a black thing?)

embeds=None,
):
"""
Expand Down Expand Up @@ -521,7 +521,7 @@ def search_objects(
if sort is not None:
params["sort"] = sort
if configuration is not None:
params['configuration'] = configuration
params["configuration"] = configuration

r_json = self.fetch_resource(url=url, params={**params, **filters})

Expand Down Expand Up @@ -552,7 +552,7 @@ def search_objects_iter(
filters=None,
dso_type=None,
sort=None,
configuration='default',
configuration="default",
embeds=None,
):
"""
Expand All @@ -579,7 +579,7 @@ def search_objects_iter(
if sort is not None:
params["sort"] = sort
if configuration is not None:
params['configuration'] = configuration
params["configuration"] = configuration

return do_paginate(url, {**params, **filters})

Expand Down Expand Up @@ -1149,18 +1149,47 @@ def get_item(self, uuid, embeds=None):
"""
Get an item, given its UUID
@param uuid: the UUID of the item
@param embeds: Optional list of resources to embed in response JSON
@return: the raw API response
@param embeds: Optional list of resources to embed in response JSON
@return: the constructed Item object or None if an error occurs
"""
# TODO - return constructed Item object instead, handling errors here?
url = f"{self.API_ENDPOINT}/core/items"
try:
id = UUID(uuid).version
# Validate the UUID format
id_version = UUID(uuid).version
url = f"{url}/{uuid}"
return self.api_get(url, parse_params(embeds=embeds), None)

# Make API GET request
response = self.api_get(url, parse_params(embeds=embeds), None)

# Handle successful response
if response.status_code == 200:
# Parse the response JSON into an Item object
return self._construct_item(response.json())
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixes #29

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh: this is a breaking API change so we should bump to version 0.2 after merging this

else:
logging.error(
"Failed to retrieve item. Status code: %s", response.status_code
)
logging.error("Response: %s", response.text)
return None
except ValueError:
logging.error("Invalid item UUID: %s", uuid)
return None
except Exception as e:
logging.error("An unexpected error occurred: %s", str(e))
return None

def _construct_item(self, item_data):
"""
Construct an Item object from API response data
@param item_data: The raw JSON data from the API
@return: An Item object
"""
try:
# Create an Item instance, using the API response data
return Item(api_resource=item_data)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given all this does is one line, I'm wondering what the advantage of this function is... the auto exception handling?

My thinking when using the constructors like this is that it lets us be a bit more abstract, and do things like

dso_type = type(ds)
...
return dso_type(api_resource=parse_json(r))

Does that make sense? Do you think we need to just be more strict about handling each different DSO type? Or, if the error handling is the point, could we instead change this to _construct_dso(self, dso_data, dso_type) ?

except KeyError as e:
logging.error("Missing expected key in item data: %s", str(e))
return None

def get_items(self, embeds=None):
"""
Expand Down Expand Up @@ -1255,6 +1284,86 @@ def update_item(self, item, embeds=None):
return None
return self.update_dso(item, params=parse_params(embeds=embeds))

def patch_item(
self,
item,
operation,
field,
value=None,
language=None,
authority=None,
confidence=-1,
place="",
):
"""
Patch item. This method performs a partial update operation (PATCH) on the given Item object.
Supports operations: 'add', 'remove', 'replace'. Does not support 'move'.

@param item: Python Item object containing all the necessary data, identifiers, and links.
@param operation: Operation to perform ('add', 'remove', 'replace').
@param path: Path to the field or property to patch.
@param value: New value for the specified path (required for 'add' and 'replace'). Ignored for 'remove'.
@return: The API response or None in case of an error.
"""
try:
if not isinstance(item, Item):
logging.error("Need a valid item")
return None

if not field or not value:
logging.error("Field and value are required")
return None

if not operation or operation not in [
self.PatchOperation.ADD,
self.PatchOperation.REPLACE,
self.PatchOperation.REMOVE,
]:
logging.error("Unsupported operation: %s", operation)
return None

if (
operation in [self.PatchOperation.ADD, self.PatchOperation.REPLACE]
and value is None
):
logging.error("Value is required for 'add' and 'replace' operations")
return None

# Construct the item URI
item_uri = f"{self.API_ENDPOINT}/core/items/{item.uuid}"

path = f"/metadata/{field}/{place}"
patch_value = {
"value": value,
"language": language,
"authority": authority,
"confidence": confidence,
}

# Perform the patch operation
response = self.api_patch(
url=item_uri,
operation=operation,
path=path,
value=patch_value,
)

if response.status_code in [200, 204]:
logging.info("Successfully patched item: %s", item.uuid)
return response
else:
logging.error(
"Failed to patch item: %s (Status: %s, Response: %s)",
item.uuid,
response.status_code,
response.text,
)
return None

except ValueError:
logging.error("Error processing patch operation", exc_info=True)
return None

def add_metadata(
self,
dso,
Expand Down Expand Up @@ -1311,6 +1420,22 @@ def add_metadata(

return dso_type(api_resource=parse_json(r))

def delete_item(self, item):
"""
Delete an item, given its UUID
@param item_uuid: the UUID of the item
@return: the raw API response
"""
try:
if not isinstance(item, Item):
logging.error("Need a valid item")
return None
url = f"{self.API_ENDPOINT}/core/items/{item.uuid}"
return self.api_delete(url)
except ValueError:
logging.error("Invalid item UUID: %s", item.uuid)
return None

def create_user(self, user, token=None, embeds=None):
"""
Create a user
Expand Down Expand Up @@ -1468,13 +1593,15 @@ def resolve_identifier_to_dso(self, identifier=None):
@return: resolved DSpaceObject or error
"""
if identifier is not None:
url = f'{self.API_ENDPOINT}/pid/find'
r = self.api_get(url, params={'id': identifier})
url = f"{self.API_ENDPOINT}/pid/find"
r = self.api_get(url, params={"id": identifier})
if r.status_code == 200:
r_json = parse_json(r)
if r_json is not None and 'uuid' in r_json:
if r_json is not None and "uuid" in r_json:
return DSpaceObject(api_resource=r_json)
elif r.status_code == 404:
logging.error(f"Not found: {identifier}")
else:
logging.error(f"Error resolving identifier {identifier} to DSO: {r.status_code}")
logging.error(
f"Error resolving identifier {identifier} to DSO: {r.status_code}"
)
85 changes: 85 additions & 0 deletions example_patch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# This software is licenced under the BSD 3-Clause licence
# available at https://opensource.org/licenses/BSD-3-Clause
# and described in the LICENCE file in the root of this project

"""
Example Python 3 application using the dspace.py API client library to patch
some resources in a DSpace 7 repository.
"""
from pprint import pprint

import os
import sys

from dspace_rest_client.client import DSpaceClient
from dspace_rest_client.models import Community, Collection, Item, Bundle, Bitstream

DEFAULT_URL = "https://localhost:8080/server/api"
DEFAULT_USERNAME = "[email protected]"
DEFAULT_PASSWORD = "password"

# UUIDs for the object we want to patch
RESOURCE_ID = "0128787c-6f79-4661-aea4-11635d6fb04f"

# Field and value to patch
FIELD = "dc.title"
VALUE = "New title"

# Configuration from environment variables
URL = os.environ.get("DSPACE_API_ENDPOINT", DEFAULT_URL)
USERNAME = os.environ.get("DSPACE_API_USERNAME", DEFAULT_USERNAME)
PASSWORD = os.environ.get("DSPACE_API_PASSWORD", DEFAULT_PASSWORD)

# Instantiate DSpace client
d = DSpaceClient(
api_endpoint=URL, username=USERNAME, password=PASSWORD, fake_user_agent=True
)

# Authenticate against the DSpace client
authenticated = d.authenticate()
if not authenticated:
print("Error logging in! Giving up.")
sys.exit(1)

# An example of searching for workflow items (any search configuration from discovery.xml can be used)
# note that the results here depend on the workflow role / access of the logged in user
search_results = d.search_objects(
query=f"search.resourceid:{RESOURCE_ID}", dso_type="item"
)
for result in search_results:
print(f"{result.name} ({result.uuid})")
print(
f"{FIELD}: {result.metadata.get(FIELD, [{'value': 'Not available'}])[0]['value']}"
)

item = d.get_item(uuid=result.uuid)
print(type(item))

if FIELD in result.metadata:
if result.metadata[FIELD][0]["value"] == VALUE:
print("Metadata is already correct, skipping")
continue
elif result.metadata[FIELD][0]["value"] != VALUE:
patch_op = d.patch_item(
item=item,
operation="replace",
field=FIELD,
value=VALUE,
)
if patch_op:
print(patch_op)
print("Metadata updated")
else:
print("Error updating metadata")
else:
patch_op = d.patch_item(
item=item,
operation="add",
field=FIELD,
value=VALUE,
)
if patch_op:
print(patch_op)
print("Metadata added")
else:
print("Error adding metadata")