/* * (C) Copyright 2006-2017 Nuxeo (http://nuxeo.com/) and others. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * * Contributors: * Florent Guillaume * Laurent Doguin */ package org.nuxeo.ecm.core.versioning; import static org.nuxeo.ecm.core.api.VersioningOption.MAJOR; import static org.nuxeo.ecm.core.api.VersioningOption.MINOR; import static org.nuxeo.ecm.core.api.VersioningOption.NONE; import java.io.Serializable; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.function.Function; import java.util.stream.Collectors; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.nuxeo.ecm.core.api.DocumentModel; import org.nuxeo.ecm.core.api.DocumentModelFactory; import org.nuxeo.ecm.core.api.LifeCycleException; import org.nuxeo.ecm.core.api.NuxeoException; import org.nuxeo.ecm.core.api.VersioningOption; import org.nuxeo.ecm.core.api.impl.DocumentModelImpl; import org.nuxeo.ecm.core.api.model.PropertyNotFoundException; import org.nuxeo.ecm.core.model.Document; import org.nuxeo.ecm.core.schema.FacetNames; /** * Implementation of the versioning service that follows standard checkout / checkin semantics. */ public class StandardVersioningService implements ExtendableVersioningService { private static final Log log = LogFactory.getLog(StandardVersioningService.class); protected static final int DEFAULT_FORMER_RULE_ORDER = 10_000; protected static final String COMPAT_ID_PREFIX = "compatibility-type-"; protected static final String COMPAT_DEFAULT_ID = "compatibility-default"; /** * @deprecated since 9.1 seems unused */ @Deprecated public static final String FILE_TYPE = "File"; /** * @deprecated since 9.1 seems unused */ @Deprecated public static final String NOTE_TYPE = "Note"; /** * @deprecated since 9.1 seems unused */ @Deprecated public static final String PROJECT_STATE = "project"; public static final String APPROVED_STATE = "approved"; public static final String OBSOLETE_STATE = "obsolete"; public static final String BACK_TO_PROJECT_TRANSITION = "backToProject"; /** * @deprecated since 9.1 seems unused */ @Deprecated protected static final String AUTO_CHECKED_OUT = "AUTO_CHECKED_OUT"; /** Key for major version in Document API. */ protected static final String MAJOR_VERSION = "ecm:majorVersion"; /** Key for minor version in Document API. */ protected static final String MINOR_VERSION = "ecm:minorVersion"; private Map<String, VersioningPolicyDescriptor> versioningPolicies = new HashMap<>(); private Map<String, VersioningFilterDescriptor> versioningFilters = new HashMap<>(); private Map<String, VersioningRestrictionDescriptor> versioningRestrictions = new HashMap<>(); @Override public String getVersionLabel(DocumentModel docModel) { String label; try { label = getMajor(docModel) + "." + getMinor(docModel); if (docModel.isCheckedOut() && !"0.0".equals(label)) { label += "+"; } } catch (PropertyNotFoundException e) { label = ""; } return label; } protected long getMajor(DocumentModel docModel) { return getVersion(docModel, VersioningService.MAJOR_VERSION_PROP); } protected long getMinor(DocumentModel docModel) { return getVersion(docModel, VersioningService.MINOR_VERSION_PROP); } protected long getVersion(DocumentModel docModel, String prop) { Object propVal = docModel.getPropertyValue(prop); if (propVal == null || !(propVal instanceof Long)) { return 0; } else { return ((Long) propVal).longValue(); } } protected long getMajor(Document doc) { return getVersion(doc, MAJOR_VERSION); } protected long getMinor(Document doc) { return getVersion(doc, MINOR_VERSION); } protected long getVersion(Document doc, String prop) { Object propVal = doc.getPropertyValue(prop); if (propVal == null || !(propVal instanceof Long)) { return 0; } else { return ((Long) propVal).longValue(); } } protected void setVersion(Document doc, long major, long minor) { doc.setPropertyValue(MAJOR_VERSION, Long.valueOf(major)); doc.setPropertyValue(MINOR_VERSION, Long.valueOf(minor)); } protected void incrementMajor(Document doc) { setVersion(doc, getMajor(doc) + 1, 0); } protected void incrementMinor(Document doc) { // make sure major is not null by re-setting it setVersion(doc, getMajor(doc), getMinor(doc) + 1); } protected void incrementByOption(Document doc, VersioningOption option) { try { if (option == MAJOR) { incrementMajor(doc); } else if (option == MINOR) { incrementMinor(doc); } // else nothing } catch (PropertyNotFoundException e) { // ignore } } @Override public void doPostCreate(Document doc, Map<String, Serializable> options) { if (doc.isVersion() || doc.isProxy()) { return; } setInitialVersion(doc); } /** * Sets the initial version on a document. Can be overridden. */ protected void setInitialVersion(Document doc) { // Create a document model for filters DocumentModelImpl documentModel = DocumentModelFactory.createDocumentModel(doc, null, null); for (VersioningPolicyDescriptor policyDescriptor : versioningPolicies.values()) { if (isPolicyMatch(policyDescriptor, null, documentModel)) { InitialStateDescriptor initialState = policyDescriptor.getInitialState(); if (initialState != null) { setVersion(doc, initialState.getMajor(), initialState.getMinor()); return; } } } setVersion(doc, 0, 0); } @Override public List<VersioningOption> getSaveOptions(DocumentModel docModel) { boolean versionable = docModel.isVersionable(); String lifeCycleState = docModel.getCoreSession().getCurrentLifeCycleState(docModel.getRef()); String type = docModel.getType(); return getSaveOptions(versionable, lifeCycleState, type); } protected List<VersioningOption> getSaveOptions(Document doc) { boolean versionable = doc.getType().getFacets().contains(FacetNames.VERSIONABLE); String lifeCycleState; try { lifeCycleState = doc.getLifeCycleState(); } catch (LifeCycleException e) { lifeCycleState = null; } String type = doc.getType().getName(); return getSaveOptions(versionable, lifeCycleState, type); } protected List<VersioningOption> getSaveOptions(boolean versionable, String lifeCycleState, String type) { if (!versionable) { return Collections.singletonList(NONE); } // try to get restriction for current type List<VersioningOption> options = computeRestrictionOptions(lifeCycleState, type); if (options == null) { // no specific restrictions on current document type - get restriction for any document type options = computeRestrictionOptions(lifeCycleState, "*"); } if (options != null) { return options; } // By default a versionable document could be incremented by all available options return Arrays.asList(VersioningOption.values()); } protected List<VersioningOption> computeRestrictionOptions(String lifeCycleState, String type) { VersioningRestrictionDescriptor restrictions = versioningRestrictions.get(type); if (restrictions != null) { // try to get restriction options for current life cycle state VersioningRestrictionOptionsDescriptor restrictionOptions = null; if (lifeCycleState != null) { restrictionOptions = restrictions.getRestrictionOption(lifeCycleState); } if (restrictionOptions == null) { // try to get restriction for any life cycle states restrictionOptions = restrictions.getRestrictionOption("*"); } if (restrictionOptions != null) { return restrictionOptions.getOptions(); } } return null; } protected VersioningOption validateOption(Document doc, VersioningOption option) { List<VersioningOption> options = getSaveOptions(doc); // some variables for exceptions String type = doc.getType().getName(); String lifeCycleState; try { lifeCycleState = doc.getLifeCycleState(); } catch (LifeCycleException e) { lifeCycleState = null; } if (option == null) { if (options.isEmpty() || options.contains(NONE)) { // Valid cases: // - we don't ask for a version and versioning is blocked by configuration // - we don't ask for a version and NONE is available as restriction return NONE; } else { // No version is asked but configuration requires that document must be versioned ie: NONE doesn't // appear in restriction contribution throw new NuxeoException("Versioning configuration restricts documents with type=" + type + "/lifeCycleState=" + lifeCycleState + " must be versioned for each updates."); } } else if (!options.contains(option)) { throw new NuxeoException("Versioning option=" + option + " is not allowed by the configuration for type=" + type + "/lifeCycleState=" + lifeCycleState); } return option; } @Override public boolean isPreSaveDoingCheckOut(Document doc, boolean isDirty, VersioningOption option, Map<String, Serializable> options) { boolean disableAutoCheckOut = Boolean.TRUE.equals(options.get(VersioningService.DISABLE_AUTO_CHECKOUT)); return !doc.isCheckedOut() && isDirty && !disableAutoCheckOut; } @Override public VersioningOption doPreSave(Document doc, boolean isDirty, VersioningOption option, String checkinComment, Map<String, Serializable> options) { option = validateOption(doc, option); if (isPreSaveDoingCheckOut(doc, isDirty, option, options)) { doCheckOut(doc); followTransitionByOption(doc, option); } // transition follow shouldn't change what postSave options will be return option; } protected void followTransitionByOption(Document doc, VersioningOption option) { String lifecycleState = doc.getLifeCycleState(); if (APPROVED_STATE.equals(lifecycleState) || OBSOLETE_STATE.equals(lifecycleState)) { doc.followTransition(BACK_TO_PROJECT_TRANSITION); } } @Override public boolean isPostSaveDoingCheckIn(Document doc, VersioningOption option, Map<String, Serializable> options) { // option = validateOption(doc, option); // validated before return doc.isCheckedOut() && option != NONE; } @Override public Document doPostSave(Document doc, VersioningOption option, String checkinComment, Map<String, Serializable> options) { if (isPostSaveDoingCheckIn(doc, option, options)) { incrementByOption(doc, option); return doc.checkIn(null, checkinComment); // auto-label } return null; } @Override public Document doCheckIn(Document doc, VersioningOption option, String checkinComment) { if (option != NONE) { incrementByOption(doc, option == MAJOR ? MAJOR : MINOR); } return doc.checkIn(null, checkinComment); // auto-label } @Override public void doCheckOut(Document doc) { Document base = doc.getBaseVersion(); doc.checkOut(); // set version number to that of the latest version if (base.isLatestVersion()) { // nothing to do, already at proper version } else { // this doc was restored from a non-latest version, find the latest one Document last = doc.getLastVersion(); if (last != null) { try { setVersion(doc, getMajor(last), getMinor(last)); } catch (PropertyNotFoundException e) { // ignore } } } } @Override @Deprecated public Map<String, VersioningRuleDescriptor> getVersioningRules() { return Collections.emptyMap(); } @Override @Deprecated public void setVersioningRules(Map<String, VersioningRuleDescriptor> versioningRules) { // Convert former rules to new one - keep initial state and restriction int order = DEFAULT_FORMER_RULE_ORDER - 1; for (Entry<String, VersioningRuleDescriptor> rules : versioningRules.entrySet()) { String documentType = rules.getKey(); VersioningRuleDescriptor versioningRule = rules.getValue(); // Compute policy and filter id String compatId = COMPAT_ID_PREFIX + documentType; // Convert the rule if (versioningRule.isEnabled()) { VersioningPolicyDescriptor policy = new VersioningPolicyDescriptor(); policy.id = compatId; policy.order = order; policy.initialState = versioningRule.initialState; policy.filterIds = new ArrayList<>(Collections.singleton(compatId)); VersioningFilterDescriptor filter = new VersioningFilterDescriptor(); filter.id = compatId; filter.types = Collections.singleton(documentType); // Register rules versioningPolicies.put(compatId, policy); versioningFilters.put(compatId, filter); // Convert save options VersioningRestrictionDescriptor restriction = new VersioningRestrictionDescriptor(); restriction.type = documentType; restriction.options = versioningRule.getOptions() .values() .stream() .map(SaveOptionsDescriptor::toRestrictionOptions) .collect(Collectors.toMap( VersioningRestrictionOptionsDescriptor::getLifeCycleState, Function.identity())); versioningRestrictions.put(restriction.type, restriction); order--; } else { versioningPolicies.remove(compatId); versioningFilters.remove(compatId); } } } @Override @Deprecated public void setDefaultVersioningRule(DefaultVersioningRuleDescriptor defaultVersioningRule) { if (defaultVersioningRule == null) { return; } // Convert former rules to new one - keep initial state and restriction VersioningPolicyDescriptor policy = new VersioningPolicyDescriptor(); policy.id = COMPAT_DEFAULT_ID; policy.order = DEFAULT_FORMER_RULE_ORDER; policy.initialState = defaultVersioningRule.initialState; // Register rule if (versioningPolicies == null) { versioningPolicies = new HashMap<>(); } versioningPolicies.put(policy.id, policy); // Convert save options VersioningRestrictionDescriptor restriction = new VersioningRestrictionDescriptor(); restriction.type = "*"; restriction.options = defaultVersioningRule.getOptions() .values() .stream() .map(SaveOptionsDescriptor::toRestrictionOptions) .collect(Collectors.toMap( VersioningRestrictionOptionsDescriptor::getLifeCycleState, Function.identity())); versioningRestrictions.put(restriction.type, restriction); } @Override public void setVersioningPolicies(Map<String, VersioningPolicyDescriptor> versioningPolicies) { this.versioningPolicies.clear(); if (versioningPolicies != null) { this.versioningPolicies.putAll(versioningPolicies); } } @Override public void setVersioningFilters(Map<String, VersioningFilterDescriptor> versioningFilters) { this.versioningFilters.clear(); if (versioningFilters != null) { this.versioningFilters.putAll(versioningFilters); } } @Override public void setVersioningRestrictions(Map<String, VersioningRestrictionDescriptor> versioningRestrictions) { this.versioningRestrictions.clear(); if (versioningRestrictions != null) { this.versioningRestrictions.putAll(versioningRestrictions); } } @Override public void doAutomaticVersioning(DocumentModel previousDocument, DocumentModel currentDocument, boolean before) { VersioningPolicyDescriptor policy = retrieveMatchingVersioningPolicy(previousDocument, currentDocument, before); if (policy != null && policy.getIncrement() != NONE) { if (before) { if (previousDocument.isCheckedOut()) { previousDocument.checkIn(policy.getIncrement(), null); // auto label // put back document in checked out state previousDocument.checkOut(); } } else { if (currentDocument.isCheckedOut()) { currentDocument.checkIn(policy.getIncrement(), null); // auto label } } } } protected VersioningPolicyDescriptor retrieveMatchingVersioningPolicy(DocumentModel previousDocument, DocumentModel currentDocument, boolean before) { return versioningPolicies.values() .stream() .sorted() .filter(policy -> policy.isBeforeUpdate() == before) .filter(policy -> isPolicyMatch(policy, previousDocument, currentDocument)) // Filter out policy with null increment - possible if we declare a policy for the // initial state for all documents .filter(policy -> policy.getIncrement() != null) .findFirst() .orElse(null); } protected boolean isPolicyMatch(VersioningPolicyDescriptor policyDescriptor, DocumentModel previousDocument, DocumentModel currentDocument) { // Relation between filters in a policy is a AND for (String filterId : policyDescriptor.getFilterIds()) { VersioningFilterDescriptor filterDescriptor = versioningFilters.get(filterId); if (filterDescriptor == null) { // TODO maybe throw something ? log.warn("Versioning filter with id=" + filterId + " is referenced in the policy with id= " + policyDescriptor.getId() + ", but doesn't exist."); } else if (!filterDescriptor.newInstance().test(previousDocument, currentDocument)) { // As it's a AND, if one fails then policy doesn't match return false; } } // All filters match the context (previousDocument + currentDocument) return true; } }