001/* 002 * Copyright 2020 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.csv; 017 018import java.io.BufferedReader; 019import java.io.IOException; 020import java.nio.charset.Charset; 021import java.nio.file.Files; 022import java.nio.file.Paths; 023import java.util.ArrayList; 024import java.util.Arrays; 025import java.util.HashMap; 026import java.util.List; 027import java.util.Map; 028import java.util.Optional; 029 030import org.apache.avalon.framework.activity.Initializable; 031import org.apache.avalon.framework.component.Component; 032import org.apache.avalon.framework.service.ServiceException; 033import org.apache.avalon.framework.service.ServiceManager; 034import org.apache.avalon.framework.service.Serviceable; 035import org.apache.commons.lang3.StringUtils; 036import org.supercsv.io.CsvListReader; 037import org.supercsv.io.ICsvListReader; 038import org.supercsv.prefs.CsvPreference; 039 040import org.ametys.cms.contenttype.ContentAttributeDefinition; 041import org.ametys.cms.contenttype.ContentType; 042import org.ametys.cms.contenttype.ContentTypeExtensionPoint; 043import org.ametys.cms.contenttype.ContentTypesHelper; 044import org.ametys.core.ui.Callable; 045import org.ametys.core.user.CurrentUserProvider; 046import org.ametys.core.user.User; 047import org.ametys.core.user.UserIdentity; 048import org.ametys.core.user.UserManager; 049import org.ametys.core.util.I18nUtils; 050import org.ametys.core.util.mail.SendMailHelper; 051import org.ametys.core.util.mail.SendMailHelper.MailBuilder; 052import org.ametys.runtime.config.Config; 053import org.ametys.runtime.i18n.I18nizableText; 054import org.ametys.runtime.i18n.I18nizableTextParameter; 055import org.ametys.runtime.model.ElementDefinition; 056import org.ametys.runtime.model.ModelHelper; 057import org.ametys.runtime.model.ModelViewItemGroup; 058import org.ametys.runtime.model.View; 059import org.ametys.runtime.model.ViewElement; 060import org.ametys.runtime.model.ViewElementAccessor; 061import org.ametys.runtime.model.ViewItem; 062import org.ametys.runtime.model.exception.BadItemTypeException; 063import org.ametys.runtime.plugin.component.AbstractLogEnabled; 064 065import jakarta.mail.MessagingException; 066 067/** 068 * Import contents from an uploaded CSV file. 069 */ 070public class ImportCSVFileHelper extends AbstractLogEnabled implements Component, Serviceable, Initializable 071{ 072 /** Avalon Role */ 073 public static final String ROLE = ImportCSVFileHelper.class.getName(); 074 075 private CurrentUserProvider _currentUserProvider; 076 private UserManager _userManager; 077 078 private ContentTypeExtensionPoint _contentTypeEP; 079 private ContentTypesHelper _contentTypesHelper; 080 081 private I18nUtils _i18nUtils; 082 private String _sysadminMail; 083 084 private CSVImporter _csvImporter; 085 086 @Override 087 public void service(ServiceManager serviceManager) throws ServiceException 088 { 089 _currentUserProvider = (CurrentUserProvider) serviceManager.lookup(CurrentUserProvider.ROLE); 090 _userManager = (UserManager) serviceManager.lookup(UserManager.ROLE); 091 092 _contentTypeEP = (ContentTypeExtensionPoint) serviceManager.lookup(ContentTypeExtensionPoint.ROLE); 093 _contentTypesHelper = (ContentTypesHelper) serviceManager.lookup(ContentTypesHelper.ROLE); 094 095 _i18nUtils = (I18nUtils) serviceManager.lookup(I18nUtils.ROLE); 096 097 _csvImporter = (CSVImporter) serviceManager.lookup(CSVImporter.ROLE); 098 } 099 100 public void initialize() throws Exception 101 { 102 _sysadminMail = Config.getInstance().getValue("smtp.mail.sysadminto"); 103 } 104 105 /** 106 * Retrieves the values for import CSV parameters 107 * @param contentTypeId the configured content type identifier 108 * @return the values for import CSV parameters 109 */ 110 @Callable 111 public Map<String, Object> getImportCSVParametersValues(String contentTypeId) 112 { 113 Map<String, Object> values = new HashMap<>(); 114 115 // Content types 116 List<String> contentTypeIds = StringUtils.isEmpty(contentTypeId) ? List.of() : List.of(contentTypeId); 117 Map<String, Object> filteredContentTypes = _contentTypesHelper.getContentTypesList(contentTypeIds, true, true, true, false, false); 118 values.put("availableContentTypes", filteredContentTypes.get("contentTypes")); 119 120 // Recipient - get current user email 121 String currentUserEmail = null; 122 UserIdentity currentUser = _currentUserProvider.getUser(); 123 124 String login = currentUser.getLogin(); 125 if (StringUtils.isNotBlank(login)) 126 { 127 String userPopulationId = currentUser.getPopulationId(); 128 User user = _userManager.getUser(userPopulationId, login); 129 currentUserEmail = user.getEmail(); 130 } 131 values.put("defaultRecipient", currentUserEmail); 132 133 return values; 134 } 135 136 /** 137 * Gets the configuration for creating/editing a collection of synchronizable contents. 138 * @param contentTypeId Content type id 139 * @param formValues map of form values 140 * @param mappingValues list of header and content attribute mapping 141 * @return A map containing information about what is needed to create/edit a collection of synchronizable contents 142 * @throws IOException IOException while reading CSV 143 */ 144 @SuppressWarnings("unchecked") 145 @Callable 146 public Map<String, Object> validateConfiguration(String contentTypeId, Map formValues, List<Map<String, Object>> mappingValues) throws IOException 147 { 148 Map<String, Object> result = new HashMap<>(); 149 Map<String, Object> mapping = new HashMap<>(); 150 List<String> contentAttributes = new ArrayList<>(); 151 mappingValues.forEach(column -> 152 { 153 if (!StringUtils.isEmpty((String) column.get("attributePath"))) 154 { 155 generateNestedMap(mapping, (String) column.get("attributePath"), (String) column.get("header"), (boolean) column.get("isId")); 156 contentAttributes.add(((String) column.get("attributePath")).replace(".", "/")); 157 } 158 }); 159 160 ContentType contentType = _contentTypeEP.getExtension(contentTypeId); 161 result.put("contentTypeName", contentType.getLabel()); 162 View view = null; 163 for (String itemPath : contentAttributes) 164 { 165 if (!ModelHelper.hasModelItem(itemPath, List.of(contentType))) 166 { 167 result.put("error", "bad-mapping"); 168 result.put("details", List.of(itemPath, contentTypeId)); 169 return result; 170 } 171 } 172 173 try 174 { 175 view = View.of(contentType, contentAttributes.toArray(new String[contentAttributes.size()])); 176 } 177 catch (IllegalArgumentException | BadItemTypeException e) 178 { 179 getLogger().error("Error while creating view", e); 180 result.put("error", "cant-create-view"); 181 result.put("details", contentTypeId); 182 return result; 183 } 184 185 if (!mapping.containsKey("id")) 186 { 187 result.put("error", "missing-id"); 188 return result; 189 } 190 191 for (ViewItem viewItem : view.getViewItems()) 192 { 193 if (!_checkViewItemContainer(viewItem, (Map<String, Object>) mapping.get("values"), result)) 194 { 195 return result; 196 } 197 } 198 result.put("success", true); 199 return result; 200 } 201 /** 202 * Gets the configuration for creating/editing a collection of synchronizable contents. 203 * @param config get all CSV related parameters: path, separating/escaping char, charset and contentType 204 * @param formValues map of form values 205 * @param mappingValues list of header and content attribute mapping 206 * @return A map containing information about what is needed to create/edit a collection of synchronizable contents 207 * @throws IOException IOException while reading CSV 208 */ 209 @Callable 210 public Map<String, Object> importContents(Map<String, Object> config, Map<String, Object> formValues, List<Map<String, Object>> mappingValues) throws IOException 211 { 212 Map<String, Object> result = new HashMap<>(); 213 String path = (String) config.get("path"); 214 String separatingChar = (String) config.get("separating-char"); 215 String escapingChar = (String) config.get("escaping-char"); 216 String contentTypeId = (String) config.get("contentType"); 217 String fileName = (String) config.get("fileName"); 218 String encoding = (String) config.get("charset"); 219 CsvPreference csvPreference = new CsvPreference.Builder(escapingChar.charAt(0), separatingChar.charAt(0), "\r\n").build(); 220 Charset charset = Charset.forName(encoding); 221 String language = (String) formValues.get("language"); 222 int createAction = Integer.valueOf((String) formValues.get("createAction")); 223 int editAction = Integer.valueOf((String) formValues.get("editAction")); 224 String workflowName = (String) formValues.get("workflow"); 225 226 Optional<String> recipient = Optional.ofNullable(config.get("recipient")) 227 .filter(String.class::isInstance) 228 .map(String.class::cast) 229 .filter(StringUtils::isNotBlank); 230 231 Map<String, Object> mapping = new HashMap<>(); 232 List<String> contentAttributes = new ArrayList<>(); 233 mappingValues.forEach(column -> 234 { 235 if (!StringUtils.isEmpty((String) column.get("attributePath"))) 236 { 237 generateNestedMap(mapping, (String) column.get("attributePath"), (String) column.get("header"), (boolean) column.get("isId")); 238 contentAttributes.add(((String) column.get("attributePath")).replace(".", "/")); 239 } 240 }); 241 242 ContentType contentType = _contentTypeEP.getExtension(contentTypeId); 243 View view = View.of(Arrays.asList(contentType), contentAttributes.toArray(new String[0])); 244 245 try (BufferedReader reader = Files.newBufferedReader(Paths.get(path), charset); 246 ICsvListReader csvMapReadernew = new CsvListReader(reader, csvPreference)) 247 { 248 Map<String, Object> importResult = _csvImporter.importContentsFromCSV(mapping, view, contentType, csvMapReadernew, createAction, editAction, workflowName, language); 249 250 @SuppressWarnings("unchecked") 251 List<String> contentIds = (List<String>) importResult.get("contentIds"); 252 if (contentIds.size() > 0) 253 { 254 result.put("importedCount", contentIds.size()); 255 result.put("nbErrors", importResult.get("nbErrors")); 256 result.put("nbWarnings", importResult.get("nbWarnings")); 257 } 258 else 259 { 260 result.put("error", "no-import"); 261 } 262 } 263 catch (Exception e) 264 { 265 getLogger().error("Error while importing contents", e); 266 result.put("error", "error"); 267 268 if (recipient.isEmpty()) 269 { 270 recipient = Optional.ofNullable(_sysadminMail) 271 .filter(StringUtils::isNotBlank); 272 } 273 } 274 finally 275 { 276 deleteFile(path); 277 } 278 279 if (recipient.isPresent()) 280 { 281 _sendMail(result, recipient.get()); 282 } 283 284 result.put("fileName", fileName); 285 return result; 286 } 287 288 private boolean _checkViewItemContainer(ViewItem viewItem, Map<String, Object> mapping, Map<String, Object> result) 289 { 290 if (viewItem instanceof ViewElement) 291 { 292 ViewElement viewElement = (ViewElement) viewItem; 293 ElementDefinition elementDefinition = viewElement.getDefinition(); 294 if (elementDefinition instanceof ContentAttributeDefinition) 295 { 296 ViewElementAccessor viewElementAccessor = (ViewElementAccessor) viewElement; 297 ContentAttributeDefinition contentAttributeDefinition = (ContentAttributeDefinition) elementDefinition; 298 return _checkViewItemContentContainer(viewElementAccessor, mapping, result, contentAttributeDefinition); 299 } 300 else 301 { 302 return true; 303 } 304 } 305 else if (viewItem instanceof ModelViewItemGroup) 306 { 307 return _checkViewItemGroupContainer(viewItem, mapping, result); 308 } 309 else 310 { 311 result.put("error", "unknown-type"); 312 result.put("details", viewItem.getName()); 313 return false; 314 } 315 } 316 317 @SuppressWarnings("unchecked") 318 private boolean _checkViewItemContentContainer(ViewElementAccessor viewElementAccessor, Map<String, Object> mapping, Map<String, Object> result, ContentAttributeDefinition contentAttributeDefinition) 319 { 320 321 boolean success = true; 322 String contentTypeId = contentAttributeDefinition.getContentTypeId(); 323 ContentType contentType = _contentTypeEP.getExtension(contentTypeId); 324 if (mapping.get(viewElementAccessor.getName()) instanceof String) 325 { 326 result.put("error", "string-as-container"); 327 result.put("details", mapping.get(viewElementAccessor.getName())); 328 return false; 329 } 330 Map<String, Object> nestedMap = (Map<String, Object>) mapping.get(viewElementAccessor.getName()); 331 if (!nestedMap.containsKey("id") || ((List<String>) nestedMap.get("id")).isEmpty()) 332 { 333 result.put("error", "missing-id-content"); 334 result.put("details", List.of(contentType.getLabel(), contentAttributeDefinition.getName())); 335 return false; 336 } 337 Map<String, Object> nestedMapValues = (Map<String, Object>) (nestedMap.get("values")); 338 for (ViewItem nestedViewItem : viewElementAccessor.getViewItems()) 339 { 340 success = success && _checkViewItemContainer(nestedViewItem, nestedMapValues, result); 341 } 342 return success; 343 } 344 345 @SuppressWarnings("unchecked") 346 private boolean _checkViewItemGroupContainer(ViewItem viewItem, Map<String, Object> mapping, Map<String, Object> result) 347 { 348 boolean success = true; 349 ModelViewItemGroup modelViewItemGroup = (ModelViewItemGroup) viewItem; 350 List<ViewItem> elementDefinition = modelViewItemGroup.getViewItems(); 351 Map<String, Object> nestedMap = (Map<String, Object>) mapping.get(viewItem.getName()); 352 353 Map<String, Object> nestedMapValues = (Map<String, Object>) (nestedMap.get("values")); 354 355 for (ViewItem nestedViewItem : elementDefinition) 356 { 357 success = success && _checkViewItemContainer(nestedViewItem, nestedMapValues, result); 358 } 359 return success; 360 } 361 362 @SuppressWarnings("unchecked") 363 private static Map<String, Object> generateNestedMap(Map<String, Object> map, String attributePath, String column, boolean isId) 364 { 365 int dotIndex = attributePath.indexOf('.'); 366 367 if (!map.containsKey("values")) 368 { 369 map.put("values", new HashMap<String, Object>()); 370 } 371 if (!map.containsKey("id")) 372 { 373 map.put("id", new ArrayList<String>()); 374 } 375 376 if (dotIndex == -1) 377 { 378 if (Boolean.valueOf(isId)) 379 { 380 ((List<String>) map.get("id")).add(attributePath); 381 } 382 ((Map<String, Object>) map.get("values")).put(attributePath, column); 383 } 384 else 385 { 386 String prefix = attributePath.split("\\.")[0]; 387 String subAttribute = attributePath.substring(dotIndex + 1); 388 389 if (!((Map<String, Object>) map.get("values")).containsKey(prefix)) 390 { 391 ((Map<String, Object>) map.get("values")).put(prefix, new HashMap<String, Object>()); 392 } 393 394 generateNestedMap((Map<String, Object>) ((Map<String, Object>) map.get("values")).get(prefix), subAttribute, column, isId); 395 } 396 return map; 397 } 398 399 private void _sendMail(Map<String, Object> importResult, String recipient) 400 { 401 I18nizableText subject = _getMailSubject(importResult); 402 I18nizableText body = _getMailBody(importResult); 403 404 try 405 { 406 MailBuilder mailBuilder = SendMailHelper.newMail() 407 .withSubject(_i18nUtils.translate(subject)) 408 .withRecipient(recipient) 409 .withTextBody(_i18nUtils.translate(body)); 410 411 mailBuilder.sendMail(); 412 } 413 catch (MessagingException | IOException e) 414 { 415 if (getLogger().isWarnEnabled()) 416 { 417 getLogger().warn("Unable to send the e-mail '" + subject + "' to '" + recipient + "'", e); 418 } 419 } 420 catch (Exception e) 421 { 422 getLogger().error("An unknown error has occured while sending the mail.", e); 423 } 424 } 425 426 private I18nizableText _getMailSubject(Map<String, Object> importResult) 427 { 428 Map<String, I18nizableTextParameter> i18nParams = Map.of("fileName", new I18nizableText((String) importResult.get("fileName"))); 429 if (importResult.containsKey("error")) 430 { 431 return new I18nizableText("plugin.contentio", "PLUGINS_CONTENTIO_CONTENT_IMPORT_MAIL_ERROR_SUBJECT", i18nParams); 432 } 433 else 434 { 435 return new I18nizableText("plugin.contentio", "PLUGINS_CONTENTIO_CONTENT_IMPORT_MAIL_SUCCESS_SUBJECT", i18nParams); 436 } 437 } 438 439 private I18nizableText _getMailBody(Map<String, Object> importResult) 440 { 441 Map<String, I18nizableTextParameter> i18nParams = new HashMap<>(); 442 i18nParams.put("fileName", new I18nizableText((String) importResult.get("fileName"))); 443 444 if (importResult.containsKey("error")) 445 { 446 if ("no-import".equals(importResult.get("error"))) 447 { 448 return new I18nizableText("plugin.contentio", "PLUGINS_CONTENTIO_CONTENT_IMPORT_MAIL_ERROR_BODY_NO_IMPORT", i18nParams); 449 } 450 else 451 { 452 return new I18nizableText("plugin.contentio", "PLUGINS_CONTENTIO_CONTENT_IMPORT_MAIL_ERROR_BODY_TEXT", i18nParams); 453 } 454 } 455 else 456 { 457 int nbErrors = (int) importResult.get("nbErrors"); 458 int nbWarnings = (int) importResult.get("nbWarnings"); 459 i18nParams.put("importedCount", new I18nizableText(String.valueOf(importResult.get("importedCount")))); 460 i18nParams.put("nbErrors", new I18nizableText(String.valueOf(nbErrors))); 461 i18nParams.put("nbWarnings", new I18nizableText(String.valueOf(nbWarnings))); 462 463 if (nbErrors == 0 && nbWarnings == 0) 464 { 465 return new I18nizableText("plugin.contentio", "PLUGINS_CONTENTIO_CONTENT_IMPORT_MAIL_SUCCESS_BODY", i18nParams); 466 } 467 else if (nbWarnings == 0) 468 { 469 return new I18nizableText("plugin.contentio", "PLUGINS_CONTENTIO_CONTENT_IMPORT_MAIL_PARTIAL_SUCCESS_BODY_ERRORS", i18nParams); 470 } 471 else if (nbErrors == 0) 472 { 473 return new I18nizableText("plugin.contentio", "PLUGINS_CONTENTIO_CONTENT_IMPORT_MAIL_PARTIAL_SUCCESS_BODY_WARNINGS", i18nParams); 474 } 475 else 476 { 477 return new I18nizableText("plugin.contentio", "PLUGINS_CONTENTIO_CONTENT_IMPORT_MAIL_PARTIAL_SUCCESS_BODY_ERRORS_AND_WARNINGS", i18nParams); 478 } 479 } 480 } 481 482 /** 483 * Delete the file related to the given path 484 * @param path path of the file 485 * @throws IOException if an error occurs while deleting the file 486 */ 487 @Callable 488 public void deleteFile(String path) throws IOException 489 { 490 Files.delete(Paths.get(path)); 491 } 492}