Completed
Pull Request — master (#488)
by Helpful
1295:51 queued 1292:33
created

UserConfirmationStep::reject()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 22
Code Lines 13

Duplication

Lines 20
Ratio 90.91 %
Metric Value
dl 20
loc 22
rs 9.2
cc 3
eloc 13
nc 3
nop 0
1
<?php
2
3
/**
4
 * Pauses progression of a deployment while external authorisation is requested.
5
 * This is performed via the default messaging service specified.
6
 *
7
 * Configure using the below code in your deploy.yml
8
 *
9
 * MessagingArguments is an arbitrary array of arguments which is understood by
10
 * the service specified as the ConfirmationMessagingService for this project.
11
 * See deploynaut/_config/messaging.yml for the default service configuration.
12
 * See deploynaut/_config/pipeline.yml for the default step configuration
13
 *
14
 * <code>
15
 * Steps:
16
 *   RequestConfirmationStep:
17
 *     Class: UserConfirmationStep
18
 *     MaxDuration: 604800 # Auto time out after a week
19
 *     Recipients:
20
 *       - 021971373
21
 *       - [email protected]
22
 *     # Time delay between each of the above recipients being sent out
23
 *     RecipientsDelay: 4000
24
 *     Permissions:
25
 *       # Permissions required to allow deployment. Ensure that the recipients above are assigned this
26
 *       - APPROVE_DEPLOYMENT
27
 *     Messages:
28
 *       # Messages sent to all users (including <requester>)
29
 *       Reject: 'Deployment for <project>/<environment> has been rejected'
30
 *       Approve: 'Deployment for <project>/<environment> has been approved'
31
 *       TimeOut: 'Deployment approval for <project>/<environment> has timed out due to no response'
32
 *       # Messages only sent to requester
33
 *       Request-Requester: 'You requested approval for deployment of <project>/<environment>. Cancel? <abortlink>'
34
 *       # Messages only sent to specified recipients
35
 *       Request-Recipient: 'Deployment for <project>/<environment> requested by <requester>. Approve? <approvelink>'
36
 *     Subjects:
37
 *       # Subject line for all users
38
 *       Reject: 'Deployment for <project>/<environment>: Rejected'
39
 *       Approve: 'Deployment for <project>/<environment>: Approved'
40
 *       TimeOut: 'Deployment for <project>/<environment>: Timeout'
41
 *       Request: 'Deployment for <project>/<environment>: Requested'
42
 *     ServiceArguments:
43
 *       # Additional arguments that make sense to the ConfirmationMessagingService
44
 *       from: [email protected]
45
 *       reply-to: [email protected]
46
 * </code>
47
 *
48
 * @method Member Responder() Member who has given an approval for this request
49
 * @property int $ResponderID
50
 * @property string $Approval
51
 * @property int $NotifiedGroup
52
 * @package deploynaut
53
 * @subpackage pipeline
54
 */
55
class UserConfirmationStep extends LongRunningPipelineStep {
56
57
	/**
58
	 * Messages
59
	 */
60
	const ALERT_APPROVE = 'Approve';
61
	const ALERT_TIMEOUT = 'TimeOut';
62
	const ALERT_REQUEST = 'Request';
63
	const ALERT_REJECT = 'Reject';
64
65
	/**
66
	 * Message roles
67
	 */
68
	const ROLE_REQUESTER = 'Requester';
69
	const ROLE_RECIPIENT = 'Recipient';
70
71
	/**
72
	 * @var array
73
	 */
74
	private static $db = array(
75
		// A finished step is approved and a failed step is rejected.
76
		// Aborted confirmation is left as None
77
		'Approval' => "Enum('Approved,Rejected,None', 'None')",
78
		// If RecipientsDelay is specified, this value records the index of the most recently notified
79
		// group of users. This will be incremented once another level of fallback has been notified.
80
		// E.g. once primary admin has been notified, the secondary admin can be notified, and this
81
		// is incremented
82
		'NotifiedGroup' => 'Int'
83
	);
84
85
	/**
86
	 * @var array
87
	 */
88
	private static $defaults = array(
89
		'Approval' => 'None',
90
		'NotifiedGroup' => 0
91
	);
92
93
	/**
94
	 * @var array
95
	 */
96
	private static $has_one = array(
97
		'Responder' => 'Member'
98
	);
99
100
	/**
101
	 * This step depends on a configured messaging service
102
	 *
103
	 * @config
104
	 * @var array
105
	 */
106
	private static $dependencies = array(
107
		'MessagingService' => '%$ConfirmationMessagingService'
108
	);
109
110
	/**
111
	 * Currently assigned messaging service
112
	 *
113
	 * @var ConfirmationMessagingService
114
	 */
115
	private $messagingService = null;
116
117
	/**
118
	 * Assign a messaging service for this step
119
	 *
120
	 * @param ConfirmationMessagingService $service
121
	 */
122
	public function setMessagingService(ConfirmationMessagingService $service) {
123
		$this->messagingService = $service;
124
	}
125
126
	/**
127
	 * Get the currently configured messaging service
128
	 *
129
	 * @return ConfirmationMessagingService
130
	 */
131
	public function getMessagingService() {
132
		return $this->messagingService;
133
	}
134
135
	/**
136
	 * Determine if the confirmation has been responded to (ether with acceptance, rejection, or cancelled)
137
	 *
138
	 * @return boolean
139
	 */
140
	public function hasResponse() {
141
		return $this->Approval !== 'None';
142
	}
143
144
	public function start() {
145
		parent::start();
146
147
		// Just in case this step is being mistakenly restarted
148
		if($this->hasResponse()) {
149
			$this->log("{$this->Title} has already been processed with a response of {$this->Approval}");
150
			$this->markFailed();
151
			return false;
152
		}
153
154
		// Begin or process this step
155
		switch($this->Status) {
156
			case 'Started':
157
				return $this->checkStatus();
158
			case 'Queued':
159
				return $this->startApproval();
160
			default:
161
				$this->log("Unable to process {$this->Title} with status of {$this->Status}");
162
				$this->markFailed();
163
				return false;
164
		}
165
	}
166
167
	/**
168
	 * Can the current user approve this pipeline?
169
	 *
170
	 * @param Member|null $member
171
	 * @return boolean
172
	 */
173
	public function canApprove($member = null) {
174
		return $this->Pipeline()->Environment()->canApprove($member);
175
	}
176
177
	/**
178
	 * When the recipient wishes to approve this request
179
	 *
180
	 * @return boolean True if successful
181
	 */
182 View Code Duplication
	public function approve() {
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
183
		// Check permission
184
		if(!$this->canApprove()) {
185
			return Security::permissionFailure(
186
				null,
187
				_t("UserConfirmationStep.DENYAPPROVE", "You do not have permission to approve this deployment")
188
			);
189
		}
190
191
		// Skip subsequent approvals if already approved / rejected
192
		if($this->hasResponse()) {
193
			return;
194
		}
195
196
		// Approve
197
		$this->Approval = 'Approved';
198
		$this->log("{$this->Title} has been approved");
199
		$this->ResponderID = Member::currentUserID();
200
		$this->finish();
201
		$this->sendMessage(self::ALERT_APPROVE);
202
		return true;
203
	}
204
205
	/**
206
	 * When the recipient wishes to reject this request
207
	 *
208
	 * @return boolean True if successful
209
	 */
210 View Code Duplication
	public function reject() {
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
211
		// Check permission
212
		if(!$this->canApprove()) {
213
			return Security::permissionFailure(
214
				null,
215
				_t("UserConfirmationStep.DENYREJECT", "You do not have permission to reject this deployment")
216
			);
217
		}
218
219
		// Skip subsequent approvals if already approved / rejected
220
		if($this->hasResponse()) {
221
			return;
222
		}
223
224
		// Reject
225
		$this->Approval = 'Rejected';
226
		$this->log("{$this->Title} has been rejected");
227
		$this->ResponderID = Member::currentUserID();
228
		$this->markFailed(false);
229
		$this->sendMessage(self::ALERT_REJECT);
230
		return true;
231
	}
232
233
	/**
234
	 * Report the status of the current request queue and makes sure it hasn't overrun it's time allowed
235
	 *
236
	 * @return boolean True if not failed
237
	 */
238
	protected function checkStatus() {
239
		// For running or queued tasks ensure that we have not exceeded
240
		// a reasonable time-elapsed to consider this job inactive
241
		if($this->isTimedOut()) {
242
			$days = round($this->MaxDuration / (24.0 * 3600.0), 1);
243
			$logMessage = "{$this->Title} took longer than {$this->MaxDuration} seconds ($days days) to be approved "
244
				. "and has timed out";
245
			$this->log($logMessage);
246
			$this->markFailed();
247
			$this->sendMessage(self::ALERT_TIMEOUT);
248
			return false;
249
		}
250
251
		// If operating on a delayed notification schedule, determine if there are further groups who should be notified
252
		if($delay = $this->getConfigSetting('RecipientsDelay')) {
253
			// Check group that should have been notified by now
254
			$age = $this->getAge();
255
			$escallateGroup = intval($age / $delay);
256
			$recipients = $this->getConfigSetting('Recipients');
257
			$lastGroup = count($recipients) - 1;
258
			// If we can notify the next group, do so
259
			// We only escallate one group at a time to ensure correct order is followed
260
			if($this->NotifiedGroup < $lastGroup && $this->NotifiedGroup < $escallateGroup) {
261
				$this->NotifiedGroup++;
262
				$groupDescription = is_array($recipients[$this->NotifiedGroup])
263
					? implode(',', $recipients[$this->NotifiedGroup])
264
					: $recipients[$this->NotifiedGroup];
265
				$this->log("Escalating approval request to group {$this->NotifiedGroup}: '$groupDescription'");
266
				// Send to selected group
267
				$this->sendMessage(self::ALERT_REQUEST, $this->NotifiedGroup);
268
				$this->write();
269
				return true;
270
			}
271
		}
272
273
274
		// While still running report no error, waiting for resque job to eventually finish.
275
		// Since this could potentially fill the log with hundreds of thousands of messages,
276
		// if it takes a few days to get a response, don't write anything.
277
		return true;
278
	}
279
280
	/**
281
	 * Initiate the approval process
282
	 */
283
	public function startApproval() {
284
		$this->Status = 'Started';
285
		$this->log("Starting {$this->Title}...");
286
		// Determine if we should use delayed notification
287
		$recipientGroup = 'all';
288
		if($this->getConfigSetting('RecipientsDelay')) {
289
			$this->NotifiedGroup = $recipientGroup = 0;
290
		}
291
		// Send to selected group
292
		$this->sendMessage(self::ALERT_REQUEST, $recipientGroup);
293
		$this->write();
294
		return true;
295
	}
296
297
	/**
298
	 * Finds a message template for a given role and message
299
	 *
300
	 * @param string $role Role name for role-customised messages. Usually 'Requester' or 'Recipient'
301
	 * @param string $messageID Message ID
302
	 * @return array Resulting array(subject, message)
303
	 */
304
	protected function generateMessageTemplate($role, $messageID) {
305
		$subject = $this->getConfigSetting('Subjects', "$messageID-$role")
306
				?: $this->getConfigSetting('Subjects', $messageID);
307
		$message = $this->getConfigSetting('Messages', "$messageID-$role")
308
				?: $this->getConfigSetting('Messages', $messageID);
309
		$substitutions = $this->getReplacements();
310
		return $this
311
			->Pipeline()
312
			->injectMessageReplacements($message, $subject, $substitutions);
313
	}
314
315
	/**
316
	 * Retrieve message replacements
317
	 *
318
	 * @return array
319
	 */
320
	public function getReplacements() {
321
		// Get member who began this request
322
		return array_merge(
323
			$this->Pipeline()->getReplacements(),
324
			array(
325
				// Note that this actually displays the link to the interface to approve,
326
				// not the direct link to the approve action
327
				'<approvelink>' => Director::absoluteURL($this->Pipeline()->Environment()->Link())
328
			)
329
		);
330
	}
331
332
	/**
333
	 * Sends a message to a specified recipient(s)
334
	 *
335
	 * @param string $messageID Message ID. One of 'Reject', 'Approve', 'TimeOut' or 'Request'
336
	 * @param mixed $recipientGroup Either a numeric index of the next recipient to send to, or "all" for all
337
	 * This is used for delayed notification so that failover recipients can be notified.
338
	 * @return boolean|null True if successful
339
	 */
340
	protected function sendMessage($messageID, $recipientGroup = 'all') {
341
		// Add additionally configured arguments
342
		$arguments = $this->getConfigSetting('ServiceArguments') ?: array();
343
344
		// Get member who began this request
345
		$author = $this->Pipeline()->Author();
346
347
		// Send message to requester
348
		if($recipientGroup === 'all' || $recipientGroup === 0) {
349
			list($subject, $message) = $this->generateMessageTemplate(self::ROLE_REQUESTER, $messageID);
350
			if($subject && $message) {
351
				$this->log("{$this->Title} sending $messageID message to {$author->Email}");
352
				$extra = array('subject' => $subject);
353
				$this->messagingService->sendMessage($this, $message, $author, array_merge($arguments, $extra));
354
			}
355
		}
356
357
		// Filter recipients based on group
358
		$recipients = $this->getConfigSetting('Recipients');
359
		if(is_array($recipients) && $recipientGroup !== 'all') {
360
			$recipients = isset($recipients[$recipientGroup])
361
				? $recipients[$recipientGroup]
362
				: null;
363
		}
364
		if(empty($recipients)) {
365
			$this->log("Skipping sending message to empty recipients");
366
			return;
367
		}
368
369
		// Send to recipients
370
		list($subject, $message) = $this->generateMessageTemplate(self::ROLE_RECIPIENT, $messageID);
371
		if($subject && $message && $recipients) {
372
			$recipientsStr = is_array($recipients) ? implode(',', $recipients) : $recipients;
373
			$this->log("{$this->Title} sending $messageID message to $recipientsStr");
374
			$extra = array('subject' => $subject);
375
			$this->messagingService->sendMessage($this, $message, $recipients, array_merge($arguments, $extra));
376
		}
377
	}
378
379
	public function getRunningDescription() {
380
381
		// Don't show options if this step has already been confirmed
382
		if($this->hasResponse() || !$this->isRunning()) {
383
			return;
384
		}
385
386
		return 'This deployment is currently awaiting approval before it can complete.';
387
	}
388
389
	public function allowedActions() {
390
		// Don't show options if this step has already been confirmed or can't be confirmed
391
		if($this->hasResponse() || !$this->isRunning() || !$this->canApprove()) {
392
			return parent::allowedActions();
393
		}
394
395
		// Return actions
396
		return array(
397
			'approve' => array(
398
				'ButtonText' => 'Approve',
399
				'ButtonType' => 'btn-success',
400
				'Link' => $this->Pipeline()->StepLink('approve'),
401
				'Title' => 'Approves the current deployment, allowing it to continue'
402
			),
403
			'reject' => array(
404
				'ButtonText' => 'Reject',
405
				'ButtonType' => 'btn-danger',
406
				'Link' => $this->Pipeline()->StepLink('reject'),
407
				'Title' => 'Deny the request to release this deployment'
408
			)
409
		);
410
	}
411
412
}
413