001/*
002 * (C) Copyright 2006-2020 Nuxeo (http://nuxeo.com/) and others.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *     http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 *
016 * Contributors:
017 *     Nuxeo - initial API and implementation
018 */
019package org.nuxeo.ecm.core.lifecycle.event;
020
021import static org.apache.commons.lang3.StringUtils.isBlank;
022
023import java.util.List;
024
025import org.apache.commons.logging.Log;
026import org.apache.commons.logging.LogFactory;
027import org.nuxeo.ecm.core.api.CoreSession;
028import org.nuxeo.ecm.core.api.DocumentModel;
029import org.nuxeo.ecm.core.api.DocumentModelList;
030import org.nuxeo.ecm.core.api.LifeCycleConstants;
031import org.nuxeo.ecm.core.api.event.CoreEventConstants;
032import org.nuxeo.ecm.core.api.event.DocumentEventCategories;
033import org.nuxeo.ecm.core.api.event.DocumentEventTypes;
034import org.nuxeo.ecm.core.api.impl.DocumentModelListImpl;
035import org.nuxeo.ecm.core.api.trash.TrashService;
036import org.nuxeo.ecm.core.event.Event;
037import org.nuxeo.ecm.core.event.EventBundle;
038import org.nuxeo.ecm.core.event.EventContext;
039import org.nuxeo.ecm.core.event.EventService;
040import org.nuxeo.ecm.core.event.PostCommitEventListener;
041import org.nuxeo.ecm.core.event.impl.DocumentEventContext;
042import org.nuxeo.ecm.core.lifecycle.LifeCycleService;
043import org.nuxeo.runtime.api.Framework;
044import org.nuxeo.runtime.services.config.ConfigurationService;
045import org.nuxeo.runtime.transaction.TransactionHelper;
046
047/**
048 * Listener for life cycle change events.
049 * <p>
050 * If event occurs on a folder, it will recurse on children to perform the same transition if possible.
051 * <p>
052 * If the transition event is about marking documents as "deleted", and a child cannot perform the transition, it will
053 * be removed.
054 * <p>
055 * Undelete transitions are not processed, but this listener instead looks for a specific documentUndeleted event. This
056 * is because we want to undelete documents (parents) under which we don't want to recurse.
057 * <p>
058 * Reinit document copy lifeCycle (BulkLifeCycleChangeListener is bound to the event documentCreatedByCopy)
059 */
060public class BulkLifeCycleChangeListener implements PostCommitEventListener {
061
062    /**
063     * @since 8.10-HF05 9.2
064     */
065    public static final String PAGINATE_GET_CHILDREN_PROPERTY = "nuxeo.bulkLifeCycleChangeListener.paginate-get-children";
066
067    /**
068     * @since 8.10-HF05 9.2
069     */
070    public static final String GET_CHILDREN_PAGE_SIZE_PROPERTY = "nuxeo.bulkLifeCycleChangeListener.get-children-page-size";
071
072    private static final Log log = LogFactory.getLog(BulkLifeCycleChangeListener.class);
073
074    @Override
075    public void handleEvent(EventBundle events) {
076        if (!events.containsEventName(LifeCycleConstants.TRANSITION_EVENT)
077                && !events.containsEventName(LifeCycleConstants.DOCUMENT_UNDELETED)
078                && !events.containsEventName(DocumentEventTypes.DOCUMENT_CREATED_BY_COPY)) {
079            return;
080        }
081        for (Event event : events) {
082            String name = event.getName();
083            if (LifeCycleConstants.TRANSITION_EVENT.equals(name) || LifeCycleConstants.DOCUMENT_UNDELETED.equals(name)
084                    || DocumentEventTypes.DOCUMENT_CREATED_BY_COPY.equals(name)) {
085                processTransition(event);
086            }
087        }
088    }
089
090    protected void processTransition(Event event) {
091        log.debug("Processing lifecycle change in async listener");
092        EventContext ctx = event.getContext();
093        if (!(ctx instanceof DocumentEventContext)) {
094            return;
095        }
096        DocumentEventContext docCtx = (DocumentEventContext) ctx;
097        DocumentModel doc = docCtx.getSourceDocument();
098        if (!doc.isFolder() && !DocumentEventTypes.DOCUMENT_CREATED_BY_COPY.equals(event.getName())) {
099            return;
100        }
101        CoreSession session = docCtx.getCoreSession();
102        if (session == null) {
103            log.error("Can not process lifeCycle change since session is null");
104            return;
105        }
106        String transition;
107        String targetState;
108        if (DocumentEventTypes.DOCUMENT_CREATED_BY_COPY.equals(event.getName())) {
109            if (!Boolean.TRUE.equals(event.getContext().getProperties().get(CoreEventConstants.RESET_LIFECYCLE))) {
110                return;
111            }
112            DocumentModelList docs = new DocumentModelListImpl();
113            docs.add(doc);
114            if (session.exists(doc.getRef())) {
115                reinitDocumentsLifeCyle(session, docs);
116                session.save();
117            }
118        } else {
119            if (LifeCycleConstants.TRANSITION_EVENT.equals(event.getName())) {
120                transition = (String) docCtx.getProperty(LifeCycleConstants.TRANSTION_EVENT_OPTION_TRANSITION);
121                if (isNonRecursiveTransition(transition, doc.getType())) {
122                    // transition should not recurse into children
123                    return;
124                }
125                if (LifeCycleConstants.UNDELETE_TRANSITION.equals(transition)) {
126                    // not processed (as we can undelete also parents)
127                    // a specific event documentUndeleted will be used instead
128                    return;
129                }
130                targetState = (String) docCtx.getProperty(LifeCycleConstants.TRANSTION_EVENT_OPTION_TO);
131            } else { // LifeCycleConstants.DOCUMENT_UNDELETED
132                transition = LifeCycleConstants.UNDELETE_TRANSITION;
133                targetState = ""; // unused
134            }
135            changeChildrenState(session, transition, targetState, doc);
136        }
137    }
138
139    protected void reinitDocumentsLifeCyle(CoreSession documentManager, DocumentModelList docs) {
140        for (DocumentModel docMod : docs) {
141            documentManager.reinitLifeCycleState(docMod.getRef());
142            if (docMod.isFolder()) {
143                DocumentModelList children = documentManager.query(String.format(
144                        "SELECT * FROM Document WHERE ecm:isTrashed = 0 AND ecm:parentId = '%s'", docMod.getRef()));
145                reinitDocumentsLifeCyle(documentManager, children);
146            }
147        }
148    }
149
150    protected boolean isNonRecursiveTransition(String transition, String type) {
151        List<String> nonRecursiveTransitions = Framework.getService(LifeCycleService.class)
152                                                        .getNonRecursiveTransitionForDocType(type);
153        return nonRecursiveTransitions.contains(transition);
154    }
155
156    /**
157     * @since 9.2
158     */
159    protected void changeChildrenState(CoreSession session, String transition, String targetState, DocumentModel doc) {
160        // Check if we need to paginate children fetch
161        ConfigurationService confService = Framework.getService(ConfigurationService.class);
162        boolean paginate = confService.isBooleanTrue(PAGINATE_GET_CHILDREN_PROPERTY);
163        if (paginate) {
164            long pageSize = confService.getLong(GET_CHILDREN_PAGE_SIZE_PROPERTY, 500);
165            // execute a first query to know total size
166            String query = String.format("SELECT * FROM Document where ecm:parentId ='%s'", doc.getId());
167            DocumentModelList documents = session.query(query, null, pageSize, 0, true);
168            changeDocumentsState(session, transition, targetState, documents);
169            session.save();
170            // commit the first page
171            TransactionHelper.commitOrRollbackTransaction();
172
173            // loop on other children
174            long nbChildren = documents.totalSize();
175            for (long offset = pageSize; offset < nbChildren; offset += pageSize) {
176                long i = offset;
177                // start a new transaction
178                TransactionHelper.runInTransaction(() -> {
179                    DocumentModelList docs = session.query(query, null, pageSize, i, false);
180                    changeDocumentsState(session, transition, targetState, docs);
181                    session.save();
182                });
183            }
184
185            // start a new transaction for following
186            TransactionHelper.startTransaction();
187        } else {
188            DocumentModelList documents = session.getChildren(doc.getRef());
189            changeDocumentsState(session, transition, targetState, documents);
190            session.save();
191        }
192    }
193
194    /**
195     * Change doc state. Don't recurse on children as following transition trigger an event which will be handled by
196     * this listener.
197     *
198     * @since 9.2
199     */
200    protected void changeDocumentsState(CoreSession session, String transition, String targetState,
201            DocumentModelList docs) {
202        TrashService trashService = Framework.getService(TrashService.class);
203        for (DocumentModel doc : docs) {
204            if (doc.getCurrentLifeCycleState() == null) {
205                if (LifeCycleConstants.DELETED_STATE.equals(targetState)) {
206                    log.debug("Doc has no lifecycle, deleting ...");
207                    session.removeDocument(doc.getRef());
208                }
209            } else if (doc.getAllowedStateTransitions().contains(transition) && !doc.isProxy()) {
210                if (LifeCycleConstants.DELETE_TRANSITION.equals(transition)) {
211                    // just skip renaming for trash mechanism
212                    // here we leverage backward compatibility mechanism in AbstractSession#followTransition
213                    doc.putContextData(TrashService.DISABLE_TRASH_RENAMING, Boolean.TRUE);
214                } else if (LifeCycleConstants.UNDELETE_TRANSITION.equals(transition)
215                        && trashService.isMangledName(doc.getName())) {
216                    // mangled children names need to be explicitely unmangled
217                    session.move(doc.getRef(), doc.getParentRef(), trashService.unmangleName(doc));
218                }
219                doc.followTransition(transition);
220                // handle children if we're handling the technical documentUndeleted event
221                if (LifeCycleConstants.UNDELETE_TRANSITION.equals(transition) && isBlank(targetState)
222                        && doc.isFolder()) {
223                    DocumentEventContext ctx = new DocumentEventContext(session, session.getPrincipal(), doc);
224                    ctx.setCategory(DocumentEventCategories.EVENT_DOCUMENT_CATEGORY);
225                    Framework.getService(EventService.class)
226                             .fireEvent(ctx.newEvent(LifeCycleConstants.DOCUMENT_UNDELETED));
227                }
228            } else {
229                if (targetState.equals(doc.getCurrentLifeCycleState())) {
230                    log.debug("Document" + doc.getRef() + " is already in the target LifeCycle state");
231                } else if (LifeCycleConstants.DELETED_STATE.equals(targetState)) {
232                    log.debug("Impossible to change state of " + doc.getRef() + " :removing");
233                    session.removeDocument(doc.getRef());
234                } else {
235                    log.debug("Document" + doc.getRef() + " has no transition to the target LifeCycle state");
236                }
237            }
238        }
239    }
240
241}