Completed
Push — master ( 38a63a...1be2e7 )
by Daniel
10:36
created

ModelAdmin::init()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 17
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 10
nc 4
nop 0
dl 0
loc 17
rs 9.4285
c 0
b 0
f 0
1
<?php
2
3
namespace SilverStripe\Admin;
4
5
use SilverStripe\Control\Controller;
6
use SilverStripe\Control\HTTPRequest;
7
use SilverStripe\Control\HTTPResponse;
8
use SilverStripe\Core\Convert;
9
use SilverStripe\Dev\BulkLoader;
10
use SilverStripe\Forms\FieldList;
11
use SilverStripe\Forms\Form;
12
use SilverStripe\Forms\FormAction;
13
use SilverStripe\Forms\GridField\GridFieldDetailForm;
14
use SilverStripe\Forms\ResetFormAction;
15
use SilverStripe\Forms\RequiredFields;
16
use SilverStripe\Forms\HiddenField;
17
use SilverStripe\Forms\FileField;
18
use SilverStripe\Forms\LiteralField;
19
use SilverStripe\Forms\CheckboxField;
20
use SilverStripe\Forms\DatetimeField;
21
use SilverStripe\Forms\GridField\GridFieldExportButton;
22
use SilverStripe\Forms\GridField\GridFieldConfig_RecordEditor;
23
use SilverStripe\Forms\GridField\GridFieldPrintButton;
24
use SilverStripe\Forms\GridField\GridField;
25
use SilverStripe\ORM\ArrayLib;
26
use SilverStripe\ORM\ArrayList;
27
use SilverStripe\ORM\DataObject;
28
use SilverStripe\ORM\Search\SearchContext;
29
use SilverStripe\ORM\SS_List;
30
use SilverStripe\Security\Member;
31
use SilverStripe\View\Requirements;
32
use SilverStripe\View\ArrayData;
33
34
/**
35
 * Generates a three-pane UI for editing model classes, with an
36
 * automatically generated search panel, tabular results and edit forms.
37
 *
38
 * Relies on data such as {@link DataObject::$db} and {@link DataObject::getCMSFields()}
39
 * to scaffold interfaces "out of the box", while at the same time providing
40
 * flexibility to customize the default output.
41
 *
42
 * @uses SearchContext
43
 */
