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}