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}