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.mobileapp.observer;
017
018import java.util.Collection;
019import java.util.HashMap;
020import java.util.List;
021import java.util.Map;
022import java.util.Map.Entry;
023import java.util.Set;
024import java.util.function.Function;
025import java.util.stream.Collectors;
026
027import org.apache.avalon.framework.context.Context;
028import org.apache.avalon.framework.context.ContextException;
029import org.apache.avalon.framework.context.Contextualizable;
030import org.apache.avalon.framework.service.ServiceException;
031import org.apache.avalon.framework.service.ServiceManager;
032import org.apache.avalon.framework.service.Serviceable;
033import org.apache.cocoon.components.ContextHelper;
034import org.apache.cocoon.environment.Request;
035
036import org.ametys.cms.ObservationConstants;
037import org.ametys.cms.data.RichText;
038import org.ametys.cms.data.RichTextHelper;
039import org.ametys.cms.data.type.ModelItemTypeConstants;
040import org.ametys.cms.repository.Content;
041import org.ametys.core.observation.AsyncObserver;
042import org.ametys.core.observation.Event;
043import org.ametys.core.right.AllowedUsers;
044import org.ametys.core.right.RightManager;
045import org.ametys.core.user.User;
046import org.ametys.core.user.UserIdentity;
047import org.ametys.core.user.UserManager;
048import org.ametys.core.user.population.UserPopulationDAO;
049import org.ametys.plugins.mobileapp.PushNotificationManager;
050import org.ametys.plugins.mobileapp.QueriesHelper;
051import org.ametys.plugins.mobileapp.UserPreferencesHelper;
052import org.ametys.plugins.queriesdirectory.Query;
053import org.ametys.plugins.repository.AmetysObjectResolver;
054import org.ametys.plugins.repository.provider.RequestAttributeWorkspaceSelector;
055import org.ametys.plugins.repository.version.ModifiableDataAwareVersionableAmetysObject;
056import org.ametys.runtime.config.Config;
057import org.ametys.runtime.plugin.component.AbstractLogEnabled;
058import org.ametys.web.WebConstants;
059import org.ametys.web.repository.content.WebContent;
060import org.ametys.web.repository.page.Page;
061import org.ametys.web.repository.page.Zone;
062import org.ametys.web.repository.page.ZoneItem;
063import org.ametys.web.repository.page.ZoneItem.ZoneType;
064import org.ametys.web.repository.site.Site;
065
066/**
067 * On validation, test each query to notify impacted users
068 */
069public class ContentValidatedObserver extends AbstractLogEnabled implements AsyncObserver, Serviceable, Contextualizable
070{
071    /** The name of the content metadata indicating the push notification was send */
072    public static final String NOTIFICATION_PUSHED_METADATA_NAME = "notificationPushed";
073
074    private static final String __DESCRIPTION_MAX_SIZE_CONF_ID = "plugin.mobileapp.push.description.richtext.max";
075    
076    private Context _context;
077
078    private QueriesHelper _queryHelper;
079    private UserPreferencesHelper _userPreferencesHelper;
080    private PushNotificationManager _pushNotificationManager;
081    private UserManager _userManager;
082    private UserPopulationDAO _userPopulationDAO;
083    private RightManager _rightManager;
084    private RichTextHelper _richTextHelper;
085    private AmetysObjectResolver _resolver;
086
087    @Override
088    public void contextualize(Context context) throws ContextException
089    {
090        _context = context;
091    }
092    
093    @Override
094    public void service(ServiceManager manager) throws ServiceException
095    {
096        _queryHelper = (QueriesHelper) manager.lookup(QueriesHelper.ROLE);
097        _userPreferencesHelper = (UserPreferencesHelper) manager.lookup(UserPreferencesHelper.ROLE);
098        _pushNotificationManager = (PushNotificationManager) manager.lookup(PushNotificationManager.ROLE);
099        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
100        _userManager = (UserManager) manager.lookup(UserManager.ROLE);
101        _userPopulationDAO = (UserPopulationDAO) manager.lookup(UserPopulationDAO.ROLE);
102        _rightManager = (RightManager) manager.lookup(RightManager.ROLE);
103        _richTextHelper = (RichTextHelper) manager.lookup(RichTextHelper.ROLE);
104    }
105
106    public boolean supports(Event event)
107    {
108        return event.getId().equals(ObservationConstants.EVENT_CONTENT_VALIDATED)
109            || event.getId().equals(ObservationConstants.EVENT_CONTENT_TAGGED)
110            || event.getId().equals(org.ametys.web.ObservationConstants.EVENT_PAGE_CHANGED);
111    }
112
113    public int getPriority()
114    {
115        return MIN_PRIORITY;
116    }
117
118    public void observe(Event event, Map<String, Object> transientVars) throws Exception
119    {
120        if (event.getId().equals(org.ametys.web.ObservationConstants.EVENT_PAGE_CHANGED))
121        {
122            String pageId = (String) event.getArguments().get(org.ametys.web.ObservationConstants.ARGS_PAGE_ID);
123            
124            Page page = _resolver.resolveById(pageId);
125            
126            for (Zone zone : page.getZones())
127            {
128                for (ZoneItem item : zone.getZoneItems())
129                {
130                    if (ZoneType.CONTENT.equals(item.getType()))
131                    {
132                        try
133                        {
134                            _observe(item.getContent());
135                        }
136                        catch (Exception e)
137                        {
138                            getLogger().error("Failed to observe {} due to following error", item.getContent().getId(), e);
139                        }
140                    }
141                }
142            }
143
144
145        }
146        else
147        {
148            String contentId = (String) event.getArguments().get(ObservationConstants.ARGS_CONTENT_ID);
149            Content defaultContent = _resolver.resolveById(contentId);
150            try
151            {
152                _observe(defaultContent);
153            }
154            catch (Exception e)
155            {
156                getLogger().error("Failed to observe {} due to following error", defaultContent.getId(), e);
157            }
158        }
159    }
160    
161    private void _observe(Content content) throws Exception
162    {
163        if (_hasBeenNotified(content))
164        {
165            getLogger().debug("Content {} has already been notified", content.getId());
166            return;
167        }
168        
169        // FIXME : Temporary fix to let time to the content to be visible from Solr queries
170        Thread.sleep(5000);
171        
172        Request request = ContextHelper.getRequest(_context);
173
174        // Retrieve current workspace
175        String currentWsp = RequestAttributeWorkspaceSelector.getForcedWorkspace(request);
176        
177        try
178        {
179            // Use live workspace
180            RequestAttributeWorkspaceSelector.setForcedWorkspace(request, WebConstants.LIVE_WORKSPACE);
181            
182            if (!_resolver.hasAmetysObjectForId(content.getId()))
183            {
184                getLogger().debug("Content with id {} does not exists in workspace live, no push notification will be sent.", content.getId());
185                return;
186            }
187        
188            Content liveContent = _resolver.resolveById(content.getId());
189            
190            if (!(liveContent instanceof WebContent webContent))
191            {
192                getLogger().debug("We currently do not support push notifications for off-site content {}", liveContent.getId());
193                return;
194            }
195            
196            // First find the queries that whose results contain the content
197            Site site = webContent.getSite();
198            Long limit = Config.getInstance().getValue(QueriesHelper.QUERY_LIMIT_CONF_ID);
199            Set<Query> queries = _queryHelper.getQueriesForResult(liveContent.getId(), site, true, limit.intValue());
200    
201            getLogger().info("{} queries found for content {}", queries.size(), liveContent.getId());
202            
203            if (queries.isEmpty())
204            {
205                return;
206            }
207    
208            // Then collect users that have read access to the content
209            Set<UserIdentity> users = _getReadAccessUser(liveContent);
210    
211            Map<String, Query> queryMap = queries.stream().collect(Collectors.toMap(Query::getId, Function.identity()));
212            Map<String, Map<UserIdentity, Set<String>>> notificationsNeeded = _getUserToNotifyByFeed(site, users, queryMap);
213    
214            Map<String, String> data = _queryHelper.getDataForContent(liveContent);
215            Map<String, String> sorts = queries.stream().collect(Collectors.toMap(Query::getId, q -> _queryHelper.getSortProperty(q, true).get(0).sortField()));
216    
217            // Finally send the notifications
218            for (Entry<String, Map<UserIdentity, Set<String>>> entry : notificationsNeeded.entrySet())
219            {
220                Map<String, Object> notificationData = new HashMap<>();
221                notificationData.putAll(data);
222                String feedId = entry.getKey();
223                notificationData.put("feed_id", feedId);
224                if (queryMap.containsKey(feedId))
225                {
226                    notificationData.put("category_name", queryMap.get(feedId).getTitle());
227                }
228    
229                String sortField = null;
230                if (sorts.containsKey(feedId))
231                {
232                    sortField = sorts.get(feedId);
233                }
234                String isoDate = _queryHelper.getContentFormattedDate(liveContent, sortField);
235                notificationData.put("date", isoDate);
236    
237                String description = _getContentDescription(liveContent);
238    
239                _pushNotificationManager.pushNotifications(liveContent.getTitle(), description, entry.getValue(), notificationData);
240            }
241        }
242        finally
243        {
244            // Restore context
245            RequestAttributeWorkspaceSelector.setForcedWorkspace(request, currentWsp);
246        }
247        
248        // Mark the content to be able to remember that it was notified
249        if (content instanceof ModifiableDataAwareVersionableAmetysObject versionable)
250        {
251            versionable.getUnversionedDataHolder().setValue(NOTIFICATION_PUSHED_METADATA_NAME, true);
252            versionable.saveChanges();
253        }
254    }
255
256    /**
257     * Get the list of impacted user tokens for each feed.
258     * @param site the current site
259     * @param users the users to check
260     * @param queryMap a map with the query identifier as key and the query as value
261     * @return a map containing for each feed identifier a map with all the impacted user identity
262     * as key and the set of the impacted user token as key
263     */
264    private Map<String, Map<UserIdentity, Set<String>>> _getUserToNotifyByFeed(Site site, Set<UserIdentity> users, Map<String, Query> queryMap)
265    {
266        Set<String> feedIds = queryMap.keySet();
267   
268        // Then find the users that have subscribed to the queries
269        Map<String, Map<UserIdentity, Set<String>>> notificationsNeeded = new HashMap<>();
270        for (UserIdentity user : users)
271        {
272            Map<String, Set<String>> tokensForUser = _userPreferencesHelper.getUserImpactedTokens(user, feedIds, site);
273            for (Entry<String, Set<String>> tokensByFeed : tokensForUser.entrySet())
274            {
275                Query query = queryMap.get(tokensByFeed.getKey());
276                
277                if (_rightManager.hasReadAccess(user, query))
278                {
279                    Map<UserIdentity, Set<String>> tokens = notificationsNeeded.computeIfAbsent(tokensByFeed.getKey(), __ -> new HashMap<>());
280                    tokens.put(user, tokensByFeed.getValue());
281                }
282            }
283        }
284        return notificationsNeeded;
285    }
286
287    private Set<UserIdentity> _getReadAccessUser(Content content)
288    {
289        AllowedUsers readAccessAllowedUsers = _rightManager.getReadAccessAllowedUsers(content);
290        if (readAccessAllowedUsers.isAnonymousAllowed() || readAccessAllowedUsers.isAnyConnectedUserAllowed())
291        {
292            List<String> userPopulationsIds = _userPopulationDAO.getUserPopulationsIds();
293            Collection<User> allUsers = _userManager.getUsersByPopulationIds(userPopulationsIds);
294            return allUsers.stream().map(User::getIdentity).collect(Collectors.toSet());
295        }
296        else
297        {
298            return readAccessAllowedUsers.resolveAllowedUsers(true);
299        }
300    }
301
302    private boolean _hasBeenNotified(Content content)
303    {
304        return content instanceof ModifiableDataAwareVersionableAmetysObject versionable
305                && versionable.getUnversionedDataHolder().hasValue(NOTIFICATION_PUSHED_METADATA_NAME);
306    }
307
308    /**
309     * Get a description for the notification.
310     * It will first try to read a "abstract" value in the content, and if not available, will try to read a rich-text stored in "content" (and cut it down).
311     * If none is available, an empty String is returned.
312     * @param content The content to read
313     * @return a description for this content
314     */
315    protected String _getContentDescription(Content content)
316    {
317        Long maxSize = Config.getInstance().getValue(__DESCRIPTION_MAX_SIZE_CONF_ID);
318        String result = "";
319
320        if (content.hasValue("abstract") && org.ametys.runtime.model.type.ModelItemTypeConstants.STRING_TYPE_ID.equals(content.getDefinition("abstract").getType().getId()))
321        {
322            result = content.getValue("abstract", false, "");
323        }
324        else if (content.hasValue("content") && ModelItemTypeConstants.RICH_TEXT_ELEMENT_TYPE_ID.equals(content.getDefinition("content").getType().getId()))
325        {
326            RichText richText = content.getValue("content");
327            result = _richTextHelper.richTextToString(richText, maxSize.intValue());
328        }
329
330        return result;
331    }
332}