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;
12 import javax.ws.rs.core.PathSegment;
13 import javax.ws.rs.core.UriInfo;
14 import org.collectionspace.services.batch.AbstractBatchInvocable;
15 import org.collectionspace.services.client.AbstractCommonListUtils;
16 import org.collectionspace.services.client.CollectionObjectClient;
17 import org.collectionspace.services.client.IQueryManager;
18 import org.collectionspace.services.client.MovementClient;
19 import org.collectionspace.services.client.PoxPayloadOut;
20 import org.collectionspace.services.client.workflow.WorkflowClient;
21 import org.collectionspace.services.common.ResourceBase;
22 import org.collectionspace.services.common.ResourceMap;
23 import org.collectionspace.services.common.api.Tools;
24 import org.collectionspace.services.common.invocable.InvocationResults;
25 import org.collectionspace.services.jaxb.AbstractCommonList;
26 import org.dom4j.DocumentException;
27 import org.jboss.resteasy.specimpl.UriInfoImpl;
28 import org.jdom.Document;
29 import org.jdom.Element;
30 import org.jdom.Namespace;
31 import org.jdom.input.SAXBuilder;
32 import org.slf4j.Logger;
33 import org.slf4j.LoggerFactory;
35 public class UpdateObjectLocationBatchJob extends AbstractBatchInvocable {
37 // FIXME: Where appropriate, get from existing constants rather than local declarations
38 private final static String COMPUTED_CURRENT_LOCATION_ELEMENT_NAME = "computedCurrentLocation";
39 private final static String CSID_ELEMENT_NAME = "csid";
40 private final static String CURRENT_LOCATION_ELEMENT_NAME = "currentLocation";
41 private final static String LIFECYCLE_STATE_ELEMENT_NAME = "currentLifeCycleState";
42 private final static String LOCATION_DATE_ELEMENT_NAME = "locationDate";
43 private final static String OBJECT_NUMBER_ELEMENT_NAME = "objectNumber";
44 private final static String UPDATE_DATE_ELEMENT_NAME = "updatedAt";
45 private final static String WORKFLOW_COMMON_SCHEMA_NAME = "workflow_common";
46 private final static String WORKFLOW_COMMON_NAMESPACE_PREFIX = "ns2";
47 private final static String WORKFLOW_COMMON_NAMESPACE_URI =
48 "http://collectionspace.org/services/workflow";
49 private final static Namespace WORKFLOW_COMMON_NAMESPACE =
50 Namespace.getNamespace(
51 WORKFLOW_COMMON_NAMESPACE_PREFIX,
52 WORKFLOW_COMMON_NAMESPACE_URI);
53 private final static String COLLECTIONOBJECTS_COMMON_SCHEMA_NAME = "collectionobjects_common";
54 private final static String COLLECTIONOBJECTS_COMMON_NAMESPACE_PREFIX = "ns2";
55 private final static String COLLECTIONOBJECTS_COMMON_NAMESPACE_URI =
56 "http://collectionspace.org/services/collectionobject";
57 private final static Namespace COLLECTIONOBJECTS_COMMON_NAMESPACE =
58 Namespace.getNamespace(
59 COLLECTIONOBJECTS_COMMON_NAMESPACE_PREFIX,
60 COLLECTIONOBJECTS_COMMON_NAMESPACE_URI);
61 private final boolean EXCLUDE_DELETED = true;
62 private final String CLASSNAME = this.getClass().getSimpleName();
63 private final Logger logger = LoggerFactory.getLogger(this.getClass());
65 // Initialization tasks
66 public UpdateObjectLocationBatchJob() {
67 setSupportedInvocationModes(Arrays.asList(INVOCATION_MODE_SINGLE, INVOCATION_MODE_LIST,
68 INVOCATION_MODE_GROUP, INVOCATION_MODE_NO_CONTEXT));
72 * The main work logic of the batch job. Will be called after setContext.
77 setCompletionStatus(STATUS_MIN_PROGRESS);
81 List<String> csids = new ArrayList<String>();
83 // Build a list of CollectionObject records to process via this
84 // batch job, depending on the invocation mode requested.
85 if (requestIsForInvocationModeSingle()) {
86 String singleCsid = getInvocationContext().getSingleCSID();
87 if (Tools.isBlank(singleCsid)) {
88 throw new Exception(CSID_VALUES_NOT_PROVIDED_IN_INVOCATION_CONTEXT);
90 csids.add(singleCsid);
92 } else if (requestIsForInvocationModeList()) {
93 List<String> listCsids = getListCsids();
94 if (listCsids.isEmpty()) {
95 throw new Exception(CSID_VALUES_NOT_PROVIDED_IN_INVOCATION_CONTEXT);
97 csids.addAll(listCsids);
98 } else if (requestIsForInvocationModeGroup()) {
99 String groupCsid = getInvocationContext().getGroupCSID();
100 if (Tools.isBlank(groupCsid)) {
101 throw new Exception(CSID_VALUES_NOT_PROVIDED_IN_INVOCATION_CONTEXT);
103 List<String> groupMemberCsids = getMemberCsidsFromGroup(CollectionObjectClient.SERVICE_NAME, groupCsid);
104 if (groupMemberCsids.isEmpty()) {
105 throw new Exception(CSID_VALUES_NOT_PROVIDED_IN_INVOCATION_CONTEXT);
107 csids.addAll(groupMemberCsids);
108 } else if (requestIsForInvocationModeNoContext()) {
109 List<String> noContextCsids = getNoContextCsids();
110 if (noContextCsids.isEmpty()) {
111 throw new Exception(CSID_VALUES_NOT_PROVIDED_IN_INVOCATION_CONTEXT);
113 csids.addAll(noContextCsids);
115 if (logger.isInfoEnabled()) {
116 logger.info("Identified " + csids.size() + " total CollectionObject(s) to be processed via the " + CLASSNAME + " batch job");
119 // Update the value of the computed current location field for each CollectionObject
120 setResults(updateComputedCurrentLocations(csids));
121 setCompletionStatus(STATUS_COMPLETE);
123 } catch (Exception e) {
124 String errMsg = "Error encountered in " + CLASSNAME + ": " + e.getLocalizedMessage();
125 setErrorResult(errMsg);
130 private InvocationResults updateComputedCurrentLocations(List<String> csids) {
131 ResourceMap resourcemap = getResourceMap();
132 ResourceBase collectionObjectResource = resourcemap.get(CollectionObjectClient.SERVICE_NAME);
133 ResourceBase movementResource = resourcemap.get(MovementClient.SERVICE_NAME);
134 String computedCurrentLocation;
139 // For each CollectionObject record
140 for (String collectionObjectCsid : csids) {
142 // FIXME: Optionally set competition status here to
143 // indicate what percentage of records have been processed.
145 // Skip over soft-deleted CollectionObject records
147 // (Invocations using the 'no context' mode have already
148 // filtered out soft-deleted records.)
149 if (!requestIsForInvocationModeNoContext()) {
150 if (isRecordDeleted(collectionObjectResource, collectionObjectCsid)) {
151 if (logger.isTraceEnabled()) {
152 logger.trace("Skipping soft-deleted CollectionObject record with CSID " + collectionObjectCsid);
157 // Get the Movement records related to this CollectionObject record
158 AbstractCommonList relatedMovements =
159 getRelatedRecords(movementResource, collectionObjectCsid, EXCLUDE_DELETED);
160 // Skip over CollectionObject records that have no related Movement records
161 if (relatedMovements.getListItem().isEmpty()) {
164 // Get the most recent 'suitable' Movement record, one which
165 // contains both a location date and a current location value
166 AbstractCommonList.ListItem mostRecentMovement = getMostRecentMovement(relatedMovements);
167 // Skip over CollectionObject records where no suitable
168 // most recent Movement record can be identified.
170 // FIXME: Clarify: it ever necessary to 'unset' a computed
171 // current location value, by setting it to a null or empty value,
172 // if that value is no longer obtainable from related Movement
174 if (mostRecentMovement == null) {
177 // Update the value of the computed current location field
178 // (and, via subclasses, this and/or other relevant fields)
179 // in the CollectionObject record
180 numUpdated = updateCollectionObjectValues(collectionObjectResource,
181 collectionObjectCsid, mostRecentMovement, resourcemap, numUpdated);
184 } catch (Exception e) {
185 String errMsg = "Error encountered in " + CLASSNAME + ": " + e.getLocalizedMessage() + " ";
186 errMsg = errMsg + "Successfully updated " + numUpdated + " CollectionObject record(s) prior to error.";
187 logger.error(errMsg);
188 setErrorResult(errMsg);
189 getResults().setNumAffected(numUpdated);
193 logger.info("Updated computedCurrentLocation values in " + numUpdated + " CollectionObject record(s).");
194 getResults().setNumAffected(numUpdated);
198 private AbstractCommonList.ListItem getMostRecentMovement(AbstractCommonList relatedMovements) {
199 Set<String> alreadyProcessedMovementCsids = new HashSet<String>();
200 AbstractCommonList.ListItem mostRecentMovement = null;
202 String currentLocation;
205 String mostRecentLocationDate = "";
206 String comparisonUpdateDate = "";
207 for (AbstractCommonList.ListItem movementListItem : relatedMovements.getListItem()) {
208 movementCsid = AbstractCommonListUtils.ListItemGetElementValue(movementListItem, CSID_ELEMENT_NAME);
209 if (Tools.isBlank(movementCsid)) {
212 // Skip over any duplicates in the list, such as records that might
213 // appear as the subject of one relation record and the object of
214 // its reciprocal relation record
215 if (alreadyProcessedMovementCsids.contains(movementCsid)) {
218 alreadyProcessedMovementCsids.add(movementCsid);
220 locationDate = AbstractCommonListUtils.ListItemGetElementValue(movementListItem, LOCATION_DATE_ELEMENT_NAME);
221 if (Tools.isBlank(locationDate)) {
224 updateDate = AbstractCommonListUtils.ListItemGetElementValue(movementListItem, UPDATE_DATE_ELEMENT_NAME);
225 if (Tools.isBlank(updateDate)) {
228 currentLocation = AbstractCommonListUtils.ListItemGetElementValue(movementListItem, CURRENT_LOCATION_ELEMENT_NAME);
229 if (Tools.isBlank(currentLocation)) {
232 // FIXME: Add optional validation here that this Movement record's
233 // currentLocation value parses successfully as an item refName,
234 // before identifying that record as the most recent Movement.
235 // Consider making this optional validation, in turn dependent on the
236 // value of a parameter passed in during batch job invocation.
237 if (logger.isTraceEnabled()) {
238 logger.trace("Location date value = " + locationDate);
239 logger.trace("Update date value = " + updateDate);
240 logger.trace("Current location value = " + currentLocation);
242 // If this record's location date value is more recent than that of other
243 // Movement records processed so far, set the current Movement record
244 // as the most recent Movement.
246 // The following comparison assumes that all values for this element/field
247 // will be consistent ISO 8601 date/time representations, each of which can
248 // be ordered via string comparison.
250 // If this is *not* the case, we should instead parse and convert these values
251 // to date/time objects.
252 if (locationDate.compareTo(mostRecentLocationDate) > 0) {
253 mostRecentLocationDate = locationDate;
254 mostRecentMovement = movementListItem;
255 comparisonUpdateDate = updateDate;
256 } else if (locationDate.compareTo(mostRecentLocationDate) == 0) {
257 // If the two location dates match, then use a tiebreaker
258 if (updateDate.compareTo(comparisonUpdateDate) > 0) {
259 // The most recent location date value doesn't need to be
260 // updated here, as the two records' values are identical
261 mostRecentMovement = movementListItem;
262 comparisonUpdateDate = updateDate;
267 return mostRecentMovement;
270 // This method can be overridden and extended to update a custom set of
271 // values in the CollectionObject record by pulling in values from its
272 // most recent related Movement record.
274 // Note: any such values must first be exposed in Movement list items,
275 // in turn via configuration in Services tenant bindings ("listResultsField").
276 protected int updateCollectionObjectValues(ResourceBase collectionObjectResource,
277 String collectionObjectCsid, AbstractCommonList.ListItem mostRecentMovement,
278 ResourceMap resourcemap, int numUpdated)
279 throws DocumentException, URISyntaxException {
280 PoxPayloadOut collectionObjectPayload;
281 String computedCurrentLocation;
283 String previousComputedCurrentLocation;
285 collectionObjectPayload = findByCsid(collectionObjectResource, collectionObjectCsid);
286 if (Tools.isBlank(collectionObjectPayload.toXML())) {
289 if (logger.isTraceEnabled()) {
290 logger.trace("Payload: " + "\n" + collectionObjectPayload);
293 // Perform the update only if the computed current location value will change
294 // as a result of the update
295 computedCurrentLocation =
296 AbstractCommonListUtils.ListItemGetElementValue(mostRecentMovement, CURRENT_LOCATION_ELEMENT_NAME);
297 previousComputedCurrentLocation = getFieldElementValue(collectionObjectPayload,
298 COLLECTIONOBJECTS_COMMON_SCHEMA_NAME, COLLECTIONOBJECTS_COMMON_NAMESPACE,
299 COMPUTED_CURRENT_LOCATION_ELEMENT_NAME);
300 if (!shouldUpdateLocation(previousComputedCurrentLocation, computedCurrentLocation)) {
304 // Perform the update only if there is a non-blank object number available.
306 // In the default CollectionObject validation handler, the object number
307 // is a required field and its (non-blank) value must be present in update
308 // payloads to successfully perform an update.
309 objectNumber = getFieldElementValue(collectionObjectPayload,
310 COLLECTIONOBJECTS_COMMON_SCHEMA_NAME, COLLECTIONOBJECTS_COMMON_NAMESPACE,
311 OBJECT_NUMBER_ELEMENT_NAME);
312 if (logger.isTraceEnabled()) {
313 logger.trace("Object number: " + objectNumber);
315 // FIXME: Consider making the requirement that a non-blank object number
316 // be present dependent on the value of a parameter passed in during
317 // batch job invocation, as some implementations may have turned off that
318 // validation requirement.
319 if (Tools.isBlank(objectNumber)) {
323 // Update the location.
324 // (Updated location values can legitimately be blank, to 'null out' existing locations.)
325 if (computedCurrentLocation == null) {
326 computedCurrentLocation = "";
329 String collectionObjectUpdatePayload =
330 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
331 + "<document name=\"collectionobject\">"
332 + " <ns2:collectionobjects_common "
333 + " xmlns:ns2=\"http://collectionspace.org/services/collectionobject\">"
334 + " <objectNumber>" + objectNumber + "</objectNumber>"
335 + " <computedCurrentLocation>" + computedCurrentLocation + "</computedCurrentLocation>"
336 + " </ns2:collectionobjects_common>"
338 if (logger.isTraceEnabled()) {
339 logger.trace("Update payload: " + "\n" + collectionObjectUpdatePayload);
341 byte[] response = collectionObjectResource.update(resourcemap, null, collectionObjectCsid,
342 collectionObjectUpdatePayload);
344 if (logger.isTraceEnabled()) {
345 logger.trace("Computed current location value for CollectionObject " + collectionObjectCsid
346 + " was set to " + computedCurrentLocation);
352 protected boolean shouldUpdateLocation(String previousLocation, String currentLocation) {
353 boolean shouldUpdate = true;
354 if (Tools.isBlank(previousLocation) && Tools.isBlank(currentLocation)) {
355 shouldUpdate = false;
356 } else if (Tools.notBlank(previousLocation) && previousLocation.equals(currentLocation)) {
357 shouldUpdate = false;
362 // #################################################################
363 // Ray Lee's convenience methods from his AbstractBatchJob class for the
364 // UC Berkeley Botanical Garden v2.4 implementation.
365 // #################################################################
366 protected PoxPayloadOut findByCsid(String serviceName, String csid) throws URISyntaxException, DocumentException {
367 ResourceBase resource = getResourceMap().get(serviceName);
368 return findByCsid(resource, csid);
371 protected PoxPayloadOut findByCsid(ResourceBase resource, String csid) throws URISyntaxException, DocumentException {
372 byte[] response = resource.get(null, createUriInfo(), csid);
373 PoxPayloadOut payload = new PoxPayloadOut(response);
377 protected UriInfo createUriInfo() throws URISyntaxException {
378 return createUriInfo("");
381 protected UriInfo createUriInfo(String queryString) throws URISyntaxException {
382 URI absolutePath = new URI("");
383 URI baseUri = new URI("");
384 return new UriInfoImpl(absolutePath, baseUri, "", queryString, Collections.<PathSegment>emptyList());
387 // #################################################################
388 // Other convenience methods
389 // #################################################################
390 protected UriInfo createRelatedRecordsUriInfo(String queryString) throws URISyntaxException {
391 URI uri = new URI(null, null, null, queryString, null);
392 return createUriInfo(uri.getRawQuery());
395 protected String getFieldElementValue(PoxPayloadOut payload, String partLabel, Namespace partNamespace, String fieldPath) {
397 SAXBuilder builder = new SAXBuilder();
399 Document document = builder.build(new StringReader(payload.toXML()));
400 Element root = document.getRootElement();
401 // The part element is always expected to have an explicit namespace.
402 Element part = root.getChild(partLabel, partNamespace);
403 // Try getting the field element both with and without a namespace.
404 // Even though a field element that lacks a namespace prefix
405 // may yet inherit its namespace from a parent, JDOM may require that
406 // the getChild() call be made without a namespace.
407 Element field = part.getChild(fieldPath, partNamespace);
409 field = part.getChild(fieldPath);
412 value = field.getText();
414 } catch (Exception e) {
415 logger.error("Error getting value from field path " + fieldPath
416 + " in schema part " + partLabel);
422 private boolean isRecordDeleted(ResourceBase resource, String collectionObjectCsid)
423 throws URISyntaxException, DocumentException {
424 boolean isDeleted = false;
425 byte[] workflowResponse = resource.getWorkflow(createUriInfo(), collectionObjectCsid);
426 if (workflowResponse != null) {
427 PoxPayloadOut payloadOut = new PoxPayloadOut(workflowResponse);
428 String workflowState =
429 getFieldElementValue(payloadOut, WORKFLOW_COMMON_SCHEMA_NAME,
430 WORKFLOW_COMMON_NAMESPACE, LIFECYCLE_STATE_ELEMENT_NAME);
431 if (Tools.notBlank(workflowState) && workflowState.equals(WorkflowClient.WORKFLOWSTATE_DELETED)) {
438 private UriInfo addFilterToExcludeSoftDeletedRecords(UriInfo uriInfo) throws URISyntaxException {
439 if (uriInfo == null) {
440 uriInfo = createUriInfo();
442 uriInfo.getQueryParameters().add(WorkflowClient.WORKFLOW_QUERY_NONDELETED, Boolean.FALSE.toString());
446 private AbstractCommonList getRecordsRelatedToCsid(ResourceBase resource, String csid,
447 String relationshipDirection, boolean excludeDeletedRecords) throws URISyntaxException {
448 UriInfo uriInfo = createUriInfo();
449 uriInfo.getQueryParameters().add(relationshipDirection, csid);
450 if (excludeDeletedRecords) {
451 uriInfo = addFilterToExcludeSoftDeletedRecords(uriInfo);
453 // The 'resource' type used here identifies the record type of the
454 // related records to be retrieved
455 AbstractCommonList relatedRecords = resource.getList(uriInfo);
456 if (logger.isTraceEnabled()) {
457 logger.trace("Identified " + relatedRecords.getTotalItems()
458 + " record(s) related to the object record via direction " + relationshipDirection + " with CSID " + csid);
460 return relatedRecords;
464 * Returns the records of a specified type that are related to a specified
465 * record, where that record is the object of the relation.
467 * @param resource a resource. The type of this resource determines the type
468 * of related records that are returned.
469 * @param csid a CSID identifying a record
470 * @param excludeDeletedRecords true if 'soft-deleted' records should be
471 * excluded from results; false if those records should be included
472 * @return a list of records of a specified type, related to a specified
474 * @throws URISyntaxException
476 private AbstractCommonList getRecordsRelatedToObjectCsid(ResourceBase resource, String csid, boolean excludeDeletedRecords) throws URISyntaxException {
477 return getRecordsRelatedToCsid(resource, csid, IQueryManager.SEARCH_RELATED_TO_CSID_AS_OBJECT, excludeDeletedRecords);
481 * Returns the records of a specified type that are related to a specified
482 * record, where that record is the subject of the relation.
484 * @param resource a resource. The type of this resource determines the type
485 * of related records that are returned.
486 * @param csid a CSID identifying a record
487 * @param excludeDeletedRecords true if 'soft-deleted' records should be
488 * excluded from results; false if those records should be included
489 * @return a list of records of a specified type, related to a specified
491 * @throws URISyntaxException
493 private AbstractCommonList getRecordsRelatedToSubjectCsid(ResourceBase resource, String csid, boolean excludeDeletedRecords) throws URISyntaxException {
494 return getRecordsRelatedToCsid(resource, csid, IQueryManager.SEARCH_RELATED_TO_CSID_AS_SUBJECT, excludeDeletedRecords);
497 private AbstractCommonList getRelatedRecords(ResourceBase resource, String csid, boolean excludeDeletedRecords)
498 throws URISyntaxException, DocumentException {
499 AbstractCommonList relatedRecords = new AbstractCommonList();
500 AbstractCommonList recordsRelatedToObjectCSID = getRecordsRelatedToObjectCsid(resource, csid, excludeDeletedRecords);
501 AbstractCommonList recordsRelatedToSubjectCSID = getRecordsRelatedToSubjectCsid(resource, csid, excludeDeletedRecords);
502 // If either list contains any related records, merge in its items
503 if (recordsRelatedToObjectCSID.getListItem().size() > 0) {
504 relatedRecords.getListItem().addAll(recordsRelatedToObjectCSID.getListItem());
506 if (recordsRelatedToSubjectCSID.getListItem().size() > 0) {
507 relatedRecords.getListItem().addAll(recordsRelatedToSubjectCSID.getListItem());
509 if (logger.isTraceEnabled()) {
510 logger.trace("Identified a total of " + relatedRecords.getListItem().size()
511 + " record(s) related to the record with CSID " + csid);
513 return relatedRecords;
516 private List<String> getCsidsList(AbstractCommonList list) {
517 List<String> csids = new ArrayList<String>();
518 for (AbstractCommonList.ListItem listitem : list.getListItem()) {
519 csids.add(AbstractCommonListUtils.ListItemGetCSID(listitem));
524 private List<String> getMemberCsidsFromGroup(String serviceName, String groupCsid) throws URISyntaxException, DocumentException {
525 ResourceMap resourcemap = getResourceMap();
526 ResourceBase resource = resourcemap.get(serviceName);
527 return getMemberCsidsFromGroup(resource, groupCsid);
530 private List<String> getMemberCsidsFromGroup(ResourceBase resource, String groupCsid) throws URISyntaxException, DocumentException {
531 // The 'resource' type used here identifies the record type of the
532 // related records to be retrieved
533 AbstractCommonList relatedRecords =
534 getRelatedRecords(resource, groupCsid, EXCLUDE_DELETED);
535 List<String> memberCsids = getCsidsList(relatedRecords);
539 private List<String> getNoContextCsids() throws URISyntaxException {
540 ResourceMap resourcemap = getResourceMap();
541 ResourceBase collectionObjectResource = resourcemap.get(CollectionObjectClient.SERVICE_NAME);
542 UriInfo uriInfo = createUriInfo();
543 uriInfo = addFilterToExcludeSoftDeletedRecords(uriInfo);
544 AbstractCommonList collectionObjects = collectionObjectResource.getList(uriInfo);
545 List<String> noContextCsids = getCsidsList(collectionObjects);
546 return noContextCsids;