Merge pull request #3350 from eileenmcnaughton/comments
[civicrm-core.git] / CRM / Extension / Manager.php
CommitLineData
6a488035
TO
1<?php
2/*
3 +--------------------------------------------------------------------+
06b69b18 4 | CiviCRM version 4.5 |
6a488035 5 +--------------------------------------------------------------------+
06b69b18 6 | Copyright CiviCRM LLC (c) 2004-2014 |
6a488035
TO
7 +--------------------------------------------------------------------+
8 | This file is a part of CiviCRM. |
9 | |
10 | CiviCRM is free software; you can copy, modify, and distribute it |
11 | under the terms of the GNU Affero General Public License |
12 | Version 3, 19 November 2007 and the CiviCRM Licensing Exception. |
13 | |
14 | CiviCRM is distributed in the hope that it will be useful, but |
15 | WITHOUT ANY WARRANTY; without even the implied warranty of |
16 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. |
17 | See the GNU Affero General Public License for more details. |
18 | |
19 | You should have received a copy of the GNU Affero General Public |
20 | License and the CiviCRM Licensing Exception along |
21 | with this program; if not, contact CiviCRM LLC |
22 | at info[AT]civicrm[DOT]org. If you have questions about the |
23 | GNU Affero General Public License or the licensing of CiviCRM, |
24 | see the CiviCRM license FAQ at http://civicrm.org/licensing |
25 +--------------------------------------------------------------------+
26*/
27
28/**
29 * The extension manager handles installing, disabling enabling, and
30 * uninstalling extensions.
31 *
32 * @package CRM
06b69b18 33 * @copyright CiviCRM LLC (c) 2004-2014
6a488035
TO
34 * $Id$
35 *
36 */
37class CRM_Extension_Manager {
38 /**
39 * The extension is fully installed and enabled
40 */
41 const STATUS_INSTALLED = 'installed';
42
43 /**
44 * The extension config has been applied to database but deactivated
45 */
46 const STATUS_DISABLED = 'disabled';
47
48 /**
49 * The extension code is visible, but nothing has been applied to DB
50 */
51 const STATUS_UNINSTALLED = 'uninstalled';
52
53 /**
54 * The extension code is not locally accessible
55 */
56 const STATUS_UNKNOWN = 'unknown';
57
58 /**
59 * The extension is fully installed and enabled
60 */
61 const STATUS_INSTALLED_MISSING = 'installed-missing';
62
63 /**
64 * The extension is fully installed and enabled
65 */
66 const STATUS_DISABLED_MISSING = 'disabled-missing';
67
68 /**
69 * @var CRM_Extension_Container_Interface
70 *
71 * Note: Treat as private. This is only public to facilitate debugging.
72 */
73 public $fullContainer;
74
75 /**
76 * @var CRM_Extension_Container_Basic|FALSE
77 *
78 * Note: Treat as private. This is only public to facilitate debugging.
79 */
80 public $defaultContainer;
81
82 /**
83 * @var CRM_Extension_Mapper
84 *
85 * Note: Treat as private. This is only public to facilitate debugging.
86 */
87 public $mapper;
88
89 /**
90 * @var array (typeName => CRM_Extension_Manager_Interface)
91 *
92 * Note: Treat as private. This is only public to facilitate debugging.
93 */
94 public $typeManagers;
95
96 /**
97 * @var array (extensionKey => statusConstant)
98 *
99 * Note: Treat as private. This is only public to facilitate debugging.
100 */
101 public $statuses;
102
103 /**
6c8f6e67 104 * @param CRM_Extension_Container_Interface $fullContainer
6a488035 105 * @param CRM_Extension_Container_Basic|FALSE $defaultContainer
6c8f6e67
EM
106 * @param CRM_Extension_Mapper $mapper
107 * @param $typeManagers
6a488035
TO
108 */
109 function __construct(CRM_Extension_Container_Interface $fullContainer, $defaultContainer, CRM_Extension_Mapper $mapper, $typeManagers) {
110 $this->fullContainer = $fullContainer;
111 $this->defaultContainer = $defaultContainer;
112 $this->mapper = $mapper;
113 $this->typeManagers = $typeManagers;
114 }
115
116 /**
117 * Install or upgrade the code for an extension -- and perform any
118 * necessary database changes (eg replacing extension metadata).
119 *
120 * This only works if the extension is stored in the default container.
121 *
122 * @param string $tmpCodeDir path to a local directory containing a copy of the new (inert) code
123 * @return void
124 * @throws CRM_Extension_Exception
125 */
126 public function replace($tmpCodeDir) {
127 if (! $this->defaultContainer) {
128 throw new CRM_Extension_Exception("Default extension container is not configured");
129 }
130
131 $newInfo = CRM_Extension_Info::loadFromFile($tmpCodeDir . DIRECTORY_SEPARATOR . CRM_Extension_Info::FILENAME);
132 $oldStatus = $this->getStatus($newInfo->key);
133
134 // find $tgtPath, $oldInfo, $typeManager
135 switch ($oldStatus) {
136 case self::STATUS_UNINSTALLED:
137 case self::STATUS_INSTALLED:
138 case self::STATUS_DISABLED:
139 // There is an old copy of the extension. Try to install in the same place -- but it must go somewhere in the default-container
140 list ($oldInfo, $typeManager) = $this->_getInfoTypeHandler($newInfo->key); // throws Exception
141 $tgtPath = $this->fullContainer->getPath($newInfo->key);
142 if (! CRM_Utils_File::isChildPath($this->defaultContainer->getBaseDir(), $tgtPath)) {
143 // force installation in the default-container
615841de 144 $oldPath = $tgtPath;
6a488035
TO
145 $tgtPath = $this->defaultContainer->getBaseDir() . DIRECTORY_SEPARATOR . $newInfo->key;
146 CRM_Core_Session::setStatus(ts('A copy of the extension (%1) is in a system folder (%2). The system copy will be preserved, but the new copy will be used.', array(
147 1 => $newInfo->key,
148 2 => $oldPath,
149 )));
150 }
151 break;
152 case self::STATUS_INSTALLED_MISSING:
153 case self::STATUS_DISABLED_MISSING:
154 // the extension does not exist in any container; we're free to put it anywhere
155 $tgtPath = $this->defaultContainer->getBaseDir() . DIRECTORY_SEPARATOR . $newInfo->key;
156 list ($oldInfo, $typeManager) = $this->_getMissingInfoTypeHandler($newInfo->key); // throws Exception
157 break;
158 case self::STATUS_UNKNOWN:
159 // the extension does not exist in any container; we're free to put it anywhere
160 $tgtPath = $this->defaultContainer->getBaseDir() . DIRECTORY_SEPARATOR . $newInfo->key;
161 $oldInfo = $typeManager = NULL;
162 break;
163 default:
615841de 164 throw new CRM_Extension_Exception("Cannot install or enable extension: {$newInfo->key}");
6a488035
TO
165 }
166
167 // move the code!
168 switch ($oldStatus) {
169 case self::STATUS_UNINSTALLED:
170 case self::STATUS_UNKNOWN:
171 // There are no DB records to worry about, so we'll just put the files in place
172 if (!CRM_Utils_File::replaceDir($tmpCodeDir, $tgtPath)) {
173 throw new CRM_Extension_Exception("Failed to move $tmpCodeDir to $tgtPath");
174 }
175 break;
176 case self::STATUS_INSTALLED:
177 case self::STATUS_INSTALLED_MISSING:
178 case self::STATUS_DISABLED:
179 case self::STATUS_DISABLED_MISSING:
180 // There are DB records; coordinate the file placement with the DB updates
181 $typeManager->onPreReplace($oldInfo, $newInfo);
182 if (!CRM_Utils_File::replaceDir($tmpCodeDir, $tgtPath)) {
183 throw new CRM_Extension_Exception("Failed to move $tmpCodeDir to $tgtPath");
184 }
185 $this->_updateExtensionEntry($newInfo);
186 $typeManager->onPostReplace($oldInfo, $newInfo);
187 break;
188 default:
615841de 189 throw new CRM_Extension_Exception("Cannot install or enable extension: {$newInfo->key}");
6a488035
TO
190 }
191
192 $this->refresh();
193 }
194
195 /**
196 * Add records of the extension to the database -- and enable it
197 *
198 * @param array $keys list of extension keys
199 * @return void
200 * @throws CRM_Extension_Exception
201 */
202 public function install($keys) {
203 $origStatuses = $this->getStatuses();
204
205 // TODO: to mitigate the risk of crashing during installation, scan
206 // keys/statuses/types before doing anything
207
208 foreach ($keys as $key) {
209 list ($info, $typeManager) = $this->_getInfoTypeHandler($key); // throws Exception
210
211 switch ($origStatuses[$key]) {
212 case self::STATUS_INSTALLED:
213 // ok, nothing to do
214 break;
215 case self::STATUS_DISABLED:
216 // re-enable it
217 $typeManager->onPreEnable($info);
218 $this->_setExtensionActive($info, 1);
219 $typeManager->onPostEnable($info);
220 break;
221 case self::STATUS_UNINSTALLED:
222 // install anew
223 $typeManager->onPreInstall($info);
224 $this->_createExtensionEntry($info);
225 $typeManager->onPostInstall($info);
226 break;
227 case self::STATUS_UNKNOWN:
228 default:
229 throw new CRM_Extension_Exception("Cannot install or enable extension: $key");
230 }
231 }
232
233 $this->statuses = NULL;
234 $this->mapper->refresh();
235 CRM_Core_Invoke::rebuildMenuAndCaches(TRUE);
3d0e24ec 236
237 foreach ($keys as $key) {
238 list ($info, $typeManager) = $this->_getInfoTypeHandler($key); // throws Exception
239 //print_r(array('post post?', $info, 'k' => $key, 'os'=> $origStatuses[$key]));
240
241 switch ($origStatuses[$key]) {
242 case self::STATUS_INSTALLED:
243 // ok, nothing to do
244 break;
245 case self::STATUS_DISABLED:
246 // re-enable it
247 break;
248 case self::STATUS_UNINSTALLED:
249 // install anew
250 $typeManager->onPostPostInstall($info);
251 break;
252 case self::STATUS_UNKNOWN:
253 default:
254 throw new CRM_Extension_Exception("Cannot install or enable extension: $key");
255 }
256 }
257
6a488035
TO
258 }
259
260 /**
261 * Add records of the extension to the database -- and enable it
262 *
263 * @param array $keys list of extension keys
264 * @return void
265 * @throws CRM_Extension_Exception
266 */
267 public function enable($keys) {
268 $this->install($keys);
269 }
270
271 /**
272 * Add records of the extension to the database -- and enable it
273 *
274 * @param array $keys list of extension keys
275 * @return void
276 * @throws CRM_Extension_Exception
277 */
278 public function disable($keys) {
279 $origStatuses = $this->getStatuses();
280
281 // TODO: to mitigate the risk of crashing during installation, scan
282 // keys/statuses/types before doing anything
283
284 foreach ($keys as $key) {
285 switch ($origStatuses[$key]) {
286 case self::STATUS_INSTALLED:
287 list ($info, $typeManager) = $this->_getInfoTypeHandler($key); // throws Exception
288 $typeManager->onPreDisable($info);
289 $this->_setExtensionActive($info, 0);
290 $typeManager->onPostDisable($info);
291 break;
292 case self::STATUS_INSTALLED_MISSING:
293 list ($info, $typeManager) = $this->_getMissingInfoTypeHandler($key); // throws Exception
294 $typeManager->onPreDisable($info);
295 $this->_setExtensionActive($info, 0);
296 $typeManager->onPostDisable($info);
297 break;
298 case self::STATUS_DISABLED:
299 case self::STATUS_DISABLED_MISSING:
300 case self::STATUS_UNINSTALLED:
301 // ok, nothing to do
302 break;
303 case self::STATUS_UNKNOWN:
304 default:
305 throw new CRM_Extension_Exception("Cannot disable unknown extension: $key");
306 }
307 }
308
309 $this->statuses = NULL;
310 $this->mapper->refresh();
311 CRM_Core_Invoke::rebuildMenuAndCaches(TRUE);
312 }
313
314 /**
315 * Remove all database references to an extension
316 *
317 * Add records of the extension to the database -- and enable it
318 *
319 * @param array $keys list of extension keys
320 * @return void
321 * @throws CRM_Extension_Exception
322 */
323 public function uninstall($keys) {
324 $origStatuses = $this->getStatuses();
325
326 // TODO: to mitigate the risk of crashing during installation, scan
327 // keys/statuses/types before doing anything
328
329 foreach ($keys as $key) {
330 switch ($origStatuses[$key]) {
331 case self::STATUS_INSTALLED:
332 case self::STATUS_INSTALLED_MISSING:
333 throw new CRM_Extension_Exception("Cannot uninstall extension; disable it first: $key");
334 break;
335 case self::STATUS_DISABLED:
336 list ($info, $typeManager) = $this->_getInfoTypeHandler($key); // throws Exception
337 $typeManager->onPreUninstall($info);
338 $this->_removeExtensionEntry($info);
339 $typeManager->onPostUninstall($info);
340 break;
341 case self::STATUS_DISABLED_MISSING:
342 list ($info, $typeManager) = $this->_getMissingInfoTypeHandler($key); // throws Exception
343 $typeManager->onPreUninstall($info);
344 $this->_removeExtensionEntry($info);
345 $typeManager->onPostUninstall($info);
346 break;
347 case self::STATUS_UNINSTALLED:
348 // ok, nothing to do
349 break;
350 case self::STATUS_UNKNOWN:
351 default:
352 throw new CRM_Extension_Exception("Cannot disable unknown extension: $key");
353 }
354 }
355
356 $this->statuses = NULL;
357 $this->mapper->refresh();
358 CRM_Core_Invoke::rebuildMenuAndCaches(TRUE);
359 }
360
361 /**
362 * Determine the status of an extension
363 *
77b97be7
EM
364 * @param $key
365 *
6a488035
TO
366 * @return string constant (STATUS_INSTALLED, STATUS_DISABLED, STATUS_UNINSTALLED, STATUS_UNKNOWN)
367 */
368 public function getStatus($key) {
369 $statuses = $this->getStatuses();
370 if (array_key_exists($key, $statuses)) {
371 return $statuses[$key];
372 } else {
373 return self::STATUS_UNKNOWN;
374 }
375 }
376
377 /**
378 * Determine the status of all extensions
379 *
380 * @return array ($key => status_constant)
381 */
382 public function getStatuses() {
383 if (!is_array($this->statuses)) {
384 $this->statuses = array();
385
386 foreach ($this->fullContainer->getKeys() as $key) {
387 $this->statuses[$key] = self::STATUS_UNINSTALLED;
388 }
389
390 $sql = '
391 SELECT full_name, is_active
392 FROM civicrm_extension
393 ';
394 $dao = CRM_Core_DAO::executeQuery($sql);
395 while ($dao->fetch()) {
396 try {
397 $path = $this->fullContainer->getPath($dao->full_name);
398 $codeExists = !empty($path) && is_dir($path);
399 } catch (CRM_Extension_Exception $e) {
400 $codeExists = FALSE;
401 }
402 if ($dao->is_active) {
403 $this->statuses[$dao->full_name] = $codeExists ? self::STATUS_INSTALLED : self::STATUS_INSTALLED_MISSING;
404 } else {
405 $this->statuses[$dao->full_name] = $codeExists ? self::STATUS_DISABLED : self::STATUS_DISABLED_MISSING;
406 }
407 }
408 }
409 return $this->statuses;
410 }
411
412 public function refresh() {
413 $this->statuses = NULL;
414 $this->fullContainer->refresh(); // and, indirectly, defaultContainer
415 $this->mapper->refresh();
416 }
417
418 // ----------------------
419
420 /**
421 * Find the $info and $typeManager for a $key
422 *
77b97be7
EM
423 * @param $key
424 *
6a488035 425 * @throws CRM_Extension_Exception
77b97be7 426 * @return array (0 => CRM_Extension_Info, 1 => CRM_Extension_Manager_Interface)
6a488035
TO
427 */
428 private function _getInfoTypeHandler($key) {
429 $info = $this->mapper->keyToInfo($key); // throws Exception
430 if (array_key_exists($info->type, $this->typeManagers)) {
431 return array($info, $this->typeManagers[$info->type]);
432 } else {
433 throw new CRM_Extension_Exception("Unrecognized extension type: " . $info->type);
434 }
435 }
436
437 /**
438 * Find the $info and $typeManager for a $key
439 *
2a6da8d7
EM
440 * @param $key
441 *
6a488035 442 * @throws CRM_Extension_Exception
2a6da8d7 443 * @return array (0 => CRM_Extension_Info, 1 => CRM_Extension_Manager_Interface)
6a488035
TO
444 */
445 private function _getMissingInfoTypeHandler($key) {
446 $info = $this->createInfoFromDB($key);
447 if ($info) {
448 if (array_key_exists($info->type, $this->typeManagers)) {
449 return array($info, $this->typeManagers[$info->type]);
450 } else {
451 throw new CRM_Extension_Exception("Unrecognized extension type: " . $info->type);
452 }
453 } else {
454 throw new CRM_Extension_Exception("Failed to reconstruct missing extension: " . $key);
455 }
456 }
457
458 private function _createExtensionEntry(CRM_Extension_Info $info) {
459 $dao = new CRM_Core_DAO_Extension();
460 $dao->label = $info->label;
461 $dao->name = $info->name;
462 $dao->full_name = $info->key;
463 $dao->type = $info->type;
464 $dao->file = $info->file;
465 $dao->is_active = 1;
466 return (bool) ($dao->insert());
467 }
468
469 private function _updateExtensionEntry(CRM_Extension_Info $info) {
470 $dao = new CRM_Core_DAO_Extension();
471 $dao->full_name = $info->key;
472 if ($dao->find(TRUE)) {
473 $dao->label = $info->label;
474 $dao->name = $info->name;
475 $dao->full_name = $info->key;
476 $dao->type = $info->type;
477 $dao->file = $info->file;
478 $dao->is_active = 1;
479 return (bool) ($dao->update());
480 } else {
481 return $this->_createExtensionEntry($info);
482 }
483 }
484
485 private function _removeExtensionEntry(CRM_Extension_Info $info) {
486 $dao = new CRM_Core_DAO_Extension();
487 $dao->full_name = $info->key;
488 if ($dao->find(TRUE)) {
489 if (CRM_Core_BAO_Extension::del($dao->id)) {
490 CRM_Core_Session::setStatus(ts('Selected option value has been deleted.'), ts('Deleted'), 'success');
491 } else {
492 throw new CRM_Extension_Exception("Failed to remove extension entry");
493 }
494 } // else: post-condition already satisified
495 }
496
497 private function _setExtensionActive(CRM_Extension_Info $info, $isActive) {
498 CRM_Core_DAO::executeQuery('UPDATE civicrm_extension SET is_active = %1 where full_name = %2', array(
499 1 => array($isActive, 'Integer'),
500 2 => array($info->key, 'String'),
501 ));
502 }
503
504 /**
505 * Auto-generate a place-holder for a missing extension using info from
506 * database.
507 *
2a6da8d7 508 * @param $key
6a488035
TO
509 * @return CRM_Extension_Info|NULL
510 */
511 public function createInfoFromDB($key) {
512 $dao = new CRM_Core_DAO_Extension();
513 $dao->full_name = $key;
514 if ($dao->find(TRUE)) {
515 $info = new CRM_Extension_Info($dao->full_name, $dao->type, $dao->name, $dao->label, $dao->file);
516 return $info;
517 } else {
518 return NULL;
519 }
520 }
521}