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}