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}