001/*
002 *  Copyright 2015 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.odf.catalog;
018
019import java.util.ArrayList;
020import java.util.HashMap;
021import java.util.HashSet;
022import java.util.LinkedList;
023import java.util.List;
024import java.util.Map;
025import java.util.Set;
026import java.util.concurrent.atomic.AtomicInteger;
027
028import org.apache.avalon.framework.component.Component;
029import org.apache.avalon.framework.service.ServiceException;
030import org.apache.avalon.framework.service.ServiceManager;
031import org.apache.avalon.framework.service.Serviceable;
032import org.apache.cocoon.ProcessingException;
033import org.apache.commons.lang.StringUtils;
034import org.quartz.JobDetail;
035import org.quartz.JobKey;
036import org.quartz.SchedulerException;
037
038import org.ametys.cms.repository.Content;
039import org.ametys.core.schedule.Runnable;
040import org.ametys.core.schedule.Runnable.FireProcess;
041import org.ametys.core.schedule.Runnable.MisfirePolicy;
042import org.ametys.core.ui.Callable;
043import org.ametys.core.user.CurrentUserProvider;
044import org.ametys.core.util.I18nUtils;
045import org.ametys.odf.ProgramItem;
046import org.ametys.odf.coursepart.CoursePart;
047import org.ametys.odf.program.Program;
048import org.ametys.odf.schedulable.CopyCatalogSchedulable;
049import org.ametys.odf.schedulable.DeleteCatalogSchedulable;
050import org.ametys.plugins.core.impl.schedule.DefaultRunnable;
051import org.ametys.plugins.core.schedule.Scheduler;
052import org.ametys.plugins.repository.AmetysObjectIterable;
053import org.ametys.plugins.repository.AmetysObjectResolver;
054import org.ametys.plugins.repository.UnknownAmetysObjectException;
055import org.ametys.runtime.i18n.I18nizableText;
056import org.ametys.runtime.i18n.I18nizableTextParameter;
057import org.ametys.runtime.plugin.component.AbstractLogEnabled;
058
059/**
060 * DAO for manipulating catalogs.
061 *
062 */
063public class CatalogDAO extends AbstractLogEnabled implements Serviceable, Component
064{
065    /** The Avalon role */
066    public static final String ROLE = CatalogDAO.class.getName();
067    
068    /** The catalog manager */
069    protected CatalogsManager _catalogsManager;
070    
071    /** The ametys object resolver */
072    protected AmetysObjectResolver _resolver;
073    
074    /** The current user provider */
075    protected CurrentUserProvider _currentUserProvider;
076
077    /** The scheduler */
078    protected Scheduler _scheduler;
079    
080    /** The I18N utils */
081    protected I18nUtils _i18nUtils;
082    
083    @Override
084    public void service(ServiceManager manager) throws ServiceException
085    {
086        _catalogsManager = (CatalogsManager) manager.lookup(CatalogsManager.ROLE);
087        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
088        _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE);
089        _scheduler = (Scheduler) manager.lookup(Scheduler.ROLE);
090        _i18nUtils = (I18nUtils) manager.lookup(I18nUtils.ROLE);
091    }
092    
093    /**
094     * Creates a new ODF catalog.
095     * @param title The title of the catalog
096     * @param name The code of the catalog
097     * @param catalogNameToCopy The catalog name to copy or null
098     * @return The id and the title of the created catalog, or an error
099     * @throws ProcessingException if creation failed
100     */
101    @Callable (rights = "ODF_Rights_Catalog_Handle")
102    public Map<String, String> createCatalog (String title, String name, String catalogNameToCopy) throws ProcessingException
103    {
104        Map<String, String> result = new HashMap<>();
105        
106        // FIXME CMS-5758 FilterNameHelper.filterName do not authorized name with number (so name is computed from JS)
107        
108        Catalog catalog = _catalogsManager.getCatalog(name);
109        if (catalog != null)
110        {
111            result.put("message", "already-exist");
112            return result;
113        }
114        
115        Catalog newCatalog = _catalogsManager.createCatalog(name, title);
116        
117        if (StringUtils.isNotEmpty(catalogNameToCopy))
118        {
119            Catalog catalogToCopy = _catalogsManager.getCatalog(catalogNameToCopy);
120            
121            if (catalogToCopy == null)
122            {
123                result.put("message", "not-found");
124                return result;
125            }
126            
127            Map<String, I18nizableTextParameter> i18nParams = new HashMap<>();
128            i18nParams.put("srcCatalog", new I18nizableText(catalogToCopy.getTitle()));
129            i18nParams.put("destCatalog", new I18nizableText(newCatalog.getTitle()));
130            
131            Map<String, Object> params = new HashMap<>();
132            params.put(CopyCatalogSchedulable.JOBDATAMAP_SRC_CATALOG_KEY, catalogToCopy.getName());
133            params.put(CopyCatalogSchedulable.JOBDATAMAP_DEST_CATALOG_KEY, newCatalog.getName());
134            
135            Runnable runnable = new DefaultRunnable(CopyCatalogSchedulable.SCHEDULABLE_ID + "$" + newCatalog.getName(),
136                new I18nizableText(_i18nUtils.translate(new I18nizableText("plugin.odf", "PLUGINS_ODF_SCHEDULABLE_COPY_CATALOG_LABEL_WITH_DETAILS", i18nParams))),
137                new I18nizableText(_i18nUtils.translate(new I18nizableText("plugin.odf", "PLUGINS_ODF_SCHEDULABLE_COPY_CATALOG_DESCRIPTION_WITH_DETAILS", i18nParams))),
138                FireProcess.NOW,
139                null /* cron*/,
140                CopyCatalogSchedulable.SCHEDULABLE_ID,
141                false /* removable */,
142                false /* modifiable */,
143                false /* deactivatable */,
144                MisfirePolicy.FIRE_ONCE,
145                false /* isVolatile */,
146                _currentUserProvider.getUser(),
147                params
148            );
149            
150            try
151            {
152                JobKey jobKey = new JobKey(runnable.getId(), Scheduler.JOB_GROUP);
153                if (_scheduler.getScheduler().checkExists(jobKey))
154                {
155                    _scheduler.getScheduler().deleteJob(jobKey);
156                }
157                _scheduler.scheduleJob(runnable);
158            }
159            catch (SchedulerException e)
160            {
161                getLogger().error("An error occured when trying to schedule the copy of the catalog '{}' to '{}'", catalogToCopy.getTitle(), newCatalog.getTitle(), e);
162            }
163        }
164        
165        result.put("id", newCatalog.getId());
166        result.put("title", newCatalog.getTitle());
167        
168        return result;
169    }
170    
171    /**
172     * Edits an ODF catalog.
173     * @param id The id of the catalog to edit
174     * @param title The title of the catalog
175     * @return The id and the title of the edited catalog, or an error
176     */
177    @Callable (rights = "ODF_Rights_Catalog_Handle")
178    public Map<String, String> editCatalog (String id, String title)
179    {
180        Map<String, String> result = new HashMap<>();
181        
182        try
183        {
184            Catalog catalog = _resolver.resolveById(id);
185            
186            catalog.setTitle(title);
187            catalog.saveChanges();
188            
189            result.put("id", catalog.getId());
190        }
191        catch (UnknownAmetysObjectException e)
192        {
193            result.put("message", "not-found");
194        }
195        
196        return result;
197    }
198    
199    /**
200     * Set a catalog as default catalog
201     * @param id The id of catalog
202     * @return The id and the title of the edited catalog, or an error
203     */
204    @Callable (rights = "ODF_Rights_Catalog_Handle")
205    public synchronized Map<String, String> setDefaultCatalog(String id)
206    {
207        Map<String, String> result = new HashMap<>();
208        
209        try
210        {
211            Catalog catalog = _resolver.resolveById(id);
212            
213            Catalog defaultCatalog = _catalogsManager.getDefaultCatalog();
214            if (defaultCatalog != null)
215            {
216                defaultCatalog.setDefault(false);
217                defaultCatalog.saveChanges();
218            }
219            catalog.setDefault(true);
220            catalog.saveChanges();
221            
222            _catalogsManager.updateDefaultCatalog();
223            
224            result.put("id", catalog.getId());
225            result.put("code", catalog.getName());
226        }
227        catch (UnknownAmetysObjectException e)
228        {
229            result.put("message", "not-found");
230        }
231        
232        return result;
233    }
234    
235    /**
236     * Removes an ODF catalog.
237     * @param catalogId the catalog's id
238     * @param forceDeletion if true, will not check if the catalog is referenced by {@code ProgramItem}s before deleting the catalog
239     * @return The id of the deleted catalog, or an error
240     */ 
241    @Callable (rights = "ODF_Rights_Catalog_Handle")
242    public Map<String, Object> removeCatalog (String catalogId, boolean forceDeletion)
243    {
244        Map<String, Object> result = new HashMap<>();
245        result.put("id", catalogId);
246        Catalog catalog = null;
247        try
248        {
249            catalog = _resolver.resolveById(catalogId);
250        }
251        catch (UnknownAmetysObjectException e)
252        {
253            result.put("error", "unknown-catalog");
254            return result;
255        }
256        
257        if (!forceDeletion)
258        {
259            List<Content> contents = _catalogsManager.getContents(catalog.getName());
260            if (!contents.isEmpty())
261            {
262                // Still has programItems
263                // We provide a summary of the remaining items
264                result.put("error", "remaining-items");
265                result.put("remainingItems", _summarizeRemainingItems(contents));
266                return result;
267            }
268        }
269        String runnableId = _doRemoveCatalog(catalog);
270        if (runnableId != null)
271        {
272            result.put("jobId", runnableId);
273        }
274        else
275        {
276            result.put("error", "job-start-failed");
277        }
278        return result;
279    }
280    
281    private String _doRemoveCatalog(Catalog catalog)
282    {
283        String runnableId = DeleteCatalogSchedulable.SCHEDULABLE_ID + "$" + catalog.getId();
284        try
285        {
286            JobKey jobKey = new JobKey(runnableId, Scheduler.JOB_GROUP);
287            // here we want to check if a deletion of the catalog is not already running or finished
288            // we rely on the job key which is not ideal, would be better to rely on the job param of the schedulable
289            if (_scheduler.getScheduler().checkExists(jobKey))
290            {
291                Map<String, Object> runningMap = _scheduler.isRunning(jobKey.getName());
292                boolean running = (boolean) runningMap.get("running");
293                if (running)
294                {
295                    // A removal of the catalog is already ongoing so we do nothing
296                    return runnableId;
297                }
298                else
299                {
300                    JobDetail jobDetail = _scheduler.getScheduler().getJobDetail(jobKey);
301                    boolean success = jobDetail.getJobDataMap().getBoolean("success");
302                    if (success)
303                    {
304                        // The catalog was already removed with success we might better do nothing
305                        return null;
306                    }
307                    else
308                    {
309                        // The job failed, so we restart it
310                        _scheduler.remove(jobKey.getName());
311                    }
312                }
313            }
314            Map<String, I18nizableTextParameter> i18nParams = new HashMap<>();
315            i18nParams.put("catalogName", new I18nizableText(catalog.getTitle()));
316            i18nParams.put("catalogId", new I18nizableText(catalog.getId()));
317    
318            Map<String, Object> params = new HashMap<>();
319            params.put(DeleteCatalogSchedulable.JOBDATAMAP_CATALOG_NAME_KEY, catalog.getName());
320            
321            Runnable runnable = new DefaultRunnable(runnableId,
322                new I18nizableText(_i18nUtils.translate(new I18nizableText("plugin.odf", "PLUGINS_ODF_SCHEDULABLE_DELETE_CATALOG_LABEL_WITH_DETAILS", i18nParams))),
323                new I18nizableText(_i18nUtils.translate(new I18nizableText("plugin.odf", "PLUGINS_ODF_SCHEDULABLE_DELETE_CATALOG_DESCRIPTION_WITH_DETAILS", i18nParams))),
324                FireProcess.NOW,
325                null /* cron*/,
326                DeleteCatalogSchedulable.SCHEDULABLE_ID,
327                true /* removable */,
328                false /* modifiable */,
329                false /* deactivatable */,
330                MisfirePolicy.FIRE_ONCE,
331                true /* isVolatile */,
332                _currentUserProvider.getUser(),
333                params
334            );
335            
336            _scheduler.scheduleJob(runnable);
337            return runnableId;
338        }
339        catch (SchedulerException e)
340        {
341            getLogger().error("An error occured when trying to schedule the deletion of the catalog '{}'", catalog.getTitle(), e);
342            return null;
343        }
344    }
345    
346    private Map<String, AtomicInteger> _summarizeRemainingItems(List<Content> contents)
347    {
348        Map<String, AtomicInteger> summary = new HashMap<>();
349        for (Content content : contents)
350        {
351            String key = content instanceof ProgramItem || content instanceof CoursePart
352                    ? StringUtils.substringAfterLast(content.getTypes()[0], ".")
353                    : "other";
354            summary.computeIfAbsent(key, __ -> new AtomicInteger()).incrementAndGet();
355        }
356        return summary;
357    }
358    
359    /**
360     * Gets the properties of a catalog.
361     * @param id The catalog id
362     * @return The properties of the catalog in a map
363     */
364    @Callable (rights = "ODF_Rights_Catalog_Handle")
365    public Map<String, Object> getCatalogProperties(String id)
366    {
367        Catalog catalog = _resolver.resolveById(id);
368        return getCatalogProperties(catalog);
369    }
370    
371    /**
372     * Gets the properties of a set of catalogs.
373     * @param ids The catalogs' id
374     * @return The properties of the catalogs
375     */
376    @Callable (rights = "ODF_Rights_Catalog_Handle")
377    public Map<String, Object> getCatalogsProperties(List<String> ids)
378    {
379        Map<String, Object> result = new HashMap<>();
380        
381        List<Map<String, Object>> catalogs = new LinkedList<>();
382        Set<String> unknownCatalogs = new HashSet<>();
383        
384        for (String id : ids)
385        {
386            try
387            {
388                Catalog catalog = _resolver.resolveById(id);
389                catalogs.add(getCatalogProperties(catalog));
390            }
391            catch (UnknownAmetysObjectException e)
392            {
393                unknownCatalogs.add(id);
394            }
395        }
396        
397        result.put("catalogs", catalogs);
398        result.put("unknownCatalogs", unknownCatalogs);
399        
400        return result;
401    }
402    
403    /**
404     * Get the properties of a catalog as a Map
405     * @param catalog The catalog
406     * @return The properties into a map object
407     */
408    public Map<String, Object> getCatalogProperties(Catalog catalog)
409    {
410        Map<String, Object> result = new HashMap<>();
411        
412        result.put("id", catalog.getId());
413        result.put("title", catalog.getTitle());
414        result.put("isDefault", catalog.isDefault());
415        result.put("code", catalog.getName());
416        
417        AmetysObjectIterable<Program> programs = _catalogsManager.getPrograms(catalog.getName());
418        result.put("nbPrograms", programs.getSize());
419        
420        return result;
421    }
422    
423    /**
424     * List all the existing catalogs
425     * @return the catalogs
426     */
427    @Callable (rights = Callable.NO_CHECK_REQUIRED)
428    public List<Object> getCatalogs()
429    {
430        List<Object> catalogs = new ArrayList<>();
431        
432        for (Catalog catalog : _catalogsManager.getCatalogs())
433        {
434            catalogs.add(List.of(catalog.getName(), catalog.getTitle()));
435        }
436        
437        return catalogs;
438    }
439}