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