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