001/**
002 * Copyright 2014 Tampere University of Technology, Pori Department
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 service.tut.pori.contentanalysis;
017
018import java.io.IOException;
019import java.io.InputStream;
020import java.util.ArrayList;
021import java.util.Collection;
022import java.util.EnumSet;
023import java.util.Iterator;
024import java.util.LinkedHashSet;
025import java.util.List;
026import java.util.Set;
027import java.util.concurrent.Callable;
028import java.util.concurrent.ExecutionException;
029import java.util.concurrent.Future;
030
031import org.apache.commons.codec.net.URLCodec;
032import org.apache.commons.lang3.ArrayUtils;
033import org.apache.commons.lang3.StringUtils;
034import org.apache.http.HttpEntity;
035import org.apache.http.client.methods.CloseableHttpResponse;
036import org.apache.http.client.methods.HttpPost;
037import org.apache.http.config.SocketConfig;
038import org.apache.http.impl.client.CloseableHttpClient;
039import org.apache.http.impl.client.HttpClientBuilder;
040import org.apache.http.impl.client.HttpClients;
041import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
042import org.apache.http.util.EntityUtils;
043import org.apache.log4j.Logger;
044
045import service.tut.pori.contentanalysis.AnalysisBackend.Capability;
046import service.tut.pori.contentanalysis.CAContentCore.ServiceType;
047import service.tut.pori.contentanalysis.PhotoParameters.AnalysisType;
048import core.tut.pori.context.ServiceInitializer;
049import core.tut.pori.http.Response;
050import core.tut.pori.http.parameters.DataGroups;
051import core.tut.pori.http.parameters.Limits;
052import core.tut.pori.users.UserIdentity;
053import core.tut.pori.utils.XMLFormatter;
054
055/**
056 * Search task used to query back-ends for results. 
057 * 
058 * This class is not asynchronous and will block for the duration of execution, also this task cannot be submitted to system schedulers.
059 * 
060 */
061public class PhotoSearchTask{
062  /** Back-end capability required for search tasks */
063  public static final Capability SEARCH_CAPABILITY = Capability.PHOTO_SEARCH;
064  private static final int CONNECTION_SOCKET_TIME_OUT = 10000;  // connection socket time out in milliseconds
065  private static final XMLFormatter FORMATTER = new XMLFormatter();
066  private static final Logger LOGGER = Logger.getLogger(PhotoSearchTask.class);
067  private static final URLCodec URLCODEC = new URLCodec(core.tut.pori.http.Definitions.ENCODING_UTF8); 
068  private EnumSet<AnalysisType> _analysisTypes = null;
069  private UserIdentity _authenticatedUser = null;
070  private DataGroups _dataGroups = null;
071  private String _guid = null;
072  private Limits _limits = null;
073  private EnumSet<ServiceType> _serviceTypeFilter = null;
074  private String _url = null;
075  private long[] _userIdFilter = null;
076  
077  /**
078   * Execute this task
079   * 
080   * @return results of the search or null if no results
081   */
082  public PhotoList execute(){
083    List<AnalysisBackend> backends = ServiceInitializer.getDAOHandler().getSQLDAO(BackendDAO.class).getBackends(SEARCH_CAPABILITY);
084    if(backends == null){
085      LOGGER.warn("No backends with capability "+SEARCH_CAPABILITY.name());
086      return null;
087    }
088    
089    try (PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager(); CloseableHttpClient client =  HttpClientBuilder.create().setConnectionManager(cm).setDefaultSocketConfig(SocketConfig.custom().setSoTimeout(CONNECTION_SOCKET_TIME_OUT).build()).build()){
090      ArrayList<Searcher> searchers = new ArrayList<>(backends.size());
091      String baseParameterString = generateParameterString(); // create the default parameter string
092      for(AnalysisBackend end : backends){
093        searchers.add(new Searcher(end, baseParameterString));
094      }
095      
096      List<Future<PhotoList>> results = ServiceInitializer.getExecutorHandler().getExecutor().invokeAll(searchers);
097      List<PhotoList> photoLists = new ArrayList<>(searchers.size());
098      for(Future<PhotoList> result : results){
099        if(result.isDone() && !result.isCancelled()){
100          try {
101            PhotoList r = result.get();
102            if(!PhotoList.isEmpty(r)){
103              photoLists.add(r);
104            }
105          } catch (ExecutionException ex) {
106            LOGGER.warn(ex, ex);
107          }
108        }else{
109          LOGGER.debug("Ignoring unsuccessful result.");
110        } // for
111      }
112      if(photoLists.isEmpty()){
113        LOGGER.debug("No search results.");
114      }else{
115        PhotoList photos = combineResults(_authenticatedUser, _dataGroups, _limits, photoLists);
116        return removeTargetPhoto(photos);
117      }
118    } catch (IOException | InterruptedException ex) {
119      LOGGER.error(ex, ex);
120    }
121    return null;
122  }
123  
124  /**
125   * Combine the given lists of photos. Non-existent and duplicate photos and photos which the user cannot access will be automatically removed.
126   * 
127   * @param authenticatedUser
128   * @param dataGroups
129   * @param limits note that if there is a limit for maximum number of results, the photos for the combined list will be selected randomly from the given photo lists
130   * @param photoLists the photo lists, note that only GUIDs have any meaning, all other details will be retrieved from the database based on the given data groups.
131   * @return the given photo lists combined into a single list, removing duplicates or null if no content or null collection was passed
132   */
133  private PhotoList combineResults(UserIdentity authenticatedUser, DataGroups dataGroups, Limits limits, Collection<PhotoList> photoLists){
134    if(photoLists == null || photoLists.isEmpty()){
135      return null;
136    }
137    Set<String> guids = new LinkedHashSet<>();
138    for(PhotoList photoList : photoLists){  // collect all unique GUIDs. Note: this will prioritize the FASTER back-end, and does not guarantee that MORE APPLICABLE results appear first. This is because we have no easy (defined) way to decide which results are better than others.
139      for(Photo photo : photoList.getPhotos()){
140        guids.add(photo.getGUID());
141      }
142    }
143    if(!StringUtils.isBlank(_guid)){ // if search target is given, make sure it does not appear in the results
144      guids.remove(_guid);
145    }
146    
147    PhotoList results = ServiceInitializer.getDAOHandler().getSolrDAO(PhotoDAO.class).search(authenticatedUser, dataGroups, guids, limits, null, null, null); // the back-ends are not guaranteed to return results the user has permissions to view, so re-search everything just in case
148    if(PhotoList.isEmpty(results)){
149      LOGGER.debug("No photos found.");
150      return null;
151    }
152    
153    List<Photo> sorted = new ArrayList<>(guids.size());
154    for(String guid : guids){ // sort the results back to the original order
155      Photo photo = results.getPhoto(guid);
156      if(photo == null){
157        LOGGER.warn("Ignored photo with non-existing GUID: "+guid);
158      }else{
159        sorted.add(photo);
160      }
161    }
162    results.setPhotos(sorted);
163    return results;
164  }
165  
166  /**
167   * Removes the photo with the given search term (GUID), if GUID based search was performed.
168   * 
169   * @param photos the list from which the reference photo is to be removed.
170   * @return the list with the reference photo removed or null if nothing was left after removal
171   */
172  private PhotoList removeTargetPhoto(PhotoList photos){
173    if(PhotoList.isEmpty(photos)){
174      LOGGER.debug("Empty photo list.");
175      return null;
176    }
177    if(_guid != null){
178      LOGGER.debug("Removing target photo, if present in the search results...");
179      for(Iterator<Photo> iter = photos.getPhotos().iterator();iter.hasNext();){
180        if(iter.next().getGUID().equals(_guid)){
181          iter.remove();
182        }
183      }
184    } // if
185    
186    if(PhotoList.isEmpty(photos)){
187      LOGGER.debug("No photos were left in the list, returning null..");
188      return null;
189    }else{
190      return photos;
191    }
192  }
193  
194  /**
195   * Create new search task using the GUID as a reference, the GUID is assumed to exist, no database lookup is going to be done to confirm it
196   * 
197   * @param authenticatedUser optional authenticated user
198   * @param analysisTypes 
199   * @param dataGroups optional data groups
200   * @param guid
201   * @param limits optional limits
202   * @param serviceTypeFilter optional service type filter
203   * @param userIdFilter optional user id filter, if given only the content for the specific users will searched for
204   * @return new task
205   * @throws IllegalArgumentException on bad GUID
206   */
207  public static PhotoSearchTask getTaskByGUID(UserIdentity authenticatedUser, EnumSet<AnalysisType> analysisTypes, DataGroups dataGroups, String guid, Limits limits, EnumSet<ServiceType> serviceTypeFilter, long[] userIdFilter) throws IllegalArgumentException{
208    if(StringUtils.isBlank(guid)){
209      throw new IllegalArgumentException("GUID was null or empty.");
210    }
211    PhotoSearchTask task = new PhotoSearchTask(authenticatedUser, analysisTypes, dataGroups, limits, serviceTypeFilter, userIdFilter);
212    task._guid = guid;
213    return task;
214  }
215  
216  /**
217   * Create new search task using the URL as a search parameter. The URL is assumed to be valid, and no validation is performed.
218   * 
219   * @param authenticatedUser optional authenticated user
220   * @param analysisTypes 
221   * @param dataGroups optional data groups
222   * @param limits optional limits
223   * @param serviceTypeFilter optional service type filter
224   * @param url
225   * @param userIdFilter optional user id filter, if given only the content for the specific users will searched for
226   * @return new task
227   * @throws IllegalArgumentException on bad URL
228   */
229  public static PhotoSearchTask getTaskByUrl(UserIdentity authenticatedUser, EnumSet<AnalysisType> analysisTypes, DataGroups dataGroups, Limits limits, EnumSet<ServiceType> serviceTypeFilter, String url, long[] userIdFilter) throws IllegalArgumentException{
230    if(StringUtils.isBlank(url)){
231      throw new IllegalArgumentException("Url was null or empty.");
232    }
233    PhotoSearchTask task = new PhotoSearchTask(authenticatedUser, analysisTypes, dataGroups, limits, serviceTypeFilter, userIdFilter);
234    task._url = url;
235    return task;
236  }
237  
238  /**
239   * 
240   * @param authenticatedUser
241   * @param analysisTypes 
242   * @param dataGroups
243   * @param limits
244   * @param serviceTypeFilter
245   * @param userIdFilter
246   */
247  private PhotoSearchTask(UserIdentity authenticatedUser, EnumSet<AnalysisType> analysisTypes, DataGroups dataGroups, Limits limits, EnumSet<ServiceType> serviceTypeFilter, long[] userIdFilter){
248    if(UserIdentity.isValid(authenticatedUser)){
249      _authenticatedUser = authenticatedUser;
250      LOGGER.debug("Searching with authenticated user, id: "+authenticatedUser.getUserId());
251    }else{
252      LOGGER.debug("No authenticated user.");
253    }
254    
255    if(!DataGroups.isEmpty(dataGroups)){
256      _dataGroups = dataGroups;
257    }
258    
259    _limits = limits;
260    
261    if(serviceTypeFilter != null && !serviceTypeFilter.isEmpty()){
262      LOGGER.debug("Using service type filter...");
263      _serviceTypeFilter = serviceTypeFilter;
264    }
265    
266    if(!ArrayUtils.isEmpty(userIdFilter)){
267      LOGGER.debug("Using user id filter...");
268      _userIdFilter = userIdFilter;
269    }
270    
271    if(analysisTypes != null && !analysisTypes.isEmpty()){
272      LOGGER.debug("Analysis types specified...");
273      _analysisTypes  = analysisTypes;
274    }
275  }
276  
277  /**
278   * Helper method for creating the default parameter string: filters, limits & data groups
279   * 
280   * @return parameter string or null if none
281   */
282  private String generateParameterString(){
283    StringBuilder sb = new StringBuilder();
284    if(_userIdFilter != null){
285      sb.append(core.tut.pori.http.Definitions.SEPARATOR_URI_QUERY_PARAMS+service.tut.pori.users.Definitions.PARAMETER_USER_ID+core.tut.pori.http.Definitions.SEPARATOR_URI_QUERY_PARAM_VALUE_SEPARATOR);
286      core.tut.pori.utils.StringUtils.append(sb, _userIdFilter, core.tut.pori.http.Definitions.SEPARATOR_URI_QUERY_PARAM_VALUES);
287    }
288    
289    if(_serviceTypeFilter != null){
290      sb.append(core.tut.pori.http.Definitions.SEPARATOR_URI_QUERY_PARAMS+Definitions.PARAMETER_SERVICE_ID+core.tut.pori.http.Definitions.SEPARATOR_URI_QUERY_PARAM_VALUE_SEPARATOR);
291      core.tut.pori.utils.StringUtils.append(sb, ServiceType.toIdArray(_serviceTypeFilter), core.tut.pori.http.Definitions.SEPARATOR_URI_QUERY_PARAM_VALUES);
292    }
293    
294    if(_limits != null){
295      sb.append(core.tut.pori.http.Definitions.SEPARATOR_URI_QUERY_PARAMS+Limits.PARAMETER_DEFAULT_NAME+core.tut.pori.http.Definitions.SEPARATOR_URI_QUERY_PARAM_VALUE_SEPARATOR);
296      sb.append(_limits.toLimitString());     
297    }
298    
299    if(_analysisTypes != null){
300      sb.append(core.tut.pori.http.Definitions.SEPARATOR_URI_QUERY_PARAMS+Definitions.PARAMETER_ANALYSIS_TYPE+core.tut.pori.http.Definitions.SEPARATOR_URI_QUERY_PARAM_VALUE_SEPARATOR);
301      Iterator<AnalysisType> iter = _analysisTypes.iterator();
302      sb.append(iter.next().toAnalysisTypeString());
303      while(iter.hasNext()){
304        sb.append(core.tut.pori.http.Definitions.SEPARATOR_URI_QUERY_PARAM_VALUES);
305        sb.append(iter.next().toAnalysisTypeString());
306      }
307    }
308    
309    if(sb.length() < 1){
310      return null;
311    }else{
312      return sb.toString();
313    }
314  }
315
316  /**
317   * Internal class, which executes the search queries to back-ends.
318   *
319   */
320  private class Searcher implements Callable<PhotoList>{
321    private AnalysisBackend _backend = null;
322    private String _baseParameterString = null;
323    
324    /**
325     * 
326     * @param end
327     * @param baseParameterString 
328     */
329    public Searcher(AnalysisBackend end, String baseParameterString){
330      _backend = end;
331      _baseParameterString = baseParameterString;
332    }
333
334    @Override
335    public PhotoList call() throws Exception {
336      StringBuilder uri = new StringBuilder(_backend.getAnalysisUri());
337      if(_url != null){
338        uri.append(Definitions.METHOD_SEARCH_SIMILAR_BY_CONTENT+"?"+Definitions.PARAMETER_URL+"=");
339        uri.append(URLCODEC.encode(_url));
340      }else{  // guid != null
341        uri.append(Definitions.METHOD_SEARCH_SIMILAR_BY_ID+"?"+Definitions.PARAMETER_GUID+"=");
342        uri.append(_guid);
343      }
344      
345      if(_baseParameterString != null){
346        uri.append(_baseParameterString);
347      } 
348      String url = uri.toString();
349      LOGGER.debug("Calling URL: "+url+" for back-end, id: "+_backend.getBackendId());
350      try (CloseableHttpClient client = HttpClients.createDefault(); CloseableHttpResponse response = client.execute(new HttpPost(url))) {
351        HttpEntity entity = response.getEntity();
352        try (InputStream content = entity.getContent()) {
353          Response r = FORMATTER.toResponse(content, PhotoList.class);
354          if(r == null){
355            LOGGER.warn("No results returned by backend, id: "+_backend.getBackendId());
356          }else{
357            return (PhotoList) r.getResponseData();
358          }
359        } finally {
360          EntityUtils.consume(entity);
361        } // try content
362      } // try response
363      return null;
364    }
365    
366  } //  class Searcher
367}