diff --git a/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitEventsApiController.php b/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitEventsApiController.php index b386bde85..594ce424a 100644 --- a/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitEventsApiController.php +++ b/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitEventsApiController.php @@ -1085,9 +1085,48 @@ public function addEvent($summit_id) */ public function updateEvent($summit_id, $event_id) { - return $this->processRequest(function() use($summit_id, $event_id){ + return $this->_updateEvent($summit_id, $event_id); + } + + #[OA\Put( + path: '/api/v1/summits/{id}/events/{event_id}/draft', + operationId: 'updateDraftEvent', + summary: 'Update an existing draft event', + description: 'Updates an existing draft event for a specific summit.', + security: [['summit_events_api_oauth2' => [SummitScopes::WriteSummitData, SummitScopes::WriteEventData]]], + x: ['required-groups' => [IGroup::SuperAdmins, IGroup::Administrators, IGroup::SummitAdministrators, IGroup::TrackChairsAdmins]], + tags: ['Summit Events'], + parameters: [ + new OA\Parameter(name: 'id', in: 'path', required: true, description: 'Summit ID or slug', schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'event_id', in: 'path', required: true, description: 'Event ID', schema: new OA\Schema(type: 'integer')), + ], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent(ref: '#/components/schemas/UpdateSummitEventRequest') + ), + responses: [ + new OA\Response(response: Response::HTTP_OK, description: 'Draft event updated successfully', content: new OA\JsonContent(ref: '#/components/schemas/SummitEvent')), + new OA\Response(response: Response::HTTP_BAD_REQUEST, description: 'Bad Request'), + new OA\Response(response: Response::HTTP_UNAUTHORIZED, description: 'Unauthorized'), + new OA\Response(response: Response::HTTP_FORBIDDEN, description: 'Forbidden'), + new OA\Response(response: Response::HTTP_NOT_FOUND, description: 'Not Found'), + new OA\Response(response: Response::HTTP_PRECONDITION_FAILED, description: 'Validation error'), + new OA\Response(response: Response::HTTP_INTERNAL_SERVER_ERROR, description: 'Server Error'), + ] + )] + /** + * @param $summit_id + * @param $event_id + * @return mixed + */ + public function updateDraftEvent($summit_id, $event_id) + { + return $this->_updateEvent($summit_id, $event_id, true); + } - Log::debug(sprintf("OAuth2SummitEventsApiController::updateEvent summit id %s event id %s", $summit_id, $event_id)); + private function _updateEvent($summit_id, $event_id, bool $saveAsIncomplete = false) + { + return $this->processRequest(function() use($summit_id, $event_id, $saveAsIncomplete) { $summit = SummitFinderStrategyFactory::build($this->repository, $this->resource_server_context)->find($summit_id); if (is_null($summit)) @@ -1106,42 +1145,30 @@ public function updateEvent($summit_id, $event_id) $payload = $this->getJsonData(); - // Creates a Validator instance and validates the data. $rules = $isAdmin ? SummitEventValidationRulesFactory::build($payload, true) : null; - if(is_null($rules)){ + if (is_null($rules)) { $rules = $isTrackChair ? SummitEventValidationRulesFactory::buildForTrackChair($payload, true) : null; } - if(is_null($rules)) + if (is_null($rules)) return $this->error403(); - $payload = $this->getJsonPayload($rules, true); - $fields = [ - 'title', - 'description', - 'social_summary', - ]; + $fields = ['title', 'description', 'social_summary']; - if($isAdmin) { - Log::debug(sprintf("OAuth2SummitEventsApiController::updateEvent summit id %s event id %s updating event", $summit_id, $event_id)); - $event = $this->service->updateEvent($summit, $event_id, HTMLCleaner::cleanData($payload, $fields)); - } - else{ - Log::debug(sprintf("OAuth2SummitEventsApiController::updateEvent summit id %s event id %s updating duration", $summit_id, $event_id)); + if ($isAdmin) { + $event = $this->service->updateEvent($summit, $event_id, HTMLCleaner::cleanData($payload, $fields), true, $saveAsIncomplete); + } else { $event = $this->service->updateDuration($payload, $summit, $event); } return $this->ok(SerializerRegistry::getInstance()->getSerializer($event, $this->getSerializerType()) - ->serialize - ( + ->serialize( SerializerUtils::getExpand(), SerializerUtils::getFields(), SerializerUtils::getRelations(), - [ - 'current_user' => $current_member - ] + ['current_user' => $current_member] ) ); }); diff --git a/app/Services/Model/ISummitService.php b/app/Services/Model/ISummitService.php index 4bc237b00..c7c9b1684 100644 --- a/app/Services/Model/ISummitService.php +++ b/app/Services/Model/ISummitService.php @@ -53,9 +53,10 @@ public function addEvent(Summit $summit, array $data); * @param int $event_id * @param array $data * @param bool $trigger_data_update + * @param bool $saveAsIncomplete * @return SummitEvent */ - public function updateEvent(Summit $summit, $event_id, array $data, bool $trigger_data_update = true); + public function updateEvent(Summit $summit, $event_id, array $data, bool $trigger_data_update = true, bool $saveAsIncomplete = false); /** * @param Summit $summit diff --git a/app/Services/Model/Imp/SummitService.php b/app/Services/Model/Imp/SummitService.php index 1982fc030..d3d723c90 100644 --- a/app/Services/Model/Imp/SummitService.php +++ b/app/Services/Model/Imp/SummitService.php @@ -617,9 +617,9 @@ public function addEvent(Summit $summit, array $data) * @param array $data * @return SummitEvent */ - public function updateEvent(Summit $summit, $event_id, array $data, bool $trigger_data_update = true) + public function updateEvent(Summit $summit, $event_id, array $data, bool $trigger_data_update = true, bool $saveAsIncomplete = false) { - return $this->saveOrUpdateEvent($summit, $data, $event_id, $trigger_data_update); + return $this->saveOrUpdateEvent($summit, $data, $event_id, $trigger_data_update, $saveAsIncomplete); } /** @@ -660,9 +660,9 @@ private function canPerformEventTypeTransition(SummitEventType $old_event_type, * @return SummitEvent * @throws Exception */ - private function saveOrUpdateEvent(Summit $summit, array $data, $event_id = null, bool $trigger_data_update = true) + private function saveOrUpdateEvent(Summit $summit, array $data, $event_id = null, bool $trigger_data_update = true, bool $saveAsIncomplete = false) { - return $this->tx_service->transaction(function () use ($summit, $data, $event_id, $trigger_data_update) { + return $this->tx_service->transaction(function () use ($summit, $data, $event_id, $trigger_data_update, $saveAsIncomplete) { Log::debug ( @@ -832,7 +832,7 @@ private function saveOrUpdateEvent(Summit $summit, array $data, $event_id = null } } - $this->saveOrUpdatePresentationData($event, $event_type, $data); + $this->saveOrUpdatePresentationData($event, $event_type, $data, $saveAsIncomplete); $this->saveOrUpdateSummitGroupEventData($event, $event_type, $data); if (!$event_type->isAllowsLocation()) @@ -881,17 +881,23 @@ private function saveOrUpdateSummitGroupEventData(SummitEvent $event, SummitEven * @param SummitEvent $event * @param SummitEventType $event_type * @param array $data + * @param bool $saveAsIncomplete * @throws EntityNotFoundException * @throws ValidationException */ - private function saveOrUpdatePresentationData(SummitEvent $event, SummitEventType $event_type, array $data) + private function saveOrUpdatePresentationData(SummitEvent $event, SummitEventType $event_type, array $data, bool $saveAsIncomplete = false) { if (!$event instanceof Presentation) return; - // if we are creating the presentation from admin, then - // we should mark it as received and complete - $event->setStatus(Presentation::STATUS_RECEIVED); - $event->setProgress(Presentation::PHASE_COMPLETE); + if ($saveAsIncomplete && $event->isPublished()) + throw new ValidationException('Cannot save a published event as incomplete.'); + + if (!$saveAsIncomplete || $event->isNew()) { + // if we are creating the presentation from admin, then + // we should mark it as received and complete + $event->setStatus(Presentation::STATUS_RECEIVED); + $event->setProgress(Presentation::PHASE_COMPLETE); + } // speakers @@ -900,13 +906,15 @@ private function saveOrUpdatePresentationData(SummitEvent $event, SummitEventTyp $shouldClearSpeakers = isset($data['speakers']) && count($data['speakers']) == 0; $speakers = $data['speakers'] ?? []; - if ($event_type->isAreSpeakersMandatory()) { - if ($shouldClearSpeakers || ($event->isNew() && count($speakers) == 0)) - throw new ValidationException('Speakers are mandatory.'); - } + if (!$saveAsIncomplete || $event->isNew()) { + if ($event_type->isAreSpeakersMandatory()) { + if ($shouldClearSpeakers || ($event->isNew() && count($speakers) == 0)) + throw new ValidationException('Speakers are mandatory.'); + } - if ($shouldClearSpeakers) { - $event->clearSpeakers(); + if ($shouldClearSpeakers) { + $event->clearSpeakers(); + } } if (count($speakers) > 0) { @@ -926,12 +934,14 @@ private function saveOrUpdatePresentationData(SummitEvent $event, SummitEventTyp $shouldClearModerator = isset($data['moderator_speaker_id']) && intval($data['moderator_speaker_id']) == 0; $moderator_id = isset($data['moderator_speaker_id']) ? intval($data['moderator_speaker_id']) : 0; - if ($event_type->isModeratorMandatory()) { - if ($shouldClearModerator || ($event->isNew() && $moderator_id == 0)) - throw new ValidationException('moderator_speaker_id is mandatory.'); - } + if (!$saveAsIncomplete || $event->isNew()) { + if ($event_type->isModeratorMandatory()) { + if ($shouldClearModerator || ($event->isNew() && $moderator_id == 0)) + throw new ValidationException('moderator_speaker_id is mandatory.'); + } - if ($shouldClearModerator) $event->unsetModerator(); + if ($shouldClearModerator) $event->unsetModerator(); + } if ($moderator_id > 0) { $moderator = $this->speaker_repository->getById($moderator_id); diff --git a/database/migrations/config/Version20260609175051.php b/database/migrations/config/Version20260609175051.php new file mode 100644 index 000000000..17b8ef6ee --- /dev/null +++ b/database/migrations/config/Version20260609175051.php @@ -0,0 +1,81 @@ +addSql($this->insertEndpoint( + self::API_NAME, + self::ENDPOINT_NAME, + self::ENDPOINT_ROUTE, + 'PUT' + )); + + foreach (self::SCOPES as $scope) { + $this->addSql($this->insertEndpointScope( + self::API_NAME, + self::ENDPOINT_NAME, + $scope + )); + } + + foreach (self::AUTH_GROUPS as $groupSlug) { + $this->addSql($this->insertEndpointAuthzGroup(self::API_NAME, self::ENDPOINT_NAME, $groupSlug)); + } + } + + public function down(Schema $schema): void + { + foreach (self::AUTH_GROUPS as $groupSlug) { + $this->addSql($this->deleteEndpointAuthzGroup(self::API_NAME, self::ENDPOINT_NAME, $groupSlug)); + } + + $this->addSql($this->deleteScopesEndpoints(self::API_NAME, self::SCOPES)); + $this->addSql($this->deleteEndpoint(self::API_NAME, self::ENDPOINT_NAME)); + } +} diff --git a/database/seeders/ApiEndpointsSeeder.php b/database/seeders/ApiEndpointsSeeder.php index fb94f015d..5972686c8 100644 --- a/database/seeders/ApiEndpointsSeeder.php +++ b/database/seeders/ApiEndpointsSeeder.php @@ -4267,6 +4267,21 @@ private function seedSummitEndpoints() IGroup::TrackChairsAdmins, ] ], + [ + 'name' => 'update-draft-event', + 'route' => '/api/v1/summits/{id}/events/{event_id}/draft', + 'http_method' => 'PUT', + 'scopes' => [ + SummitScopes::WriteSummitData, + SummitScopes::WriteEventData + ], + 'authz_groups' => [ + IGroup::SuperAdmins, + IGroup::Administrators, + IGroup::SummitAdministrators, + IGroup::TrackChairsAdmins, + ] + ], [ 'name' => 'update-event-live-info', 'route' => '/api/v1/summits/{id}/events/{event_id}/live-info', diff --git a/routes/api_v1.php b/routes/api_v1.php index fad69a60c..a04d118b7 100644 --- a/routes/api_v1.php +++ b/routes/api_v1.php @@ -692,6 +692,7 @@ }); Route::put('', ['middleware' => 'auth.user', 'uses' => 'OAuth2SummitEventsApiController@updateEvent']); + Route::put('/draft', ['middleware' => 'auth.user', 'uses' => 'OAuth2SummitEventsApiController@updateDraftEvent']); Route::put('live-info', ['uses' => 'OAuth2SummitEventsApiController@updateEventLiveInfo']); Route::delete('', ['middleware' => 'auth.user', 'uses' => 'OAuth2SummitEventsApiController@deleteEvent']); Route::put('/publish', ['middleware' => 'auth.user', 'uses' => 'OAuth2SummitEventsApiController@publishEvent']); diff --git a/tests/InsertOrdersTestData.php b/tests/InsertOrdersTestData.php index d99ea55c3..03f3ee3e1 100644 --- a/tests/InsertOrdersTestData.php +++ b/tests/InsertOrdersTestData.php @@ -139,4 +139,14 @@ protected static function InsertOrdersTestData() self::$em->persist(self::$summit); self::$em->flush(); } -} \ No newline at end of file + + protected static function clearOrdersTestData(): void + { + if (!is_null(self::$default_ticket_type) && !is_null(self::$default_ticket_type->getId())) { + DB::table('SummitTicketType') + ->where('ID', self::$default_ticket_type->getId()) + ->update(['QuantitySold' => 0]); + self::$em->clear(); + } + } +} diff --git a/tests/oauth2/OAuth2SummitEventsApiTest.php b/tests/oauth2/OAuth2SummitEventsApiTest.php index d8133eb62..870a63905 100644 --- a/tests/oauth2/OAuth2SummitEventsApiTest.php +++ b/tests/oauth2/OAuth2SummitEventsApiTest.php @@ -12,10 +12,12 @@ * limitations under the License. **/ use App\Models\Foundation\Main\IGroup; +use App\Services\Model\ISummitService; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\App; use models\utils\SilverstripeBaseModel; use services\model\IPresentationService; +use models\summit\Presentation; use models\summit\SummitEvent; final class OAuth2SummitEventsApiTest extends ProtectedApiTestCase @@ -37,6 +39,7 @@ protected function setUp():void public function tearDown():void { + self::clearOrdersTestData(); self::clearSummitTestData(); parent::tearDown(); } @@ -477,6 +480,67 @@ public function testUpdateEvent() } + public function testUpdateDraftEventDoesNotCompleteIncompletePresentation() + { + $presentation = new Presentation(); + self::$summit->addEvent($presentation); + $presentation->setTitle("Incomplete Draft Presentation"); + $presentation->setAbstract("Draft abstract"); + $presentation->setCategory(self::$defaultTrack); + $presentation->setType(self::$defaultPresentationType); + $presentation->setProgress(Presentation::PHASE_SUMMARY); + self::$em->persist($presentation); + self::$em->flush(); + + $params = [ + 'id' => self::$summit->getId(), + 'event_id' => $presentation->getId(), + ]; + + $data = [ + 'title' => 'Updated Draft Title', + ]; + + $response = $this->action( + "PUT", + "OAuth2SummitEventsApiController@updateDraftEvent", + $params, + [], + [], + [], + $this->getAuthHeaders(), + json_encode($data) + ); + + $this->assertResponseStatus(200); + $content = $response->getContent(); + $event = json_decode($content); + $this->assertEquals('Updated Draft Title', $event->title); + $this->assertNotEquals(Presentation::PHASE_COMPLETE, $event->progress); + $this->assertNotEquals(Presentation::STATUS_RECEIVED, $event->status); + } + + public function testUpdateDraftEventOnPublishedPresentationReturns412() + { + $presentation = self::$presentations[0]; + + $response = $this->action( + "PUT", + "OAuth2SummitEventsApiController@updateDraftEvent", + [ + 'id' => self::$summit->getId(), + 'event_id' => $presentation->getId(), + ], + [], + [], + [], + $this->getAuthHeaders(), + json_encode(['title' => 'Should Not Update']) + ); + + $this->assertResponseStatus(412); + } + public function testPublishEvent($start_date = 1509789600, $end_date = 1509791400) { $this->markTestSkipped('Skipped test: needs review');