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 */
016
017package org.ametys.plugins.odfpilotage.helper;
018
019import java.util.ArrayList;
020import java.util.Collections;
021import java.util.HashMap;
022import java.util.List;
023import java.util.Map;
024import java.util.Map.Entry;
025import java.util.Objects;
026import java.util.Optional;
027import java.util.TreeMap;
028import java.util.stream.Collectors;
029import java.util.stream.Stream;
030
031import org.apache.avalon.framework.context.Context;
032import org.apache.avalon.framework.context.ContextException;
033import org.apache.avalon.framework.context.Contextualizable;
034import org.apache.avalon.framework.service.ServiceException;
035import org.apache.avalon.framework.service.ServiceManager;
036import org.apache.cocoon.components.ContextHelper;
037import org.apache.cocoon.environment.Request;
038import org.apache.cocoon.environment.Session;
039import org.apache.commons.lang3.StringUtils;
040
041import org.ametys.cms.repository.Content;
042import org.ametys.cms.repository.ModifiableDefaultContent;
043import org.ametys.cms.workflow.ContentWorkflowHelper;
044import org.ametys.cms.workflow.EditContentAccessDeniedException;
045import org.ametys.core.ui.Callable;
046import org.ametys.core.util.I18nUtils;
047import org.ametys.odf.ProgramItem;
048import org.ametys.odf.course.Course;
049import org.ametys.odf.courselist.CourseList;
050import org.ametys.odf.courselist.CourseListContainer;
051import org.ametys.odf.coursepart.CoursePart;
052import org.ametys.odf.orgunit.OrgUnit;
053import org.ametys.odf.orgunit.OrgUnitFactory;
054import org.ametys.odf.program.Program;
055import org.ametys.plugins.contentstree.TreeConfiguration;
056import org.ametys.plugins.odfpilotage.cost.CostComputationComponent;
057import org.ametys.plugins.odfpilotage.cost.entity.CostComputationData;
058import org.ametys.plugins.odfpilotage.cost.entity.Effectives;
059import org.ametys.plugins.odfpilotage.cost.entity.EqTD;
060import org.ametys.plugins.odfpilotage.cost.entity.Groups;
061import org.ametys.plugins.odfpilotage.cost.entity.NormDetails;
062import org.ametys.plugins.odfpilotage.cost.entity.OverriddenData;
063import org.ametys.plugins.odfpilotage.cost.entity.ProgramItemData;
064import org.ametys.plugins.odfpilotage.cost.entity.VolumesOfHours;
065import org.ametys.plugins.repository.AmetysObjectIterable;
066import org.ametys.plugins.repository.UnknownAmetysObjectException;
067import org.ametys.plugins.repository.query.QueryHelper;
068import org.ametys.runtime.model.ModelItem;
069import org.ametys.runtime.model.View;
070
071import com.opensymphony.workflow.InvalidActionException;
072import com.opensymphony.workflow.WorkflowException;
073
074/**
075 * This component handle the content of the cost modeling tool
076 */
077public class CostComputationTreeHelper extends ODFContentsTreeHelper implements Contextualizable
078{
079    private static int _ACTION = 2;
080    
081    private static final String __COST_DATA_KEY = "cost-data-key";
082    
083    /** The cost computation component */
084    protected CostComputationComponent _costComputationComponent;
085   
086    /** Workflow helper component */
087    protected ContentWorkflowHelper _contentWorkflowHelper;
088    
089    /** The I18N utils */
090    protected I18nUtils _i18nUtils;
091
092    /** The context */
093    protected Context _context;
094    
095    @Override
096    public void contextualize(Context context) throws ContextException
097    {
098        _context = context;
099    }
100    
101    @Override 
102    public void service(ServiceManager smanager) throws ServiceException
103    {
104        super.service(smanager);
105        _costComputationComponent = (CostComputationComponent) smanager.lookup(CostComputationComponent.ROLE);
106        _contentWorkflowHelper = (ContentWorkflowHelper) smanager.lookup(ContentWorkflowHelper.ROLE);
107        _i18nUtils = (I18nUtils) smanager.lookup(I18nUtils.ROLE);
108    }
109    
110    /**
111     * Get the cost data from user session
112     * @return the cost data
113     */
114    protected CostComputationData _getCostData()
115    {
116        Request request = ContextHelper.getRequest(_context);
117        Session session = request.getSession();
118        
119        CostComputationData costData = (CostComputationData) session.getAttribute(__COST_DATA_KEY);
120        if (costData == null)
121        {
122            throw new UnsupportedOperationException("Cost data is not initalized in session attribute '" + __COST_DATA_KEY + "'");
123        }
124        
125        return costData;
126    }
127    
128    /**
129     * Set the cost data to user session
130     * @param costData the cost data
131     */
132    protected void _setCostData(CostComputationData costData)
133    {
134        Request request = ContextHelper.getRequest(_context);
135        Session session = request.getSession();
136        
137        session.setAttribute(__COST_DATA_KEY, costData);
138    }
139    
140    /**
141     * Launch the cost computation component algorithm 
142     * @param content the content to compute
143     * @param catalog the catalog
144     * @param lang the lang
145     * @param overriddenData overridden data by the user
146     */
147    protected void _launchCostComputation(Content content, String catalog, String lang, OverriddenData overriddenData)
148    {
149        CostComputationData costData = content instanceof OrgUnit
150            ? _costComputationComponent.computeCostsOnOrgUnit((OrgUnit) content, catalog, lang, overriddenData)
151            : (content instanceof Program
152                ? _costComputationComponent.computeCostsOnProgram((Program) content, overriddenData)
153                : null
154            );
155        
156        if (costData != null)
157        {
158            _setCostData(costData);
159        }
160    }
161    
162    /**
163     * Get the children contents according the tree configuration
164     * @param parentContent the root content
165     * @param treeConfiguration the tree configuration
166     * @param catalog the catalog
167     * @param lang the lang
168     * @return the children content for each child attributes
169     */
170    protected Map<String, List<Content>> _getChildrenContent(Content parentContent, TreeConfiguration treeConfiguration, String catalog, String lang)
171    {
172        Map<String, List<Content>> children = new HashMap<>();
173        if (parentContent instanceof OrgUnit)
174        {
175            OrgUnit orgUnit = (OrgUnit) parentContent;
176            List<Content> contents = orgUnit.getSubOrgUnits()
177                    .stream()
178                    .map(this::_resolveSilently)
179                    .filter(Objects::nonNull)
180                    .collect(Collectors.toList());
181            if (orgUnit.getParentOrgUnit() == null && !contents.isEmpty())
182            {
183                children.put("childOrgUnits", contents);
184            }
185            else
186            {
187                List<Program> programs = _odfHelper.getProgramsFromOrgUnit((OrgUnit) parentContent, catalog, lang);
188                contents = programs.stream()
189                        .filter(Content.class::isInstance)
190                        .map(Content.class::cast)
191                        .collect(Collectors.toList());
192
193                // _programsLink n'est pas un vrai attribut, cet identifiant n'est jamais utilisé 
194                children.put("_programsLink", contents);
195            }
196        }
197        else
198        {
199            children = super.getChildrenContent(parentContent, treeConfiguration);
200        }
201        return children;
202    }
203    
204    /**
205     * Get the children contents according the tree configuration
206     * @param contentId the parent content
207     * @param treeId the tree configuration
208     * @param contentPath the content path
209     * @param catalog the catalog
210     * @param lang the lang
211     * @return the children content
212     */
213    @Callable
214    public Map<String, Object> getChildrenContent(String contentId, String treeId, String contentPath, String catalog, String lang)
215    {
216        TreeConfiguration treeConfiguration = _getTreeConfiguration(treeId);
217        Content parentContent = _getParentContent(contentId);
218        Map<String, List<Content>> children = _getChildrenContent(parentContent, treeConfiguration, catalog, lang);
219   
220        Map<String, Object> infos = new HashMap<>();
221
222        List<Map<String, Object>> childrenInfos = new ArrayList<>();
223        infos.put("children", childrenInfos);
224        
225        boolean lastLevel = parentContent instanceof Course && !((Course) parentContent).hasCourseLists();
226
227        for (String attributePath : children.keySet())
228        {
229            for (Content childContent : children.get(attributePath))
230            {
231                // Ignore course part not on last level
232                if (lastLevel || !(childContent instanceof CoursePart))
233                {
234                    Map<String, Object> childInfo = content2Json(childContent, contentPath + ModelItem.ITEM_PATH_SEPARATOR + childContent.getName());
235                    childInfo.put("metadataPath", attributePath);
236                    
237                    if (!hasChildrenContent(childContent, treeConfiguration))
238                    {
239                        childInfo.put("children", Collections.EMPTY_LIST);
240                    }
241                    
242                    childrenInfos.add(childInfo);
243                }
244            }
245        }
246        return infos;
247    }
248    
249    /**
250     * Get the root node informations
251     * @param contentId The content
252     * @param catalog the catalog
253     * @param lang the lang
254     * @param overriddenData overridden data by the user
255     * @return The informations
256     */
257    @Callable
258    public Map<String, Object> getRootNodeInformations(String contentId, String catalog, String lang, Map<String, Map<String, String>> overriddenData)
259    {
260        return getNodeInformations(contentId, catalog, lang, overriddenData);
261    }
262    
263    /**
264     * Get the node informations
265     * @param contentId The content
266     * @param catalog the catalog
267     * @param lang the lang
268     * @param overriddenData Overridden data by the user
269     * @return The informations
270     */
271    @Callable
272    public Map<String, Object> getNodeInformations(String contentId, String catalog, String lang, Map<String, Map<String, String>> overriddenData)
273    {
274        Content content = _ametysResolver.resolveById(contentId);
275        _launchCostComputation(content, catalog, lang, new OverriddenData(overriddenData));
276        Map<String, Object> json = content2Json(content, content.getName());
277        if (content instanceof Program)
278        {
279            json.put("lang", content.getLanguage());
280            json.put("catalog", ((Program) content).getCatalog());
281        }
282        return json;
283    }
284    
285    /**
286     * Get the default JSON representation of a content of the tree
287     * @param content the content
288     * @param contentPath the content path
289     * @return the content as JSON
290     */
291    protected Map<String, Object> content2Json(Content content, String contentPath)
292    {
293        Map<String, Object> infos = super.content2Json(content);
294       
295        CostComputationData costData = _getCostData();
296        ProgramItemData data = costData.get(content.getId());
297        
298        if (data != null && !(content instanceof CourseList))
299        {
300            String realContentPath =
301                    !(content instanceof OrgUnit) && _ametysResolver.query(QueryHelper.getXPathQuery(StringUtils.substringBefore(contentPath, ModelItem.ITEM_PATH_SEPARATOR), OrgUnitFactory.ORGUNIT_NODETYPE, null)).getSize() > 0
302                    ? StringUtils.substringAfter(contentPath, ModelItem.ITEM_PATH_SEPARATOR)
303                    : contentPath;
304            infos.putAll(_programItemData2json(data, realContentPath));
305        }
306        
307        return infos;
308    }
309    
310    private Map<String, Object> _programItemData2json(ProgramItemData data, String contentPath)
311    {
312        Map<String, Object> json = new HashMap<>();
313        json.put("effectives", _effectives2json(data.getEffectives(), contentPath));
314        json.put("groups", _groups2json(data.getGroups()));
315        json.put("norm", _normDetails2json(data.getNormDetails()));
316        json.put("volumesOfHours", _volumesOfHours2json(data.getVolumesOfHours()));
317        json.put("eqTD", _eqTD2json(data.getEqTD(), contentPath));
318        json.put("heRatio", data.getHeRatio());
319        return json;
320    }
321    
322    private Map<String, Object> _effectives2json(Effectives effectives, String contentPath)
323    {
324        Map<String, Object> json = new HashMap<>();
325        if (effectives != null)
326        {
327            effectives.getOverriddenEffective().map(Double::longValue).ifPresent(eff -> json.put("overridden", eff));
328            effectives.getGlobalEnteredEffective().map(Double::longValue).ifPresent(eff -> json.put("entered", eff));
329            json.put("estimated", effectives.getEstimatedEffective().longValue());
330            json.put("computed", effectives.getComputedEffective().longValue());
331            
332            // Prepare items to calculate local effectives
333            String parentPath = StringUtils.substringBeforeLast(contentPath, ModelItem.ITEM_PATH_SEPARATOR);
334            Double localEffectives = null;
335            
336            // Fill the distribution
337            Map<String, Object> distribution = new TreeMap<>();
338            Map<String, Double> estimatedEffectives = effectives.getEstimatedEffectiveByPath();
339            for (String path : estimatedEffectives.keySet())
340            {
341                String pathWithTitles = _getContentsFromPath(path)
342                    .map(Content::getTitle)                                                 // Get the content title
343                    .collect(Collectors.joining(" > "));                                    // Join the results with a ' > ' separator
344                distribution.put(pathWithTitles, estimatedEffectives.get(path));
345
346                if (parentPath.endsWith(path))
347                {
348                    localEffectives = estimatedEffectives.get(path);
349                }
350            }
351            
352            // If localEffectives not found
353            if (localEffectives == null)
354            {
355                // Number of cumulative occurrences, a shared element 3 times, should be divided by 3,
356                // then if it is shared several times in the path, it should be divided as well
357                Integer nbOccurrences = _getContentsFromPath(contentPath)
358                    .map(this::_countParents)
359                    .filter(size -> size > 0)
360                    .reduce(1, (a, b) -> a * b);
361                
362                localEffectives = effectives.getOverriddenEffective()
363                    .or(() -> effectives.getGlobalEnteredEffective())
364                    .orElse(effectives.getEstimatedEffective());
365                localEffectives = localEffectives / nbOccurrences;
366            }
367            json.put("local", localEffectives.longValue());
368            
369            json.put("distribution", distribution);
370        }
371        return json;
372    }
373    
374    private Map<String, Object> _groups2json(Groups groups)
375    {
376        Map<String, Object> json = new HashMap<>();
377        if (groups != null)
378        {
379            json.put("overridden", groups.getOverriddenGroups());
380            json.put("entered", groups.getGroupsToOpen());
381            json.put("computed", groups.getComputedGroups());
382        }
383        return json;
384    }
385    
386    private Map<String, Object> _normDetails2json(NormDetails normDetails)
387    {
388        Map<String, Object> json = new HashMap<>();
389        if (normDetails != null)
390        {
391            json.put("effMax", normDetails.getEffectiveMax());
392            json.put("effMinSup", normDetails.getEffectiveMinSup());
393            json.put("label", normDetails.getNormLabel());
394        }
395        return json;
396    }
397    
398    private Map<String, Object> _volumesOfHours2json(VolumesOfHours volumesOfHours)
399    {
400        Map<String, Object> json = new HashMap<>();
401        if (volumesOfHours != null)
402        {
403            json.put("original", volumesOfHours.getOriginalVolumeOfHours());
404            json.putAll(volumesOfHours.getVolumes());
405        }
406        return json;
407    }
408    
409    private Map<String, Object> _eqTD2json(EqTD eqTD, String contentPath)
410    {
411        Map<String, Object> json = new HashMap<>();
412        if (eqTD != null)
413        {
414            json.put("global", eqTD.getGlobalEqTD());
415            json.put("local", eqTD.getLocalEqTD(contentPath));
416
417            String parentPath = StringUtils.substringBeforeLast(contentPath, ModelItem.ITEM_PATH_SEPARATOR);
418            Double proratedEqTD = eqTD.getProratedEqTD()
419                .entrySet()
420                .stream()
421                .filter(e -> parentPath.endsWith(e.getKey()))
422                .map(Entry::getValue)
423                .reduce(0D, Double::sum);
424            json.put("prorated", proratedEqTD);
425        }
426        return json;
427    }
428    
429    private Content _resolveSilently(String contentId)
430    {
431        try 
432        {
433            return _ametysResolver.resolveById(contentId);
434        }
435        catch (UnknownAmetysObjectException e) 
436        {
437            return null;
438        }
439    }
440    
441    /**
442     * Launch the cost computation component algorithm with overridden data by the user 
443     * @param contentsToRefresh all open contents in the tool to refresh
444     * @param contentId the root node
445     * @param catalog the catalog
446     * @param lang the lang
447     * @param overriddenData overridden data by the user
448     * @return new values associated with their path
449     */
450    @Callable 
451    public Map<String, Map<String, Object>> refresh(Map<String, String> contentsToRefresh, String contentId, String catalog, String lang, Map<String, Map<String, String>> overriddenData)
452    {
453        Map<String, Map<String, Object>> refreshedData = new HashMap<>();
454        Content content = _ametysResolver.resolveById(contentId);
455        OverriddenData data = new OverriddenData(overriddenData);
456        
457        _launchCostComputation(content, catalog, lang, data);
458        for (Entry<String, String> entry : contentsToRefresh.entrySet())
459        {
460            Content item = _ametysResolver.resolveById(entry.getValue());
461            refreshedData.put(entry.getKey(), content2Json(item, entry.getKey()));
462        }
463        return refreshedData;
464    }
465    
466    /**
467     * Retrieve old data to compare with the new computed one
468     * @return all old data to compare
469     */
470    @Callable
471    public Map<String, Object> getOldData()
472    {
473        Map<String, Object> data = new HashMap<>();
474
475        CostComputationData costData = _getCostData();
476        for (String itemId : costData.keySet())
477        {
478            Map<String, Object> itemMap = new HashMap<>();
479            ProgramItemData itemData = costData.get(itemId);
480            EqTD eqTD = itemData.getEqTD();
481            if (eqTD != null)
482            {
483                itemMap.put("globalEqTD", eqTD.getGlobalEqTD());
484                itemMap.put("proratedEqTD", eqTD.getProratedEqTD());
485                itemMap.put("localEqTD", eqTD.getLocalEqTD());
486            }
487            itemMap.put("heRatio", itemData.getHeRatio());
488            data.put(itemId, itemMap);
489        }
490        
491        return data;
492    }
493    
494    /**
495     * Save overridden data to the contents
496     * @param overriddenData overridden data by the user
497     * @return true if the saving is successful
498     */
499    @Callable
500    public Map<String, Object> saveOverriddenData(Map<String, Map<String, String>> overriddenData)
501    {
502        Map<String, Object> result = new HashMap<>();
503        for (Entry<String, Map<String, String>> data : overriddenData.entrySet())
504        {
505            String contentId = data.getKey();
506            Map<String, String> contentValues = data.getValue();
507            
508            if (!contentValues.isEmpty())
509            {
510                String status = null;
511                Map<String, String> json = new HashMap<>();
512                json.put("id", contentId);
513                
514                try
515                {
516                    ModifiableDefaultContent content = _ametysResolver.resolveById(data.getKey());
517                    json.put("title", content.getTitle());
518                    if (content.hasValue("code"))
519                    {
520                        json.put("code", content.getValue("code"));
521                    }
522                    
523                    Map<String, Object> values = new HashMap<>();
524                    
525                    // If it is a course part
526                    if (content instanceof CoursePart)
527                    {
528                        Optional.of("groups")
529                            .map(contentValues::get)
530                            .map(Long::parseLong)
531                            .filter(v -> !Objects.equals(content.getValue("groupsToOpen"), v))
532                            .ifPresent(v -> values.put("groupsToOpen", v));
533                        
534                        Optional.of("nbHours")
535                            .map(contentValues::get)
536                            .map(Double::parseDouble)
537                            .filter(v -> !Objects.equals(content.getValue("nbHours"), v))
538                            .ifPresent(v -> values.put("nbHours", v));
539                    }
540                    // If it is a course list container
541                    else if (content instanceof CourseListContainer)
542                    {
543                        Optional.of("effectivesGlobal")
544                            .map(contentValues::get)
545                            .map(Long::parseLong)
546                            .filter(v -> !Objects.equals(content.getValue("numberOfStudentsEstimated"), v))
547                            .ifPresent(v -> values.put("numberOfStudentsEstimated", v));
548                    }
549                    
550                    if (!values.isEmpty())
551                    {
552                        _contentWorkflowHelper.editContent(content, values, _ACTION, View.of(content.getModel(), values.keySet().toArray(String[]::new)));
553                        status = "updated";
554                        if (getLogger().isDebugEnabled())
555                        {
556                            getLogger().debug("[SimulatorSaveData] Content {} has been updated from cost simulator", content.getTitle());
557                        }
558                    }
559                }
560                catch (UnknownAmetysObjectException e)
561                {
562                    status = "unknown";
563                    getLogger().warn("[SimulatorSaveData] Content '{}' doesn't exists anymore", contentId);
564                }
565                catch (InvalidActionException e)
566                {
567                    status = "invalidAction";
568                    getLogger().warn("[SimulatorSaveData] Invalid edit action (could be a lack of rights) on content '{}'", contentId, e);
569                }
570                catch (WorkflowException e)
571                {
572                    status = Optional.of(e)
573                        .map(WorkflowException::getRootCause)
574                        .filter(EditContentAccessDeniedException.class::isInstance)
575                        .map(EditContentAccessDeniedException.class::cast)
576                        .map(
577                            cause ->
578                            {
579                                json.put("attributeLabel", _i18nUtils.translate(cause.getModelItem().getLabel()));
580                                json.put("attributePath", cause.getModelItem().getPath());
581                                return "attributeRight";
582                            }
583                        )
584                        .orElse("workflow");
585                    getLogger().warn("[SimulatorSaveData] Workflow problem on content '{}'", contentId, e);
586                }
587                catch (Exception e)
588                {
589                    status = "error";
590                    getLogger().error("[SimulatorSaveData] Unknown error while updating content '{}'", contentId, e);
591                }
592                
593                if (status != null)
594                {
595                    @SuppressWarnings("unchecked")
596                    List<Map<String, String>> jsonContents = (List<Map<String, String>>) result.computeIfAbsent(status, __ -> new ArrayList<>());
597                    jsonContents.add(json);
598                }
599            }
600        }
601        return result;
602    }
603    
604    private Stream<Content> _getContentsFromPath(String path)
605    {
606        return Stream.of(path.split("/"))                                               // Split the path
607                .sequential()                                                           // Take care of the order
608                .filter(StringUtils::isNotEmpty)                                        // Remove empty names
609                .map(part -> QueryHelper.getXPathQuery(part, "ametys:content", null))   // Build the query to get the content with the current name
610                .map(_ametysResolver::<Content>query)                                   // Run the query
611                .map(AmetysObjectIterable::stream)                                      // Stream the results
612                .map(Stream::findFirst)                                                 // Keep only the first result, it shouldn't have more than one
613                .filter(Optional::isPresent)                                            // Ignore unexisting elements, it shouldn't happen
614                .map(Optional::get);                                                    // Get the optional element
615    }
616    
617    private Integer _countParents(Content content)
618    {
619        if (content instanceof ProgramItem programItem)
620        {
621            return _odfHelper.getParentProgramItems(programItem).size();
622        }
623        
624        if (content instanceof CoursePart coursePart)
625        {
626            return coursePart.getCourses().size();
627        }
628        
629        return 0;
630    }
631}