From ccd2d9098461b35c5d43faef14636a0c51bfbf5f Mon Sep 17 00:00:00 2001 From: Marc Michalsky forumZFD Date: Fri, 29 Jan 2021 17:50:00 +0100 Subject: [PATCH] create TwingleEvent.sync api --- CRM/TwingleCampaign/BAO/TwingleEvent.php | 186 ++++---- api/v3/TwingleEvent/Get.php | 4 +- api/v3/TwingleEvent/Sync.php | 400 ++++++++++++++++++ .../phpunit/api/v3/TwingleEvent/SyncTest.php | 53 +++ 4 files changed, 535 insertions(+), 108 deletions(-) create mode 100644 api/v3/TwingleEvent/Sync.php create mode 100644 tests/phpunit/api/v3/TwingleEvent/SyncTest.php diff --git a/CRM/TwingleCampaign/BAO/TwingleEvent.php b/CRM/TwingleCampaign/BAO/TwingleEvent.php index d11b1af..af22ef3 100644 --- a/CRM/TwingleCampaign/BAO/TwingleEvent.php +++ b/CRM/TwingleCampaign/BAO/TwingleEvent.php @@ -3,8 +3,8 @@ use CRM_TwingleCampaign_Utils_ExtensionCache as Cache; use CRM_TwingleCampaign_Utils_StringOperations as StringOps; use CRM_TwingleCampaign_BAO_Campaign as Campaign; -use CRM_TwingleCampaign_BAO_TwingleApiCall as TwingleApiCall; use CRM_TwingleCampaign_BAO_Configuration as Configuration; +use CRM_TwingleCampaign_ExtensionUtil as E; class CRM_TwingleCampaign_BAO_TwingleEvent extends Campaign { @@ -14,27 +14,32 @@ class CRM_TwingleCampaign_BAO_TwingleEvent extends Campaign { * @param array $event * Result array of Twingle API call to * https://project.twingle.de/api/$project_id/event + * @param int|null $id * - * @throws Exception + * @throws \Exception */ - protected function __construct(array $event) { - parent::__construct($event); + public function __construct(array $event, int $id = NULL) { + parent::__construct($event, $id); $this->prefix = 'twingle_event_'; $this->values['campaign_type_id'] = 'twingle_event'; $this->id_custom_field = Cache::getInstance() ->getCustomFieldMapping()['twingle_event_id']; - $this->values['parent_id'] = $this->getParentCampaignId(); + try { + $this->values['parent_id'] = $this->getParentCampaignId(); + } catch (CiviCRM_API3_Exception $e) { + $errorMessage = $e->getMessage(); + throw new Exception("Could not identify parent Campaign: $errorMessage"); + } } + /** - * Synchronizes events between Twingle and CiviCRM (both directions) + * Synchronizes events between Twingle and CiviCRM (only Twingle -> CiviCRM) * based on the timestamp. * * @param array $values - * @param TwingleApiCall $twingleApi - * @param int $user * @param bool $is_test * If TRUE, don't do any changes * @@ -42,12 +47,10 @@ class CRM_TwingleCampaign_BAO_TwingleEvent extends Campaign { * Returns a response array that contains title, id, event_id, project_id * and status or NULL if $values is not an array * - * @throws \CiviCRM_API3_Exception + * @throws \Exception */ public static function sync( array $values, - TwingleApiCall &$twingleApi, - int $user, bool $is_test = FALSE ): ?array { @@ -59,67 +62,48 @@ class CRM_TwingleCampaign_BAO_TwingleEvent extends Campaign { $event = new self($values); } catch (Exception $e) { $errorMessage = $e->getMessage(); - - // Log Exception - Civi::log()->error( + Civi::log()->error(E::LONG_NAME . + " failed to instantiate TwingleEvent: $errorMessage" + ); + throw new Exception( "Failed to instantiate TwingleEvent: $errorMessage" ); - - // Return result array with error description - return [ - "title" => $values['description'], - "event_id" => (int) $values['id'], - "project_id" => (int) $values['project_id'], - "status" => - "Failed to instantiate TwingleEvent: $errorMessage", - ]; } // Check if the TwingleEvent campaign already exists if (!$event->exists()) { - // ... if not, get embed data and create event + // ... if not, create event try { $result = $event->create($is_test); } catch (Exception $e) { $errorMessage = $e->getMessage(); - - // Log Exception - Civi::log()->error( + Civi::log()->error(E::LONG_NAME . + " could not create campaign from TwingleEvent: $errorMessage" + ); + throw new Exception( "Could not create campaign from TwingleEvent: $errorMessage" ); - - // Return result array with error description - return [ - "title" => $values['description'], - "event_id" => (int) $values['id'], - "project_id" => (int) $values['project_id'], - "status" => - "Could not create campaign from TwingleEvent: $errorMessage", - ]; } } else { $result = $event->getResponse('TwingleEvent exists'); // If Twingle's version of the event is newer than the CiviCRM - // TwingleEvent campaign update the campaign + // TwingleEvent campaign, update the campaign if ($values['updated_at'] > $event->lastUpdate()) { try { $event->update($values); - $result = $event->create(); + $result = $event->create($is_test); $result['status'] = $result['status'] == 'TwingleEvent created' ? 'TwingleEvent updated' : 'TwingleEvent Update failed'; } catch (Exception $e) { $errorMessage = $e->getMessage(); - - // Log Exception - Civi::log()->error( - "Could not update TwingleEvent campaign: $errorMessage" + Civi::log()->error(E::LONG_NAME . + " could not update TwingleEvent campaign: $errorMessage" ); - // Return result array with error description - $result = $event->getResponse( + throw new Exception( "Could not update TwingleEvent campaign: $errorMessage" ); } @@ -141,74 +125,58 @@ class CRM_TwingleCampaign_BAO_TwingleEvent extends Campaign { /** * Create the Event as a campaign in CiviCRM if it does not exist * - * @param bool $is_test - * If true: don't do any changes - * - * @return array - * Returns a response array that contains title, id, project_id and status + * @return bool + * Returns _TRUE_ id creation was successful or _FALSE_ if it creation failed * * @throws CiviCRM_API3_Exception * @throws Exception */ - public function create(bool $is_test = FALSE): array { + public function create(): bool { - // Create campaign only if it does not already exist - if (!$is_test) { + // Prepare project values for import into database + $values_prepared_for_import = $this->values; + self::formatValues( + $values_prepared_for_import, + self::IN + ); + self::translateKeys( + $values_prepared_for_import, + self::IN + ); + $formattedValues = $values_prepared_for_import; + $this->translateCustomFields( + $values_prepared_for_import, + self::IN + ); - // Prepare project values for import into database - $values_prepared_for_import = $this->values; - self::formatValues( - $values_prepared_for_import, - self::IN - ); - self::translateKeys( - $values_prepared_for_import, - self::IN - ); - $formattedValues = $values_prepared_for_import; - $this->translateCustomFields( - $values_prepared_for_import, - self::IN - ); + // Set id + $values_prepared_for_import['id'] = $this->id; - // Set id - $values_prepared_for_import['id'] = $this->id; + // Create campaign + $result = civicrm_api3('Campaign', 'create', $values_prepared_for_import); - // Create campaign - $result = civicrm_api3('Campaign', 'create', $values_prepared_for_import); + // Update id + $this->id = $result['id']; - // Update id - $this->id = $result['id']; - - // Check if campaign was created successfully - if ($result['is_error'] == 0) { - $response = $this->getResponse("$this->className created"); - } - else { - $response = $this->getResponse("$this->className creation failed"); - } - - // Start a case for event initiator - if ( - $response['status'] == 'TwingleEvent created' && - Configuration::get('twinglecampaign_start_case') - ) { - $result = civicrm_api3('Case', 'create', [ - 'contact_id' => $formattedValues['contact_id'], - 'case_type_id' => Configuration::get('twinglecampaign_start_case'), - 'subject' => $formattedValues['title'], - 'start_date' => $formattedValues['created_at'], - 'status_id' => "Open", - ]); - } - - } - // If this is a test, do not create campaign - else { - $response = $this->getResponse("$this->className not yet created"); + // Check if campaign was created successfully + if ($result['is_error'] != 0) { + throw new Exception($result['error_message']); } - return $response; + // Start a case for event initiator + // TODO: save Case in Campaign and test if it already exists + if ( + Configuration::get('twinglecampaign_start_case') + ) { + $result = civicrm_api3('Case', 'create', [ + 'contact_id' => $formattedValues['contact_id'], + 'case_type_id' => Configuration::get('twinglecampaign_start_case'), + 'subject' => $formattedValues['title'], + 'start_date' => $formattedValues['created_at'], + 'status_id' => "Open", + ]); + } + return TRUE; } @@ -336,22 +304,28 @@ class CRM_TwingleCampaign_BAO_TwingleEvent extends Campaign { /** * Get a response that describes the status of a TwingleEvent * - * @param string $status - * status of the TwingleEvent you want the response for + * @param string|null $status + * status of the TwingleEvent you want to give back along with the response * * @return array - * Returns a response array that contains title, id, project_id and status + * Returns a response array that contains title, id, event_id, project_id and + * status */ - public function getResponse(string $status): array { - return [ + public function getResponse(string $status = NULL): array { + $response = [ 'title' => $this->values['description'], 'id' => (int) $this->id, 'event_id' => (int) $this->values['id'], 'project_id' => (int) $this->values['project_id'], 'status' => $status, ]; + if ($status) { + $response['status'] = $status; + } + return $response; } + /** * Matches a single string that should contain first and lastname to match a * contact or create a new one if it does not exist yet. diff --git a/api/v3/TwingleEvent/Get.php b/api/v3/TwingleEvent/Get.php index 7ebf0c7..2d8b9ce 100644 --- a/api/v3/TwingleEvent/Get.php +++ b/api/v3/TwingleEvent/Get.php @@ -93,7 +93,7 @@ function _civicrm_api3_twingle_event_Get_spec(array &$spec) { * @return array * API result descriptor * - * @throws CiviCRM_API3_Exception|API_Exception + * @throws CiviCRM_API3_Exception * @see civicrm_api3_create_success * */ @@ -147,7 +147,7 @@ function civicrm_api3_twingle_event_Get(array $params): array { TwingleEvent::formatValues($returnValues[$event['id']], TwingleEvent::OUT); } catch (Exception $e) { - throw new API_Exception($e->getMessage()); + throw new CiviCRM_API3_Exception($e->getMessage()); } } diff --git a/api/v3/TwingleEvent/Sync.php b/api/v3/TwingleEvent/Sync.php new file mode 100644 index 0000000..0a47627 --- /dev/null +++ b/api/v3/TwingleEvent/Sync.php @@ -0,0 +1,400 @@ + 'id', + 'title' => E::ts('Campaign ID'), + 'type' => CRM_Utils_Type::T_INT, + 'api.required' => 0, + 'description' => E::ts('Unique Campaign ID'), + ]; + $spec['event_id'] = [ + 'name' => 'event_id', + 'title' => E::ts('Twingle Event ID'), + 'type' => CRM_Utils_Type::T_INT, + 'api.required' => 0, + 'description' => E::ts('Twingle ID for this Event'), + ]; + $spec['is_test'] = [ + 'name' => 'is_test', + 'title' => E::ts('Test'), + 'type' => CRM_Utils_Type::T_BOOLEAN, + 'api.required' => 0, + 'description' => E::ts('If this is set true, no database change will be made'), + ]; + $spec['twingle_api_key'] = [ + 'name' => 'twingle_api_key', + 'title' => E::ts('Twingle API key'), + 'type' => CRM_Utils_Type::T_STRING, + 'api.required' => 0, + 'description' => E::ts('The key to access the Twingle API'), + ]; +} + +/** + * # TwingleEvent.Sync API + * + * Synchronize one ore more campaigns of the type TwingleEvent between CiviCRM + * and Twingle. + * _NOTE:_ Changes on TwingleEvents are not meant to get pushed to Twingle, so + * the synchronization takes place only one way + * + * * If you pass an **id** or **event_id** parameter, only one event will be + * synchronized. + * + * * If you pass a **project_id** as parameter, all events of that project will + * be synchronized. + * + * * If you pass no **id**, **event_id** or **project_id** parameter, all events + * will be synchronized. + * + * @param array $params + * + * @return array + * API result descriptor + * @throws \CiviCRM_API3_Exception + * @see civicrm_api3_create_success + */ +function civicrm_api3_twingle_event_Sync(array $params): array { + + // For logging purpose + $extensionName = E::LONG_NAME; + + // If call provides an API key, use it instead of the API key set + // on the extension settings page + $apiKey = empty($params['twingle_api_key']) + ? trim(Civi::settings()->get('twingle_api_key')) + : trim($params['twingle_api_key']); + + // Try to retrieve twingleApi from cache or create a new + $twingleApi = Civi::cache()->get('twinglecampaign_twingle_api'); + if (NULL === $twingleApi || $params['twingle_api_key']) { + try { + $twingleApi = new TwingleApiCall($apiKey); + } catch (Exception $e) { + return civicrm_api3_create_error($e->getMessage()); + } + Civi::cache('long')->set('twinglecampaign_twingle_api', $twingleApi); + } + + // If an id or a project_id is given, synchronize only this one campaign + if ($params['id'] || $params['event_id']) { + + // Get project from db via API + $params['sequential'] = 1; + $result = civicrm_api3('TwingleEvent', 'getsingle', $params); + if ($result['is_error'] == 0) { + + // Get the event from Twingle + if ($result['values'][0]['event_id']) { + $event_from_twingle = $twingleApi->getEvent( + $result['values'][0]['project_id'], + $result['values'][0]['event_id'] + ); + + // instantiate event from CiviCRM + try { + $event = instantiateEvent($result['values'][0]); + } catch (CiviCRM_API3_Exception $e) { + Civi::log()->error( + $e->getMessage(), + $e->getExtraParams() + ); + return civicrm_api3_create_error( + $e->getMessage(), + $e->getExtraParams() + ); + } + // Synchronize events + if (!empty($event_from_twingle)) { + return sync($event, $event_from_twingle, $twingleApi, $params); + } + + // If Twingle does not know an event with the given event_id, give error + else { + return civicrm_api3_create_error( + "The event_id appears to be unknown to Twingle", + $event->getResponse() + ); + } + } + } + + // If the project could not get retrieved from TwingleEvent.getsingle, + // forward API error message + else { + Civi::log()->error( + "$extensionName could retrieve project from TwingleEvent.getsingle", + $result + ); + return $result; + } + } + + // If no id but an event_id and/or a project_id is given, synchronize all + // all events or just the events of the given project + + $result_values = []; + + // Counter for sync errors + $errors_occurred = 0; + + // Get all events for given project from Twingle and CiviCRM + if ($params['project_id']) { + $events_from_twingle = $twingleApi->getEvent($params['project_id']); + $events_from_civicrm = civicrm_api3( + 'TwingleEvent', + 'get', + ['project_id' => $params['project_id']] + ); + } + // Get all events for all projects from Twingle + else { + $events_from_twingle = []; + + // Get all TwingleProject campaigns of type "event" from CiviCRM + $projects_from_civicrm = civicrm_api3( + 'TwingleProject', + 'get', + [ + 'type' => 'event', + 'sequential' => 1, + 'is_active' => TRUE, + ] + ); + + // Get all TwingleEvent campaigns from CiviCRM + $events_from_civicrm = civicrm_api3( + 'TwingleEvent', + 'get', + ['sequential' => 1] + ); + } + + // Get all events for the chosen project from Twingle + foreach ($projects_from_civicrm['values'] as $project_from_civicrm) { + $event_from_twingle = $twingleApi->getEvent($project_from_civicrm['project_id']); + array_push( + $events_from_twingle, + $event_from_twingle + ); + } + $events_from_twingle = array_merge(... $events_from_twingle); + + // Synchronize existing events or create new ones + foreach ($events_from_twingle as $event_from_twingle) { + + // Create missing events as campaigns in CiviCRM + if (!in_array($event_from_twingle['id'], + array_column($events_from_civicrm['values'], 'event_id'))) { + + // Instantiate Event + try { + $event = instantiateEvent($event_from_twingle); + } catch (CiviCRM_API3_Exception $e) { + Civi::log()->error( + $e->getMessage(), + $e->getExtraParams() + ); + return civicrm_api3_create_error( + $e->getMessage(), + $e->getExtraParams() + ); + } + + // If this is a test, do not make db changes + if ($params['is_test']) { + $result_values[$event->getId()] = + $event->getResponse('Ready to create TwingleEvent'); + } + + try { + $event->create(); + $result_values[$event->getId()] = + $event->getResponse('TwingleEvent created'); + } catch (Exception $e) { + $errors_occurred++; + $errorMessage = $e->getMessage(); + Civi::log()->error( + "$extensionName could not create TwingleEvent: $errorMessage", + $event->getResponse() + ); + $result_values[$event->getId()] = $event->getResponse( + "TwingleEvent could not get created: $errorMessage" + ); + } + } + } + + // Synchronize existing events + foreach ($events_from_civicrm['values'] as $event_from_civicrm) { + foreach ($events_from_twingle as $event_from_twingle) { + if ($event_from_twingle['id'] == $event_from_civicrm['event_id']) { + + // instantiate project with values from TwingleEvent.Get + $event = instantiateEvent($event_from_civicrm); + + // sync event + $result = sync($event, $event_from_twingle, $twingleApi, $params); + if ($result['is_error'] != 0) { + $errors_occurred++; + $result_values[$event->getId()] = + $event->getResponse($result['error_message']); + } + else { + $result_values[$event->getId()] = $result['values']; + } + break; + } + } + } + + // Give back results + if ($errors_occurred > 0) { + $errorMessage = ($errors_occurred > 1) + ? "$errors_occurred synchronisation processes resulted with an error" + : "1 synchronisation process resulted with an error"; + return civicrm_api3_create_error( + $errorMessage, + $result_values + ); + } + else { + return civicrm_api3_create_success( + $result_values, + $params, + 'TwingleProject', + 'Sync' + ); + } +} + + +/** + * Instantiates a TwingleEvent + * + * @param $values + * + * @return \CRM_TwingleCampaign_BAO_TwingleEvent + * @throws \CiviCRM_API3_Exception + */ +function instantiateEvent($values): CRM_TwingleCampaign_BAO_TwingleEvent { + try { + return new TwingleEvent($values); + } catch (Exception $e) { + throw new CiviCRM_API3_Exception( + $e->getMessage(), + 'instantiation_failed', + [ + 'title' => $values['description'], + 'id' => (int) $values['id'], + 'event_id' => (int) $values['event_id'], + 'project_id' => (int) $values['project_id'], + ] + ); + } +} + + +/** + * Update a TwingleEvent campaign locally + * + * @param array $event_from_twingle + * @param \CRM_TwingleCampaign_BAO_TwingleEvent $event + * @param array $params + * @param \CRM_TwingleCampaign_BAO_TwingleApiCall $twingleApi + * + * @return array + */ +function updateLocally(array $event_from_twingle, + TwingleEvent $event, + array $params, + TwingleApiCall $twingleApi): array { + + // For logging purpose + $extensionName = E::LONG_NAME; + + try { + $event->update($event_from_twingle); + // If this is a test, do not make db changes + if ($params['is_test']) { + return civicrm_api3_create_success( + $event->getResponse('TwingleEvent ready to update'), + $params, + 'TwingleEvent', + 'Sync' + ); + } + // ... else, update local TwingleEvent campaign + $event->create(); + return civicrm_api3_create_success( + $event->getResponse('TwingleEvent updated successfully'), + $params, + 'TwingleEvent', + 'Sync' + ); + } catch (Exception $e) { + $errorMessage = $e->getMessage(); + Civi::log()->error( + "$extensionName could not update TwingleEvent campaign: $errorMessage", + $event->getResponse() + ); + return civicrm_api3_create_error( + "Could not update TwingleEvent campaign: $errorMessage", + $event->getResponse() + ); + } +} + + +/** + * Synchronize a TwingleEvent campaign with an event from Twingle one way + * + * _NOTE:_ Changes on TwingleEvents are not meant to get pushed to Twingle, so + * the synchronization takes place only one way + * + * @param \CRM_TwingleCampaign_BAO_TwingleEvent $event + * @param array $event_from_twingle + * @param \CRM_TwingleCampaign_BAO_TwingleApiCall $twingleApi + * @param array $params + * + * @return array + * @throws \CiviCRM_API3_Exception + */ +function sync(TwingleEvent $event, + array $event_from_twingle, + TwingleApiCall $twingleApi, + array $params): array { + + // If Twingle's timestamp of the event differs from the timestamp of the + // CiviCRM TwingleProject campaign, update the campaign on CiviCRM's side. + // NOTE: Changes on TwingleEvents are not meant to get pushed to Twingle + if ($event_from_twingle['updated_at'] != $event->lastUpdate()) { + return updateLocally($event_from_twingle, $event, $params, $twingleApi); + } + + // If both versions are still synchronized + else { + $response[] = $event->getResponse('TwingleEvent up to date'); + return civicrm_api3_create_success( + $response, + $params, + 'TwingleEvent', + 'Sync' + ); + } +} + + diff --git a/tests/phpunit/api/v3/TwingleEvent/SyncTest.php b/tests/phpunit/api/v3/TwingleEvent/SyncTest.php new file mode 100644 index 0000000..8cb90d2 --- /dev/null +++ b/tests/phpunit/api/v3/TwingleEvent/SyncTest.php @@ -0,0 +1,53 @@ +installMe(__DIR__) + ->apply(); + } + + /** + * The setup() method is executed before the test is executed (optional). + */ + public function setUp() { + parent::setUp(); + } + + /** + * The tearDown() method is executed after the test was executed (optional) + * This can be used for cleanup. + */ + public function tearDown() { + parent::tearDown(); + } + + /** + * Simple example test case. + * + * Note how the function name begins with the word "test". + */ + public function testApiExample() { + $result = civicrm_api3('TwingleEvent', 'Sync', array('magicword' => 'sesame')); + $this->assertEquals('Twelve', $result['values'][12]['name']); + } + +}