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;
035import org.apache.commons.lang3.StringUtils;
036
037import org.ametys.cms.ObservationConstants;
038import org.ametys.cms.data.RichText;
039import org.ametys.cms.data.RichTextHelper;
040import org.ametys.cms.data.type.ModelItemTypeConstants;
041import org.ametys.cms.repository.Content;
042import org.ametys.core.observation.AsyncObserver;
043import org.ametys.core.observation.Event;
044import org.ametys.core.right.AllowedUsers;
045import org.ametys.core.right.RightManager;
046import org.ametys.core.user.User;
047import org.ametys.core.user.UserIdentity;
048import org.ametys.core.user.UserManager;
049import org.ametys.core.user.population.UserPopulationDAO;
050import org.ametys.plugins.mobileapp.PushNotificationManager;
051import org.ametys.plugins.mobileapp.QueriesHelper;
052import org.ametys.plugins.mobileapp.UserPreferencesHelper;
053import org.ametys.plugins.queriesdirectory.Query;
054import org.ametys.plugins.repository.AmetysObjectResolver;
055import org.ametys.plugins.repository.provider.RequestAttributeWorkspaceSelector;
056import org.ametys.plugins.repository.version.ModifiableDataAwareVersionableAmetysObject;
057import org.ametys.runtime.config.Config;
058import org.ametys.runtime.plugin.component.AbstractLogEnabled;
059import org.ametys.web.WebConstants;
060import org.ametys.web.repository.content.WebContent;
061import org.ametys.web.repository.site.Site;
062
063/**
064 * On validation, test each query to notify impacted users
065 */
066public class ContentValidatedObserver extends AbstractLogEnabled implements AsyncObserver, Serviceable, Contextualizable
067{
068    /** The name of the content metadata indicating the push notification was send */
069    public static final String NOTIFICATION_PUSHED_METADATA_NAME = "notificationPushed";
070
071    private static final String __DESCRIPTION_MAX_SIZE_CONF_ID = "plugin.mobileapp.push.description.richtext.max";
072    
073    private Context _context;
074
075    private QueriesHelper _queryHelper;
076    private UserPreferencesHelper _userPreferencesHelper;
077    private PushNotificationManager _pushNotificationManager;
078    private UserManager _userManager;
079    private UserPopulationDAO _userPopulationDAO;
080    private RightManager _rightManager;
081    private RichTextHelper _richTextHelper;
082    private AmetysObjectResolver _resolver;
083
084    @Override
085    public void contextualize(Context context) throws ContextException
086    {
087        _context = context;
088    }
089    
090    @Override
091    public void service(ServiceManager manager) throws ServiceException
092    {
093        _queryHelper = (QueriesHelper) manager.lookup(QueriesHelper.ROLE);
094        _userPreferencesHelper = (UserPreferencesHelper) manager.lookup(UserPreferencesHelper.ROLE);
095        _pushNotificationManager = (PushNotificationManager) manager.lookup(PushNotificationManager.ROLE);
096        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
097        _userManager = (UserManager) manager.lookup(UserManager.ROLE);
098        _userPopulationDAO = (UserPopulationDAO) manager.lookup(UserPopulationDAO.ROLE);
099        _rightManager = (RightManager) manager.lookup(RightManager.ROLE);
100        _richTextHelper = (RichTextHelper) manager.lookup(RichTextHelper.ROLE);
101    }
102
103    public boolean supports(Event event)
104    {
105        return event.getId().equals(ObservationConstants.EVENT_CONTENT_VALIDATED)
106            || event.getId().equals(ObservationConstants.EVENT_CONTENT_TAGGED);
107    }
108
109    public int getPriority()
110    {
111        return MIN_PRIORITY;
112    }
113
114    public void observe(Event event, Map<String, Object> transientVars) throws Exception
115    {
116        String contentId = (String) event.getArguments().get(ObservationConstants.ARGS_CONTENT_ID);
117        Content defaultContent = _resolver.resolveById(contentId);
118        if (defaultContent instanceof ModifiableDataAwareVersionableAmetysObject versionable
119                && versionable.getUnversionedDataHolder().hasValue(NOTIFICATION_PUSHED_METADATA_NAME))
120        {
121            getLogger().debug("Content {} has already been notified", contentId);
122            return;
123        }
124        
125        // FIXME : Temporary fix to let time to the content to be visible from Solr queries
126        Thread.sleep(5000);
127        
128        
129        Request request = ContextHelper.getRequest(_context);
130
131        // Retrieve current workspace
132        String currentWsp = RequestAttributeWorkspaceSelector.getForcedWorkspace(request);
133        
134        try
135        {
136            // Use live workspace
137            RequestAttributeWorkspaceSelector.setForcedWorkspace(request, WebConstants.LIVE_WORKSPACE);
138            
139            if (!_resolver.hasAmetysObjectForId(contentId))
140            {
141                getLogger().debug("Content with id {} does not exists in workspace live, no push notification will be sent.", contentId);
142                return;
143            }
144        
145            Content content = _resolver.resolveById(contentId);
146            
147            
148            if (!(content instanceof WebContent webContent))
149            {
150                getLogger().debug("We currently do not support push notifications for off-site content {}", content.getId());
151                return;
152            }
153            
154            // First find the queries that whose results contain the content
155            Site site = webContent.getSite();
156            Long limit = Config.getInstance().getValue(QueriesHelper.QUERY_LIMIT_CONF_ID);
157            Set<Query> queries = _queryHelper.getQueriesForResult(content.getId(), site, true, limit.intValue());
158    
159            getLogger().info("{} queries found for content {}", queries.size(), content.getId());
160            
161            if (queries.isEmpty())
162            {
163                return;
164            }
165    
166            // Then collect users that have read access to the content
167            Set<UserIdentity> users;
168            AllowedUsers readAccessAllowedUsers = _rightManager.getReadAccessAllowedUsers(content);
169            if (readAccessAllowedUsers.isAnonymousAllowed() || readAccessAllowedUsers.isAnyConnectedUserAllowed())
170            {
171                List<String> userPopulationsIds = _userPopulationDAO.getUserPopulationsIds();
172                Collection<User> allUsers = _userManager.getUsersByPopulationIds(userPopulationsIds);
173                users = allUsers.stream().map(User::getIdentity).collect(Collectors.toSet());
174            }
175            else
176            {
177                users = readAccessAllowedUsers.resolveAllowedUsers(true);
178            }
179    
180            Map<String, Query> queryMap = queries.stream().collect(Collectors.toMap(Query::getId, Function.identity()));
181            Set<String> feedIds = queryMap.keySet();
182    
183            // Then find the users that have subscribed to the queries
184            Map<String, Map<UserIdentity, Set<String>>> notificationsNeeded = new HashMap<>();
185            for (UserIdentity user : users)
186            {
187                Map<String, Set<String>> tokensForUser = _userPreferencesHelper.getUserImpactedTokens(user, feedIds, site);
188                for (Entry<String, Set<String>> tokensByFeed : tokensForUser.entrySet())
189                {
190                    Query query = queryMap.get(tokensByFeed.getKey());
191                    
192                    if (_rightManager.hasReadAccess(user, query))
193                    {
194                        Map<UserIdentity, Set<String>> tokens = notificationsNeeded.computeIfAbsent(tokensByFeed.getKey(), __ -> new HashMap<>());
195                        tokens.put(user, tokensByFeed.getValue());
196                    }
197                }
198            }
199    
200            Map<String, String> data = _queryHelper.getDataForContent(content);
201            Map<String, String> sorts = queries.stream().collect(Collectors.toMap(Query::getId, q -> _queryHelper.getSortProperty(q, true).get(0).sortField()));
202    
203            // Finally send the notifications
204            for (Entry<String, Map<UserIdentity, Set<String>>> entry : notificationsNeeded.entrySet())
205            {
206                Map<String, Object> notificationData = new HashMap<>();
207                notificationData.putAll(data);
208                String feedId = entry.getKey();
209                notificationData.put("feed_id", feedId);
210                if (queryMap.containsKey(feedId))
211                {
212                    notificationData.put("category_name", queryMap.get(feedId).getTitle());
213                }
214    
215                String sortField = null;
216                if (sorts.containsKey(feedId))
217                {
218                    sortField = sorts.get(feedId);
219                }
220                String isoDate = _queryHelper.getContentFormattedDate(content, sortField);
221                notificationData.put("date", isoDate);
222    
223                String description = _getContentDescription(content);
224    
225                _pushNotificationManager.pushNotifications(content.getTitle(), description, entry.getValue(), notificationData);
226            }
227        }
228        finally
229        {
230            // Restore context
231            RequestAttributeWorkspaceSelector.setForcedWorkspace(request, currentWsp);
232        }
233        
234        // Mark the content to be able to remember that it was notified
235        if (defaultContent instanceof ModifiableDataAwareVersionableAmetysObject versionable)
236        {
237            versionable.getUnversionedDataHolder().setValue(NOTIFICATION_PUSHED_METADATA_NAME, true);
238            versionable.saveChanges();
239        }
240    }
241
242    /**
243     * Get a description for the notification.
244     * 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).
245     * If none is available, an empty String is returned.
246     * @param content The content to read
247     * @return a description for this content
248     */
249    protected String _getContentDescription(Content content)
250    {
251        Long maxSize = Config.getInstance().getValue(__DESCRIPTION_MAX_SIZE_CONF_ID);
252        String result = "";
253
254        if (content.hasValue("abstract") && org.ametys.runtime.model.type.ModelItemTypeConstants.STRING_TYPE_ID.equals(content.getDefinition("abstract").getType().getId()))
255        {
256            result = content.getValueOrDefault("abstract", StringUtils.EMPTY);
257        }
258        else if (content.hasValue("content") && ModelItemTypeConstants.RICH_TEXT_ELEMENT_TYPE_ID.equals(content.getDefinition("content").getType().getId()))
259        {
260            RichText richText = content.getValue("content");
261            result = _richTextHelper.richTextToString(richText, maxSize.intValue());
262        }
263
264        return result;
265    }
266}