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.logging.log4j.LogManager;
045import org.apache.logging.log4j.Logger;
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.model.PropertyNotFoundException;
054import org.nuxeo.ecm.core.api.versioning.VersioningService;
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 Logger log = LogManager.getLogger(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 instanceof Long) {
151            return ((Long) propVal).longValue();
152        } else {
153            return 0;
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 instanceof Long) {
168            return ((Long) propVal).longValue();
169        } else {
170            return 0;
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        DocumentModel docModel = DocumentModelFactory.createDocumentModel(doc, null);
215        InitialStateDescriptor initialState = versioningPolicies.values()
216                                                                .stream()
217                                                                .sorted()
218                                                                .filter(policy -> policy.getInitialState() != null)
219                                                                .filter(policy -> isPolicyMatch(policy, null, docModel))
220                                                                .map(VersioningPolicyDescriptor::getInitialState)
221                                                                .findFirst()
222                                                                .orElseGet(InitialStateDescriptor::new);
223        setVersion(doc, initialState.getMajor(), initialState.getMinor());
224    }
225
226    @Override
227    public List<VersioningOption> getSaveOptions(DocumentModel docModel) {
228        boolean versionable = docModel.isVersionable();
229        String lifeCycleState = docModel.getCoreSession().getCurrentLifeCycleState(docModel.getRef());
230        String type = docModel.getType();
231        return getSaveOptions(versionable, lifeCycleState, type);
232    }
233
234    protected List<VersioningOption> getSaveOptions(Document doc) {
235        boolean versionable = doc.getType().getFacets().contains(FacetNames.VERSIONABLE);
236        String lifeCycleState;
237        try {
238            lifeCycleState = doc.getLifeCycleState();
239        } catch (LifeCycleException e) {
240            lifeCycleState = null;
241        }
242        String type = doc.getType().getName();
243        return getSaveOptions(versionable, lifeCycleState, type);
244    }
245
246    protected List<VersioningOption> getSaveOptions(boolean versionable, String lifeCycleState, String type) {
247        if (!versionable) {
248            return Collections.singletonList(NONE);
249        }
250
251        // try to get restriction for current type
252        List<VersioningOption> options = computeRestrictionOptions(lifeCycleState, type);
253        if (options == null) {
254            // no specific restrictions on current document type - get restriction for any document type
255            options = computeRestrictionOptions(lifeCycleState, "*");
256        }
257        if (options != null) {
258            return options;
259        }
260
261        // By default a versionable document could be incremented by all available options
262        return Arrays.asList(VersioningOption.values());
263    }
264
265    protected List<VersioningOption> computeRestrictionOptions(String lifeCycleState, String type) {
266        VersioningRestrictionDescriptor restrictions = versioningRestrictions.get(type);
267        if (restrictions != null) {
268            // try to get restriction options for current life cycle state
269            VersioningRestrictionOptionsDescriptor restrictionOptions = null;
270            if (lifeCycleState != null) {
271                restrictionOptions = restrictions.getRestrictionOption(lifeCycleState);
272            }
273            if (restrictionOptions == null) {
274                // try to get restriction for any life cycle states
275                restrictionOptions = restrictions.getRestrictionOption("*");
276            }
277            if (restrictionOptions != null) {
278                return restrictionOptions.getOptions();
279            }
280        }
281        return null;
282    }
283
284    protected VersioningOption validateOption(Document doc, VersioningOption option) {
285        List<VersioningOption> options = getSaveOptions(doc);
286        // some variables for exceptions
287        String type = doc.getType().getName();
288        String lifeCycleState;
289        try {
290            lifeCycleState = doc.getLifeCycleState();
291        } catch (LifeCycleException e) {
292            lifeCycleState = null;
293        }
294        if (option == null) {
295            if (options.isEmpty() || options.contains(NONE)) {
296                // Valid cases:
297                // - we don't ask for a version and versioning is blocked by configuration
298                // - we don't ask for a version and NONE is available as restriction
299                return NONE;
300            } else {
301                // No version is asked but configuration requires that document must be versioned ie: NONE doesn't
302                // appear in restriction contribution
303                throw new NuxeoException("Versioning configuration restricts documents with type=" + type
304                        + "/lifeCycleState=" + lifeCycleState + " must be versioned for each updates.");
305            }
306        } else if (!options.contains(option)) {
307            throw new NuxeoException("Versioning option=" + option + " is not allowed by the configuration for type="
308                    + type + "/lifeCycleState=" + lifeCycleState);
309        }
310        return option;
311    }
312
313    @Override
314    public boolean isPreSaveDoingCheckOut(Document doc, boolean isDirty, VersioningOption option,
315            Map<String, Serializable> options) {
316        boolean disableAutoCheckOut = Boolean.TRUE.equals(options.get(VersioningService.DISABLE_AUTO_CHECKOUT));
317        return !doc.isCheckedOut() && isDirty && !disableAutoCheckOut;
318    }
319
320    @Override
321    public VersioningOption doPreSave(CoreSession session, Document doc, boolean isDirty, VersioningOption option,
322            String checkinComment, Map<String, Serializable> options) {
323        option = validateOption(doc, option);
324        if (isPreSaveDoingCheckOut(doc, isDirty, option, options)) {
325            doCheckOut(doc);
326            followTransitionByOption(session, doc, options);
327        }
328        // transition follow shouldn't change what postSave options will be
329        return option;
330    }
331
332    protected void followTransitionByOption(CoreSession session, Document doc, Map<String, Serializable> options) {
333        String lifecycleState = doc.getLifeCycleState();
334        if ((APPROVED_STATE.equals(lifecycleState) || OBSOLETE_STATE.equals(lifecycleState))
335                && doc.getAllowedStateTransitions().contains(BACK_TO_PROJECT_TRANSITION)) {
336            doc.followTransition(BACK_TO_PROJECT_TRANSITION);
337            if (session != null) {
338                // Send an event to notify that the document state has changed
339                sendEvent(session, doc, lifecycleState, options);
340            }
341        }
342    }
343
344    @Override
345    public boolean isPostSaveDoingCheckIn(Document doc, VersioningOption option, Map<String, Serializable> options) {
346        // option = validateOption(doc, option); // validated before
347        return doc.isCheckedOut() && option != NONE;
348    }
349
350    @Override
351    public Document doPostSave(CoreSession session, Document doc, VersioningOption option, String checkinComment,
352            Map<String, Serializable> options) {
353        if (isPostSaveDoingCheckIn(doc, option, options)) {
354            incrementByOption(doc, option);
355            return doc.checkIn(null, checkinComment); // auto-label
356        }
357        return null;
358    }
359
360    @Override
361    public Document doCheckIn(Document doc, VersioningOption option, String checkinComment) {
362        if (option != NONE) {
363            incrementByOption(doc, option == MAJOR ? MAJOR : MINOR);
364        }
365        return doc.checkIn(null, checkinComment); // auto-label
366    }
367
368    @Override
369    public void doCheckOut(Document doc) {
370        Document base = doc.getBaseVersion();
371        doc.checkOut();
372        // set version number to that of the latest version
373        // nothing to do if base is latest version, already at proper version
374        if (!base.isLatestVersion()) {
375            // this doc was restored from a non-latest version, find the latest one
376            Document last = doc.getLastVersion();
377            if (last != null) {
378                try {
379                    setVersion(doc, getMajor(last), getMinor(last));
380                } catch (PropertyNotFoundException e) {
381                    // ignore
382                }
383            }
384        }
385    }
386
387    @Override
388    @Deprecated
389    public Map<String, VersioningRuleDescriptor> getVersioningRules() {
390        return Collections.emptyMap();
391    }
392
393    @Override
394    @Deprecated
395    public void setVersioningRules(Map<String, VersioningRuleDescriptor> versioningRules) {
396        // Convert former rules to new one - keep initial state and restriction
397        int order = DEFAULT_FORMER_RULE_ORDER - 1;
398        for (Entry<String, VersioningRuleDescriptor> rules : versioningRules.entrySet()) {
399            String documentType = rules.getKey();
400            VersioningRuleDescriptor versioningRule = rules.getValue();
401            // Compute policy and filter id
402            String compatId = COMPAT_ID_PREFIX + documentType;
403
404            // Convert the rule
405            if (versioningRule.isEnabled()) {
406                VersioningPolicyDescriptor policy = new VersioningPolicyDescriptor();
407                policy.id = compatId;
408                policy.order = order;
409                policy.initialState = versioningRule.initialState;
410                policy.filterIds = new ArrayList<>(Collections.singleton(compatId));
411
412                VersioningFilterDescriptor filter = new VersioningFilterDescriptor();
413                filter.id = compatId;
414                filter.types = Collections.singleton(documentType);
415
416                // Register rules
417                versioningPolicies.put(compatId, policy);
418                versioningFilters.put(compatId, filter);
419
420                // Convert save options
421                VersioningRestrictionDescriptor restriction = new VersioningRestrictionDescriptor();
422                restriction.type = documentType;
423                restriction.options = versioningRule.getOptions()
424                                                    .values()
425                                                    .stream()
426                                                    .map(SaveOptionsDescriptor::toRestrictionOptions)
427                                                    .collect(Collectors.toMap(
428                                                            VersioningRestrictionOptionsDescriptor::getLifeCycleState,
429                                                            Function.identity()));
430                versioningRestrictions.put(restriction.type, restriction);
431
432                order--;
433            } else {
434                versioningPolicies.remove(compatId);
435                versioningFilters.remove(compatId);
436            }
437        }
438    }
439
440    @Override
441    @Deprecated
442    public void setDefaultVersioningRule(DefaultVersioningRuleDescriptor defaultVersioningRule) {
443        if (defaultVersioningRule == null) {
444            return;
445        }
446        // Convert former rules to new one - keep initial state and restriction
447        VersioningPolicyDescriptor policy = new VersioningPolicyDescriptor();
448        policy.id = COMPAT_DEFAULT_ID;
449        policy.order = DEFAULT_FORMER_RULE_ORDER;
450        policy.initialState = defaultVersioningRule.initialState;
451
452        // Register rule
453        if (versioningPolicies == null) {
454            versioningPolicies = new HashMap<>();
455        }
456        versioningPolicies.put(policy.id, policy);
457
458        // Convert save options
459        VersioningRestrictionDescriptor restriction = new VersioningRestrictionDescriptor();
460        restriction.type = "*";
461        restriction.options = defaultVersioningRule.getOptions()
462                                                   .values()
463                                                   .stream()
464                                                   .map(SaveOptionsDescriptor::toRestrictionOptions)
465                                                   .collect(Collectors.toMap(
466                                                           VersioningRestrictionOptionsDescriptor::getLifeCycleState,
467                                                           Function.identity()));
468        versioningRestrictions.put(restriction.type, restriction);
469    }
470
471    @Override
472    public void setVersioningPolicies(Map<String, VersioningPolicyDescriptor> versioningPolicies) {
473        this.versioningPolicies.clear();
474        if (versioningPolicies != null) {
475            this.versioningPolicies.putAll(versioningPolicies);
476        }
477    }
478
479    @Override
480    public void setVersioningFilters(Map<String, VersioningFilterDescriptor> versioningFilters) {
481        this.versioningFilters.clear();
482        if (versioningFilters != null) {
483            this.versioningFilters.putAll(versioningFilters);
484        }
485    }
486
487    @Override
488    public void setVersioningRestrictions(Map<String, VersioningRestrictionDescriptor> versioningRestrictions) {
489        this.versioningRestrictions.clear();
490        if (versioningRestrictions != null) {
491            this.versioningRestrictions.putAll(versioningRestrictions);
492        }
493    }
494
495    @Override
496    public void doAutomaticVersioning(DocumentModel previousDocument, DocumentModel currentDocument, boolean before) {
497        VersioningPolicyDescriptor policy = retrieveMatchingVersioningPolicy(previousDocument, currentDocument, before);
498        if (policy != null && policy.getIncrement() != NONE) {
499            if (before) {
500                if (previousDocument.isCheckedOut()) {
501                    previousDocument.checkIn(policy.getIncrement(), null); // auto label
502                    // put back document in checked out state
503                    previousDocument.checkOut();
504                }
505            } else {
506                if (currentDocument.isCheckedOut()) {
507                    currentDocument.checkIn(policy.getIncrement(), null); // auto label
508                }
509            }
510        }
511    }
512
513    protected VersioningPolicyDescriptor retrieveMatchingVersioningPolicy(DocumentModel previousDocument,
514            DocumentModel currentDocument, boolean before) {
515        return versioningPolicies.values()
516                                 .stream()
517                                 .filter(policy -> policy.isBeforeUpdate() == before)
518                                 // Filter out policy with null increment - possible if we declare a policy for the
519                                 // initial state for all documents
520                                 .filter(policy -> policy.getIncrement() != null)
521                                 .sorted()
522                                 .filter(policy -> isPolicyMatch(policy, previousDocument, currentDocument))
523                                 .findFirst()
524                                 .orElse(null);
525    }
526
527    protected boolean isPolicyMatch(VersioningPolicyDescriptor policyDescriptor, DocumentModel previousDocument,
528            DocumentModel currentDocument) {
529        // Relation between filters in a policy is a AND
530        for (String filterId : policyDescriptor.getFilterIds()) {
531            VersioningFilterDescriptor filterDescriptor = versioningFilters.get(filterId);
532            if (filterDescriptor == null) {
533                log.warn("Versioning filter with id={} is referenced in the policy with id={}, but doesn't exist.",
534                        filterId, policyDescriptor.getId());
535            } else if (!filterDescriptor.newInstance().test(previousDocument, currentDocument)) {
536                // As it's a AND, if one fails then policy doesn't match
537                return false;
538            }
539        }
540        // All filters match the context (previousDocument + currentDocument)
541        log.debug("Document {} is a candidate for {}", currentDocument.getRef(), policyDescriptor);
542        return true;
543    }
544
545    protected void sendEvent(CoreSession session, Document doc, String previousLifecycleState,
546            Map<String, Serializable> options) {
547        DocumentModel docModel = DocumentModelFactory.createDocumentModel(doc, session);
548
549        DocumentEventContext ctx = new DocumentEventContext(session, session.getPrincipal(), docModel);
550
551        ctx.setProperty(TRANSTION_EVENT_OPTION_FROM, previousLifecycleState);
552        ctx.setProperty(TRANSTION_EVENT_OPTION_TO, doc.getLifeCycleState());
553        ctx.setProperty(TRANSTION_EVENT_OPTION_TRANSITION, BACK_TO_PROJECT_TRANSITION);
554        ctx.setProperty(REPOSITORY_NAME, session.getRepositoryName());
555        ctx.setProperty(DOC_LIFE_CYCLE, BACK_TO_PROJECT_TRANSITION);
556        ctx.setProperty(CATEGORY, DocumentEventCategories.EVENT_LIFE_CYCLE_CATEGORY);
557        ctx.setProperty(COMMENT, options.get(COMMENT));
558
559        Framework.getService(EventService.class).fireEvent(ctx.newEvent(TRANSITION_EVENT));
560    }
561
562}