1 package org.collectionspace.services.batch.nuxeo;
3 import java.io.StringReader;
5 import java.net.URISyntaxException;
6 import java.util.ArrayList;
7 import java.util.Arrays;
8 import java.util.Collections;
9 import java.util.HashSet;
10 import java.util.List;
13 import javax.ws.rs.core.PathSegment;
14 import javax.ws.rs.core.UriInfo;
16 import org.collectionspace.services.batch.AbstractBatchInvocable;
17 import org.collectionspace.services.batch.UriInfoImpl;
18 import org.collectionspace.services.client.AbstractCommonListUtils;
19 import org.collectionspace.services.client.CollectionObjectClient;
20 import org.collectionspace.services.client.IQueryManager;
21 import org.collectionspace.services.client.MovementClient;
22 import org.collectionspace.services.client.PoxPayloadOut;
23 import org.collectionspace.services.client.workflow.WorkflowClient;
24 import org.collectionspace.services.common.NuxeoBasedResource;
25 import org.collectionspace.services.common.ResourceMap;
26 import org.collectionspace.services.common.api.RefNameUtils;
27 import org.collectionspace.services.common.api.Tools;
28 import org.collectionspace.services.common.invocable.InvocationResults;
29 import org.collectionspace.services.jaxb.AbstractCommonList;
30 import org.dom4j.DocumentException;
31 //import org.jboss.resteasy.specimpl.UriInfoImpl;
32 import org.jdom.Document;
33 import org.jdom.Element;
34 import org.jdom.Namespace;
35 import org.jdom.input.SAXBuilder;
36 import org.slf4j.Logger;
37 import org.slf4j.LoggerFactory;
39 public class UpdateObjectLocationBatchJob extends AbstractBatchInvocable {
41 // FIXME: Where appropriate, get from existing constants rather than local declarations
42 private final static String COMPUTED_CURRENT_LOCATION_ELEMENT_NAME = "computedCurrentLocation";
43 private final static String CSID_ELEMENT_NAME = "csid";
44 private final static String CURRENT_LOCATION_ELEMENT_NAME = "currentLocation";
45 private final static String LIFECYCLE_STATE_ELEMENT_NAME = "currentLifeCycleState";
46 private final static String LOCATION_DATE_ELEMENT_NAME = "locationDate";
47 private final static String OBJECT_NUMBER_ELEMENT_NAME = "objectNumber";
48 private final static String UPDATE_DATE_ELEMENT_NAME = "updatedAt";
49 private final static String WORKFLOW_COMMON_SCHEMA_NAME = "workflow_common";
50 private final static String WORKFLOW_COMMON_NAMESPACE_PREFIX = "ns2";
51 private final static String WORKFLOW_COMMON_NAMESPACE_URI =
52 "http://collectionspace.org/services/workflow";
53 private final static Namespace WORKFLOW_COMMON_NAMESPACE =
54 Namespace.getNamespace(
55 WORKFLOW_COMMON_NAMESPACE_PREFIX,
56 WORKFLOW_COMMON_NAMESPACE_URI);
57 private final static String COLLECTIONOBJECTS_COMMON_SCHEMA_NAME = "collectionobjects_common";
58 private final static String COLLECTIONOBJECTS_COMMON_NAMESPACE_PREFIX = "ns2";
59 private final static String COLLECTIONOBJECTS_COMMON_NAMESPACE_URI =
60 "http://collectionspace.org/services/collectionobject";
61 private final static Namespace COLLECTIONOBJECTS_COMMON_NAMESPACE =
62 Namespace.getNamespace(
63 COLLECTIONOBJECTS_COMMON_NAMESPACE_PREFIX,
64 COLLECTIONOBJECTS_COMMON_NAMESPACE_URI);
65 private final boolean EXCLUDE_DELETED = true;
66 private final String CLASSNAME = this.getClass().getSimpleName();
67 private final Logger logger = LoggerFactory.getLogger(this.getClass());
69 // Initialization tasks
70 public UpdateObjectLocationBatchJob() {
71 setSupportedInvocationModes(Arrays.asList(INVOCATION_MODE_SINGLE, INVOCATION_MODE_LIST,
72 INVOCATION_MODE_GROUP, INVOCATION_MODE_NO_CONTEXT));
76 * The main work logic of the batch job. Will be called after setContext.
81 setCompletionStatus(STATUS_MIN_PROGRESS);
85 List<String> csids = new ArrayList<String>();
87 // Build a list of CollectionObject records to process via this
88 // batch job, depending on the invocation mode requested.
89 if (requestIsForInvocationModeSingle()) {
90 String singleCsid = getInvocationContext().getSingleCSID();
91 if (Tools.isBlank(singleCsid)) {
92 throw new Exception(CSID_VALUES_NOT_PROVIDED_IN_INVOCATION_CONTEXT);
94 csids.add(singleCsid);
96 } else if (requestIsForInvocationModeList()) {
97 List<String> listCsids = getListCsids();
98 if (listCsids.isEmpty()) {
99 throw new Exception(CSID_VALUES_NOT_PROVIDED_IN_INVOCATION_CONTEXT);
101 csids.addAll(listCsids);
102 } else if (requestIsForInvocationModeGroup()) {
103 String groupCsid = getInvocationContext().getGroupCSID();
104 if (Tools.isBlank(groupCsid)) {
105 throw new Exception(CSID_VALUES_NOT_PROVIDED_IN_INVOCATION_CONTEXT);
107 List<String> groupMemberCsids = getMemberCsidsFromGroup(CollectionObjectClient.SERVICE_NAME, groupCsid);
108 if (groupMemberCsids.isEmpty()) {
109 throw new Exception(CSID_VALUES_NOT_PROVIDED_IN_INVOCATION_CONTEXT);
111 csids.addAll(groupMemberCsids);
112 } else if (requestIsForInvocationModeNoContext()) {
113 List<String> noContextCsids = getNoContextCsids();
114 if (noContextCsids.isEmpty()) {
115 throw new Exception(CSID_VALUES_NOT_PROVIDED_IN_INVOCATION_CONTEXT);
117 csids.addAll(noContextCsids);
119 if (logger.isInfoEnabled()) {
120 logger.info("Identified " + csids.size() + " total CollectionObject(s) to be processed via the " + CLASSNAME + " batch job");
123 // Update the value of the computed current location field for each CollectionObject
124 setResults(updateComputedCurrentLocations(csids));
125 setCompletionStatus(STATUS_COMPLETE);
127 } catch (Exception e) {
128 String errMsg = "Error encountered in " + CLASSNAME + ": " + e.getLocalizedMessage();
129 setErrorResult(errMsg);
134 private InvocationResults updateComputedCurrentLocations(List<String> csids) {
135 ResourceMap resourcemap = getResourceMap();
136 NuxeoBasedResource collectionObjectResource = (NuxeoBasedResource) resourcemap.get(CollectionObjectClient.SERVICE_NAME);
137 NuxeoBasedResource movementResource = (NuxeoBasedResource) resourcemap.get(MovementClient.SERVICE_NAME);
138 String computedCurrentLocation;
143 // For each CollectionObject record
144 for (String collectionObjectCsid : csids) {
146 // FIXME: Optionally set competition status here to
147 // indicate what percentage of records have been processed.
149 // Skip over soft-deleted CollectionObject records
151 // (Invocations using the 'no context' mode have already
152 // filtered out soft-deleted records.)
153 if (!requestIsForInvocationModeNoContext()) {
154 if (isRecordDeleted(collectionObjectResource, collectionObjectCsid)) {
155 if (logger.isTraceEnabled()) {
156 logger.trace("Skipping soft-deleted CollectionObject record with CSID " + collectionObjectCsid);
161 // Get the Movement records related to this CollectionObject record
162 AbstractCommonList relatedMovements =
163 getRelatedRecords(movementResource, collectionObjectCsid, EXCLUDE_DELETED);
164 // Skip over CollectionObject records that have no related Movement records
165 if (relatedMovements.getListItem().isEmpty()) {
168 // Get the most recent 'suitable' Movement record, one which
169 // contains both a location date and a current location value
170 AbstractCommonList.ListItem mostRecentMovement = getMostRecentMovement(relatedMovements);
171 // Skip over CollectionObject records where no suitable
172 // most recent Movement record can be identified.
174 // FIXME: Clarify: it ever necessary to 'unset' a computed
175 // current location value, by setting it to a null or empty value,
176 // if that value is no longer obtainable from related Movement
178 if (mostRecentMovement == null) {
181 // Update the value of the computed current location field
182 // (and, via subclasses, this and/or other relevant fields)
183 // in the CollectionObject record
184 numUpdated = updateCollectionObjectValues(collectionObjectResource,
185 collectionObjectCsid, mostRecentMovement, resourcemap, numUpdated);
188 } catch (Exception e) {
189 String errMsg = "Error encountered in " + CLASSNAME + ": " + e.getLocalizedMessage() + " ";
190 errMsg = errMsg + "Successfully updated " + numUpdated + " CollectionObject record(s) prior to error.";
191 logger.error(errMsg);
192 setErrorResult(errMsg);
193 getResults().setNumAffected(numUpdated);
197 logger.info("Updated computedCurrentLocation values in " + numUpdated + " CollectionObject record(s).");
198 getResults().setNumAffected(numUpdated);
202 private AbstractCommonList.ListItem getMostRecentMovement(AbstractCommonList relatedMovements) {
203 Set<String> alreadyProcessedMovementCsids = new HashSet<String>();
204 AbstractCommonList.ListItem mostRecentMovement = null;
206 String currentLocation;
209 String mostRecentLocationDate = "";
210 String comparisonUpdateDate = "";
211 for (AbstractCommonList.ListItem movementListItem : relatedMovements.getListItem()) {
212 movementCsid = AbstractCommonListUtils.ListItemGetElementValue(movementListItem, CSID_ELEMENT_NAME);
213 if (Tools.isBlank(movementCsid)) {
216 // Skip over any duplicates in the list, such as records that might
217 // appear as the subject of one relation record and the object of
218 // its reciprocal relation record
219 if (alreadyProcessedMovementCsids.contains(movementCsid)) {
222 alreadyProcessedMovementCsids.add(movementCsid);
224 locationDate = AbstractCommonListUtils.ListItemGetElementValue(movementListItem, LOCATION_DATE_ELEMENT_NAME);
225 if (Tools.isBlank(locationDate)) {
228 updateDate = AbstractCommonListUtils.ListItemGetElementValue(movementListItem, UPDATE_DATE_ELEMENT_NAME);
229 if (Tools.isBlank(updateDate)) {
232 currentLocation = AbstractCommonListUtils.ListItemGetElementValue(movementListItem, CURRENT_LOCATION_ELEMENT_NAME);
233 if (Tools.isBlank(currentLocation)) {
236 // Validate that this Movement record's currentLocation value parses
237 // successfully as an item refName, before identifying that record
238 // as the most recent Movement.
240 // TODO: Consider making this optional validation, in turn dependent on the
241 // value of a parameter passed in during batch job invocation.
242 if (RefNameUtils.parseAuthorityTermInfo(currentLocation) == null) {
243 logger.warn(String.format("Could not parse current location refName '%s' in Movement record",
248 if (logger.isTraceEnabled()) {
249 logger.trace("Location date value = " + locationDate);
250 logger.trace("Update date value = " + updateDate);
251 logger.trace("Current location value = " + currentLocation);
254 // If this record's location date value is more recent than that of other
255 // Movement records processed so far, set the current Movement record
256 // as the most recent Movement.
258 // The following comparison assumes that all values for this element/field
259 // will be consistent ISO 8601 date/time representations, each of which can
260 // be ordered via string comparison.
262 // If this is *not* the case, we should instead parse and convert these values
263 // to date/time objects.
264 if (locationDate.compareTo(mostRecentLocationDate) > 0) {
265 mostRecentLocationDate = locationDate;
266 mostRecentMovement = movementListItem;
267 comparisonUpdateDate = updateDate;
268 } else if (locationDate.compareTo(mostRecentLocationDate) == 0) {
269 // If the two location dates match, then use a tiebreaker
270 if (updateDate.compareTo(comparisonUpdateDate) > 0) {
271 // The most recent location date value doesn't need to be
272 // updated here, as the two records' values are identical
273 mostRecentMovement = movementListItem;
274 comparisonUpdateDate = updateDate;
279 return mostRecentMovement;
282 // This method can be overridden and extended to update a custom set of
283 // values in the CollectionObject record by pulling in values from its
284 // most recent related Movement record.
286 // Note: any such values must first be exposed in Movement list items,
287 // in turn via configuration in Services tenant bindings ("listResultsField").
288 protected int updateCollectionObjectValues(NuxeoBasedResource collectionObjectResource,
289 String collectionObjectCsid, AbstractCommonList.ListItem mostRecentMovement,
290 ResourceMap resourcemap, int numUpdated)
291 throws DocumentException, URISyntaxException {
292 PoxPayloadOut collectionObjectPayload;
293 String computedCurrentLocation;
295 String previousComputedCurrentLocation;
297 collectionObjectPayload = findByCsid(collectionObjectResource, collectionObjectCsid);
298 if (Tools.isBlank(collectionObjectPayload.toXML())) {
301 if (logger.isTraceEnabled()) {
302 logger.trace("Payload: " + "\n" + collectionObjectPayload);
305 // Perform the update only if the computed current location value will change
306 // as a result of the update
307 computedCurrentLocation =
308 AbstractCommonListUtils.ListItemGetElementValue(mostRecentMovement, CURRENT_LOCATION_ELEMENT_NAME);
309 previousComputedCurrentLocation = getFieldElementValue(collectionObjectPayload,
310 COLLECTIONOBJECTS_COMMON_SCHEMA_NAME, COLLECTIONOBJECTS_COMMON_NAMESPACE,
311 COMPUTED_CURRENT_LOCATION_ELEMENT_NAME);
312 if (!shouldUpdateLocation(previousComputedCurrentLocation, computedCurrentLocation)) {
316 // Perform the update only if there is a non-blank object number available.
318 // In the default CollectionObject validation handler, the object number
319 // is a required field and its (non-blank) value must be present in update
320 // payloads to successfully perform an update.
321 objectNumber = getFieldElementValue(collectionObjectPayload,
322 COLLECTIONOBJECTS_COMMON_SCHEMA_NAME, COLLECTIONOBJECTS_COMMON_NAMESPACE,
323 OBJECT_NUMBER_ELEMENT_NAME);
324 if (logger.isTraceEnabled()) {
325 logger.trace("Object number: " + objectNumber);
327 // FIXME: Consider making the requirement that a non-blank object number
328 // be present dependent on the value of a parameter passed in during
329 // batch job invocation, as some implementations may have turned off that
330 // validation requirement.
331 if (Tools.isBlank(objectNumber)) {
335 // At this point in the code, the most recent related Movement record
336 // should not have a null current location, as such records are
337 // excluded from consideration altogether in getMostRecentMovement().
338 // This is a redundant fallback check, in case that code somehow fails
339 // or is modified or deleted.
340 if (computedCurrentLocation == null) {
344 // Update the location.
345 String collectionObjectUpdatePayload =
346 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
347 + "<document name=\"collectionobject\">"
348 + " <ns2:collectionobjects_common "
349 + " xmlns:ns2=\"http://collectionspace.org/services/collectionobject\">"
350 + " <objectNumber>" + objectNumber + "</objectNumber>"
351 + " <computedCurrentLocation>" + computedCurrentLocation + "</computedCurrentLocation>"
352 + " </ns2:collectionobjects_common>"
354 if (logger.isTraceEnabled()) {
355 logger.trace("Update payload: " + "\n" + collectionObjectUpdatePayload);
357 byte[] response = collectionObjectResource.update(resourcemap, null, collectionObjectCsid,
358 collectionObjectUpdatePayload);
360 if (logger.isTraceEnabled()) {
361 logger.trace("Computed current location value for CollectionObject " + collectionObjectCsid
362 + " was set to " + computedCurrentLocation);
368 protected boolean shouldUpdateLocation(String previousLocation, String currentLocation) {
369 boolean shouldUpdate = true;
370 if (Tools.isBlank(previousLocation) && Tools.isBlank(currentLocation)) {
371 shouldUpdate = false;
372 } else if (Tools.notBlank(previousLocation) && previousLocation.equals(currentLocation)) {
373 shouldUpdate = false;
378 // #################################################################
379 // Ray Lee's convenience methods from his AbstractBatchJob class for the
380 // UC Berkeley Botanical Garden v2.4 implementation.
381 // #################################################################
382 protected PoxPayloadOut findByCsid(String serviceName, String csid) throws URISyntaxException, DocumentException {
383 NuxeoBasedResource resource = (NuxeoBasedResource) getResourceMap().get(serviceName);
384 return findByCsid(resource, csid);
387 protected PoxPayloadOut findByCsid(NuxeoBasedResource resource, String csid) throws URISyntaxException, DocumentException {
388 byte[] response = resource.get(null, createUriInfo(), csid);
389 PoxPayloadOut payload = new PoxPayloadOut(response);
393 protected UriInfo createUriInfo() throws URISyntaxException {
394 return createUriInfo("");
397 private UriInfo createUriInfo(String queryString) throws URISyntaxException {
398 URI absolutePath = new URI("");
399 URI baseUri = new URI("");
400 return new UriInfoImpl(absolutePath, baseUri, "", queryString, Collections.<PathSegment>emptyList());
403 // #################################################################
404 // Other convenience methods
405 // #################################################################
406 protected UriInfo createRelatedRecordsUriInfo(String queryString) throws URISyntaxException {
407 URI uri = new URI(null, null, null, queryString, null);
408 return createUriInfo(uri.getRawQuery());
411 protected String getFieldElementValue(PoxPayloadOut payload, String partLabel, Namespace partNamespace, String fieldPath) {
413 SAXBuilder builder = new SAXBuilder();
415 Document document = builder.build(new StringReader(payload.toXML()));
416 Element root = document.getRootElement();
417 // The part element is always expected to have an explicit namespace.
418 Element part = root.getChild(partLabel, partNamespace);
419 // Try getting the field element both with and without a namespace.
420 // Even though a field element that lacks a namespace prefix
421 // may yet inherit its namespace from a parent, JDOM may require that
422 // the getChild() call be made without a namespace.
423 Element field = part.getChild(fieldPath, partNamespace);
425 field = part.getChild(fieldPath);
428 value = field.getText();
430 } catch (Exception e) {
431 logger.error("Error getting value from field path " + fieldPath
432 + " in schema part " + partLabel);
438 private boolean isRecordDeleted(NuxeoBasedResource resource, String collectionObjectCsid)
439 throws URISyntaxException, DocumentException {
440 boolean isDeleted = false;
441 byte[] workflowResponse = resource.getWorkflow(createUriInfo(), collectionObjectCsid);
442 if (workflowResponse != null) {
443 PoxPayloadOut payloadOut = new PoxPayloadOut(workflowResponse);
444 String workflowState =
445 getFieldElementValue(payloadOut, WORKFLOW_COMMON_SCHEMA_NAME,
446 WORKFLOW_COMMON_NAMESPACE, LIFECYCLE_STATE_ELEMENT_NAME);
447 if (Tools.notBlank(workflowState) && workflowState.equals(WorkflowClient.WORKFLOWSTATE_DELETED)) {
454 private UriInfo addFilterToExcludeSoftDeletedRecords(UriInfo uriInfo) throws URISyntaxException {
455 if (uriInfo == null) {
456 uriInfo = createUriInfo();
458 uriInfo.getQueryParameters().add(WorkflowClient.WORKFLOW_QUERY_NONDELETED, Boolean.FALSE.toString());
462 private AbstractCommonList getRecordsRelatedToCsid(NuxeoBasedResource resource, String csid,
463 String relationshipDirection, boolean excludeDeletedRecords) throws URISyntaxException {
464 UriInfo uriInfo = createUriInfo();
465 uriInfo.getQueryParameters().add(relationshipDirection, csid);
466 if (excludeDeletedRecords) {
467 uriInfo = addFilterToExcludeSoftDeletedRecords(uriInfo);
469 // The 'resource' type used here identifies the record type of the
470 // related records to be retrieved
471 AbstractCommonList relatedRecords = resource.getList(uriInfo);
472 if (logger.isTraceEnabled()) {
473 logger.trace("Identified " + relatedRecords.getTotalItems()
474 + " record(s) related to the object record via direction " + relationshipDirection + " with CSID " + csid);
476 return relatedRecords;
480 * Returns the records of a specified type that are related to a specified
481 * record, where that record is the object of the relation.
483 * @param resource a resource. The type of this resource determines the type
484 * of related records that are returned.
485 * @param csid a CSID identifying a record
486 * @param excludeDeletedRecords true if 'soft-deleted' records should be
487 * excluded from results; false if those records should be included
488 * @return a list of records of a specified type, related to a specified
490 * @throws URISyntaxException
492 private AbstractCommonList getRecordsRelatedToObjectCsid(NuxeoBasedResource resource, String csid, boolean excludeDeletedRecords) throws URISyntaxException {
493 return getRecordsRelatedToCsid(resource, csid, IQueryManager.SEARCH_RELATED_TO_CSID_AS_OBJECT, excludeDeletedRecords);
497 * Returns the records of a specified type that are related to a specified
498 * record, where that record is the subject of the relation.
500 * @param resource a resource. The type of this resource determines the type
501 * of related records that are returned.
502 * @param csid a CSID identifying a record
503 * @param excludeDeletedRecords true if 'soft-deleted' records should be
504 * excluded from results; false if those records should be included
505 * @return a list of records of a specified type, related to a specified
507 * @throws URISyntaxException
509 private AbstractCommonList getRecordsRelatedToSubjectCsid(NuxeoBasedResource resource, String csid, boolean excludeDeletedRecords) throws URISyntaxException {
510 return getRecordsRelatedToCsid(resource, csid, IQueryManager.SEARCH_RELATED_TO_CSID_AS_SUBJECT, excludeDeletedRecords);
513 private AbstractCommonList getRelatedRecords(NuxeoBasedResource resource, String csid, boolean excludeDeletedRecords)
514 throws URISyntaxException, DocumentException {
515 AbstractCommonList relatedRecords = new AbstractCommonList();
516 AbstractCommonList recordsRelatedToObjectCSID = getRecordsRelatedToObjectCsid(resource, csid, excludeDeletedRecords);
517 AbstractCommonList recordsRelatedToSubjectCSID = getRecordsRelatedToSubjectCsid(resource, csid, excludeDeletedRecords);
518 // If either list contains any related records, merge in its items
519 if (recordsRelatedToObjectCSID.getListItem().size() > 0) {
520 relatedRecords.getListItem().addAll(recordsRelatedToObjectCSID.getListItem());
522 if (recordsRelatedToSubjectCSID.getListItem().size() > 0) {
523 relatedRecords.getListItem().addAll(recordsRelatedToSubjectCSID.getListItem());
525 if (logger.isTraceEnabled()) {
526 logger.trace("Identified a total of " + relatedRecords.getListItem().size()
527 + " record(s) related to the record with CSID " + csid);
529 return relatedRecords;
532 private List<String> getCsidsList(AbstractCommonList list) {
533 List<String> csids = new ArrayList<String>();
534 for (AbstractCommonList.ListItem listitem : list.getListItem()) {
535 csids.add(AbstractCommonListUtils.ListItemGetCSID(listitem));
540 private List<String> getMemberCsidsFromGroup(String serviceName, String groupCsid) throws URISyntaxException, DocumentException {
541 ResourceMap resourcemap = getResourceMap();
542 NuxeoBasedResource resource = (NuxeoBasedResource) resourcemap.get(serviceName);
543 return getMemberCsidsFromGroup(resource, groupCsid);
546 private List<String> getMemberCsidsFromGroup(NuxeoBasedResource resource, String groupCsid) throws URISyntaxException, DocumentException {
547 // The 'resource' type used here identifies the record type of the
548 // related records to be retrieved
549 AbstractCommonList relatedRecords =
550 getRelatedRecords(resource, groupCsid, EXCLUDE_DELETED);
551 List<String> memberCsids = getCsidsList(relatedRecords);
555 private List<String> getNoContextCsids() throws URISyntaxException {
556 ResourceMap resourcemap = getResourceMap();
557 NuxeoBasedResource collectionObjectResource = (NuxeoBasedResource) resourcemap.get(CollectionObjectClient.SERVICE_NAME);
558 UriInfo uriInfo = createUriInfo();
559 uriInfo = addFilterToExcludeSoftDeletedRecords(uriInfo);
560 AbstractCommonList collectionObjects = collectionObjectResource.getList(uriInfo);
561 List<String> noContextCsids = getCsidsList(collectionObjects);
562 return noContextCsids;