]> git.aero2k.de Git - tmp/jakarta-migration.git/blob
76ab41f6e5ce1137f2bfa408987be8ff20191192
[tmp/jakarta-migration.git] /
1 package org.collectionspace.services.listener;
2
3 import java.util.GregorianCalendar;
4 import java.util.HashSet;
5 import java.util.Set;
6
7 import org.apache.commons.logging.Log;
8 import org.apache.commons.logging.LogFactory;
9
10 import org.collectionspace.services.client.workflow.WorkflowClient;
11 import org.collectionspace.services.common.api.Tools;
12 import org.collectionspace.services.movement.nuxeo.MovementConstants;
13 import org.collectionspace.services.nuxeo.client.java.CoreSessionInterface;
14 import org.collectionspace.services.nuxeo.client.java.CoreSessionWrapper;
15 import org.collectionspace.services.nuxeo.util.NuxeoUtils;
16
17 import org.nuxeo.ecm.core.api.ClientException;
18 import org.nuxeo.ecm.core.api.DocumentModel;
19 import org.nuxeo.ecm.core.api.DocumentModelList;
20 import org.nuxeo.ecm.core.api.event.DocumentEventTypes;
21 import org.nuxeo.ecm.core.event.Event;
22 import org.nuxeo.ecm.core.event.EventContext;
23 import org.nuxeo.ecm.core.event.EventListener;
24 import org.nuxeo.ecm.core.event.impl.DocumentEventContext;
25
26 public abstract class AbstractUpdateObjectLocationValues implements EventListener {
27
28     // FIXME: We might experiment here with using log4j instead of Apache Commons Logging;
29     // am using the latter to follow Ray's pattern for now
30     private final static Log logger = LogFactory.getLog(AbstractUpdateObjectLocationValues.class);
31     // FIXME: Make the following message, or its equivalent, a constant usable by all event listeners
32     private final static String NO_FURTHER_PROCESSING_MESSAGE =
33             "This event listener will not continue processing this event ...";
34     private final static GregorianCalendar EARLIEST_COMPARISON_DATE = new GregorianCalendar(1600, 1, 1);
35     private final static String RELATIONS_COMMON_SCHEMA = "relations_common"; // FIXME: Get from external constant
36     private final static String RELATION_DOCTYPE = "Relation"; // FIXME: Get from external constant
37     private final static String SUBJECT_CSID_PROPERTY = "subjectCsid"; // FIXME: Get from external constant
38     private final static String OBJECT_CSID_PROPERTY = "objectCsid"; // FIXME: Get from external constant
39     private final static String SUBJECT_DOCTYPE_PROPERTY = "subjectDocumentType"; // FIXME: Get from external constant
40     private final static String OBJECT_DOCTYPE_PROPERTY = "objectDocumentType"; // FIXME: Get from external constant
41     protected final static String COLLECTIONOBJECTS_COMMON_SCHEMA = "collectionobjects_common"; // FIXME: Get from external constant
42     private final static String COLLECTIONOBJECT_DOCTYPE = "CollectionObject"; // FIXME: Get from external constant
43     protected final static String COMPUTED_CURRENT_LOCATION_PROPERTY = "computedCurrentLocation"; // FIXME: Create and then get from external constant
44     protected final static String MOVEMENTS_COMMON_SCHEMA = "movements_common"; // FIXME: Get from external constant
45     private final static String MOVEMENT_DOCTYPE = MovementConstants.NUXEO_DOCTYPE;
46     private final static String LOCATION_DATE_PROPERTY = "locationDate"; // FIXME: Get from external constant
47     protected final static String CURRENT_LOCATION_PROPERTY = "currentLocation"; // FIXME: Get from external constant
48     protected final static String COLLECTIONSPACE_CORE_SCHEMA = "collectionspace_core"; // FIXME: Get from external constant
49     protected final static String CREATED_AT_PROPERTY = "createdAt"; // FIXME: Get from external constant
50     protected final static String UPDATED_AT_PROPERTY = "updatedAt"; // FIXME: Get from external constant
51     private final static String NONVERSIONED_NONPROXY_DOCUMENT_WHERE_CLAUSE_FRAGMENT =
52             "AND ecm:isCheckedInVersion = 0"
53             + "AND ecm:isProxy = 0 ";
54     private final static String ACTIVE_DOCUMENT_WHERE_CLAUSE_FRAGMENT =
55             "AND (ecm:currentLifeCycleState <> 'deleted') "
56             + NONVERSIONED_NONPROXY_DOCUMENT_WHERE_CLAUSE_FRAGMENT;
57     private boolean isAboutToBeRemovedEvent = false;
58     private String movementCsidToFilter;
59     private String eventType;
60     
61     public enum EventNotificationDocumentType {
62         // Document type about which we've received a notification
63
64         MOVEMENT, RELATION;
65     }
66
67     @Override
68     public void handleEvent(Event event) throws ClientException {
69
70         logger.trace("In handleEvent in UpdateObjectLocationOnMove ...");
71
72         EventContext eventContext = event.getContext();
73         if (eventContext == null) {
74             return;
75         }
76
77         if (!(eventContext instanceof DocumentEventContext)) {
78             return;
79         }
80         DocumentEventContext docEventContext = (DocumentEventContext) eventContext;
81         DocumentModel docModel = docEventContext.getSourceDocument();
82
83         eventType = event.getName();
84         if (logger.isTraceEnabled()) {
85             logger.trace("A(n) " + eventType + " event was received by UpdateObjectLocationOnMove ...");
86         }
87         if (eventType.equals(DocumentEventTypes.ABOUT_TO_REMOVE)) {
88             isAboutToBeRemovedEvent = true;
89         }
90
91         // If this document event involves a Relation record, does this pertain to
92         // a relationship between a Movement record and a CollectionObject record?
93         //
94         // If not, we're not interested in processing this document event
95         // in this event handler, as it will have no bearing on updating a
96         // computed current location for a CollectionObject.
97
98         //
99         // (The rest of the code flow below is then identical to that which
100         // is followed when this document event involves a Movement record.)
101         String movementCsid = "";
102         Enum<EventNotificationDocumentType> notificationDocumentType;
103         if (documentMatchesType(docModel, RELATION_DOCTYPE)) {
104             if (logger.isTraceEnabled()) {
105                 logger.trace("An event involving a Relation document was received by UpdateObjectLocationOnMove ...");
106             }
107             // Get a Movement CSID from the Relation record.
108             //
109             // If we can't get it - if this Relation doesn't involve a
110             // Movement - then we don't have a pertinent relation record
111             // that can be processed by this event listener / handler.)
112             movementCsid = getCsidForDesiredDocTypeFromRelation(docModel, MOVEMENT_DOCTYPE, COLLECTIONOBJECT_DOCTYPE);
113             if (Tools.isBlank(movementCsid)) {
114                 logger.warn("Could not obtain CSID for Movement record from document event.");
115                 logger.warn(NO_FURTHER_PROCESSING_MESSAGE);
116                 return;
117             }
118             // If this Relation record is about to be (hard) deleted, set aside
119             // its CSID so it can filtered out in all subsequent processing.
120             if (isAboutToBeRemovedEvent) {
121                 movementCsidToFilter = movementCsid;
122             }
123             notificationDocumentType = EventNotificationDocumentType.RELATION;
124         } else if (documentMatchesType(docModel, MOVEMENT_DOCTYPE)) {
125             // Otherwise, get a Movement CSID directly from the Movement record.
126             if (logger.isTraceEnabled()) {
127                 logger.trace("An event involving a Movement document was received by UpdateObjectLocationOnMove ...");
128             }
129             // FIXME: exclude update events for Movement records here, if we can
130             // identify that we'll still be properly handling update events
131             // that include a relations list as part of the update payload,
132             // perhaps because that may trigger a separate event notification.
133             movementCsid = NuxeoUtils.getCsid(docModel);
134             if (Tools.isBlank(movementCsid)) {
135                 logger.warn("Could not obtain CSID for Movement record from document event.");
136                 logger.warn(NO_FURTHER_PROCESSING_MESSAGE);
137                 return;
138             }
139             // If this Movement record is about to be (hard) deleted, set aside
140             // its CSID so it can filtered out in all subsequent processing.
141             if (isAboutToBeRemovedEvent) {
142                 movementCsidToFilter = movementCsid;
143             }
144             notificationDocumentType = EventNotificationDocumentType.MOVEMENT;
145         } else {
146             if (logger.isTraceEnabled()) {
147                 logger.trace("This event did not involve a document relevant to UpdateObjectLocationOnMove ...");
148             }
149             return;
150         }
151
152         // Note: currently, all Document lifecycle transitions on
153         // the relevant doctype(s) are handled by this event handler,
154         // not just transitions between 'soft deleted' and active states.
155         //
156         // We are assuming that we'll want to re-compute current locations
157         // for related CollectionObjects on all such transitions, as the
158         // semantics of such transitions are opaque to this event handler,
159         // because arbitrary workflows can be bound to those doctype(s).
160         //
161         // If we need to filter out some of those lifecycle transitions,
162         // such as excluding transitions to the 'locked' workflow state; or,
163         // alternately, if we want to restrict this event handler's
164         // scope to handle only transitions into the 'soft deleted' state,
165         // we can add additional checks for doing so at this point in the code.
166
167         // For debugging
168         if (logger.isTraceEnabled()) {
169             logger.trace("Movement CSID=" + movementCsid);
170             logger.trace("Notification document type=" + notificationDocumentType.name());
171         }
172
173         // All Nuxeo sessions that get passed around to CollectionSpace code need to be
174         // wrapped inside of a CoreSessionWrapper
175         CoreSessionInterface coreSession = new CoreSessionWrapper(docEventContext.getCoreSession());
176         Set<String> collectionObjectCsids = new HashSet<>();
177
178         if (notificationDocumentType == EventNotificationDocumentType.RELATION) {
179             String relatedCollectionObjectCsid =
180                     getCsidForDesiredDocTypeFromRelation(docModel, COLLECTIONOBJECT_DOCTYPE, MOVEMENT_DOCTYPE);
181             collectionObjectCsids.add(relatedCollectionObjectCsid);
182         } else if (notificationDocumentType == EventNotificationDocumentType.MOVEMENT) {
183             collectionObjectCsids.addAll(getCollectionObjectCsidsRelatedToMovement(movementCsid, coreSession));
184         }
185
186         if (collectionObjectCsids.isEmpty()) {
187             if (logger.isTraceEnabled()) {
188                 logger.trace("Could not obtain any CSIDs of related CollectionObject records.");
189                 logger.trace(NO_FURTHER_PROCESSING_MESSAGE);
190             }
191             return;
192         } else {
193             if (logger.isTraceEnabled()) {
194                 logger.trace("Found " + collectionObjectCsids.size() + " CSID(s) of related CollectionObject records.");
195             }
196         }
197         // Iterate through the list of CollectionObject CSIDs found.
198         // For each CollectionObject, obtain its most recent, related Movement,
199         // and update relevant field(s) with values from that Movement record.
200         DocumentModel collectionObjectDocModel;
201         DocumentModel mostRecentMovementDocModel;
202         for (String collectionObjectCsid : collectionObjectCsids) {
203             if (logger.isTraceEnabled()) {
204                 logger.trace("CollectionObject CSID=" + collectionObjectCsid);
205             }
206             // Verify that the CollectionObject is both retrievable and active.
207             collectionObjectDocModel = getCurrentDocModelFromCsid(coreSession, collectionObjectCsid);
208             if (collectionObjectDocModel == null) {
209                 if (logger.isTraceEnabled()) {
210                     logger.trace("CollectionObject is not current (i.e. is a non-current version), is a proxy, or is unretrievable.");
211                 }
212                 continue;
213             }
214             // Verify that the CollectionObject record is active.
215             if (!isActiveDocument(collectionObjectDocModel)) {
216                 if (logger.isTraceEnabled()) {
217                     logger.trace("CollectionObject is inactive (i.e. deleted or in an otherwise inactive lifestyle state).");
218                 }
219                 continue;
220             }
221             // Get the CollectionObject's most recent, related Movement.
222             mostRecentMovementDocModel = getMostRecentMovement(coreSession, collectionObjectCsid,
223                     isAboutToBeRemovedEvent, movementCsidToFilter);
224             if (mostRecentMovementDocModel == null) {
225                 continue;
226             }
227             // Update the CollectionObject with values from that Movement.
228             collectionObjectDocModel =
229                     updateCollectionObjectValuesFromMovement(collectionObjectDocModel, mostRecentMovementDocModel);
230             if (logger.isTraceEnabled()) {
231                 String computedCurrentLocationRefName =
232                         (String) collectionObjectDocModel.getProperty(COLLECTIONOBJECTS_COMMON_SCHEMA,
233                         COMPUTED_CURRENT_LOCATION_PROPERTY);
234                 logger.trace("computedCurrentLocation refName after value update=" + computedCurrentLocationRefName);
235             }
236             coreSession.saveDocument(collectionObjectDocModel);
237         }
238     }
239
240     /**
241      * Returns the CSIDs of CollectionObject records that are related to a
242      * Movement record.
243      *
244      * @param movementCsid the CSID of a Movement record.
245      * @param coreSession a repository session.
246      * @throws ClientException
247      * @return the CSIDs of the CollectionObject records, if any, which are
248      * related to the Movement record.
249      */
250     private Set<String> getCollectionObjectCsidsRelatedToMovement(String movementCsid,
251             CoreSessionInterface coreSession) throws ClientException {
252
253         Set<String> csids = new HashSet<>();
254
255         // Via an NXQL query, get a list of active relation records where:
256         // * This movement record's CSID is the subject CSID of the relation,
257         //   and its object document type is a CollectionObject doctype;
258         // or
259         // * This movement record's CSID is the object CSID of the relation,
260         //   and its subject document type is a CollectionObject doctype.
261         //
262         // Some values below are hard-coded for readability, rather than
263         // being obtained from constants.
264         String query = String.format(
265                 "SELECT * FROM %1$s WHERE " // collectionspace_core:tenantId = 1 "
266                 + "("
267                 + "  (%2$s:subjectCsid = '%3$s' "
268                 + "  AND %2$s:objectDocumentType = '%4$s') "
269                 + " OR "
270                 + "  (%2$s:objectCsid = '%3$s' "
271                 + "  AND %2$s:subjectDocumentType = '%4$s') "
272                 + ")"
273                 + ACTIVE_DOCUMENT_WHERE_CLAUSE_FRAGMENT,
274                 RELATION_DOCTYPE, RELATIONS_COMMON_SCHEMA, movementCsid, COLLECTIONOBJECT_DOCTYPE);
275         DocumentModelList relationDocModels = coreSession.query(query);
276         if (relationDocModels == null || relationDocModels.isEmpty()) {
277             // Encountering a Movement record that is not related to any
278             // CollectionObject is potentially a normal occurrence, so no
279             // error messages are logged here when we stop handling this event.
280             return csids;
281         }
282         // Iterate through the list of Relation records found and build
283         // a list of CollectionObject CSIDs, by extracting the relevant CSIDs
284         // from those Relation records.
285         String csid;
286         for (DocumentModel relationDocModel : relationDocModels) {
287             csid = getCsidForDesiredDocTypeFromRelation(relationDocModel, COLLECTIONOBJECT_DOCTYPE, MOVEMENT_DOCTYPE);
288             if (Tools.notBlank(csid)) {
289                 csids.add(csid);
290             }
291         }
292         return csids;
293     }
294
295 // FIXME: Generic methods like many of those below might be split off from
296 // this specific event listener/handler, into an event handler utilities
297 // class, base classes, or otherwise.
298 //
299 // FIXME: Identify whether the equivalent of the documentMatchesType utility
300 // method is already implemented and substitute a call to the latter if so.
301 // This may well already exist.
302     /**
303      * Identifies whether a document matches a supplied document type.
304      *
305      * @param docModel a document model.
306      * @param docType a document type string.
307      * @return true if the document matches the supplied document type; false if
308      * it does not.
309      */
310     protected static boolean documentMatchesType(DocumentModel docModel, String docType) {
311         if (docModel == null || Tools.isBlank(docType)) {
312             return false;
313         }
314         if (docModel.getType().startsWith(docType)) {
315             return true;
316         } else {
317             return false;
318         }
319     }
320
321     /**
322      * Identifies whether a document is an active document; currently, whether
323      * it is not in the 'deleted' workflow state.
324      *
325      * @param docModel
326      * @return true if the document is an active document; false if it is not.
327      */
328     protected static boolean isActiveDocument(DocumentModel docModel) {
329         if (docModel == null) {
330             return false;
331         }
332         boolean isActiveDocument = false;
333         try {
334             if (!docModel.getCurrentLifeCycleState().equals(WorkflowClient.WORKFLOWSTATE_DELETED)) {
335                 isActiveDocument = true;
336             }
337         } catch (ClientException ce) {
338             logger.warn("Error while identifying whether document is an active document: ", ce);
339         }
340         return isActiveDocument;
341     }
342
343     /**
344      * Returns the current document model for a record identified by a CSID.
345      *
346      * Excludes documents which have been versioned (i.e. are a non-current
347      * version of a document), are a proxy for another document, or are
348      * un-retrievable via their CSIDs.
349      *
350      * @param session a repository session.
351      * @param collectionObjectCsid a CollectionObject identifier (CSID)
352      * @return a document model for the document identified by the supplied
353      * CSID.
354      */
355     protected static DocumentModel getCurrentDocModelFromCsid(CoreSessionInterface session, String collectionObjectCsid) {
356         DocumentModelList collectionObjectDocModels = null;
357         try {
358             final String query = "SELECT * FROM "
359                     + NuxeoUtils.BASE_DOCUMENT_TYPE
360                     + " WHERE "
361                     + NuxeoUtils.getByNameWhereClause(collectionObjectCsid)
362                     + " "
363                     + NONVERSIONED_NONPROXY_DOCUMENT_WHERE_CLAUSE_FRAGMENT;
364             collectionObjectDocModels = session.query(query);
365         } catch (Exception e) {
366             logger.warn("Exception in query to get active document model for CollectionObject: ", e);
367         }
368         if (collectionObjectDocModels == null || collectionObjectDocModels.isEmpty()) {
369             logger.warn("Could not get active document models for CollectionObject(s).");
370             return null;
371         } else if (collectionObjectDocModels.size() != 1) {
372             logger.debug("Found more than 1 active document with CSID=" + collectionObjectCsid);
373             return null;
374         }
375         return collectionObjectDocModels.get(0);
376     }
377
378     // FIXME: A quick first pass, using an only partly query-based technique for
379     // getting the most recent Movement record related to a CollectionObject,
380     // augmented by procedural code.
381     //
382     // Could be replaced by a potentially more performant method, based on a query.
383     //
384     // E.g. the following is a sample CMIS query for retrieving Movement records
385     // related to a CollectionObject, which might serve as the basis for that query.
386     /*
387      "SELECT DOC.nuxeo:pathSegment, DOC.dc:title, REL.dc:title,"
388      + "REL.relations_common:objectCsid, REL.relations_common:subjectCsid FROM Movement DOC "
389      + "JOIN Relation REL ON REL.relations_common:objectCsid = DOC.nuxeo:pathSegment "
390      + "WHERE REL.relations_common:subjectCsid = '5b4c617e-53a0-484b-804e' "
391      + "AND DOC.nuxeo:isVersion = false "
392      + "ORDER BY DOC.collectionspace_core:updatedAt DESC";
393      */
394     /**
395      * Returns the most recent Movement record related to a CollectionObject.
396      *
397      * This method currently returns the related Movement record with the latest
398      * (i.e. most recent in time) Location Date field value.
399      *
400      * @param session a repository session.
401      * @param collectionObjectCsid a CollectionObject identifier (CSID)
402      * @param isAboutToBeRemovedEvent whether the current event involves a
403      * record that is slated for removal (hard deletion)
404      * @param movementCsidToFilter the CSID of a Movement record slated for
405      * deletion. This record should be filtered out, prior to returning the most
406      * recent Movement record.
407      * @throws ClientException
408      * @return the most recent Movement record related to the CollectionObject
409      * identified by the supplied CSID.
410      */
411     protected static DocumentModel getMostRecentMovement(CoreSessionInterface session, String collectionObjectCsid,
412             boolean isAboutToBeRemovedEvent, String aboutToBeRemovedMovementCsidToFilter)
413             throws ClientException {
414         DocumentModel mostRecentMovementDocModel = null;
415         // Get Relation records for Movements related to this CollectionObject.
416         //
417         // Some values below are hard-coded for readability, rather than
418         // being obtained from constants.
419         String query = String.format(
420                 "SELECT * FROM %1$s WHERE " // collectionspace_core:tenantId = 1 "
421                 + "("
422                 + "  (%2$s:subjectCsid = '%3$s' "
423                 + "  AND %2$s:objectDocumentType = '%4$s') "
424                 + " OR "
425                 + "  (%2$s:objectCsid = '%3$s' "
426                 + "  AND %2$s:subjectDocumentType = '%4$s') "
427                 + ")"
428                 + ACTIVE_DOCUMENT_WHERE_CLAUSE_FRAGMENT,
429                 RELATION_DOCTYPE, RELATIONS_COMMON_SCHEMA, collectionObjectCsid, MOVEMENT_DOCTYPE);
430         if (logger.isTraceEnabled()) {
431             logger.trace("query=" + query);
432         }
433         DocumentModelList relationDocModels = session.query(query);
434         if (relationDocModels == null || relationDocModels.isEmpty()) {
435             logger.warn("Unexpectedly found no relations to Movement records to/from to this CollectionObject record.");
436             return mostRecentMovementDocModel;
437         } else {
438             if (logger.isTraceEnabled()) {
439                 logger.trace("Found " + relationDocModels.size() + " relations to Movement records to/from this CollectionObject record.");
440             }
441         }
442         // Iterate through related movement records, to find the related
443         // Movement record with the most recent location date.
444         GregorianCalendar mostRecentLocationDate = EARLIEST_COMPARISON_DATE;
445         // Note: the following value is used to compare any two records, rather
446         // than to identify the most recent value so far encountered. Thus, it may
447         // occasionally be set backward or forward in time, within the loop below.
448         GregorianCalendar comparisonUpdatedDate = EARLIEST_COMPARISON_DATE;
449         DocumentModel movementDocModel;
450         Set<String> alreadyProcessedMovementCsids = new HashSet<>();
451         String relatedMovementCsid;
452         for (DocumentModel relationDocModel : relationDocModels) {
453             // Due to the 'OR' operator in the query above, related Movement
454             // record CSIDs may reside in either the subject or object CSID fields
455             // of the relation record. Whichever CSID value doesn't match the
456             // CollectionObject's CSID is inferred to be the related Movement record's CSID.
457             relatedMovementCsid = (String) relationDocModel.getProperty(RELATIONS_COMMON_SCHEMA, SUBJECT_CSID_PROPERTY);
458             if (relatedMovementCsid.equals(collectionObjectCsid)) {
459                 relatedMovementCsid = (String) relationDocModel.getProperty(RELATIONS_COMMON_SCHEMA, OBJECT_CSID_PROPERTY);
460             }
461             if (Tools.isBlank(relatedMovementCsid)) {
462                 continue;
463             }
464             // Because of the OR clause used in the query above, there may be
465             // two or more Relation records returned in the query results that
466             // reference the same Movement record, as either the subject
467             // or object of a relation to the same CollectionObject record;
468             // we need to filter out those duplicates.
469             if (alreadyProcessedMovementCsids.contains(relatedMovementCsid)) {
470                 continue;
471             } else {
472                 alreadyProcessedMovementCsids.add(relatedMovementCsid);
473             }
474             if (logger.isTraceEnabled()) {
475                 logger.trace("Related movement CSID=" + relatedMovementCsid);
476             }
477             // If our event involves a Movement record that is about to be
478             // (hard) deleted, filter out that record.
479             if (isAboutToBeRemovedEvent && Tools.notBlank(aboutToBeRemovedMovementCsidToFilter)) {
480                 if (relatedMovementCsid.equals(aboutToBeRemovedMovementCsidToFilter)) {
481                     if (logger.isTraceEnabled()) {
482                         logger.trace("Skipping about-to-be-deleted Movement record or referenced, related Movement record ...");
483                     }
484                     continue;
485                 }
486             }
487             movementDocModel = getCurrentDocModelFromCsid(session, relatedMovementCsid);
488             if (movementDocModel == null) {
489                 if (logger.isTraceEnabled()) {
490                     logger.trace("Movement is not current (i.e. is a non-current version), is a proxy, or is unretrievable.");
491                 }
492                 continue;
493             }
494             // Verify that the Movement record is active.
495             if (!isActiveDocument(movementDocModel)) {
496                 if (logger.isTraceEnabled()) {
497                     logger.trace("Movement is inactive (i.e. is deleted or in another inactive lifestyle state).");
498                 }
499                 continue;
500             }
501             GregorianCalendar locationDate =
502                     (GregorianCalendar) movementDocModel.getProperty(MOVEMENTS_COMMON_SCHEMA, LOCATION_DATE_PROPERTY);
503             // If the current Movement record lacks a location date, it cannot
504             // be established as the most recent Movement record; skip over it.
505             if (locationDate == null) {
506                 continue;
507             }
508             GregorianCalendar updatedDate =
509                     (GregorianCalendar) movementDocModel.getProperty(COLLECTIONSPACE_CORE_SCHEMA, UPDATED_AT_PROPERTY);
510             if (locationDate.after(mostRecentLocationDate)) {
511                 mostRecentLocationDate = locationDate;
512                 if (updatedDate != null) {
513                     comparisonUpdatedDate = updatedDate;
514                 }
515                 mostRecentMovementDocModel = movementDocModel;
516                 // If the current Movement record's location date is identical
517                 // to that of the (at this time) most recent Movement record, then
518                 // instead compare the two records using their update date values
519             } else if (locationDate.compareTo(mostRecentLocationDate) == 0) {
520                 if (updatedDate != null && updatedDate.after(comparisonUpdatedDate)) {
521                     // The most recent location date value doesn't need to be
522                     // updated here, as the two records' values are identical
523                     comparisonUpdatedDate = updatedDate;
524                     mostRecentMovementDocModel = movementDocModel;
525                 }
526             }
527         }
528         return mostRecentMovementDocModel;
529     }
530
531     /**
532      * Returns the CSID for a desired document type from a Relation record,
533      * where the relationship involves two specified, different document types.
534      *
535      * @param relationDocModel a document model for a Relation record.
536      * @param desiredDocType a desired document type.
537      * @param relatedDocType a related document type.
538      * @throws ClientException
539      * @return the CSID from the desired document type in the relation. Returns
540      * an empty string if the Relation record does not involve both the desired
541      * and related document types, or if the desired document type is at both
542      * ends of the relation.
543      */
544     protected static String getCsidForDesiredDocTypeFromRelation(DocumentModel relationDocModel,
545             String desiredDocType, String relatedDocType) throws ClientException {
546         String csid = "";
547         String subjectDocType = (String) relationDocModel.getProperty(RELATIONS_COMMON_SCHEMA, SUBJECT_DOCTYPE_PROPERTY);
548         String objectDocType = (String) relationDocModel.getProperty(RELATIONS_COMMON_SCHEMA, OBJECT_DOCTYPE_PROPERTY);
549         if (subjectDocType.startsWith(desiredDocType) && objectDocType.startsWith(desiredDocType)) {
550             return csid;
551         }
552         if (subjectDocType.startsWith(desiredDocType) && objectDocType.startsWith(relatedDocType)) {
553             csid = (String) relationDocModel.getProperty(RELATIONS_COMMON_SCHEMA, SUBJECT_CSID_PROPERTY);
554         } else if (subjectDocType.startsWith(relatedDocType) && objectDocType.startsWith(desiredDocType)) {
555             csid = (String) relationDocModel.getProperty(RELATIONS_COMMON_SCHEMA, OBJECT_CSID_PROPERTY);
556         }
557         return csid;
558     }
559
560     // The following method can be extended by sub-classes to update
561     // different/multiple values; e.g. values for moveable locations ("crates").
562     /**
563      * Updates a CollectionObject record with selected values from a Movement
564      * record.
565      *
566      * @param collectionObjectDocModel a document model for a CollectionObject
567      * record.
568      * @param movementDocModel a document model for a Movement record.
569      * @return a potentially updated document model for the CollectionObject
570      * record.
571      * @throws ClientException
572      */
573     protected abstract DocumentModel updateCollectionObjectValuesFromMovement(DocumentModel collectionObjectDocModel,
574             DocumentModel movementDocModel)
575             throws ClientException;
576 }