001/*
002 * (C) Copyright 2006-2017 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 *     Florent Guillaume
018 *     Laurent Doguin
019 */
020package org.nuxeo.ecm.core.versioning;
021
022import static org.nuxeo.ecm.core.api.LifeCycleConstants.TRANSITION_EVENT;
023import static org.nuxeo.ecm.core.api.LifeCycleConstants.TRANSTION_EVENT_OPTION_FROM;
024import static org.nuxeo.ecm.core.api.LifeCycleConstants.TRANSTION_EVENT_OPTION_TO;
025import static org.nuxeo.ecm.core.api.LifeCycleConstants.TRANSTION_EVENT_OPTION_TRANSITION;
026import static org.nuxeo.ecm.core.api.VersioningOption.MAJOR;
027import static org.nuxeo.ecm.core.api.VersioningOption.MINOR;
028import static org.nuxeo.ecm.core.api.VersioningOption.NONE;
029import static org.nuxeo.ecm.core.api.event.CoreEventConstants.DOC_LIFE_CYCLE;
030import static org.nuxeo.ecm.core.api.event.CoreEventConstants.REPOSITORY_NAME;
031import static org.nuxeo.ecm.core.api.event.CoreEventConstants.SESSION_ID;
032
033import java.io.Serializable;
034import java.util.ArrayList;
035import java.util.Arrays;
036import java.util.Collections;
037import java.util.HashMap;
038import java.util.List;
039import java.util.Map;
040import java.util.Map.Entry;
041import java.util.function.Function;
042import java.util.stream.Collectors;
043
044import org.apache.commons.logging.Log;
045import org.apache.commons.logging.LogFactory;
046import org.nuxeo.ecm.core.api.CoreSession;
047import org.nuxeo.ecm.core.api.DocumentModel;
048import org.nuxeo.ecm.core.api.DocumentModelFactory;
049import org.nuxeo.ecm.core.api.LifeCycleException;
050import org.nuxeo.ecm.core.api.NuxeoException;
051import org.nuxeo.ecm.core.api.VersioningOption;
052import org.nuxeo.ecm.core.api.event.DocumentEventCategories;
053import org.nuxeo.ecm.core.api.impl.DocumentModelImpl;
054import org.nuxeo.ecm.core.api.model.PropertyNotFoundException;
055import org.nuxeo.ecm.core.event.EventService;
056import org.nuxeo.ecm.core.event.impl.DocumentEventContext;
057import org.nuxeo.ecm.core.model.Document;
058import org.nuxeo.ecm.core.schema.FacetNames;
059import org.nuxeo.runtime.api.Framework;
060
061/**
062 * Implementation of the versioning service that follows standard checkout / checkin semantics.
063 */
064public class StandardVersioningService implements ExtendableVersioningService {
065
066    private static final Log log = LogFactory.getLog(StandardVersioningService.class);
067
068    protected static final int DEFAULT_FORMER_RULE_ORDER = 10_000;
069
070    protected static final String COMPAT_ID_PREFIX = "compatibility-type-";
071
072    protected static final String COMPAT_DEFAULT_ID = "compatibility-default";
073
074    /**
075     * @deprecated since 9.1 seems unused
076     */
077    @Deprecated
078    public static final String FILE_TYPE = "File";
079
080    /**
081     * @deprecated since 9.1 seems unused
082     */
083    @Deprecated
084    public static final String NOTE_TYPE = "Note";
085
086    /**
087     * @deprecated since 9.1 seems unused
088     */
089    @Deprecated
090    public static final String PROJECT_STATE = "project";
091
092    public static final String APPROVED_STATE = "approved";
093
094    public static final String OBSOLETE_STATE = "obsolete";
095
096    public static final String BACK_TO_PROJECT_TRANSITION = "backToProject";
097
098    /**
099     * @deprecated since 9.1 seems unused
100     */
101    @Deprecated
102    protected static final String AUTO_CHECKED_OUT = "AUTO_CHECKED_OUT";
103
104    /** Key for major version in Document API. */
105    protected static final String MAJOR_VERSION = "ecm:majorVersion";
106
107    /** Key for minor version in Document API. */
108    protected static final String MINOR_VERSION = "ecm:minorVersion";
109
110    /**
111     * @since 9.3
112     */
113    public static final String CATEGORY = "category";
114
115    /**
116     * @since 9.3
117     */
118    public static final String COMMENT = "comment";
119
120    private Map<String, VersioningPolicyDescriptor> versioningPolicies = new HashMap<>();
121
122    private Map<String, VersioningFilterDescriptor> versioningFilters = new HashMap<>();
123
124    private Map<String, VersioningRestrictionDescriptor> versioningRestrictions = new HashMap<>();
125
126    @Override
127    public String getVersionLabel(DocumentModel docModel) {
128        String label;
129        try {
130            label = getMajor(docModel) + "." + getMinor(docModel);
131            if (docModel.isCheckedOut() && !"0.0".equals(label)) {
132                label += "+";
133            }
134        } catch (PropertyNotFoundException e) {
135            label = "";
136        }
137        return label;
138    }
139
140    protected long getMajor(DocumentModel docModel) {
141        return getVersion(docModel, VersioningService.MAJOR_VERSION_PROP);
142    }
143
144    protected long getMinor(DocumentModel docModel) {
145        return getVersion(docModel, VersioningService.MINOR_VERSION_PROP);
146    }
147
148    protected long getVersion(DocumentModel docModel, String prop) {
149        Object propVal = docModel.getPropertyValue(prop);
150        if (propVal == null || !(propVal instanceof Long)) {
151            return 0;
152        } else {
153            return ((Long) propVal).longValue();
154        }
155    }
156
157    protected long getMajor(Document doc) {
158        return getVersion(doc, MAJOR_VERSION);
159    }
160
161    protected long getMinor(Document doc) {
162        return getVersion(doc, MINOR_VERSION);
163    }
164
165    protected long getVersion(Document doc, String prop) {
166        Object propVal = doc.getPropertyValue(prop);
167        if (propVal == null || !(propVal instanceof Long)) {
168            return 0;
169        } else {
170            return ((Long) propVal).longValue();
171        }
172    }
173
174    protected void setVersion(Document doc, long major, long minor) {
175        doc.setPropertyValue(MAJOR_VERSION, Long.valueOf(major));
176        doc.setPropertyValue(MINOR_VERSION, Long.valueOf(minor));
177    }
178
179    protected void incrementMajor(Document doc) {
180        setVersion(doc, getMajor(doc) + 1, 0);
181    }
182
183    protected void incrementMinor(Document doc) {
184        // make sure major is not null by re-setting it
185        setVersion(doc, getMajor(doc), getMinor(doc) + 1);
186    }
187
188    protected void incrementByOption(Document doc, VersioningOption option) {
189        try {
190            if (option == MAJOR) {
191                incrementMajor(doc);
192            } else if (option == MINOR) {
193                incrementMinor(doc);
194            }
195            // else nothing
196        } catch (PropertyNotFoundException e) {
197            // ignore
198        }
199    }
200
201    @Override
202    public void doPostCreate(Document doc, Map<String, Serializable> options) {
203        if (doc.isVersion() || doc.isProxy()) {
204            return;
205        }
206        setInitialVersion(doc);
207    }
208
209    /**
210     * Sets the initial version on a document. Can be overridden.
211     */
212    protected void setInitialVersion(Document doc) {
213        // Create a document model for filters
214        DocumentModelImpl documentModel = DocumentModelFactory.createDocumentModel(doc, null, null);
215        for (VersioningPolicyDescriptor policyDescriptor : versioningPolicies.values()) {
216            if (isPolicyMatch(policyDescriptor, null, documentModel)) {
217                InitialStateDescriptor initialState = policyDescriptor.getInitialState();
218                if (initialState != null) {
219                    setVersion(doc, initialState.getMajor(), initialState.getMinor());
220                    return;
221                }
222            }
223        }
224        setVersion(doc, 0, 0);
225    }
226
227    @Override
228    public List<VersioningOption> getSaveOptions(DocumentModel docModel) {
229        boolean versionable = docModel.isVersionable();
230        String lifeCycleState = docModel.getCoreSession().getCurrentLifeCycleState(docModel.getRef());
231        String type = docModel.getType();
232        return getSaveOptions(versionable, lifeCycleState, type);
233    }
234
235    protected List<VersioningOption> getSaveOptions(Document doc) {
236        boolean versionable = doc.getType().getFacets().contains(FacetNames.VERSIONABLE);
237        String lifeCycleState;
238        try {
239            lifeCycleState = doc.getLifeCycleState();
240        } catch (LifeCycleException e) {
241            lifeCycleState = null;
242        }
243        String type = doc.getType().getName();
244        return getSaveOptions(versionable, lifeCycleState, type);
245    }
246
247    protected List<VersioningOption> getSaveOptions(boolean versionable, String lifeCycleState, String type) {
248        if (!versionable) {
249            return Collections.singletonList(NONE);
250        }
251
252        // try to get restriction for current type
253        List<VersioningOption> options = computeRestrictionOptions(lifeCycleState, type);
254        if (options == null) {
255            // no specific restrictions on current document type - get restriction for any document type
256            options = computeRestrictionOptions(lifeCycleState, "*");
257        }
258        if (options != null) {
259            return options;
260        }
261
262        // By default a versionable document could be incremented by all available options
263        return Arrays.asList(VersioningOption.values());
264    }
265
266    protected List<VersioningOption> computeRestrictionOptions(String lifeCycleState, String type) {
267        VersioningRestrictionDescriptor restrictions = versioningRestrictions.get(type);
268        if (restrictions != null) {
269            // try to get restriction options for current life cycle state
270            VersioningRestrictionOptionsDescriptor restrictionOptions = null;
271            if (lifeCycleState != null) {
272                restrictionOptions = restrictions.getRestrictionOption(lifeCycleState);
273            }
274            if (restrictionOptions == null) {
275                // try to get restriction for any life cycle states
276                restrictionOptions = restrictions.getRestrictionOption("*");
277            }
278            if (restrictionOptions != null) {
279                return restrictionOptions.getOptions();
280            }
281        }
282        return null;
283    }
284
285    protected VersioningOption validateOption(Document doc, VersioningOption option) {
286        List<VersioningOption> options = getSaveOptions(doc);
287        // some variables for exceptions
288        String type = doc.getType().getName();
289        String lifeCycleState;
290        try {
291            lifeCycleState = doc.getLifeCycleState();
292        } catch (LifeCycleException e) {
293            lifeCycleState = null;
294        }
295        if (option == null) {
296            if (options.isEmpty() || options.contains(NONE)) {
297                // Valid cases:
298                // - we don't ask for a version and versioning is blocked by configuration
299                // - we don't ask for a version and NONE is available as restriction
300                return NONE;
301            } else {
302                // No version is asked but configuration requires that document must be versioned ie: NONE doesn't
303                // appear in restriction contribution
304                throw new NuxeoException("Versioning configuration restricts documents with type=" + type
305                        + "/lifeCycleState=" + lifeCycleState + " must be versioned for each updates.");
306            }
307        } else if (!options.contains(option)) {
308            throw new NuxeoException("Versioning option=" + option + " is not allowed by the configuration for type="
309                    + type + "/lifeCycleState=" + lifeCycleState);
310        }
311        return option;
312    }
313
314    @Override
315    public boolean isPreSaveDoingCheckOut(Document doc, boolean isDirty, VersioningOption option,
316            Map<String, Serializable> options) {
317        boolean disableAutoCheckOut = Boolean.TRUE.equals(options.get(VersioningService.DISABLE_AUTO_CHECKOUT));
318        return !doc.isCheckedOut() && isDirty && !disableAutoCheckOut;
319    }
320
321    @Override
322    public VersioningOption doPreSave(CoreSession session, Document doc, boolean isDirty, VersioningOption option,
323            String checkinComment, Map<String, Serializable> options) {
324        option = validateOption(doc, option);
325        if (isPreSaveDoingCheckOut(doc, isDirty, option, options)) {
326            doCheckOut(doc);
327            followTransitionByOption(session, doc, options);
328        }
329        // transition follow shouldn't change what postSave options will be
330        return option;
331    }
332
333    protected void followTransitionByOption(CoreSession session, Document doc, Map<String, Serializable> options) {
334        String lifecycleState = doc.getLifeCycleState();
335        if ((APPROVED_STATE.equals(lifecycleState) || OBSOLETE_STATE.equals(lifecycleState))
336                && doc.getAllowedStateTransitions().contains(BACK_TO_PROJECT_TRANSITION)) {
337            doc.followTransition(BACK_TO_PROJECT_TRANSITION);
338            if (session != null) {
339                // Send an event to notify that the document state has changed
340                sendEvent(session, doc, lifecycleState, options);
341            }
342        }
343    }
344
345    @Override
346    public boolean isPostSaveDoingCheckIn(Document doc, VersioningOption option, Map<String, Serializable> options) {
347        // option = validateOption(doc, option); // validated before
348        return doc.isCheckedOut() && option != NONE;
349    }
350
351    @Override
352    public Document doPostSave(CoreSession session, Document doc, VersioningOption option, String checkinComment,
353            Map<String, Serializable> options) {
354        if (isPostSaveDoingCheckIn(doc, option, options)) {
355            incrementByOption(doc, option);
356            return doc.checkIn(null, checkinComment); // auto-label
357        }
358        return null;
359    }
360
361    @Override
362    public Document doCheckIn(Document doc, VersioningOption option, String checkinComment) {
363        if (option != NONE) {
364            incrementByOption(doc, option == MAJOR ? MAJOR : MINOR);
365        }
366        return doc.checkIn(null, checkinComment); // auto-label
367    }
368
369    @Override
370    public void doCheckOut(Document doc) {
371        Document base = doc.getBaseVersion();
372        doc.checkOut();
373        // set version number to that of the latest version
374        if (base.isLatestVersion()) {
375            // nothing to do, already at proper version
376        } else {
377            // this doc was restored from a non-latest version, find the latest one
378            Document last = doc.getLastVersion();
379            if (last != null) {
380                try {
381                    setVersion(doc, getMajor(last), getMinor(last));
382                } catch (PropertyNotFoundException e) {
383                    // ignore
384                }
385            }
386        }
387    }
388
389    @Override
390    @Deprecated
391    public Map<String, VersioningRuleDescriptor> getVersioningRules() {
392        return Collections.emptyMap();
393    }
394
395    @Override
396    @Deprecated
397    public void setVersioningRules(Map<String, VersioningRuleDescriptor> versioningRules) {
398        // Convert former rules to new one - keep initial state and restriction
399        int order = DEFAULT_FORMER_RULE_ORDER - 1;
400        for (Entry<String, VersioningRuleDescriptor> rules : versioningRules.entrySet()) {
401            String documentType = rules.getKey();
402            VersioningRuleDescriptor versioningRule = rules.getValue();
403            // Compute policy and filter id
404            String compatId = COMPAT_ID_PREFIX + documentType;
405
406            // Convert the rule
407            if (versioningRule.isEnabled()) {
408                VersioningPolicyDescriptor policy = new VersioningPolicyDescriptor();
409                policy.id = compatId;
410                policy.order = order;
411                policy.initialState = versioningRule.initialState;
412                policy.filterIds = new ArrayList<>(Collections.singleton(compatId));
413
414                VersioningFilterDescriptor filter = new VersioningFilterDescriptor();
415                filter.id = compatId;
416                filter.types = Collections.singleton(documentType);
417
418                // Register rules
419                versioningPolicies.put(compatId, policy);
420                versioningFilters.put(compatId, filter);
421
422                // Convert save options
423                VersioningRestrictionDescriptor restriction = new VersioningRestrictionDescriptor();
424                restriction.type = documentType;
425                restriction.options = versioningRule.getOptions()
426                                                    .values()
427                                                    .stream()
428                                                    .map(SaveOptionsDescriptor::toRestrictionOptions)
429                                                    .collect(Collectors.toMap(
430                                                            VersioningRestrictionOptionsDescriptor::getLifeCycleState,
431                                                            Function.identity()));
432                versioningRestrictions.put(restriction.type, restriction);
433
434                order--;
435            } else {
436                versioningPolicies.remove(compatId);
437                versioningFilters.remove(compatId);
438            }
439        }
440    }
441
442    @Override
443    @Deprecated
444    public void setDefaultVersioningRule(DefaultVersioningRuleDescriptor defaultVersioningRule) {
445        if (defaultVersioningRule == null) {
446            return;
447        }
448        // Convert former rules to new one - keep initial state and restriction
449        VersioningPolicyDescriptor policy = new VersioningPolicyDescriptor();
450        policy.id = COMPAT_DEFAULT_ID;
451        policy.order = DEFAULT_FORMER_RULE_ORDER;
452        policy.initialState = defaultVersioningRule.initialState;
453
454        // Register rule
455        if (versioningPolicies == null) {
456            versioningPolicies = new HashMap<>();
457        }
458        versioningPolicies.put(policy.id, policy);
459
460        // Convert save options
461        VersioningRestrictionDescriptor restriction = new VersioningRestrictionDescriptor();
462        restriction.type = "*";
463        restriction.options = defaultVersioningRule.getOptions()
464                                                   .values()
465                                                   .stream()
466                                                   .map(SaveOptionsDescriptor::toRestrictionOptions)
467                                                   .collect(Collectors.toMap(
468                                                           VersioningRestrictionOptionsDescriptor::getLifeCycleState,
469                                                           Function.identity()));
470        versioningRestrictions.put(restriction.type, restriction);
471    }
472
473    @Override
474    public void setVersioningPolicies(Map<String, VersioningPolicyDescriptor> versioningPolicies) {
475        this.versioningPolicies.clear();
476        if (versioningPolicies != null) {
477            this.versioningPolicies.putAll(versioningPolicies);
478        }
479    }
480
481    @Override
482    public void setVersioningFilters(Map<String, VersioningFilterDescriptor> versioningFilters) {
483        this.versioningFilters.clear();
484        if (versioningFilters != null) {
485            this.versioningFilters.putAll(versioningFilters);
486        }
487    }
488
489    @Override
490    public void setVersioningRestrictions(Map<String, VersioningRestrictionDescriptor> versioningRestrictions) {
491        this.versioningRestrictions.clear();
492        if (versioningRestrictions != null) {
493            this.versioningRestrictions.putAll(versioningRestrictions);
494        }
495    }
496
497    @Override
498    public void doAutomaticVersioning(DocumentModel previousDocument, DocumentModel currentDocument, boolean before) {
499        VersioningPolicyDescriptor policy = retrieveMatchingVersioningPolicy(previousDocument, currentDocument, before);
500        if (policy != null && policy.getIncrement() != NONE) {
501            if (before) {
502                if (previousDocument.isCheckedOut()) {
503                    previousDocument.checkIn(policy.getIncrement(), null); // auto label
504                    // put back document in checked out state
505                    previousDocument.checkOut();
506                }
507            } else {
508                if (currentDocument.isCheckedOut()) {
509                    currentDocument.checkIn(policy.getIncrement(), null); // auto label
510                }
511            }
512        }
513    }
514
515    protected VersioningPolicyDescriptor retrieveMatchingVersioningPolicy(DocumentModel previousDocument,
516            DocumentModel currentDocument, boolean before) {
517        return versioningPolicies.values()
518                                 .stream()
519                                 .sorted()
520                                 .filter(policy -> policy.isBeforeUpdate() == before)
521                                 .filter(policy -> isPolicyMatch(policy, previousDocument, currentDocument))
522                                 // Filter out policy with null increment - possible if we declare a policy for the
523                                 // initial state for all documents
524                                 .filter(policy -> policy.getIncrement() != null)
525                                 .findFirst()
526                                 .orElse(null);
527    }
528
529    protected boolean isPolicyMatch(VersioningPolicyDescriptor policyDescriptor, DocumentModel previousDocument,
530            DocumentModel currentDocument) {
531        // Relation between filters in a policy is a AND
532        for (String filterId : policyDescriptor.getFilterIds()) {
533            VersioningFilterDescriptor filterDescriptor = versioningFilters.get(filterId);
534            if (filterDescriptor == null) {
535                // TODO maybe throw something ?
536                log.warn("Versioning filter with id=" + filterId + " is referenced in the policy with id= "
537                        + policyDescriptor.getId() + ", but doesn't exist.");
538            } else if (!filterDescriptor.newInstance().test(previousDocument, currentDocument)) {
539                // As it's a AND, if one fails then policy doesn't match
540                return false;
541            }
542        }
543        // All filters match the context (previousDocument + currentDocument)
544        return true;
545    }
546
547    protected void sendEvent(CoreSession session, Document doc, String previousLifecycleState, Map<String, Serializable> options) {
548        String sid = session.getSessionId();
549        DocumentModel docModel = DocumentModelFactory.createDocumentModel(doc, sid, null);
550
551        DocumentEventContext ctx = new DocumentEventContext(session, session.getPrincipal(), docModel);
552
553        ctx.setProperty(TRANSTION_EVENT_OPTION_FROM, previousLifecycleState);
554        ctx.setProperty(TRANSTION_EVENT_OPTION_TO, doc.getLifeCycleState());
555        ctx.setProperty(TRANSTION_EVENT_OPTION_TRANSITION, BACK_TO_PROJECT_TRANSITION);
556        ctx.setProperty(REPOSITORY_NAME, session.getRepositoryName());
557        ctx.setProperty(SESSION_ID, sid);
558        ctx.setProperty(DOC_LIFE_CYCLE, BACK_TO_PROJECT_TRANSITION);
559        ctx.setProperty(CATEGORY, DocumentEventCategories.EVENT_LIFE_CYCLE_CATEGORY);
560        ctx.setProperty(COMMENT, options.get(COMMENT));
561
562        Framework.getService(EventService.class).fireEvent(ctx.newEvent(TRANSITION_EVENT));
563    }
564
565}