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}