44
abstract class ModelAdmin extends LeftAndMain {
45
46
	private static $url_rule = '/$ModelClass/$Action';
47
48
	/**
49
	 * List of all managed {@link DataObject}s in this interface.
50
	 *
51
	 * Simple notation with class names only:
52
	 * <code>
53
	 * array('MyObjectClass','MyOtherObjectClass')
54
	 * </code>
55
	 *
56
	 * Extended notation with options (e.g. custom titles):
57
	 * <code>
58
	 * array(
59
	 *   'MyObjectClass' => array('title' => "Custom title")
60
	 * )
61
	 * </code>
62
	 *
63
	 * Available options:
64
	 * - 'title': Set custom titles for the tabs or dropdown names
65
	 *
66
	 * @config
67
	 * @var array|string
68
	 */
69
	private static $managed_models = null;
70
71
	/**
72
	 * Override menu_priority so that ModelAdmin CMSMenu objects
73
	 * are grouped together directly above the Help menu item.
74
	 * @var float
75
	 */
76
	private static $menu_priority = -0.5;
77
78
	private static $menu_icon = 'framework/admin/client/src/sprites/menu-icons/16x16/db.png';
79
80
	private static $allowed_actions = array(
81
		'ImportForm',
82
		'SearchForm',
83
	);
84
85
	private static $url_handlers = array(
86
		'$ModelClass/$Action' => 'handleAction'
87
	);
88
89
	/**
90
	 * @var String
91
	 */
92
	protected $modelClass;
93
94
	/**
95
	 * Change this variable if you don't want the Import from CSV form to appear.
96
	 * This variable can be a boolean or an array.
97
	 * If array, you can list className you want the form to appear on. i.e. array('myClassOne','myClassTwo')
98
	 */
99
	public $showImportForm = true;
100
101
	/**
102
	 * Change this variable if you don't want the search form to appear.
103
	 * This variable can be a boolean or an array.
104
	 * If array, you can list className you want the form to appear on. i.e. array('myClassOne','myClassTwo')
105
	 */
106
	public $showSearchForm = true;
107
108
	/**
109
	 * List of all {@link DataObject}s which can be imported through
110
	 * a subclass of {@link BulkLoader} (mostly CSV data).
111
	 * By default {@link CsvBulkLoader} is used, assuming a standard mapping
112
	 * of column names to {@link DataObject} properties/relations.
113
	 *
114
	 * e.g. "BlogEntry" => "BlogEntryCsvBulkLoader"
115
	 *
116
	 * @config
117
	 * @var array
118
	 */
119
	private static $model_importers = null;
120
121
	/**
122
	 * Amount of results showing on a single page.
123
	 *
124
	 * @config
125
	 * @var int
126
	 */
127
	private static $page_length = 30;
128
129
	/**
130
	 * Initialize the model admin interface. Sets up embedded jquery libraries and requisite plugins.
131
	 */
132
	protected function init() {
133
		parent::init();
134
135
		$models = $this->getManagedModels();
136
137
		if($this->getRequest()->param('ModelClass')) {
138
			$this->modelClass = $this->unsanitiseClassName($this->getRequest()->param('ModelClass'));
139
		} else {
140
			reset($models);
141
			$this->modelClass = key($models);
142
		}
143
144
		// security check for valid models
145
		if(!array_key_exists($this->modelClass, $models)) {
146
			user_error('ModelAdmin::init(): Invalid Model class', E_USER_ERROR);
147
		}
148
	}
149
150
	public function Link($action = null) {
151
		if(!$action) $action = $this->sanitiseClassName($this->modelClass);
152
		return parent::Link($action);
153
	}
154
155
	public function getEditForm($id = null, $fields = null) {
156
		$list = $this->getList();
157
		$exportButton = new GridFieldExportButton('buttons-before-left');
158
		$exportButton->setExportColumns($this->getExportFields());
159
		$listField = GridField::create(
160
			$this->sanitiseClassName($this->modelClass),
161
			false,
162
			$list,
163
			$fieldConfig = GridFieldConfig_RecordEditor::create($this->stat('page_length'))
164
				->addComponent($exportButton)
165
				->removeComponentsByType('SilverStripe\\Forms\\GridField\\GridFieldFilterHeader')
166
				->addComponents(new GridFieldPrintButton('buttons-before-left'))
167
		);
168
169
		// Validation
170
		if(singleton($this->modelClass)->hasMethod('getCMSValidator')) {
171
			$detailValidator = singleton($this->modelClass)->getCMSValidator();
172
			/** @var GridFieldDetailForm $detailform */
173
			$detailform = $listField->getConfig()->getComponentByType('SilverStripe\\Forms\\GridField\\GridFieldDetailForm');
174
			$detailform->setValidator($detailValidator);
175
		}
176
177
		$form = Form::create(
178
			$this,
179
			'EditForm',
180
			new FieldList($listField),
181
			new FieldList()
182
		)->setHTMLID('Form_EditForm');
183
		$form->addExtraClass('cms-edit-form cms-panel-padded center flexbox-area-grow');
184
		$form->setTemplate($this->getTemplatesWithSuffix('_EditForm'));
185
		$editFormAction = Controller::join_links($this->Link($this->sanitiseClassName($this->modelClass)), 'EditForm');
186
		$form->setFormAction($editFormAction);
187
		$form->setAttribute('data-pjax-fragment', 'CurrentForm');
188
189
		$this->extend('updateEditForm', $form);
190
191
		return $form;
192
	}
193
194
	/**
195
	 * Define which fields are used in the {@link getEditForm} GridField export.
196
	 * By default, it uses the summary fields from the model definition.
197
	 *
198
	 * @return array
199
	 */
200
	public function getExportFields() {
201
		return singleton($this->modelClass)->summaryFields();
202
	}
203
204
	/**
205
	 * @return SearchContext
206
	 */
207
	public function getSearchContext() {
208
		$context = DataObject::singleton($this->modelClass)->getDefaultSearchContext();
209
210
		// Namespace fields, for easier detection if a search is present
211
		foreach($context->getFields() as $field) {
212
			$field->setName(sprintf('q[%s]', $field->getName()));
213
		}
214
		foreach($context->getFilters() as $filter) {
215
			$filter->setFullName(sprintf('q[%s]', $filter->getFullName()));
216
		}
217
218
		$this->extend('updateSearchContext', $context);
219
220
		return $context;
221
	}
222
223
	/**
224
	 * @return Form|bool
225
	 */
226
	public function SearchForm() {
227
		if(!$this->showSearchForm ||
228
			(is_array($this->showSearchForm) && !in_array($this->modelClass, $this->showSearchForm))
229
		) {
230
			return false;
231
		}
232
		$context = $this->getSearchContext();
233
		/** @skipUpgrade */
234
		$form = new Form($this, "SearchForm",
235
			$context->getSearchFields(),
236
			new FieldList(
237
				FormAction::create('search', _t('MemberTableField.APPLY_FILTER', 'Apply Filter'))
238
					->setUseButtonTag(true)->addExtraClass('ss-ui-action-constructive'),
239
				ResetFormAction::create('clearsearch', _t('ModelAdmin.RESET','Reset'))
240
					->setUseButtonTag(true)
241
			),
242
			new RequiredFields()
243
		);
244
		$form->setFormMethod('get');
245
		$form->setFormAction($this->Link($this->sanitiseClassName($this->modelClass)));
246
		$form->addExtraClass('cms-search-form');
247
		$form->disableSecurityToken();
248
		$form->loadDataFrom($this->getRequest()->getVars());
249
250
		$this->extend('updateSearchForm', $form);
251
252
		return $form;
253
	}
254
255
	public function getList() {
256
		$context = $this->getSearchContext();
257
		$params = $this->getRequest()->requestVar('q');
258
259
		if(is_array($params)) {
260
			$params = ArrayLib::array_map_recursive('trim', $params);
261
262
			// Parse all DateFields to handle user input non ISO 8601 dates
263
			foreach($context->getFields() as $field) {
264
				if($field instanceof DatetimeField && !empty($params[$field->getName()])) {
265
					$params[$field->getName()] = date('Y-m-d', strtotime($params[$field->getName()]));
266
				}
267
			}
268
		}
269
270
		$list = $context->getResults($params);
271
272
		$this->extend('updateList', $list);
273
274
		return $list;
275
	}
276
277
278
	/**
279
	 * Returns managed models' create, search, and import forms
280
	 * @uses SearchContext
281
	 * @uses SearchFilter
282
	 * @return SS_List of forms
283
	 */
284
	protected function getManagedModelTabs() {
285
		$models = $this->getManagedModels();
286
		$forms  = new ArrayList();
287
288
		foreach($models as $class => $options) {
0 ignored issues
show
Bug introduced by
The expression $models of type object|integer|double|null|array|boolean is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
289
			$forms->push(new ArrayData(array (
290
				'Title'     => $options['title'],
291
				'ClassName' => $class,
292
				'Link' => $this->Link($this->sanitiseClassName($class)),
293
				'LinkOrCurrent' => ($class == $this->modelClass) ? 'current' : 'link'
294
			)));
295
		}
296
297
		return $forms;
298
	}
299
300
	/**
301
	 * Sanitise a model class' name for inclusion in a link
302
	 *
303
	 * @param string $class
304
	 * @return string
305
	 */
306
	protected function sanitiseClassName($class) {
307
		return str_replace('\\', '-', $class);
308
	}
309
310
	/**
311
	 * Unsanitise a model class' name from a URL param
312
	 *
313
	 * @param string $class
314
	 * @return string
315
	 */
316
	protected function unsanitiseClassName($class) {
317
		return str_replace('-', '\\', $class);
318
	}
319
320
	/**
321
	 * @return array Map of class name to an array of 'title' (see {@link $managed_models})
322
	 */
323
	public function getManagedModels() {
324
		$models = $this->stat('managed_models');
325
		if(is_string($models)) {
326
			$models = array($models);
327
		}
328
		if(!count($models)) {
329
			user_error(
330
				'ModelAdmin::getManagedModels():
331
				You need to specify at least one DataObject subclass in public static $managed_models.
332
				Make sure that this property is defined, and that its visibility is set to "public"',
333
				E_USER_ERROR
334
			);
335
		}
336
337
		// Normalize models to have their model class in array key
338
		foreach($models as $k => $v) {
0 ignored issues
show
Bug introduced by
The expression $models of type object|integer|double|null|array|boolean is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
339
			if(is_numeric($k)) {
340
				$models[$v] = array('title' => singleton($v)->i18n_plural_name());
341
				unset($models[$k]);
342
			}
343
		}
344
345
		return $models;
346
	}
347
348
	/**
349
	 * Returns all importers defined in {@link self::$model_importers}.
350
	 * If none are defined, we fall back to {@link self::managed_models}
351
	 * with a default {@link CsvBulkLoader} class. In this case the column names of the first row
352
	 * in the CSV file are assumed to have direct mappings to properties on the object.
353
	 *
354
	 * @return array Map of model class names to importer instances
355
	 */
356
	public function getModelImporters() {
357
		$importerClasses = $this->stat('model_importers');
358
359
		// fallback to all defined models if not explicitly defined
360
		if(is_null($importerClasses)) {
361
			$models = $this->getManagedModels();
362
			foreach($models as $modelName => $options) {
0 ignored issues
show
Bug introduced by
The expression $models of type object|integer|double|null|array|boolean is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
363
				$importerClasses[$modelName] = 'SilverStripe\\Dev\\CsvBulkLoader';
364
			}
365
		}
366
367
		$importers = array();
368
		foreach($importerClasses as $modelClass => $importerClass) {
0 ignored issues
show
Bug introduced by
The expression $importerClasses of type object|integer|double|string|array|boolean|null is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
369
			$importers[$modelClass] = new $importerClass($modelClass);
370
		}
371
372
		return $importers;
373
	}
374
375
	/**
376
	 * Generate a CSV import form for a single {@link DataObject} subclass.
377
	 *
378
	 * @return Form|false
379
	 */
380
	public function ImportForm() {
381
		$modelSNG = singleton($this->modelClass);
382
		$modelName = $modelSNG->i18n_singular_name();
383
		// check if a import form should be generated
384
		if(!$this->showImportForm ||
385
			(is_array($this->showImportForm) && !in_array($this->modelClass, $this->showImportForm))
386
		) {
387
			return false;
388
		}
389
390
		$importers = $this->getModelImporters();
391
		if(!$importers || !isset($importers[$this->modelClass])) return false;
0 ignored issues
show
Bug Best Practice introduced by
The expression $importers of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
392
393
		if(!$modelSNG->canCreate(Member::currentUser())) return false;
394
395
		$fields = new FieldList(
396
			new HiddenField('ClassName', _t('ModelAdmin.CLASSTYPE'), $this->modelClass),
397
			new FileField('_CsvFile', false)
398
		);
399
400
		// get HTML specification for each import (column names etc.)
401
		$importerClass = $importers[$this->modelClass];
402
		/** @var BulkLoader $importer */
403
		$importer = new $importerClass($this->modelClass);
404
		$spec = $importer->getImportSpec();
405
		$specFields = new ArrayList();
406
		foreach($spec['fields'] as $name => $desc) {
407
			$specFields->push(new ArrayData(array('Name' => $name, 'Description' => $desc)));
408
		}
409
		$specRelations = new ArrayList();
410
		foreach($spec['relations'] as $name => $desc) {
411
			$specRelations->push(new ArrayData(array('Name' => $name, 'Description' => $desc)));
412
		}
413
		$specHTML = $this->customise(array(
414
			'ClassName' => $this->sanitiseClassName($this->modelClass),
415
			'ModelName' => Convert::raw2att($modelName),
416
			'Fields' => $specFields,
417
			'Relations' => $specRelations,
418
		))->renderWith($this->getTemplatesWithSuffix('_ImportSpec'));
419
420
		$fields->push(new LiteralField("SpecFor{$modelName}", $specHTML));
421
		$fields->push(
422
			new CheckboxField('EmptyBeforeImport', _t('ModelAdmin.EMPTYBEFOREIMPORT', 'Replace data'),
423
				false)
424
		);
425
426
		$actions = new FieldList(
427
			new FormAction('import', _t('ModelAdmin.IMPORT', 'Import from CSV'))
428
		);
429
430
		$form = new Form(
431
			$this,
432
			"ImportForm",
433
			$fields,
434
			$actions
435
		);
436
		$form->setFormAction(
437
			Controller::join_links($this->Link($this->sanitiseClassName($this->modelClass)), 'ImportForm')
438
		);
439
440
		$this->extend('updateImportForm', $form);
441
442
		return $form;
443
	}
444
445
	/**
446
	 * Imports the submitted CSV file based on specifications given in
447
	 * {@link self::model_importers}.
448
	 * Redirects back with a success/failure message.
449
	 *
450
	 * @todo Figure out ajax submission of files via jQuery.form plugin
451
	 *
452
	 * @param array $data
453
	 * @param Form $form
454
	 * @param HTTPRequest $request
455
	 * @return bool|HTTPResponse
456
	 */
457
	public function import($data, $form, $request) {
0 ignored issues
show
Unused Code introduced by
The parameter $request is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
Coding Style introduced by
import uses the super-global variable $_FILES which is generally not recommended.

Instead of super-globals, we recommend to explicitly inject the dependencies of your class. This makes your code less dependent on global state and it becomes generally more testable:

// Bad
class Router
{
    public function generate($path)
    {
        return $_SERVER['HOST'].$path;
    }
}

// Better
class Router
{
    private $host;

    public function __construct($host)
    {
        $this->host = $host;
    }

    public function generate($path)
    {
        return $this->host.$path;
    }
}

class Controller
{
    public function myAction(Request $request)
    {
        // Instead of
        $page = isset($_GET['page']) ? intval($_GET['page']) : 1;

        // Better (assuming you use the Symfony2 request)
        $page = $request->query->get('page', 1);
    }
}
Loading history...
458
		if(!$this->showImportForm || (is_array($this->showImportForm)
459
				&& !in_array($this->modelClass,$this->showImportForm))) {
460
461
			return false;
462
		}
463
464
		$importers = $this->getModelImporters();
465
		/** @var BulkLoader $loader */
466
		$loader = $importers[$this->modelClass];
467
468
		// File wasn't properly uploaded, show a reminder to the user
469
		if(
470
			empty($_FILES['_CsvFile']['tmp_name']) ||
471
			file_get_contents($_FILES['_CsvFile']['tmp_name']) == ''
472
		) {
473
			$form->sessionMessage(_t('ModelAdmin.NOCSVFILE', 'Please browse for a CSV file to import'), 'good');
474
			$this->redirectBack();
475
			return false;
476
		}
477
478
		if (!empty($data['EmptyBeforeImport']) && $data['EmptyBeforeImport']) { //clear database before import
479
			$loader->deleteExistingRecords = true;
480
		}
481
		$results = $loader->load($_FILES['_CsvFile']['tmp_name']);
482
483
		$message = '';
484
		if($results->CreatedCount()) $message .= _t(
485
			'ModelAdmin.IMPORTEDRECORDS', "Imported {count} records.",
486
			array('count' => $results->CreatedCount())
487
		);
488
		if($results->UpdatedCount()) $message .= _t(
489
			'ModelAdmin.UPDATEDRECORDS', "Updated {count} records.",
490
			array('count' => $results->UpdatedCount())
491
		);
492
		if($results->DeletedCount()) $message .= _t(
493
			'ModelAdmin.DELETEDRECORDS', "Deleted {count} records.",
494
			array('count' => $results->DeletedCount())
495
		);
496
		if(!$results->CreatedCount() && !$results->UpdatedCount()) {
497
			$message .= _t('ModelAdmin.NOIMPORT', "Nothing to import");
498
		}
499
500
		$form->sessionMessage($message, 'good');
501
		return $this->redirectBack();
502
	}
503
504
	/**
505
	 * @param bool $unlinked
506
	 * @return ArrayList
507
	 */
508
	public function Breadcrumbs($unlinked = false) {
509
		$items = parent::Breadcrumbs($unlinked);
510
511
		// Show the class name rather than ModelAdmin title as root node
512
		$models = $this->getManagedModels();
513
		$params = $this->getRequest()->getVars();
514
		if(isset($params['url'])) unset($params['url']);
515
516
		$items[0]->Title = $models[$this->modelClass]['title'];
517
		$items[0]->Link = Controller::join_links(
518
			$this->Link($this->sanitiseClassName($this->modelClass)),
519
			'?' . http_build_query($params)
520
		);
521
522
		return $items;
523
	}
524
525
}
526