diff --git a/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitEventsApiController.php b/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitEventsApiController.php index 8e34843e..b01a3018 100644 --- a/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitEventsApiController.php +++ b/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitEventsApiController.php @@ -32,6 +32,7 @@ use utils\FilterParser; use utils\FilterParserException; use utils\OrderParser; use utils\PagingInfo; +use utils\PagingResponse; /** * Class OAuth2SummitEventsApiController @@ -851,7 +852,6 @@ final class OAuth2SummitEventsApiController extends OAuth2ProtectedController } catch (Exception $ex) { Log::error($ex); - return $this->error500($ex); } } @@ -881,4 +881,60 @@ final class OAuth2SummitEventsApiController extends OAuth2ProtectedController } } + public function getScheduleEmptySpots($summit_id){ + try + { + $summit = SummitFinderStrategyFactory::build($this->repository, $this->resource_server_context)->find($summit_id); + if (is_null($summit)) return $this->error404(); + $filter = null; + if (Input::has('filter')) { + $filter = FilterParser::parse(Input::get('filter'), [ + 'location_id' => ['=='], + 'start_date' => ['>='], + 'end_date' => ['<='], + 'gap' => ['>', '<', '<=', '>=', '=='], + ]); + } + + if(empty($filter)) + throw new ValidationException("filter param is mandatory!"); + + $gaps = []; + foreach ($this->service->getSummitScheduleEmptySpots($summit, $filter) as $gap) + { + $gaps[] = SerializerRegistry::getInstance()->getSerializer($gap)->serialize(); + } + + $response = new PagingResponse + ( + count($gaps), + count($gaps), + 1, + 1, + $gaps + ); + + return $this->ok($response->toArray()); + } + catch (EntityNotFoundException $ex1) + { + Log::warning($ex1); + return $this->error404(); + } + catch (ValidationException $ex2) + { + Log::warning($ex2); + return $this->error412($ex2->getMessages()); + } + catch(FilterParserException $ex3){ + Log::warning($ex3); + return $this->error412($ex3->getMessages()); + } + catch (Exception $ex) + { + Log::error($ex); + return $this->error500($ex); + } + } + } \ No newline at end of file diff --git a/app/Http/Utils/Filters/Filter.php b/app/Http/Utils/Filters/Filter.php index d9190dde..64b7cfd5 100644 --- a/app/Http/Utils/Filters/Filter.php +++ b/app/Http/Utils/Filters/Filter.php @@ -356,4 +356,24 @@ final class Filter } return $sql; } + + /** + * @param string $field + * @return array + */ + public function getFilterCollectionByField($field){ + $list = []; + $filter = $this->getFilter($field); + + if(is_array($filter)){ + if(is_array($filter[0])){ + foreach ($filter[0] as $filter_element) + $list[] = intval($filter_element->getValue()); + } + else{ + $list[] = intval($filter[0]->getValue()); + } + } + return $list; + } } \ No newline at end of file diff --git a/app/Http/Utils/Filters/FilterParser.php b/app/Http/Utils/Filters/FilterParser.php index 93e1b565..bf8a3d88 100644 --- a/app/Http/Utils/Filters/FilterParser.php +++ b/app/Http/Utils/Filters/FilterParser.php @@ -23,8 +23,9 @@ final class FilterParser */ public static function parse($filters, $allowed_fields = array()) { - $res = []; - $matches = []; + $res = []; + $matches = []; + $and_fields = []; if (!is_array($filters)) $filters = array($filters); @@ -81,6 +82,10 @@ final class FilterParser throw new FilterParserException(sprintf("%s op is not allowed for filter by field %s",$op, $field)); } + if(in_array($field, $and_fields)) + throw new FilterParserException(sprintf("filter by field %s is already on an and expression", $field)); + + $and_fields[] = $field; $f = self::buildFilter($field, $op, $value); } diff --git a/app/Http/Utils/Order.php b/app/Http/Utils/Order.php index 4e148d6d..bdf57615 100644 --- a/app/Http/Utils/Order.php +++ b/app/Http/Utils/Order.php @@ -26,7 +26,7 @@ final class Order */ private $ordering; - public function __construct($ordering = array()) + public function __construct($ordering = []) { $this->ordering = $ordering; } diff --git a/app/Http/routes.php b/app/Http/routes.php index 165097fb..e50a949b 100644 --- a/app/Http/routes.php +++ b/app/Http/routes.php @@ -198,7 +198,11 @@ Route::group([ Route::get('', 'OAuth2SummitEventsApiController@getUnpublishedEvents'); //Route::get('{event_id}', 'OAuth2SummitEventsApiController@getUnpublisedEvent'); }); - Route::get('/published', 'OAuth2SummitEventsApiController@getScheduledEvents'); + Route::group(array('prefix' => 'published'), function () { + Route::get('', 'OAuth2SummitEventsApiController@getScheduledEvents'); + Route::get('/empty-spots', 'OAuth2SummitEventsApiController@getScheduleEmptySpots'); + }); + Route::post('', [ 'middleware' => 'auth.user:administrators', 'uses' => 'OAuth2SummitEventsApiController@addEvent']); Route::group(array('prefix' => '{event_id}'), function () { diff --git a/app/ModelSerializers/SerializerRegistry.php b/app/ModelSerializers/SerializerRegistry.php index c1946add..ab47f75d 100644 --- a/app/ModelSerializers/SerializerRegistry.php +++ b/app/ModelSerializers/SerializerRegistry.php @@ -1,4 +1,16 @@ registry['SummitMemberFavorite'] = SummitMemberFavoriteSerializer::class; $this->registry['SummitEntityEvent'] = SummitEntityEventSerializer::class; $this->registry['SummitEventWithFile'] = SummitEventWithFileSerializer::class; - + $this->registry['SummitScheduleEmptySpot'] = SummitScheduleEmptySpotSerializer::class; // locations $this->registry['SummitVenue'] = SummitVenueSerializer::class; $this->registry['SummitVenueRoom'] = SummitVenueRoomSerializer::class; diff --git a/app/ModelSerializers/SummitScheduleEmptySpotSerializer.php b/app/ModelSerializers/SummitScheduleEmptySpotSerializer.php new file mode 100644 index 00000000..5788ff6f --- /dev/null +++ b/app/ModelSerializers/SummitScheduleEmptySpotSerializer.php @@ -0,0 +1,29 @@ + 'location_id:json_int', + 'StartDateTime' => 'start_date:datetime_epoch', + 'EndDateTime' => 'end_date:datetime_epoch', + 'TotalMinutes' => 'total_minutes:json_int', + ]; +} \ No newline at end of file diff --git a/app/Models/Foundation/Summit/SummitScheduleEmptySpot.php b/app/Models/Foundation/Summit/SummitScheduleEmptySpot.php new file mode 100644 index 00000000..3652314d --- /dev/null +++ b/app/Models/Foundation/Summit/SummitScheduleEmptySpot.php @@ -0,0 +1,83 @@ +location_id = $location_id; + $this->start_date_time = $start_date_time; + $this->end_date_time = $end_date_time; + } + + /** + * @return int + */ + public function getLocationId() + { + return $this->location_id; + } + + /** + * @return DateTime + */ + public function getStartDateTime() + { + return $this->start_date_time; + } + + /** + * @return DateTime + */ + public function getEndDateTime() + { + return $this->end_date_time; + } + + /** + * @return int + */ + public function getTotalMinutes(){ + $interval = $this->end_date_time->diff($this->start_date_time); + $total_minutes = $interval->days * 24 * 60; + $total_minutes += $interval->h * 60; + $total_minutes += $interval->i; + return intval($total_minutes); + } +} \ No newline at end of file diff --git a/app/Models/Utils/IntervalParser.php b/app/Models/Utils/IntervalParser.php new file mode 100644 index 00000000..5f54bce2 --- /dev/null +++ b/app/Models/Utils/IntervalParser.php @@ -0,0 +1,51 @@ +format('h')); + $start_min = intval($from->format('i')); + + do{ + $aux_to = clone $aux_from; + $aux_to->setTime(23, 59, 59); + + if($aux_to > $to){ + $aux_to = clone $to; + } + $intervals[] = [ + $aux_from, + $aux_to + ]; + $aux_from = clone $aux_from; + $aux_from->add(new \DateInterval('P1D')); + + } while($aux_to < $to); + + return $intervals; + } +} \ No newline at end of file diff --git a/app/Repositories/Summit/DoctrineSummitEventRepository.php b/app/Repositories/Summit/DoctrineSummitEventRepository.php index 84ff4a8f..e5cc3560 100644 --- a/app/Repositories/Summit/DoctrineSummitEventRepository.php +++ b/app/Repositories/Summit/DoctrineSummitEventRepository.php @@ -1,5 +1,4 @@ tx_service->transaction(function () use + ( + $summit, + $filter + ){ + $gaps = []; + $order = new Order([ + OrderElement::buildAscFor("location_id"), + OrderElement::buildAscFor("start_date"), + ]); + + // parse locations ids + + if(!$filter->hasFilter('location_id')) + throw new ValidationException("missing required filter location_id"); + + $location_ids = $filter->getFilterCollectionByField('location_id'); + + // parse start_date filter + $start_datetime_filter = $filter->getFilter('start_date'); + if(is_null($start_datetime_filter)) + throw new ValidationException("missing required filter start_date"); + $start_datetime_unix = intval($start_datetime_filter[0]->getValue()); + $start_datetime = new \DateTime("@$start_datetime_unix"); + // parse end_date filter + $end_datetime_filter = $filter->getFilter('end_date'); + if(is_null($end_datetime_filter)) + throw new ValidationException("missing required filter end_date"); + $end_datetime_unix = intval($end_datetime_filter[0]->getValue()); + $end_datetime = new \DateTime("@$end_datetime_unix"); + // gap size filter + + $gap_size_filter = $filter->getFilter('gap'); + if(is_null($end_datetime_filter)) + throw new ValidationException("missing required filter gap"); + + $gap_size = $gap_size_filter[0]; + + $summit_time_zone = $summit->getTimeZone(); + $start_datetime->setTimezone($summit_time_zone); + $end_datetime->setTimezone($summit_time_zone); + + $intervals = IntervalParser::getInterval($start_datetime, $end_datetime); + + foreach($location_ids as $location_id) { + + foreach($intervals as $interval) { + + $events_filter = new Filter(); + $events_filter->addFilterCondition(FilterParser::buildFilter('published', '==', '1')); + $events_filter->addFilterCondition(FilterParser::buildFilter('summit_id', '==', $summit->getId())); + $events_filter->addFilterCondition(FilterParser::buildFilter('location_id', '==', intval($location_id))); + $events_filter->addFilterCondition(FilterParser::buildFilter('start_date', '>=', $interval[0]->getTimestamp())); + $events_filter->addFilterCondition(FilterParser::buildFilter('end_date', '<=', $interval[1]->getTimestamp())); + + $paging_response = $this->event_repository->getAllByPage + ( + new PagingInfo(1, PHP_INT_MAX), + $events_filter, + $order + ); + + $gap_start_date = $interval[0]; + $gap_end_date = clone $gap_start_date; + // check published items + foreach ($paging_response->getItems() as $event) { + + while + ( + ( + $gap_end_date->getTimestamp() + (self::MIN_EVENT_MINUTES * 60) + ) + <= $event->getLocalStartDate()->getTimestamp() + ) { + $max_gap_end_date = clone $gap_end_date; + $max_gap_end_date->setTime(23, 59, 59); + if ($gap_end_date->getTimestamp() + (self::MIN_EVENT_MINUTES * 60) > $max_gap_end_date->getTimestamp()) break; + $gap_end_date->add(new DateInterval('PT' . self::MIN_EVENT_MINUTES . 'M')); + } + + if ($gap_start_date->getTimestamp() == $gap_end_date->getTimestamp()) { + // no gap! + $gap_start_date = $event->getLocalEndDate(); + $gap_end_date = clone $gap_start_date; + continue; + } + + // check min gap ... + if(self::checkGapCriteria($gap_size, $gap_end_date->diff($gap_start_date))) + $gaps[] = new SummitScheduleEmptySpot($location_id, $gap_start_date, $gap_end_date); + $gap_start_date = $event->getLocalEndDate(); + $gap_end_date = clone $gap_start_date; + } + + // check last possible gap ( from last $gap_start_date till $interval[1] + + if($gap_start_date < $interval[1]){ + // last possible gap + if(self::checkGapCriteria($gap_size, $interval[1]->diff($gap_start_date))) + $gaps[] = new SummitScheduleEmptySpot($location_id, $gap_start_date, $interval[1]); + } + } + } + + return $gaps; + + }); + } + + + /** + * @param FilterElement $gap_size_criteria + * @param DateInterval $interval + * @return bool + */ + private static function checkGapCriteria + ( + FilterElement $gap_size_criteria, + DateInterval $interval + ) + { + $total_minutes = $interval->days * 24 * 60; + $total_minutes += $interval->h * 60; + $total_minutes += $interval->i; + + switch($gap_size_criteria->getOperator()){ + case '=': + { + return intval($gap_size_criteria->getValue()) == $total_minutes; + } + break; + case '<': + { + return $total_minutes < intval($gap_size_criteria->getValue()); + } + break; + case '>': + { + return $total_minutes > intval($gap_size_criteria->getValue()); + } + break; + case '<=': + { + return $total_minutes <= intval($gap_size_criteria->getValue()); + } + break; + case '>=': + { + return $total_minutes >= intval($gap_size_criteria->getValue()); + } + break; + } + return false; + } } \ No newline at end of file diff --git a/database/seeds/ApiEndpointsSeeder.php b/database/seeds/ApiEndpointsSeeder.php index 95d46137..c43678c3 100644 --- a/database/seeds/ApiEndpointsSeeder.php +++ b/database/seeds/ApiEndpointsSeeder.php @@ -200,6 +200,15 @@ class ApiEndpointsSeeder extends Seeder sprintf(SummitScopes::ReadAllSummitData, $current_realm) ], ), + array( + 'name' => 'get-schedule-empty-spots', + 'route' => '/api/v1/summits/{id}/events/published/empty-spots', + 'http_method' => 'GET', + 'scopes' => [ + sprintf(SummitScopes::ReadSummitData, $current_realm), + sprintf(SummitScopes::ReadAllSummitData, $current_realm) + ], + ), array( 'name' => 'get-unpublished-events', 'route' => '/api/v1/summits/{id}/events/unpublished', diff --git a/tests/AdminActionsCalendarSyncPreProcessorTest.php b/tests/AdminActionsCalendarSyncPreProcessorTest.php index 97ff0c8e..f8901773 100644 --- a/tests/AdminActionsCalendarSyncPreProcessorTest.php +++ b/tests/AdminActionsCalendarSyncPreProcessorTest.php @@ -15,7 +15,6 @@ use models\summit\IAbstractCalendarSyncWorkRequestRepository; use models\summit\ICalendarSyncInfoRepository; use models\summit\SummitEvent; use models\summit\CalendarSync\WorkQueue\AdminSummitEventActionSyncWorkRequest; -use models\summit\CalendarSync\WorkQueue\AdminSummitLocationActionSyncWorkRequest; use models\summit\CalendarSync\WorkQueue\AbstractCalendarSyncWorkRequest; /** * Class AdminActionsCalendarSyncPreProcessorTest diff --git a/tests/OAuth2SummitApiTest.php b/tests/OAuth2SummitApiTest.php index 1fb6bfab..cb42b2e6 100644 --- a/tests/OAuth2SummitApiTest.php +++ b/tests/OAuth2SummitApiTest.php @@ -1,5 +1,4 @@ $summit_id, - 'page' => 1, - 'per_page' => 50, + 'id' => $summit_id, + 'page' => 1, + 'per_page' => 50, 'location_id' => 52, - 'filter' => array + 'filter' => array ( 'tags=@Nova', 'speaker=@Todd' @@ -2460,4 +2464,50 @@ final class OAuth2SummitApiTest extends ProtectedApiTest } + public function testGetScheduleEmptySpotsBySummit() + { + $summit_repository = EntityManager::getRepository(\models\summit\Summit::class); + $summit = $summit_repository->getById(23); + $summit_time_zone = $summit->getTimeZone(); + $start_datetime = new DateTime( "2017-11-04 07:00:00", $summit_time_zone); + $end_datetime = new DateTime("2017-11-05 18:00:00", $summit_time_zone); + $start_datetime_unix = $start_datetime->getTimestamp(); + $end_datetime_unix = $end_datetime->getTimestamp(); + + $params = [ + + 'id' => 23, + 'filter' => + [ + 'location_id==318,location_id==320', + 'start_date>='.$start_datetime_unix, + 'end_date<='.$end_datetime_unix, + 'gap==10', + ], + ]; + + $headers = [ + + "HTTP_Authorization" => " Bearer " . $this->access_token, + "CONTENT_TYPE" => "application/json" + ]; + + $response = $this->action + ( + "GET", + "OAuth2SummitEventsApiController@getScheduleEmptySpots", + $params, + [], + [], + [], + $headers + ); + + $content = $response->getContent(); + $this->assertResponseStatus(200); + + $gaps = json_decode($content); + $this->assertTrue(!is_null($gaps)); + } + } \ No newline at end of file diff --git a/tests/SearchEmptySpotsTest.php b/tests/SearchEmptySpotsTest.php new file mode 100644 index 00000000..a812636e --- /dev/null +++ b/tests/SearchEmptySpotsTest.php @@ -0,0 +1,97 @@ +getById(23); + $summit_time_zone = $summit->getTimeZone(); + $start_datetime = new DateTime( "2017-11-04 07:00:00", $summit_time_zone); + $end_datetime = new DateTime("2017-11-05 18:00:00", $summit_time_zone); + + $intervals = IntervalParser::getInterval($start_datetime, $end_datetime); + + $this->assertTrue(count($intervals) == 2); + } + + public function testIntervalParse1(){ + $summit_repository = EntityManager::getRepository(\models\summit\Summit::class); + $summit = $summit_repository->getById(23); + $summit_time_zone = $summit->getTimeZone(); + $start_datetime = new DateTime( "2017-11-04 07:00:00", $summit_time_zone); + $end_datetime = new DateTime("2017-11-04 18:00:00", $summit_time_zone); + + $intervals = IntervalParser::getInterval($start_datetime, $end_datetime); + + $this->assertTrue(count($intervals) == 1); + } + + public function testIntervalParser3(){ + $summit_repository = EntityManager::getRepository(\models\summit\Summit::class); + $summit = $summit_repository->getById(23); + $summit_time_zone = $summit->getTimeZone(); + $start_datetime = new DateTime( "2017-11-04 07:00:00", $summit_time_zone); + $end_datetime = new DateTime("2017-11-06 18:00:00", $summit_time_zone); + + $intervals = IntervalParser::getInterval($start_datetime, $end_datetime); + + $this->assertTrue(count($intervals) == 4); + } + + public function testFindSpots(){ + + $summit_repository = EntityManager::getRepository(\models\summit\Summit::class); + $summit = $summit_repository->getById(23); + $summit_time_zone = $summit->getTimeZone(); + $start_datetime = new DateTime( "2017-11-04 07:00:00", $summit_time_zone); + $end_datetime = new DateTime("2017-11-05 18:00:00", $summit_time_zone); + $start_datetime_unix = $start_datetime->getTimestamp(); + $end_datetime_unix = $end_datetime->getTimestamp(); + + $service = App::make(\services\model\ISummitService::class); + if(!$service instanceof ISummitService ) + return ; + + $filter = FilterParser::parse + ( + [ + 'location_id==318,location_id==320', + 'start_date>='.$start_datetime_unix, + 'end_date<='.$end_datetime_unix, + 'gap==10', + ], + [ + 'location_id' => ['=='], + 'start_date' => ['>='], + 'end_date' => ['<='], + 'gap' => ['>', '<', '<=', '>=', '=='], + ] + ); + + $gaps = $service->getSummitScheduleEmptySpots($summit, $filter); + + $this->assertTrue(count($gaps) > 0); + } +} \ No newline at end of file