001/*
002 *  Copyright 2017 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.userdirectory;
017
018import java.util.ArrayList;
019import java.util.Arrays;
020import java.util.Iterator;
021import java.util.LinkedHashSet;
022import java.util.List;
023import java.util.Objects;
024import java.util.Optional;
025import java.util.Set;
026import java.util.stream.Collectors;
027
028import org.apache.avalon.framework.activity.Initializable;
029import org.apache.avalon.framework.component.Component;
030import org.apache.avalon.framework.context.Context;
031import org.apache.avalon.framework.context.ContextException;
032import org.apache.avalon.framework.context.Contextualizable;
033import org.apache.avalon.framework.service.ServiceException;
034import org.apache.avalon.framework.service.ServiceManager;
035import org.apache.avalon.framework.service.Serviceable;
036import org.apache.cocoon.components.ContextHelper;
037import org.apache.cocoon.environment.Request;
038import org.apache.commons.lang.StringUtils;
039
040import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
041import org.ametys.cms.data.ContentValue;
042import org.ametys.cms.languages.LanguagesManager;
043import org.ametys.cms.repository.Content;
044import org.ametys.cms.repository.ContentQueryHelper;
045import org.ametys.cms.repository.ContentTypeExpression;
046import org.ametys.cms.repository.LanguageExpression;
047import org.ametys.core.cache.AbstractCacheManager;
048import org.ametys.core.cache.Cache;
049import org.ametys.core.user.UserIdentity;
050import org.ametys.plugins.contentio.synchronize.rights.SynchronizedRootContentHelper;
051import org.ametys.plugins.core.impl.cache.AbstractCacheKey;
052import org.ametys.plugins.repository.AmetysObjectIterable;
053import org.ametys.plugins.repository.AmetysObjectResolver;
054import org.ametys.plugins.repository.ModifiableTraversableAmetysObject;
055import org.ametys.plugins.repository.RepositoryConstants;
056import org.ametys.plugins.repository.UnknownAmetysObjectException;
057import org.ametys.plugins.repository.collection.AmetysObjectCollection;
058import org.ametys.plugins.repository.provider.RequestAttributeWorkspaceSelector;
059import org.ametys.plugins.repository.query.expression.AndExpression;
060import org.ametys.plugins.repository.query.expression.Expression;
061import org.ametys.plugins.repository.query.expression.Expression.Operator;
062import org.ametys.plugins.repository.query.expression.UserExpression;
063import org.ametys.runtime.i18n.I18nizableText;
064import org.ametys.runtime.plugin.component.AbstractLogEnabled;
065
066/**
067 * Helper for user directory.
068 */
069public class UserDirectoryHelper extends AbstractLogEnabled implements Component, Serviceable, Initializable, Contextualizable
070{
071    /** The avalon role. */
072    public static final String ROLE = UserDirectoryHelper.class.getName();
073    
074    /** The root node name of the plugin */
075    public static final String USER_DIRECTORY_ROOT_NODE = "userdirectory";
076    
077    /** The orgUnit parent attribute */
078    public static final String ORGUNITS_ATTRIBUTE = "orgunits";
079    
080    /** The orgUnit content type */
081    public static final String ORGUNIT_CONTENT_TYPE = "org.ametys.plugins.userdirectory.Content.udorgunit";
082    
083    /** The parent content type id */
084    public static final String ABSTRACT_USER_CONTENT_TYPE = "org.ametys.plugins.userdirectory.Content.user";
085    
086    /** The ametys object resolver */
087    protected AmetysObjectResolver _resolver;
088    
089    /** The content type extension point */
090    protected ContentTypeExtensionPoint _cTypeEP;
091    
092    /** The cache manager */
093    protected AbstractCacheManager _cacheManager;
094    
095    /** The languages manager */
096    protected LanguagesManager _languagesManager;
097    
098    /** The context */
099    protected Context _context;
100    
101    static class UserElementKey extends AbstractCacheKey
102    {
103        UserElementKey(String lang, UserIdentity userIdentity)
104        {
105            super(lang, userIdentity);
106        }
107        
108        static UserElementKey of(String lang, UserIdentity userIdentity)
109        {
110            return new UserElementKey(lang, userIdentity);
111        }
112        
113        static UserElementKey of(String lang)
114        {
115            return new UserElementKey(lang, null);
116        }
117    }
118    
119    @Override
120    public void service(ServiceManager manager) throws ServiceException
121    {
122        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
123        _cTypeEP = (ContentTypeExtensionPoint) manager.lookup(ContentTypeExtensionPoint.ROLE);
124        _cacheManager = (AbstractCacheManager) manager.lookup(AbstractCacheManager.ROLE);
125        _languagesManager = (LanguagesManager) manager.lookup(LanguagesManager.ROLE);
126    }
127    
128    @Override
129    public void initialize() throws Exception
130    {
131        _cacheManager.createMemoryCache(ROLE, 
132                new I18nizableText("plugin.user-directory", "PLUGINS_USER_DIRECTORY_USER_CACHE_LABEL"),
133                new I18nizableText("plugin.user-directory", "PLUGINS_USER_DIRECTORY_USER_CACHE_DESCRIPTION"),
134                true,
135                null);
136    }
137    
138    public void contextualize(Context context) throws ContextException
139    {
140        _context = context;
141    }
142    
143    /**
144     * Gets the root of user directory contents
145     * @return the root of user directory contents
146     */
147    public AmetysObjectCollection getUserDirectoryRootContent()
148    {
149        return getUserDirectoryRootContent(false);
150    }
151    
152    /**
153     * Gets the root of user directory contents
154     * @param create <code>true</code> to create automatically the root when missing.
155     * @return the root of user directory contents
156     */
157    public AmetysObjectCollection getUserDirectoryRootContent(boolean create)
158    {
159        ModifiableTraversableAmetysObject pluginsNode = _resolver.resolveByPath("/ametys:plugins/");
160        
161        boolean needSave = false;
162        if (!pluginsNode.hasChild(USER_DIRECTORY_ROOT_NODE))
163        {
164            if (create)
165            {
166                pluginsNode.createChild(USER_DIRECTORY_ROOT_NODE, "ametys:unstructured");
167                needSave = true;
168            }
169            else
170            {
171                throw new UnknownAmetysObjectException("Node '/ametys:plugins/userdirectory' is missing");
172            }
173        }
174        
175        ModifiableTraversableAmetysObject userdirectoryNode = pluginsNode.getChild(USER_DIRECTORY_ROOT_NODE);
176        if (!userdirectoryNode.hasChild(SynchronizedRootContentHelper.IMPORTED_CONTENTS_ROOT_NODE))
177        {
178            if (create)
179            {
180                userdirectoryNode.createChild(SynchronizedRootContentHelper.IMPORTED_CONTENTS_ROOT_NODE, "ametys:collection");
181                needSave = true;
182            }
183            else
184            {
185                throw new UnknownAmetysObjectException("Node '/ametys:plugins/userdirectory/ametys:contents' is missing");
186            }
187        }
188        
189        if (needSave)
190        {
191            pluginsNode.saveChanges();
192        }
193        
194        return userdirectoryNode.getChild(SynchronizedRootContentHelper.IMPORTED_CONTENTS_ROOT_NODE);
195    }
196    
197    /**
198     * Get the list of orgunits content
199     * @param user the user content
200     * @return the list of orgunits
201     */
202    public List<Content> getOrgUnits(Content user)
203    {
204        ContentValue[] contents = user.getValue(ORGUNITS_ATTRIBUTE);
205        if (contents == null)
206        {
207            return List.of();
208        }
209        
210        return Arrays.stream(contents)
211                     .map(v -> v.getContentIfExists())
212                     .flatMap(Optional::stream)
213                     .collect(Collectors.toList());
214    }
215    
216    /**
217     * Get user contents from user identity
218     * @param user the user identity
219     * @param givenLanguage the given language
220     * @return the user contents
221     */
222    public List<Content> getUserContents(UserIdentity user, String givenLanguage)
223    {
224        List<Content> userContents = new ArrayList<>();
225        Iterator<String> languagesIterator = _getLanguagesToTest(givenLanguage).iterator();
226        while (userContents.isEmpty() && languagesIterator.hasNext())
227        {
228            String lang = languagesIterator.next();
229            UserElementKey key = UserElementKey.of(lang, user);
230            List<String> contentIds = _getCache().get(key, k -> _requestUserContentIds(user, lang));
231            
232            userContents = contentIds.stream()
233                    .map(id -> this._resolveContent(id, lang))
234                    .filter(Objects::nonNull)
235                    .toList();
236        }
237        
238        return userContents;
239    }
240    
241    private Content _resolveContent(String contentId, String lang)
242    {
243        try
244        {
245            return contentId != null ? _resolver.resolveById(contentId) : null;
246        }
247        catch (UnknownAmetysObjectException e) 
248        {
249            // The content's id is always retrieved in default workspace: content may not exist in current workspace
250            getLogger().warn("User content with id '{}' was not found in current workspace for language '{}'", contentId, lang);
251        }
252        
253        return null;
254    }
255    
256    /**
257     * Get user content from user identity
258     * @param user the user identity
259     * @param givenLanguage the given language
260     * @return the user content (null if it not exist)
261     */
262    public Content getUserContent(UserIdentity user, String givenLanguage)
263    {
264        List<Content> userContents = getUserContents(user, givenLanguage);
265        return userContents.isEmpty() ? null : userContents.get(0);
266    }
267    
268    private Set<String> _getLanguagesToTest(String givenLang)
269    {
270        Set<String> languages = new LinkedHashSet<>();
271        
272        // First, test the given language if not blank
273        if (StringUtils.isNotBlank(givenLang))
274        {
275            languages.add(givenLang);
276        }
277        
278        // Then test english language
279        languages.add("en");
280        
281        // Finaly test other languages
282        languages.addAll(_languagesManager.getAvailableLanguages().keySet());
283        
284        return languages;
285    }
286
287    /**
288     * Request the list of user content id
289     * @param user the user identity
290     * @param lang the lang
291     * @return the list of user content id (empty if it not exist)
292     */
293    protected List<String> _requestUserContentIds(UserIdentity user, String lang)
294    {
295        Request request = ContextHelper.getRequest(_context);
296        
297        // Retrieve current workspace
298        String currentWsp = RequestAttributeWorkspaceSelector.getForcedWorkspace(request);
299        
300        try
301        {
302            // Use default workspace
303            RequestAttributeWorkspaceSelector.setForcedWorkspace(request, RepositoryConstants.DEFAULT_WORKSPACE);
304         
305            // Content type condition
306            List<String> cTypeIds = new ArrayList<>();
307            cTypeIds.add(ABSTRACT_USER_CONTENT_TYPE);
308            cTypeIds.addAll(_cTypeEP.getSubTypes(ABSTRACT_USER_CONTENT_TYPE));
309            Expression cTyPeExpr = new ContentTypeExpression(Operator.EQ, cTypeIds.toArray(new String[cTypeIds.size()]));
310            
311            // Lang condition
312            Expression langExpr = new LanguageExpression(Operator.EQ, lang);
313            // Login condition
314            Expression userExpr = new UserExpression("user", Operator.EQ, user);
315            
316            Expression finalExpr = new AndExpression(cTyPeExpr, langExpr, userExpr);
317            String query = ContentQueryHelper.getContentXPathQuery(finalExpr);
318            
319            AmetysObjectIterable<Content> contents = _resolver.query(query);
320            return contents.stream()
321                    .map(Content::getId)
322                    .toList();
323        }
324        finally
325        {
326            // Restore context
327            RequestAttributeWorkspaceSelector.setForcedWorkspace(request, currentWsp);
328        }
329    }
330    
331    private Cache<UserElementKey, List<String>> _getCache()
332    {
333        return _cacheManager.get(ROLE);
334    }
335    
336    /**
337     * Invalidate the user content cache for one language. If lang is <code>null</code>, invalidate all
338     * @param lang the lang. Can be <code>null</code>
339     */
340    public void invalidateUserContentCache(String lang)
341    {
342        if (StringUtils.isNotBlank(lang)) 
343        {
344            _getCache().invalidate(UserElementKey.of(lang));
345        }
346        else 
347        {
348            _getCache().invalidateAll();
349        }
350    }
351}