1
|
|
|
<?php |
2
|
|
|
|
3
|
|
|
/** |
4
|
|
|
* Iteratively exports GridField data to a CSV file on disk, in order to support large exports. |
5
|
|
|
* The generated file can be downloaded by the user through a CMS UI provided in {@link GridFieldQueuedExportButton}. |
6
|
|
|
* |
7
|
|
|
* Simulates a request to the GridFieldQueuedExportButton controller to retrieve the GridField instance, |
8
|
|
|
* from which the original data context can be derived (as an {@link SS_List instance). |
9
|
|
|
* This is a necessary workaround due to the limitations on serialising GridField's data description logic. |
10
|
|
|
* While a DataList is serialisable, other SS_List instances might not be. |
11
|
|
|
* We'd also need to consider custom value transformations applied via GridField->customDataFields lambdas. |
12
|
|
|
* |
13
|
|
|
* Relies on GridField being accessible in its original CMS controller context to the user |
14
|
|
|
* who triggered the export. |
15
|
|
|
*/ |
16
|
|
|
class GenerateCSVJob extends AbstractQueuedJob { |
|
|
|
|
17
|
|
|
|
18
|
|
|
public function __construct() { |
19
|
|
|
$this->ID = uniqid(); |
|
|
|
|
20
|
|
|
$this->Seperator = ','; |
|
|
|
|
21
|
|
|
$this->IncludeHeader = true; |
|
|
|
|
22
|
|
|
$this->HeadersOutput = false; |
|
|
|
|
23
|
|
|
$this->totalSteps = 1; |
24
|
|
|
} |
25
|
|
|
|
26
|
|
|
/** |
27
|
|
|
* @return string |
28
|
|
|
*/ |
29
|
|
|
public function getJobType() { |
30
|
|
|
return QueuedJob::QUEUED; |
31
|
|
|
} |
32
|
|
|
|
33
|
|
|
/** |
34
|
|
|
* @return string |
35
|
|
|
*/ |
36
|
|
|
public function getTitle() { |
37
|
|
|
return "Export a CSV of a Gridfield"; |
38
|
|
|
} |
39
|
|
|
|
40
|
|
|
/** |
41
|
|
|
* @return string |
42
|
|
|
*/ |
43
|
|
|
public function getSignature() { |
44
|
|
|
return md5(get_class($this) . '-' . $this->ID); |
|
|
|
|
45
|
|
|
} |
46
|
|
|
/** |
47
|
|
|
* @param GridField $gridField |
48
|
|
|
*/ |
49
|
|
|
function setGridField(GridField $gridField) { |
|
|
|
|
50
|
|
|
$this->GridFieldName = $gridField->getName(); |
|
|
|
|
51
|
|
|
$this->GridFieldURL = $gridField->Link(); |
|
|
|
|
52
|
|
|
} |
53
|
|
|
|
54
|
|
|
/** |
55
|
|
|
* @param $session |
56
|
|
|
*/ |
57
|
|
|
function setSession($session) { |
|
|
|
|
58
|
|
|
// None of the gridfield actions are needed, and they make the stored session bigger, so pull |
59
|
|
|
// them out. |
60
|
|
|
$actionkeys = array_filter(array_keys($session), function ($i) { |
61
|
|
|
return strpos($i, 'gf_') === 0; |
62
|
|
|
}); |
63
|
|
|
|
64
|
|
|
$session = array_diff_key($session, array_flip($actionkeys)); |
65
|
|
|
|
66
|
|
|
// This causes problems with logins |
67
|
|
|
unset($session['HTTP_USER_AGENT']); |
68
|
|
|
|
69
|
|
|
$this->Session = $session; |
|
|
|
|
70
|
|
|
} |
71
|
|
|
|
72
|
|
|
function setColumns($columns) { |
|
|
|
|
73
|
|
|
$this->Columns = $columns; |
|
|
|
|
74
|
|
|
} |
75
|
|
|
|
76
|
|
|
function setSeparator($seperator) { |
|
|
|
|
77
|
|
|
$this->Separator = $seperator; |
|
|
|
|
78
|
|
|
} |
79
|
|
|
|
80
|
|
|
function setIncludeHeader($includeHeader) { |
|
|
|
|
81
|
|
|
$this->IncludeHeader = $includeHeader; |
|
|
|
|
82
|
|
|
} |
83
|
|
|
|
84
|
|
|
protected function getOutputPath() { |
85
|
|
|
$base = ASSETS_PATH . '/.exports'; |
86
|
|
|
if (!is_dir($base)) mkdir($base, 0770, true); |
87
|
|
|
|
88
|
|
|
// Although the string is random, so should be hard to guess, also try and block access directly. |
89
|
|
|
// Only works in Apache though |
90
|
|
|
if (!file_exists("$base/.htaccess")) { |
91
|
|
|
file_put_contents("$base/.htaccess", "Deny from all\nRewriteRule .* - [F]\n"); |
92
|
|
|
} |
93
|
|
|
|
94
|
|
|
$folder = $base.'/'.$this->getSignature(); |
95
|
|
|
if (!is_dir($folder)) mkdir($folder, 0770, true); |
96
|
|
|
|
97
|
|
|
return $folder.'/'.$this->getSignature().'.csv'; |
98
|
|
|
} |
99
|
|
|
|
100
|
|
|
/** |
101
|
|
|
* @return GridField |
102
|
|
|
* @throws SS_HTTPResponse_Exception |
103
|
|
|
*/ |
104
|
|
|
protected function getGridField() { |
105
|
|
|
$session = $this->Session; |
|
|
|
|
106
|
|
|
|
107
|
|
|
// Store state in session, and pass ID to client side. |
108
|
|
|
$state = array( |
109
|
|
|
'grid' => $this->GridFieldName, |
|
|
|
|
110
|
|
|
'actionName' => 'findgridfield', |
111
|
|
|
'args' => null |
112
|
|
|
); |
113
|
|
|
|
114
|
|
|
// Ensure $id doesn't contain only numeric characters |
115
|
|
|
$id = 'gf_' . substr(md5(serialize($state)), 0, 8); |
116
|
|
|
|
117
|
|
|
// Simulate CSRF token use, hardcode to a random value in our fake session |
118
|
|
|
// so GridField can evaluate it in the Director::test() execution |
119
|
|
|
$token = Injector::inst()->create('RandomGenerator')->randomToken('sha1'); |
120
|
|
|
|
121
|
|
|
// Add new form action into session for GridField to find when Director::test is called below |
122
|
|
|
$session[$id] = $state; |
123
|
|
|
$session['SecurityID'] = $token; |
124
|
|
|
|
125
|
|
|
// Construct the URL |
126
|
|
|
$actionKey = 'action_gridFieldAlterAction?' . http_build_query(['StateID' => $id]); |
127
|
|
|
$actionValue = 'Find Gridfield'; |
128
|
|
|
|
129
|
|
|
$url = Controller::join_links( |
130
|
|
|
$this->GridFieldURL, |
|
|
|
|
131
|
|
|
'?' .http_build_query([$actionKey => $actionValue, 'SecurityID' => $token]) |
132
|
|
|
); |
133
|
|
|
|
134
|
|
|
// Restore into the current session the user the job is exporting as |
135
|
|
|
Session::set("loggedInAs", $session['loggedInAs']); |
136
|
|
|
|
137
|
|
|
// Then make a sub-query that should return a special SS_HTTPResponse with the gridfield object |
138
|
|
|
$res = Director::test($url, null, new Session($session), 'GET'); |
139
|
|
|
|
140
|
|
|
// Great, it did, we can return it |
141
|
|
|
if ($res instanceof GridFieldQueuedExportButton_Response) { |
142
|
|
|
$gridField = $res->getGridField(); |
143
|
|
|
$gridField->getConfig()->removeComponentsByType('GridFieldPaginator'); |
144
|
|
|
$gridField->getConfig()->removeComponentsByType('GridFieldPageCount'); |
145
|
|
|
|
146
|
|
|
return $gridField; |
147
|
|
|
} else { |
148
|
|
|
user_error('Couldn\'t restore GridField', E_USER_ERROR); |
149
|
|
|
} |
150
|
|
|
} |
151
|
|
|
|
152
|
|
|
/** |
153
|
|
|
* @param $gridField |
154
|
|
|
* @param $columns |
155
|
|
|
*/ |
156
|
|
|
protected function outputHeader($gridField, $columns) { |
|
|
|
|
157
|
|
|
$fileData = ''; |
158
|
|
|
$separator = $this->Separator; |
|
|
|
|
159
|
|
|
|
160
|
|
|
$headers = array(); |
161
|
|
|
|
162
|
|
|
// determine the CSV headers. If a field is callable (e.g. anonymous function) then use the |
163
|
|
|
// source name as the header instead |
164
|
|
|
foreach ($columns as $columnSource => $columnHeader) { |
165
|
|
|
$headers[] = (!is_string($columnHeader) && is_callable($columnHeader)) ? $columnSource : $columnHeader; |
166
|
|
|
} |
167
|
|
|
|
168
|
|
|
$fileData .= "\"" . implode("\"{$separator}\"", array_values($headers)) . "\""; |
169
|
|
|
$fileData .= "\n"; |
170
|
|
|
|
171
|
|
|
file_put_contents($this->getOutputPath(), $fileData, FILE_APPEND); |
172
|
|
|
} |
173
|
|
|
|
174
|
|
|
/** |
175
|
|
|
* This method is adapted from GridField->generateExportFileData() |
176
|
|
|
* |
177
|
|
|
* @param GridField $gridField |
178
|
|
|
* @param array $columns |
179
|
|
|
* @param int $start |
180
|
|
|
* @param int $count |
181
|
|
|
*/ |
182
|
|
|
protected function outputRows(GridField $gridField, $columns, $start, $count) { |
183
|
|
|
$fileData = ''; |
184
|
|
|
$separator = $this->Separator; |
|
|
|
|
185
|
|
|
|
186
|
|
|
$items = $gridField->getManipulatedList(); |
187
|
|
|
$items = $items->limit($count, $start); |
188
|
|
|
|
189
|
|
|
foreach ($items as $item) { |
190
|
|
|
if (!$item->hasMethod('canView') || $item->canView()) { |
191
|
|
|
$columnData = array(); |
192
|
|
|
|
193
|
|
|
foreach ($columns as $columnSource => $columnHeader) { |
194
|
|
|
if (!is_string($columnHeader) && is_callable($columnHeader)) { |
195
|
|
|
if ($item->hasMethod($columnSource)) { |
196
|
|
|
$relObj = $item->{$columnSource}(); |
197
|
|
|
} else { |
198
|
|
|
$relObj = $item->relObject($columnSource); |
199
|
|
|
} |
200
|
|
|
|
201
|
|
|
$value = $columnHeader($relObj); |
202
|
|
|
} else { |
203
|
|
|
$value = $gridField->getDataFieldValue($item, $columnSource); |
204
|
|
|
|
205
|
|
|
if ($value === null) { |
206
|
|
|
$value = $gridField->getDataFieldValue($item, $columnHeader); |
207
|
|
|
} |
208
|
|
|
} |
209
|
|
|
|
210
|
|
|
$value = str_replace(array("\r", "\n"), "\n", $value); |
211
|
|
|
$columnData[] = '"' . str_replace('"', '""', $value) . '"'; |
212
|
|
|
} |
213
|
|
|
|
214
|
|
|
$fileData .= implode($separator, $columnData); |
215
|
|
|
$fileData .= "\n"; |
216
|
|
|
} |
217
|
|
|
|
218
|
|
|
if ($item->hasMethod('destroy')) { |
219
|
|
|
$item->destroy(); |
220
|
|
|
} |
221
|
|
|
} |
222
|
|
|
|
223
|
|
|
file_put_contents($this->getOutputPath(), $fileData, FILE_APPEND); |
224
|
|
|
} |
225
|
|
|
|
226
|
|
|
public function setup() { |
227
|
|
|
$gridField = $this->getGridField(); |
228
|
|
|
$this->totalSteps = $gridField->getManipulatedList()->count(); |
229
|
|
|
} |
230
|
|
|
|
231
|
|
|
/** |
232
|
|
|
* Generate export fields for CSV. |
233
|
|
|
* |
234
|
|
|
* @param GridField $gridField |
|
|
|
|
235
|
|
|
* @return array |
236
|
|
|
*/ |
237
|
|
|
public function process() { |
238
|
|
|
$gridField = $this->getGridField(); |
239
|
|
|
$columns = $this->Columns ?: singleton($gridField->getModelClass())->summaryFields(); |
|
|
|
|
240
|
|
|
|
241
|
|
|
if ($this->IncludeHeader && !$this->HeadersOutput) { |
|
|
|
|
242
|
|
|
$this->outputHeader($gridField, $columns); |
243
|
|
|
$this->HeadersOutput = true; |
|
|
|
|
244
|
|
|
} |
245
|
|
|
|
246
|
|
|
$this->outputRows($gridField, $columns, $this->currentStep, 100); |
|
|
|
|
247
|
|
|
|
248
|
|
|
$this->currentStep += 100; |
249
|
|
|
|
250
|
|
|
if ($this->currentStep >= $this->totalSteps) { |
251
|
|
|
$this->isComplete = true; |
252
|
|
|
} |
253
|
|
|
} |
254
|
|
|
} |
255
|
|
|
|
You can fix this by adding a namespace to your class:
When choosing a vendor namespace, try to pick something that is not too generic to avoid conflicts with other libraries.