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