001/* 002 * Copyright 2016 Anyware Services 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 */ 016package org.ametys.plugins.contentio.synchronize; 017 018import java.io.IOException; 019import java.util.ArrayList; 020import java.util.Arrays; 021import java.util.HashMap; 022import java.util.List; 023import java.util.Map; 024import java.util.stream.Collectors; 025 026import org.apache.avalon.framework.service.ServiceException; 027import org.apache.avalon.framework.service.ServiceManager; 028import org.apache.commons.lang3.StringUtils; 029import org.slf4j.Logger; 030 031import org.ametys.cms.repository.Content; 032import org.ametys.cms.repository.ContentQueryHelper; 033import org.ametys.cms.repository.ContentTypeExpression; 034import org.ametys.cms.repository.LanguageExpression; 035import org.ametys.cms.repository.ModifiableContent; 036import org.ametys.cms.repository.WorkflowAwareContent; 037import org.ametys.cms.workflow.ContentWorkflowHelper; 038import org.ametys.core.observation.Event; 039import org.ametys.core.observation.ObservationManager; 040import org.ametys.core.schedule.progression.ContainerProgressionTracker; 041import org.ametys.core.schedule.progression.ProgressionTrackerFactory; 042import org.ametys.core.schedule.progression.SimpleProgressionTracker; 043import org.ametys.core.user.CurrentUserProvider; 044import org.ametys.core.util.HttpUtils; 045import org.ametys.core.util.I18nUtils; 046import org.ametys.core.util.mail.SendMailHelper; 047import org.ametys.plugins.repository.AmetysObjectIterable; 048import org.ametys.plugins.repository.query.expression.AndExpression; 049import org.ametys.plugins.repository.query.expression.Expression; 050import org.ametys.plugins.repository.query.expression.Expression.Operator; 051import org.ametys.plugins.repository.query.expression.OrExpression; 052import org.ametys.plugins.repository.query.expression.StringExpression; 053import org.ametys.plugins.workflow.AbstractWorkflowComponent; 054import org.ametys.plugins.workflow.AbstractWorkflowComponent.ConditionFailure; 055import org.ametys.runtime.config.Config; 056import org.ametys.runtime.i18n.I18nizableText; 057 058import com.opensymphony.workflow.InvalidActionException; 059import com.opensymphony.workflow.WorkflowException; 060 061import jakarta.mail.MessagingException; 062 063/** 064 * Abstract implementation of {@link SynchronizableContentsCollection}. 065 */ 066public abstract class AbstractSynchronizableContentsCollection extends AbstractStaticSynchronizableContentsCollection 067{ 068 /** SCC unique ID field */ 069 protected static final String SCC_UNIQUE_ID = "scc$uniqueid"; 070 071 /** The i18n utils */ 072 protected I18nUtils _i18nUtils; 073 /** The current user provider */ 074 protected CurrentUserProvider _currentUserProvider; 075 /** The observation manager */ 076 protected ObservationManager _observationManager; 077 /** The content workflow helper */ 078 protected ContentWorkflowHelper _contentWorkflowHelper; 079 080 /** Number of errors encountered */ 081 protected int _nbError; 082 /** True if there is a global error during synchronization */ 083 protected boolean _hasGlobalError; 084 085 /** Number of created contents */ 086 protected int _nbCreatedContents; 087 /** Number of synchronized contents */ 088 protected int _nbSynchronizedContents; 089 /** Number of unchanged contents */ 090 protected int _nbNotChangedContents; 091 /** Number of deleted contents */ 092 protected int _nbDeletedContents; 093 094 @Override 095 public void service(ServiceManager manager) throws ServiceException 096 { 097 super.service(manager); 098 _i18nUtils = (I18nUtils) manager.lookup(I18nUtils.ROLE); 099 _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE); 100 _observationManager = (ObservationManager) manager.lookup(ObservationManager.ROLE); 101 _contentWorkflowHelper = (ContentWorkflowHelper) manager.lookup(ContentWorkflowHelper.ROLE); 102 } 103 104 @Override 105 public List<ModifiableContent> populate(Logger logger, ContainerProgressionTracker progressionTracker) 106 { 107 ContainerProgressionTracker internalPopulaitePT = progressionTracker.addContainerStep("internalpopulate", new I18nizableText("plugin.contentio", "PLUGINS_CONTENTIO_SCHEDULER_SYNCHRONIZE_COLLECTION_IMPORT_SYNCHRONIZE_CONTENTS_STEP_LABEL"), 2); 108 109 SimpleProgressionTracker deleteUnexistingContentsPT = progressionTracker.addSimpleStep("deleteunexistingcontents", new I18nizableText("plugin.contentio", "PLUGINS_CONTENTIO_SCHEDULER_SYNCHRONIZE_COLLECTION_DELETE_UNEXISTING_CONTENTS_STEP_LABEL")); 110 111 SimpleProgressionTracker notifyPT = progressionTracker.addSimpleStep("notify", new I18nizableText("plugin.contentio", "PLUGINS_CONTENTIO_SCHEDULER_SYNCHRONIZE_COLLECTION_NOTIFY_OBSERVERS_AND_SEND_MAILS_STEP_LABEL")); 112 113 _nbCreatedContents = 0; 114 _nbSynchronizedContents = 0; 115 _nbNotChangedContents = 0; 116 _nbDeletedContents = 0; 117 _nbError = 0; 118 _hasGlobalError = false; 119 120 logger.info("Start synchronization of collection '{}'", getId()); 121 List<Long> times = new ArrayList<>(); 122 times.add(System.currentTimeMillis()); 123 124 // Do populate 125 List<ModifiableContent> populatedContents = _internalPopulate(logger, internalPopulaitePT); 126 127 if (!_hasGlobalError && removalSync()) 128 { 129 // Delete old contents if source prevails 130 deleteUnexistingContents(logger); 131 } 132 deleteUnexistingContentsPT.increment(); 133 134 times.add(System.currentTimeMillis()); 135 logger.info("[Synchronization of collection '{}'] Populated in {} ms", getId(), times.get(times.size() - 1) - times.get(times.size() - 2)); 136 _logSynchronizationResult(logger); 137 138 notifyPT.setSize(2); 139 140 if (_hasSomethingChanged()) 141 { 142 // Do not notify obeservers if there is no change 143 Map<String, Object> eventParams = new HashMap<>(); 144 eventParams.put(org.ametys.plugins.contentio.synchronize.observation.ObservationConstants.ARGS_COLLECTION_ID, this.getId()); 145 eventParams.put(org.ametys.plugins.contentio.synchronize.observation.ObservationConstants.ARGS_COLLECTION_CONTENT_TYPE, this.getContentType()); 146 _observationManager.notify(new Event(org.ametys.plugins.contentio.synchronize.observation.ObservationConstants.EVENT_CONTENT_COLLECTION_SYNCHRONIZED, _currentUserProvider.getUser(), eventParams)); 147 148 times.add(System.currentTimeMillis()); 149 logger.info("[Synchronization of collection '{}'] Listeners notified in {} ms", getId(), times.get(times.size() - 1) - times.get(times.size() - 2)); 150 151 } 152 notifyPT.increment(); 153 154 if (_nbError > 0 && getReportMails().length() > 0) 155 { 156 try 157 { 158 logger.warn("{} contents were not created/updated because of an error.", _nbError); 159 sendErrorMail(_nbError); 160 161 times.add(System.currentTimeMillis()); 162 logger.info("[Synchronization of collection '{}'] Error mail sent in {} ms", getId(), times.get(times.size() - 1) - times.get(times.size() - 2)); 163 } 164 catch (MessagingException | IOException e) 165 { 166 logger.warn("Unable to send mail", e); 167 } 168 } 169 notifyPT.increment(); 170 171 logger.info("[Synchronization of collection '{}'] Total in {} ms", getId(), times.get(times.size() - 1) - times.get(0)); 172 173 return populatedContents; 174 } 175 176 /** 177 * Internal implementation of {@link #populate(Logger, ContainerProgressionTracker)} 178 * @param logger The logger 179 * @return The list of created/synchronized contents 180 */ 181 protected List<ModifiableContent> _internalPopulate(Logger logger) 182 { 183 return _internalPopulate(logger, ProgressionTrackerFactory.createContainerProgressionTracker("Internal populate", logger)); 184 } 185 186 /** 187 * Internal implementation of {@link #populate(Logger, ContainerProgressionTracker)} 188 * @param logger The logger 189 * @param progressionTracker The progression tracker 190 * @return The list of created/synchronized contents 191 */ 192 protected abstract List<ModifiableContent> _internalPopulate(Logger logger, ContainerProgressionTracker progressionTracker); 193 194 @Override 195 public void empty(Logger logger) 196 { 197 // Get all contents from the SCC 198 Expression collectionExpression = _sccHelper.getCollectionExpression(getId()); 199 String xPathQuery = ContentQueryHelper.getContentXPathQuery(collectionExpression); 200 List<ModifiableContent> contentsToRemove = _resolver.<ModifiableContent>query(xPathQuery) 201 .stream() 202 .toList(); 203 // Process to the deletion 204 _removeSCCOrDeleteContents(contentsToRemove, logger); 205 } 206 207 /** 208 * Delete contents created by a previous synchronization which does not exist anymore in remote source. 209 * If the content belongs to several SCC, only the current SCC reference will be deleted. 210 * @param logger The logger 211 */ 212 protected void deleteUnexistingContents(Logger logger) 213 { 214 String query = _getContentPathQuery(null, null, null, true); 215 AmetysObjectIterable<ModifiableContent> contents = _resolver.query(query); 216 217 List<ModifiableContent> contentsToRemove = _getContentsToRemove(contents); 218 219 if (!contentsToRemove.isEmpty()) 220 { 221 if (logger.isInfoEnabled()) 222 { 223 contentsToRemove.stream().forEach(content -> logger.info("The content '{}' ({}) does not exist anymore in remote source: it will be deleted if possible.", content.getTitle(), content.getId())); 224 } 225 226 _nbDeletedContents = _removeSCCOrDeleteContents(contentsToRemove, logger); 227 } 228 } 229 230 /** 231 * Filter the contents to remove. 232 * @param contents The list of all the available contents 233 * @return The {@link List} of {@link Content} to remove. 234 */ 235 protected abstract List<ModifiableContent> _getContentsToRemove(AmetysObjectIterable<ModifiableContent> contents); 236 237 /** 238 * For each content, if the content has only this SCC, try to delete it, but if it has several SCC, remove the current collection from the SCC property. 239 * @param contents The contents to delete or to remove the SCC 240 * @param logger The logger 241 * @return the number of really deleted contents 242 */ 243 protected int _removeSCCOrDeleteContents(List<ModifiableContent> contents, Logger logger) 244 { 245 logger.info("Remove SCC on contents with multiple SCC... {} contents for all the deletion process.", contents.size()); 246 List<Content> contentsToReallyDelete = new ArrayList<>(); 247 for (ModifiableContent content : contents) 248 { 249 // There is more than one SCC, only remove the SCC reference 250 if (_sccHelper.getSynchronizableCollectionIds(content).size() > 1) 251 { 252 _sccHelper.removeSCCProperty(content, getId()); 253 content.saveChanges(); 254 } 255 // It was the only SCC, completely remove the content 256 else 257 { 258 contentsToReallyDelete.add(content); 259 } 260 } 261 262 logger.info("Remove contents with single SCC... {} contents remaining.", contentsToReallyDelete.size()); 263 int nbDeletedContents = _deleteContents(contentsToReallyDelete, logger); 264 logger.info("Contents deleting process ended. {} contents has been deleted.", nbDeletedContents); 265 266 return nbDeletedContents; 267 } 268 269 /** 270 * Delete contents. 271 * @param contentsToRemove List of contents to remove 272 * @param logger The logger 273 * @return the number of deleted contents 274 */ 275 protected int _deleteContents(List<Content> contentsToRemove, Logger logger) 276 { 277 return _contentDAO.forceDeleteContentsWithLog(contentsToRemove, null, logger); 278 } 279 280 /** 281 * Logs the result of the synchronization, containing 282 * <ul> 283 * <li>The number of created contents</li> 284 * <li>The number of synchronized contents</li> 285 * <li>The number of unchanged contents</li> 286 * <li>The number of deleted contents</li> 287 * </ul> 288 * @param logger the logger 289 */ 290 protected void _logSynchronizationResult(Logger logger) 291 { 292 logger.info("{} contents were created", _nbCreatedContents); 293 logger.info("{} contents were updated", _nbSynchronizedContents); 294 logger.info("{} contents did not changed", _nbNotChangedContents); 295 logger.info("{} contents were deleted", _nbDeletedContents); 296 } 297 298 /** 299 * Checks if some content have changed during the synchronization 300 * @return <code>true</code> if some contents have changed, <code>false</code> otherwise 301 */ 302 protected boolean _hasSomethingChanged() 303 { 304 return _nbCreatedContents > 0 || _nbSynchronizedContents > 0 || _nbDeletedContents > 0; 305 } 306 307 /** 308 * Sends the report mails 309 * @param nbError The number of error 310 * @throws MessagingException if a messaging error occurred 311 * @throws IOException if an error occurred building the message 312 */ 313 protected void sendErrorMail(int nbError) throws MessagingException, IOException 314 { 315 String pluginName = "plugin.contentio"; 316 List<String> params = new ArrayList<>(); 317 params.add(getId()); 318 String subject = _i18nUtils.translate(new I18nizableText(pluginName, "PLUGINS_CONTENTIO_POPULATE_REPORT_MAIL_SUBJECT", params)); 319 320 params.clear(); 321 params.add(String.valueOf(nbError)); 322 params.add(getId()); 323 String baseUrl = HttpUtils.sanitize(Config.getInstance().getValue("cms.url")); 324 params.add(baseUrl + "/_admin/index.html?uitool=uitool-admin-logs"); 325 String body = _i18nUtils.translate(new I18nizableText(pluginName, "PLUGINS_CONTENTIO_POPULATE_REPORT_MAIL_BODY", params)); 326 327 SendMailHelper.newMail() 328 .withSubject(subject) 329 .withTextBody(body) 330 .withRecipients(Arrays.asList(getReportMails().split("\\n"))) 331 .sendMail(); 332 } 333 334 /** 335 * Validates a content after import 336 * @param content The content to validate 337 * @param validationActionId Validation action ID to use for this content 338 * @param logger The logger 339 */ 340 protected void validateContent(WorkflowAwareContent content, int validationActionId, Logger logger) 341 { 342 Map<String, Object> inputs = new HashMap<>(); 343 344 try 345 { 346 _contentWorkflowHelper.doAction(content, validationActionId, inputs); 347 logger.info("The content {} has been validated after import", content); 348 } 349 catch (WorkflowException | InvalidActionException e) 350 { 351 String failuresAsString = _getActionFailuresAsString(inputs); 352 logger.error("The content {} cannot be validated after import{}", content, failuresAsString, e); 353 } 354 } 355 356 private String _getActionFailuresAsString(Map<String, Object> actionInputs) 357 { 358 String failuresAsString = ""; 359 if (actionInputs.containsKey(AbstractWorkflowComponent.FAIL_CONDITIONS_KEY)) 360 { 361 @SuppressWarnings("unchecked") 362 List<ConditionFailure> failures = (List<ConditionFailure>) actionInputs.get(AbstractWorkflowComponent.FAIL_CONDITIONS_KEY); 363 if (!failures.isEmpty()) 364 { 365 failuresAsString = ", due to the following error(s):\n" + String.join("\n", failures.stream().map(ConditionFailure::text).toList()); 366 } 367 } 368 369 return failuresAsString; 370 } 371 372 /** 373 * Creates content action with result from request 374 * @param contentType Type of the content to create 375 * @param workflowName Workflow to use for this content 376 * @param initialActionId Action ID for initialization 377 * @param lang The language 378 * @param contentTitle The content title 379 * @param logger The logger 380 * @return The content id, or null of a workflow error occured 381 */ 382 protected ModifiableContent createContentAction(String contentType, String workflowName, int initialActionId, String lang, String contentTitle, Logger logger) 383 { 384 try 385 { 386 logger.info("Creating content '{}' with the content type '{}' for language {}", contentTitle, getContentType(), lang); 387 String desiredContentName = _contentPrefix + "-" + contentTitle + "-" + lang; 388 389 Map<String, Object> inputs = _getAdditionalInputsForContentCreation(); 390 Map<String, Object> result = _contentWorkflowHelper.createContent( 391 workflowName, 392 initialActionId, 393 desiredContentName, 394 contentTitle, 395 new String[] {contentType}, 396 null, 397 lang, 398 inputs); 399 400 return (ModifiableContent) result.get(Content.class.getName()); 401 } 402 catch (WorkflowException e) 403 { 404 _nbError++; 405 logger.error("Failed to initialize workflow for content {} and language {}", contentTitle, lang, e); 406 return null; 407 } 408 } 409 410 /** 411 * Retrieves additional inputs for content creation 412 * @return the additional inputs for content creation 413 */ 414 protected Map<String, Object> _getAdditionalInputsForContentCreation() 415 { 416 // no additional inputs by default 417 return new HashMap<>(); 418 } 419 420 /** 421 * Construct the query to retrieve the content. 422 * @param lang Lang 423 * @param idValue Synchronization value 424 * @param contentType Content type 425 * @param forceStrictCheck <code>true</code> to force strict mode to check the collection, otherwise it read the "checkCollection" option 426 * @return The {@link List} of {@link Expression} 427 */ 428 protected List<Expression> _getExpressionsList(String lang, String idValue, String contentType, boolean forceStrictCheck) 429 { 430 List<Expression> expList = new ArrayList<>(); 431 432 if (forceStrictCheck) 433 { 434 expList.add(_sccHelper.getCollectionExpression(getId())); 435 } 436 else if (checkCollection()) 437 { 438 expList.add( 439 getCompatibleSCC(true).stream() 440 .map(_sccHelper::getCollectionExpression) 441 .collect(Collectors.toCollection(OrExpression::new)) 442 ); 443 } 444 445 if (StringUtils.isNotBlank(contentType)) 446 { 447 expList.add(new ContentTypeExpression(Operator.EQ, contentType)); 448 } 449 450 if (StringUtils.isNotBlank(idValue)) 451 { 452 expList.add(new StringExpression(getIdField(), Operator.EQ, idValue)); 453 } 454 455 if (StringUtils.isNotBlank(lang)) 456 { 457 expList.add(new LanguageExpression(Operator.EQ, lang)); 458 } 459 460 return expList; 461 } 462 463 /** 464 * Construct the query to retrieve the content. 465 * @param lang Lang 466 * @param idValue Synchronization value 467 * @param contentType Content type 468 * @param forceStrictCheck <code>true</code> to force strict mode to check the collection, otherwise it read the "checkCollection" option 469 * @return The XPATH query 470 */ 471 protected String _getContentPathQuery(String lang, String idValue, String contentType, boolean forceStrictCheck) 472 { 473 List<Expression> expList = _getExpressionsList(lang, idValue, contentType, forceStrictCheck); 474 AndExpression andExp = new AndExpression(expList); 475 return ContentQueryHelper.getContentXPathQuery(andExp); 476 } 477 478 /** 479 * Remove empty parameters to the map 480 * @param searchParameters the parameters 481 * @return the map of none empty parameters 482 */ 483 protected Map<String, Object> _removeEmptyParameters(Map<String, Object> searchParameters) 484 { 485 Map<String, Object> result = new HashMap<>(); 486 for (String parameterName : searchParameters.keySet()) 487 { 488 Object parameterValue = searchParameters.get(parameterName); 489 if (_isParamNotEmpty(parameterValue)) 490 { 491 result.put(parameterName, parameterValue); 492 } 493 } 494 495 return result; 496 } 497 498 /** 499 * Check if the parameter value is empty 500 * @param parameterValue the parameter value 501 * @return true if the parameter value is empty 502 */ 503 protected boolean _isParamNotEmpty(Object parameterValue) 504 { 505 return parameterValue != null && !(parameterValue instanceof String && StringUtils.isBlank((String) parameterValue)); 506 } 507 508 public Map<String, Integer> getSynchronizationResult() 509 { 510 Map<String, Integer> result = new HashMap<>(); 511 512 result.put(RESULT_NB_CREATED_CONTENTS, _nbCreatedContents); 513 result.put(RESULT_NB_SYNCHRONIZED_CONTENTS, _nbSynchronizedContents); 514 result.put(RESULT_NB_NOT_CHANGED_CONTENTS, _nbNotChangedContents); 515 result.put(RESULT_NB_DELETED_CONTENTS, _nbDeletedContents); 516 517 return result; 518 } 519}