1 package org.collectionspace.services.listener;
3 import java.util.GregorianCalendar;
4 import java.util.HashSet;
6 import org.apache.commons.logging.Log;
7 import org.apache.commons.logging.LogFactory;
8 import org.collectionspace.services.client.workflow.WorkflowClient;
9 import org.collectionspace.services.common.api.Tools;
10 import org.collectionspace.services.movement.nuxeo.MovementConstants;
11 import org.collectionspace.services.nuxeo.util.NuxeoUtils;
12 import org.nuxeo.ecm.core.api.ClientException;
13 import org.nuxeo.ecm.core.api.CoreSession;
14 import org.nuxeo.ecm.core.api.DocumentModel;
15 import org.nuxeo.ecm.core.api.DocumentModelList;
16 import org.nuxeo.ecm.core.event.Event;
17 import org.nuxeo.ecm.core.event.EventContext;
18 import org.nuxeo.ecm.core.event.EventListener;
19 import org.nuxeo.ecm.core.event.impl.DocumentEventContext;
21 public abstract class AbstractUpdateObjectLocationValues implements EventListener {
23 // FIXME: We might experiment here with using log4j instead of Apache Commons Logging;
24 // am using the latter to follow Ray's pattern for now
25 private final static Log logger = LogFactory.getLog(AbstractUpdateObjectLocationValues.class);
26 // FIXME: Make the following message, or its equivalent, a constant usable by all event listeners
27 private final static String NO_FURTHER_PROCESSING_MESSAGE =
28 "This event listener will not continue processing this event ...";
29 private final static GregorianCalendar EARLIEST_COMPARISON_DATE = new GregorianCalendar(1600, 1, 1);
30 private final static String RELATIONS_COMMON_SCHEMA = "relations_common"; // FIXME: Get from external constant
31 private final static String RELATION_DOCTYPE = "Relation"; // FIXME: Get from external constant
32 private final static String SUBJECT_CSID_PROPERTY = "subjectCsid"; // FIXME: Get from external constant
33 private final static String OBJECT_CSID_PROPERTY = "objectCsid"; // FIXME: Get from external constant
34 private final static String SUBJECT_DOCTYPE_PROPERTY = "subjectDocumentType"; // FIXME: Get from external constant
35 private final static String OBJECT_DOCTYPE_PROPERTY = "objectDocumentType"; // FIXME: Get from external constant
36 protected final static String COLLECTIONOBJECTS_COMMON_SCHEMA = "collectionobjects_common"; // FIXME: Get from external constant
37 private final static String COLLECTIONOBJECT_DOCTYPE = "CollectionObject"; // FIXME: Get from external constant
38 protected final static String COMPUTED_CURRENT_LOCATION_PROPERTY = "computedCurrentLocation"; // FIXME: Create and then get from external constant
39 protected final static String MOVEMENTS_COMMON_SCHEMA = "movements_common"; // FIXME: Get from external constant
40 private final static String MOVEMENT_DOCTYPE = MovementConstants.NUXEO_DOCTYPE;
41 private final static String LOCATION_DATE_PROPERTY = "locationDate"; // FIXME: Get from external constant
42 protected final static String CURRENT_LOCATION_PROPERTY = "currentLocation"; // FIXME: Get from external constant
43 private final static String ACTIVE_DOCUMENT_WHERE_CLAUSE_FRAGMENT =
44 "AND (ecm:currentLifeCycleState <> 'deleted') "
45 + "AND ecm:isProxy = 0 "
46 + "AND ecm:isCheckedInVersion = 0";
48 public enum EventNotificationDocumentType {
49 // Document type about which we've received a notification
55 public void handleEvent(Event event) throws ClientException {
57 logger.trace("In handleEvent in UpdateObjectLocationOnMove ...");
59 EventContext eventContext = event.getContext();
60 if (eventContext == null) {
64 if (!(eventContext instanceof DocumentEventContext)) {
67 DocumentEventContext docEventContext = (DocumentEventContext) eventContext;
68 DocumentModel docModel = docEventContext.getSourceDocument();
70 // If this document event involves a Relation record, does this pertain to
71 // a relationship between a Movement record and a CollectionObject record?
73 // If not, we're not interested in processing this document event
74 // in this event handler, as it will have no bearing on updating a
75 // computed current location for a CollectionObject.
78 // (The rest of the code flow below is then identical to that which
79 // is followed when this document event involves a Movement record.
80 String movementCsid = "";
81 Enum notificationDocumentType;
82 if (documentMatchesType(docModel, RELATION_DOCTYPE)) {
83 if (logger.isTraceEnabled()) {
84 logger.trace("An event involving a Relation document was received by UpdateObjectLocationOnMove ...");
86 // Get a Movement CSID from the Relation record. (If we can't
87 // get it, then we don't have a pertinent relation record.)
88 movementCsid = getCsidForDesiredDocTypeFromRelation(docModel, MOVEMENT_DOCTYPE, COLLECTIONOBJECT_DOCTYPE);
89 if (Tools.isBlank(movementCsid)) {
90 logger.warn("Could not obtain CSID for Movement record from document event.");
91 logger.warn(NO_FURTHER_PROCESSING_MESSAGE);
94 notificationDocumentType = EventNotificationDocumentType.RELATION;
95 } else if (documentMatchesType(docModel, MOVEMENT_DOCTYPE)) {
96 // Otherwise, get a Movement CSID directly from the Movement record.
97 if (logger.isTraceEnabled()) {
98 logger.trace("An event involving a Movement document was received by UpdateObjectLocationOnMove ...");
100 // FIXME: exclude creation events for Movement records here, if we can
101 // identify that we'l still be properly handling creation events
102 // that include a relations list as part of the creation payload,
103 // perhaps because that may trigger a separate event notification.
104 movementCsid = NuxeoUtils.getCsid(docModel);
105 if (Tools.isBlank(movementCsid)) {
106 logger.warn("Could not obtain CSID for Movement record from document event.");
107 logger.warn(NO_FURTHER_PROCESSING_MESSAGE);
110 notificationDocumentType = EventNotificationDocumentType.MOVEMENT;
112 if (logger.isTraceEnabled()) {
113 logger.trace("This event did not involve a document relevant to UpdateObjectLocationOnMove ...");
118 // Note: currently, all Document lifecycle transitions on
119 // the relevant doctype(s) are handled by this event handler,
120 // not just transitions between 'soft deleted' and active states.
122 // We are assuming that we'll want to re-compute current locations
123 // for related CollectionObjects on all such transitions, as the
124 // semantics of such transitions are opaque to this event handler,
125 // because arbitrary workflows can be bound to those doctype(s).
127 // If we need to filter out some of those lifecycle transitions,
128 // such as excluding transitions to the 'locked' workflow state; or,
129 // alternately, if we want to restrict this event handler's
130 // scope to handle only transitions into the 'soft deleted' state,
131 // we can add additional checks for doing so at this point in the code.
134 if (logger.isTraceEnabled()) {
135 logger.trace("Movement CSID=" + movementCsid);
136 logger.trace("Notification document type=" + notificationDocumentType.name());
139 CoreSession coreSession = docEventContext.getCoreSession();
140 Set<String> collectionObjectCsids = new HashSet<>();
142 if (notificationDocumentType == EventNotificationDocumentType.RELATION) {
143 String relatedCollectionObjectCsid =
144 getCsidForDesiredDocTypeFromRelation(docModel, COLLECTIONOBJECT_DOCTYPE, MOVEMENT_DOCTYPE);
145 collectionObjectCsids.add(relatedCollectionObjectCsid);
146 } else if (notificationDocumentType == EventNotificationDocumentType.MOVEMENT) {
147 collectionObjectCsids.addAll(getCollectionObjectCsidsRelatedToMovement(movementCsid, coreSession));
150 if (collectionObjectCsids.isEmpty()) {
151 logger.warn("Could not obtain any CSIDs of related CollectionObject records.");
152 logger.warn(NO_FURTHER_PROCESSING_MESSAGE);
155 if (logger.isTraceEnabled()) {
156 logger.trace("Found " + collectionObjectCsids.size() + " CSIDs of related CollectionObject records.");
159 // Iterate through the list of CollectionObject CSIDs found.
160 // For each CollectionObject, obtain its most recent, related Movement,
161 // and update relevant field(s) with values from that Movement record.
162 DocumentModel collectionObjectDocModel;
163 DocumentModel mostRecentMovementDocModel;
164 for (String collectionObjectCsid : collectionObjectCsids) {
165 if (logger.isTraceEnabled()) {
166 logger.trace("CollectionObject CSID=" + collectionObjectCsid);
168 // Verify that the CollectionObject is retrievable.
169 collectionObjectDocModel = getDocModelFromCsid(coreSession, collectionObjectCsid);
170 if (collectionObjectDocModel == null) {
173 // Verify that the CollectionObject record is active.
174 if (!isActiveDocument(collectionObjectDocModel)) {
177 // Get the CollectionObject's most recent, related Movement.
178 mostRecentMovementDocModel = getMostRecentMovement(coreSession, collectionObjectCsid);
179 if (mostRecentMovementDocModel == null) {
182 // Update the CollectionObject with values from that Movement.
183 collectionObjectDocModel =
184 updateCollectionObjectValuesFromMovement(collectionObjectDocModel, mostRecentMovementDocModel);
185 coreSession.saveDocument(collectionObjectDocModel);
190 * Returns the CSIDs of CollectionObject records that are related to a
193 * @param movementCsid the CSID of a Movement record.
194 * @param coreSession a repository session.
195 * @throws ClientException
196 * @return the CSIDs of the CollectionObject records, if any, which are
197 * related to the Movement record.
199 private Set<String> getCollectionObjectCsidsRelatedToMovement(String movementCsid,
200 CoreSession coreSession) throws ClientException {
202 Set<String> csids = new HashSet<>();
204 // Via an NXQL query, get a list of active relation records where:
205 // * This movement record's CSID is the subject CSID of the relation,
206 // and its object document type is a CollectionObject doctype;
208 // * This movement record's CSID is the object CSID of the relation,
209 // and its subject document type is a CollectionObject doctype.
211 // Some values below are hard-coded for readability, rather than
212 // being obtained from constants.
213 String query = String.format(
214 "SELECT * FROM %1$s WHERE " // collectionspace_core:tenantId = 1 "
216 + " (%2$s:subjectCsid = '%3$s' "
217 + " AND %2$s:objectDocumentType = '%4$s') "
219 + " (%2$s:objectCsid = '%3$s' "
220 + " AND %2$s:subjectDocumentType = '%4$s') "
222 + ACTIVE_DOCUMENT_WHERE_CLAUSE_FRAGMENT,
223 RELATION_DOCTYPE, RELATIONS_COMMON_SCHEMA, movementCsid, COLLECTIONOBJECT_DOCTYPE);
224 DocumentModelList relationDocModels = coreSession.query(query);
225 if (relationDocModels == null || relationDocModels.isEmpty()) {
226 // Encountering a Movement record that is not related to any
227 // CollectionObject is potentially a normal occurrence, so no
228 // error messages are logged here when we stop handling this event.
231 // Iterate through the list of Relation records found and build
232 // a list of CollectionObject CSIDs, by extracting the relevant CSIDs
233 // from those Relation records.
235 for (DocumentModel relationDocModel : relationDocModels) {
236 csid = getCsidForDesiredDocTypeFromRelation(relationDocModel, COLLECTIONOBJECT_DOCTYPE, MOVEMENT_DOCTYPE);
237 if (Tools.notBlank(csid)) {
244 // FIXME: Generic methods like many of those below might be split off from
245 // this specific event listener/handler, into an event handler utilities
246 // class, base classes, or otherwise.
248 // FIXME: Identify whether the equivalent of the documentMatchesType utility
249 // method is already implemented and substitute a call to the latter if so.
250 // This may well already exist.
252 * Identifies whether a document matches a supplied document type.
254 * @param docModel a document model.
255 * @param docType a document type string.
256 * @return true if the document matches the supplied document type; false if
259 protected static boolean documentMatchesType(DocumentModel docModel, String docType) {
260 if (docModel == null || Tools.isBlank(docType)) {
263 if (docModel.getType().startsWith(docType)) {
271 * Identifies whether a document is an active document; that is, if it is
272 * not a versioned record; not a proxy (symbolic link to an actual record);
273 * and not in the 'deleted' workflow state.
275 * (A note relating the latter: Nuxeo appears to send 'documentModified'
276 * events even on workflow transitions, such when records are 'soft deleted'
277 * by being transitioned to the 'deleted' workflow state.)
280 * @return true if the document is an active document; false if it is not.
282 protected static boolean isActiveDocument(DocumentModel docModel) {
283 if (docModel == null) {
286 boolean isActiveDocument = false;
288 if (!docModel.isVersion()
289 && !docModel.isProxy()
290 && !docModel.getCurrentLifeCycleState().equals(WorkflowClient.WORKFLOWSTATE_DELETED)) {
291 isActiveDocument = true;
293 } catch (ClientException ce) {
294 logger.warn("Error while identifying whether document is an active document: ", ce);
296 return isActiveDocument;
300 * Returns a document model for a record identified by a CSID.
302 * @param session a repository session.
303 * @param collectionObjectCsid a CollectionObject identifier (CSID)
304 * @return a document model for the record identified by the supplied CSID.
306 protected static DocumentModel getDocModelFromCsid(CoreSession session, String collectionObjectCsid) {
307 DocumentModelList collectionObjectDocModels = null;
309 final String query = "SELECT * FROM "
310 + NuxeoUtils.BASE_DOCUMENT_TYPE
312 + NuxeoUtils.getByNameWhereClause(collectionObjectCsid);
313 collectionObjectDocModels = session.query(query);
314 } catch (Exception e) {
315 logger.warn("Exception in query to get document model for CollectionObject: ", e);
317 if (collectionObjectDocModels == null || collectionObjectDocModels.isEmpty()) {
318 logger.warn("Could not get document models for CollectionObject(s).");
320 } else if (collectionObjectDocModels.size() != 1) {
321 logger.debug("Found more than 1 document with CSID=" + collectionObjectCsid);
324 return collectionObjectDocModels.get(0);
327 // FIXME: A quick first pass, using an only partly query-based technique for
328 // getting the most recent Movement record related to a CollectionObject,
329 // augmented by procedural code.
331 // Could be replaced by a potentially more performant method, based on a query.
333 // E.g. the following is a sample CMIS query for retrieving Movement records
334 // related to a CollectionObject, which might serve as the basis for that query.
336 "SELECT DOC.nuxeo:pathSegment, DOC.dc:title, REL.dc:title,"
337 + "REL.relations_common:objectCsid, REL.relations_common:subjectCsid FROM Movement DOC "
338 + "JOIN Relation REL ON REL.relations_common:objectCsid = DOC.nuxeo:pathSegment "
339 + "WHERE REL.relations_common:subjectCsid = '5b4c617e-53a0-484b-804e' "
340 + "AND DOC.nuxeo:isVersion = false "
341 + "ORDER BY DOC.collectionspace_core:updatedAt DESC";
344 * Returns the most recent Movement record related to a CollectionObject.
346 * This method currently returns the related Movement record with the latest
347 * (i.e. most recent in time) Location Date field value.
349 * @param session a repository session.
350 * @param collectionObjectCsid a CollectionObject identifier (CSID)
351 * @throws ClientException
352 * @return the most recent Movement record related to the CollectionObject
353 * identified by the supplied CSID.
355 protected static DocumentModel getMostRecentMovement(CoreSession session, String collectionObjectCsid)
356 throws ClientException {
357 DocumentModel mostRecentMovementDocModel = null;
358 // Get Relation records for Movements related to this CollectionObject.
360 // Some values below are hard-coded for readability, rather than
361 // being obtained from constants.
362 String query = String.format(
363 "SELECT * FROM %1$s WHERE " // collectionspace_core:tenantId = 1 "
365 + " (%2$s:subjectCsid = '%3$s' "
366 + " AND %2$s:objectDocumentType = '%4$s') "
368 + " (%2$s:objectCsid = '%3$s' "
369 + " AND %2$s:subjectDocumentType = '%4$s') "
371 + ACTIVE_DOCUMENT_WHERE_CLAUSE_FRAGMENT,
372 RELATION_DOCTYPE, RELATIONS_COMMON_SCHEMA, collectionObjectCsid, MOVEMENT_DOCTYPE);
373 if (logger.isTraceEnabled()) {
374 logger.trace("query=" + query);
376 DocumentModelList relationDocModels = session.query(query);
377 if (relationDocModels == null || relationDocModels.isEmpty()) {
378 logger.warn("Unexpectedly found no relations to Movement records to/from to this CollectionObject record.");
379 return mostRecentMovementDocModel;
381 if (logger.isTraceEnabled()) {
382 logger.trace("Found " + relationDocModels.size() + " relations to Movement records to/from this CollectionObject record.");
385 // Iterate through related movement records, to find the related
386 // Movement record with the most recent location date.
387 GregorianCalendar mostRecentLocationDate = EARLIEST_COMPARISON_DATE;
388 DocumentModel movementDocModel;
389 Set<String> alreadyProcessedMovementCsids = new HashSet<>();
390 String relatedMovementCsid;
391 for (DocumentModel relationDocModel : relationDocModels) {
392 // Due to the 'OR' operator in the query above, related Movement
393 // record CSIDs may reside in either the subject or object CSID fields
394 // of the relation record. Whichever CSID value doesn't match the
395 // CollectionObject's CSID is inferred to be the related Movement record's CSID.
396 relatedMovementCsid = (String) relationDocModel.getProperty(RELATIONS_COMMON_SCHEMA, SUBJECT_CSID_PROPERTY);
397 if (relatedMovementCsid.equals(collectionObjectCsid)) {
398 relatedMovementCsid = (String) relationDocModel.getProperty(RELATIONS_COMMON_SCHEMA, OBJECT_CSID_PROPERTY);
400 if (Tools.isBlank(relatedMovementCsid)) {
403 // Because of the OR clause used in the query above, there may be
404 // two or more Relation records returned in the query results that
405 // reference the same Movement record, as either the subject
406 // or object of a relation to the same CollectionObject record;
407 // we need to filter out those duplicates.
408 if (alreadyProcessedMovementCsids.contains(relatedMovementCsid)) {
411 alreadyProcessedMovementCsids.add(relatedMovementCsid);
413 if (logger.isTraceEnabled()) {
414 logger.trace("Related movement CSID=" + relatedMovementCsid);
416 movementDocModel = getDocModelFromCsid(session, relatedMovementCsid);
417 if (movementDocModel == null) {
421 // Verify that the Movement record is active. This will also exclude
422 // versioned Movement records from the computation of the current
423 // location, for tenants that are versioning such records.
424 if (!isActiveDocument(movementDocModel)) {
425 if (logger.isTraceEnabled()) {
426 logger.trace("Skipping this inactive Movement record ...");
430 GregorianCalendar locationDate =
431 (GregorianCalendar) movementDocModel.getProperty(MOVEMENTS_COMMON_SCHEMA, LOCATION_DATE_PROPERTY);
432 if (locationDate == null) {
435 if (locationDate.after(mostRecentLocationDate)) {
436 mostRecentLocationDate = locationDate;
437 mostRecentMovementDocModel = movementDocModel;
440 return mostRecentMovementDocModel;
444 * Returns the CSID for a desired document type from a Relation record,
445 * where the relationship involves two specified, different document types.
447 * @param relationDocModel a document model for a Relation record.
448 * @param desiredDocType a desired document type.
449 * @param relatedDocType a related document type.
450 * @throws ClientException
451 * @return the CSID from the desired document type in the relation. Returns
452 * an empty string if the Relation record does not involve both the desired
453 * and related document types, or if the desired document type is at both
454 * ends of the relation.
456 protected static String getCsidForDesiredDocTypeFromRelation(DocumentModel relationDocModel,
457 String desiredDocType, String relatedDocType) throws ClientException {
459 String subjectDocType = (String) relationDocModel.getProperty(RELATIONS_COMMON_SCHEMA, SUBJECT_DOCTYPE_PROPERTY);
460 String objectDocType = (String) relationDocModel.getProperty(RELATIONS_COMMON_SCHEMA, OBJECT_DOCTYPE_PROPERTY);
461 if (subjectDocType.startsWith(desiredDocType) && objectDocType.startsWith(desiredDocType)) {
464 if (subjectDocType.startsWith(desiredDocType) && objectDocType.startsWith(relatedDocType)) {
465 csid = (String) relationDocModel.getProperty(RELATIONS_COMMON_SCHEMA, SUBJECT_CSID_PROPERTY);
466 } else if (subjectDocType.startsWith(relatedDocType) && objectDocType.startsWith(desiredDocType)) {
467 csid = (String) relationDocModel.getProperty(RELATIONS_COMMON_SCHEMA, OBJECT_CSID_PROPERTY);
472 // The following method can be extended by sub-classes to update
473 // different/multiple values; e.g. values for moveable locations ("crates").
475 * Updates a CollectionObject record with selected values from a Movement
478 * @param collectionObjectDocModel a document model for a CollectionObject
480 * @param movementDocModel a document model for a Movement record.
481 * @return a potentially updated document model for the CollectionObject
483 * @throws ClientException
485 protected abstract DocumentModel updateCollectionObjectValuesFromMovement(DocumentModel collectionObjectDocModel,
486 DocumentModel movementDocModel)
487 throws ClientException;