package org.karmaexchange.resources;
import static org.karmaexchange.util.OfyService.ofy;
import static org.karmaexchange.util.UserService.getCurrentUserKey;
import java.util.Collection;
import java.util.Date;
import java.util.List;
import javax.annotation.Nullable;
import javax.servlet.ServletContext;
import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Request;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriInfo;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.karmaexchange.dao.Event;
import org.karmaexchange.dao.Event.ParticipantType;
import org.karmaexchange.dao.BaseEvent;
import org.karmaexchange.dao.GeoPtWrapper;
import org.karmaexchange.dao.KeyWrapper;
import org.karmaexchange.dao.Review;
import org.karmaexchange.dao.User;
import org.karmaexchange.resources.msg.ErrorResponseMsg;
import org.karmaexchange.resources.msg.EventParticipantView;
import org.karmaexchange.resources.msg.EventSearchView;
import org.karmaexchange.resources.msg.EventView;
import org.karmaexchange.resources.msg.ExpandedEventSearchView;
import org.karmaexchange.resources.msg.ListResponseMsg;
import org.karmaexchange.resources.msg.ErrorResponseMsg.ErrorInfo;
import org.karmaexchange.resources.msg.ListResponseMsg.PagingInfo;
import org.karmaexchange.resources.msg.ReviewCommentView;
import org.karmaexchange.util.OfyUtil;
import org.karmaexchange.util.PaginatedQuery;
import org.karmaexchange.util.PaginatedQuery.ConditionFilter;
import org.karmaexchange.util.PaginatedQuery.FilterQueryClause;
import org.karmaexchange.util.SearchUtil;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Lists;
import com.googlecode.objectify.Key;
import com.javadocmd.simplelatlng.LatLng;
import com.javadocmd.simplelatlng.LatLngTool;
import com.javadocmd.simplelatlng.util.LengthUnit;
import com.sun.jersey.core.util.MultivaluedMapImpl;
@Path("/event")
@NoArgsConstructor
public class EventResource extends BaseDaoResource<Event, EventView> {
public static final String START_TIME_PARAM = "start_time";
public static final String END_TIME_PARAM = "end_time";
public static final String SEARCH_TYPE_PARAM = "type";
public static final String KEYWORDS_PARAM = "keywords";
public static final String SEARCH_DIST_PARAM = "distance";
// Limiting the scope for distance searches allows for future geo-hashing based search
// implementations.
public static final List<Integer> PERMITTED_DIST_VALUES = Lists.newArrayList(5, 10 , 50);
public static final String SEARCH_LATITUDE_PARAM = "lat";
public static final String SEARCH_LONGITUDE_PARAM = "lng";
public static final int DEFAULT_NUM_PARTICIPANT_VIEW_RESULTS = 10;
public static final int DEFAULT_NUM_REVIEWS = 3;
public static final int MAX_SEARCH_KEYWORDS = 20;
public enum EventSearchType {
UPCOMING,
PAST,
INTERVAL,
DESCENDING
}
public EventResource(UriInfo uriInfo, Request request, ServletContext servletContext) {
super(uriInfo, request, servletContext);
}
@Override
protected EventView createBaseDaoView(Event event) {
return new EventView(event);
}
@GET
@Produces({MediaType.APPLICATION_JSON})
public ListResponseMsg<EventSearchView> getResources() {
return eventSearch(uriInfo, ImmutableList.<FilterQueryClause>of(), null);
}
public static ListResponseMsg<EventSearchView> eventSearch(UriInfo uriInfo,
Collection<? extends FilterQueryClause> filters, @Nullable Key<User> eventSearchUserKey) {
EventSearchResults eventSearchResult =
eventSearch(uriInfo, null, filters, eventSearchUserKey);
return ListResponseMsg.create(
eventSearchResult.results,
eventSearchResult.pagingInfo);
}
public static void eventSearchWarmup() {
MultivaluedMap<String, String> reqParams = new MultivaluedMapImpl();
eventSearch(null, reqParams, ImmutableList.<FilterQueryClause>of(), null);
}
private static EventSearchType getEventSearchType(
MultivaluedMap<String, String> reqParams) {
EventSearchType searchType = reqParams.containsKey(SEARCH_TYPE_PARAM) ?
EventSearchType.valueOf(reqParams.getFirst(SEARCH_TYPE_PARAM)) : null;
Long startTimeValue = reqParams.containsKey(START_TIME_PARAM) ?
Long.valueOf(reqParams.getFirst(START_TIME_PARAM)) : null;
Long endTimeValue = reqParams.containsKey(END_TIME_PARAM) ?
Long.valueOf(reqParams.getFirst(END_TIME_PARAM)) : null;
if (searchType == null) {
if ((startTimeValue != null) && (endTimeValue != null)) {
searchType = EventSearchType.INTERVAL;
} else {
searchType = EventSearchType.UPCOMING;
}
}
return searchType;
}
public static<T extends BaseEvent<T>> PaginatedQuery.Result<T> eventBaseQuery(
Class<T> eventBaseClass,
@Nullable UriInfo uriInfo,
@Nullable MultivaluedMap<String, String> reqParams,
Collection<? extends FilterQueryClause> filters,
Key<?> ancestorKey) {
if (uriInfo != null) {
reqParams = uriInfo.getQueryParameters();
}
EventSearchType searchType = getEventSearchType(reqParams);
Long startTimeValue = reqParams.containsKey(START_TIME_PARAM) ?
Long.valueOf(reqParams.getFirst(START_TIME_PARAM)) : null;
Long endTimeValue = reqParams.containsKey(END_TIME_PARAM) ?
Long.valueOf(reqParams.getFirst(END_TIME_PARAM)) : null;
if ((endTimeValue != null) && (searchType != EventSearchType.INTERVAL)) {
throw ErrorResponseMsg.createException(
"parameter '" + END_TIME_PARAM + "' can only be specified " +
"with a query '" + SEARCH_TYPE_PARAM + "' of '" + EventSearchType.INTERVAL + "'",
ErrorInfo.Type.BAD_REQUEST);
}
if ((searchType == EventSearchType.INTERVAL) &&
( (startTimeValue == null) || (endTimeValue == null)) ) {
throw ErrorResponseMsg.createException(
"parameters '" + START_TIME_PARAM + "' and '" + END_TIME_PARAM + "' must be " +
"specified for a query '" + SEARCH_TYPE_PARAM +
"' of '" + EventSearchType.INTERVAL + "'",
ErrorInfo.Type.BAD_REQUEST);
}
if ((searchType == EventSearchType.DESCENDING) &&
( (startTimeValue != null) || (endTimeValue != null)) ) {
throw ErrorResponseMsg.createException(
"parameters '" + START_TIME_PARAM + "' and '" + END_TIME_PARAM +
"' can not be specified with a query '" + SEARCH_TYPE_PARAM + "' of '" +
EventSearchType.DESCENDING + "'",
ErrorInfo.Type.BAD_REQUEST);
}
Date startTime = (startTimeValue == null) ? new Date() : new Date(startTimeValue);
Date endTime = (endTimeValue == null) ? new Date() : new Date(endTimeValue);
PaginatedQuery.Builder<T> queryBuilder =
PaginatedQuery.Builder.create(
eventBaseClass, uriInfo, reqParams, DEFAULT_NUM_SEARCH_RESULTS)
.addFilters(filters);
queryBuilder.setOrder(
((searchType == EventSearchType.UPCOMING) || (searchType == EventSearchType.INTERVAL)) ?
"startTime" : "-startTime");
if (searchType == EventSearchType.INTERVAL) {
queryBuilder.addFilter(new ConditionFilter("startTime >=", startTime));
queryBuilder.addFilter(new ConditionFilter("startTime <", endTime));
} else if (searchType != EventSearchType.DESCENDING) {
queryBuilder.addFilter(new ConditionFilter(
(searchType == EventSearchType.UPCOMING) ? "startTime >=" : "startTime <", startTime));
}
if (ancestorKey != null) {
queryBuilder.setAncestor(ancestorKey);
}
return queryBuilder.build().execute();
}
private static EventSearchResults eventSearch(@Nullable UriInfo uriInfo,
@Nullable MultivaluedMap<String, String> reqParams,
Collection<? extends FilterQueryClause> filters,
@Nullable Key<User> eventSearchUserKey) {
if (uriInfo != null) {
reqParams = uriInfo.getQueryParameters();
}
List<FilterQueryClause> combinedFilters = Lists.newArrayList(filters);
String keywords = reqParams.getFirst(KEYWORDS_PARAM);
if (keywords != null) {
combinedFilters.add(new ConditionFilter("searchableTokens",
SearchUtil.getSearchableTokens(keywords, MAX_SEARCH_KEYWORDS).toArray()));
}
PaginatedQuery.Result<Event> queryResult = eventBaseQuery(
Event.class, uriInfo, reqParams, combinedFilters, null);
PostFilteredEventSearchResults postFilteredResults =
postFilterByDistance(reqParams, queryResult);
boolean loadReviews = (eventSearchUserKey != null) &&
eventSearchUserKey.equals(getCurrentUserKey());
EventSearchType searchType = getEventSearchType(reqParams);
return new EventSearchResults(
EventSearchView.create(postFilteredResults.results, searchType, eventSearchUserKey,
loadReviews),
postFilteredResults.pagingInfo);
}
@Data
private static class EventSearchResults {
private final List<EventSearchView> results;
@Nullable
private final PagingInfo pagingInfo;
}
@Data
private static class PostFilteredEventSearchResults {
private final List<Event> results;
@Nullable
private final PagingInfo pagingInfo;
}
/*
* This is not scalable at all. But, it works for demo purposes.
* TODO(avaliani): make spatial search scalable.
*/
private static PostFilteredEventSearchResults postFilterByDistance(
MultivaluedMap<String, String> reqParams, PaginatedQuery.Result<Event> queryResult) {
Integer maxDistInMiles = reqParams.containsKey(SEARCH_DIST_PARAM) ?
Integer.valueOf(reqParams.getFirst(SEARCH_DIST_PARAM)) : null;
Double lattitude = reqParams.containsKey(SEARCH_LATITUDE_PARAM) ?
Double.valueOf(reqParams.getFirst(SEARCH_LATITUDE_PARAM)) : null;
Double longitude = reqParams.containsKey(SEARCH_LONGITUDE_PARAM) ?
Double.valueOf(reqParams.getFirst(SEARCH_LONGITUDE_PARAM)) : null;
if (maxDistInMiles == null) {
return new PostFilteredEventSearchResults(queryResult.getSearchResults(),
queryResult.getPagingInfo());
}
if (!PERMITTED_DIST_VALUES.contains(maxDistInMiles)) {
throw ErrorResponseMsg.createException(
"invalid value for parameter '" + SEARCH_DIST_PARAM + "', permitted values: " +
PERMITTED_DIST_VALUES,
ErrorInfo.Type.BAD_REQUEST);
}
if (lattitude == null) {
throw ErrorResponseMsg.createException("parameter not specified: " + SEARCH_LATITUDE_PARAM,
ErrorInfo.Type.BAD_REQUEST);
}
if (longitude == null) {
throw ErrorResponseMsg.createException("parameter not specified: " + SEARCH_LONGITUDE_PARAM,
ErrorInfo.Type.BAD_REQUEST);
}
LatLng userLoc = new LatLng(lattitude, longitude);
List<Event> filteredResults = Lists.newArrayList();
do {
for (Event event : queryResult.getSearchResults()) {
if ( (event.getLocation() != null) &&
(event.getLocation().getAddress() != null) &&
(event.getLocation().getAddress().getGeoPt() != null) ) {
GeoPtWrapper eventGeoPt = event.getLocation().getAddress().getGeoPt();
LatLng eventLoc = new LatLng(eventGeoPt.getLatitude(), eventGeoPt.getLongitude());
double distanceInMiles = LatLngTool.distance(userLoc, eventLoc, LengthUnit.MILE);
if (distanceInMiles <= maxDistInMiles) {
filteredResults.add(event);
}
}
}
if ((filteredResults.size() < queryResult.getQuery().getLimit()) &&
queryResult.hasMoreResults()) {
queryResult = queryResult.fetchNextBatch();
} else {
break;
}
} while (true);
return new PostFilteredEventSearchResults(filteredResults, queryResult.getPagingInfo());
}
@Path("{event_key}/expanded_search_view")
@GET
@Produces({MediaType.APPLICATION_JSON})
public Response getExpandedEventSearchView(
@PathParam("event_key") String eventKeyStr) {
Event event = getResourceObj(eventKeyStr);
return Response.ok(ExpandedEventSearchView.create(event)).build();
}
@Path("{event_key}/participants/{participant_type}")
@GET
@Produces({MediaType.APPLICATION_JSON})
public ListResponseMsg<EventParticipantView> getParticipants(
@PathParam("event_key") String eventKeyStr,
@PathParam("participant_type") ParticipantType participantType) {
Event event = getResourceObj(eventKeyStr);
List<KeyWrapper<User>> participants = event.getParticipants(participantType);
List<KeyWrapper<User>> offsettedResult =
PagingInfo.offsetResult(participants, uriInfo, DEFAULT_NUM_PARTICIPANT_VIEW_RESULTS);
return ListResponseMsg.create(
EventParticipantView.get(offsettedResult),
PagingInfo.create(participants.size(), uriInfo, DEFAULT_NUM_PARTICIPANT_VIEW_RESULTS));
}
@Path("{event_key}/participants/{participant_type}")
@POST
@Consumes({MediaType.APPLICATION_JSON})
public Response upsertParticipants(
@PathParam("event_key") String eventKeyStr,
@PathParam("participant_type") ParticipantType participantType,
@QueryParam("user") String userKeyStr) {
Key<User> userKey =
(userKeyStr == null) ? getCurrentUserKey() : OfyUtil.<User>createKey(userKeyStr);
Key<Event> eventKey = OfyUtil.createKey(eventKeyStr);
Event.upsertParticipant(eventKey, userKey, participantType);
return Response.ok().build();
}
@Path("{event_key}/participants")
@DELETE
public void deleteParticipants(
@PathParam("event_key") String eventKeyStr,
@QueryParam("user") String userKeyStr) {
Key<User> userKey = (userKeyStr == null) ? getCurrentUserKey() : Key.<User>create(userKeyStr);
Key<Event> eventKey = OfyUtil.createKey(eventKeyStr);
Event.deleteParticipant(eventKey, userKey);
}
@Path("{event_key}/review")
@GET
@Produces({MediaType.APPLICATION_JSON})
public Review getReview(
@PathParam("event_key") String eventKeyStr) {
return ofy().load().key(Review.getKeyForCurrentUser(Key.<Event>create(eventKeyStr))).now();
}
@Path("{event_key}/review")
@POST
@Consumes({MediaType.APPLICATION_JSON})
public void upsertReview(
@PathParam("event_key") String eventKeyStr,
Review review) {
Event.mutateEventReviewForCurrentUser(Key.<Event>create(eventKeyStr), review);
}
@Path("{event_key}/review")
@DELETE
public void deleteReview(
@PathParam("event_key") String eventKeyStr) {
Event.mutateEventReviewForCurrentUser(Key.<Event>create(eventKeyStr), null);
}
@Path("{event_key}/review_comment_view")
@GET
@Produces({MediaType.APPLICATION_JSON})
public ListResponseMsg<ReviewCommentView> getReviewComments(
@PathParam("event_key") String eventKeyStr) {
Key<Event> eventKey = OfyUtil.createKey(eventKeyStr);
PaginatedQuery.Builder<Review> queryBuilder =
PaginatedQuery.Builder.create(Review.class, uriInfo, DEFAULT_NUM_REVIEWS)
.setAncestor(eventKey)
.setOrder("-commentCreationDate");
PaginatedQuery.Result<Review> queryResult = queryBuilder.build().execute();
return ListResponseMsg.create(
ReviewCommentView.create(queryResult.getSearchResults()),
queryResult.getPagingInfo());
}
}