Completed
Push — master ( 3a7c63...41526f )
by René
01:23 queued 01:19
created

OptionService::shift()   A

Complexity

Conditions 4
Paths 5

Size

Total Lines 21
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 20

Importance

Changes 0
Metric Value
eloc 12
dl 0
loc 21
ccs 0
cts 15
cp 0
rs 9.8666
c 0
b 0
f 0
cc 4
nc 5
nop 3
crap 20
1
<?php
2
/**
3
 * @copyright Copyright (c) 2017 Vinzenz Rosenkranz <[email protected]>
4
 *
5
 * @author René Gieling <[email protected]>
6
 *
7
 * @license GNU AGPL version 3 or any later version
8
 *
9
 *  This program is free software: you can redistribute it and/or modify
10
 *  it under the terms of the GNU Affero General Public License as
11
 *  published by the Free Software Foundation, either version 3 of the
12
 *  License, or (at your option) any later version.
13
 *
14
 *  This program is distributed in the hope that it will be useful,
15
 *  but WITHOUT ANY WARRANTY; without even the implied warranty of
16
 *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17
 *  GNU Affero General Public License for more details.
18
 *
19
 *  You should have received a copy of the GNU Affero General Public License
20
 *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
21
 *
22
 */
23
24
namespace OCA\Polls\Service;
25
26
use DateTime;
27
use OCP\AppFramework\Db\DoesNotExistException;
28
use OCA\Polls\Exceptions\NotAuthorizedException;
29
use OCA\Polls\Exceptions\BadRequestException;
30
use OCA\Polls\Exceptions\DuplicateEntryException;
31
use OCA\Polls\Exceptions\InvalidPollTypeException;
32
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
33
34
use OCA\Polls\Db\OptionMapper;
35
use OCA\Polls\Db\VoteMapper;
36
use OCA\Polls\Db\Vote;
37
use OCA\Polls\Db\Option;
38
use OCA\Polls\Db\PollMapper;
39
use OCA\Polls\Db\Poll;
40
use OCA\Polls\Db\Watch;
41
use OCA\Polls\Model\Acl;
42
43
class OptionService {
44
45
	/** @var Acl */
46
	private $acl;
47
48
	/** @var Option */
49
	private $option;
50
51
	/** @var int */
52
	private $countParticipants;
53
54
	/** @var Poll */
55
	private $poll;
56
57
	/** @var Option[] */
58
	private $options;
59
60
	/** @var Vote[] */
61
	private $votes;
62
63
	/** @var OptionMapper */
64
	private $optionMapper;
65
66
	/** @var PollMapper */
67
	private $pollMapper;
68
69
	/** @var VoteMapper */
70
	private $voteMapper;
71
72
	/** @var WatchService */
73
	private $watchService;
74
75
	public function __construct(
76
		Acl $acl,
77
		Option $option,
78
		OptionMapper $optionMapper,
79
		PollMapper $pollMapper,
80
		VoteMapper $voteMapper,
81
		WatchService $watchService
82
	) {
83
		$this->acl = $acl;
84
		$this->option = $option;
85
		$this->optionMapper = $optionMapper;
86
		$this->pollMapper = $pollMapper;
87
		$this->voteMapper = $voteMapper;
88
		$this->watchService = $watchService;
89
	}
90
91
	/**
92
	 * Get all options of given poll
93
	 *
94
	 * @return Option[]
95
	 *
96
	 * @psalm-return array<array-key, Option>
97
	 */
98
	public function list(int $pollId = 0, string $token = ''): array {
99
		if ($token) {
100
			$this->acl->setToken($token)->request(Acl::PERMISSION_VIEW);
101
			$pollId = $this->acl->getPollId();
102
		} else {
103
			$this->acl->setPollId($pollId)->request(Acl::PERMISSION_VIEW);
104
		}
105
106
		try {
107
			$this->poll = $this->pollMapper->find($pollId);
108
			$this->options = $this->optionMapper->findByPoll($pollId);
109
			$this->votes = $this->voteMapper->findByPoll($pollId);
110
			$this->countParticipants = count($this->voteMapper->findParticipantsByPoll($pollId));
111
112
			$this->calculateVotes();
113
114
			if ($this->poll->getHideBookedUp() && !$this->acl->isAllowed(Acl::PERMISSION_EDIT)) {
115
				// hide booked up options except the user has edit permission
116
				$this->filterBookedUp();
117
			} elseif ($this->acl->isAllowed(Acl::PERMISSION_SEE_RESULTS)) {
118
				$this->calculateRanks();
119
			}
120
121
			return array_values($this->options);
122
		} catch (DoesNotExistException $e) {
123
			return [];
124
		}
125
	}
126
127
	/**
128
	 * Get option
129
	 *
130
	 * @return Option
131
	 */
132
	public function get(int $optionId): Option {
133
		$this->acl->setPollId($this->optionMapper->find($optionId)->getPollId())
134
			->request(Acl::PERMISSION_VIEW);
135
		return $this->optionMapper->find($optionId);
136
	}
137
138
139
	/**
140
	 * Add a new option
141
	 *
142
	 * @return Option
143
	 */
144
	public function add(int $pollId, int $timestamp = 0, string $pollOptionText = '', ?int $duration = 0): Option {
145
		$this->acl->setPollId($pollId)->request(Acl::PERMISSION_EDIT);
146
		$this->option = new Option();
147
		$this->option->setPollId($pollId);
148
		$this->option->setOrder($this->getHighestOrder($this->option->getPollId()) + 1);
149
		$this->setOption($timestamp, $pollOptionText, $duration);
150
151
		try {
152
			$this->option = $this->optionMapper->insert($this->option);
153
			$this->watchService->writeUpdate($this->option->getPollId(), Watch::OBJECT_OPTIONS);
154
		} catch (UniqueConstraintViolationException $e) {
155
			throw new DuplicateEntryException('This option already exists');
156
		}
157
		return $this->option;
158
	}
159
160
	/**
161
	 * Update option
162
	 *
163
	 * @return Option
164
	 */
165
	public function update(int $optionId, int $timestamp = 0, ?string $pollOptionText = '', ?int $duration = 0): Option {
166
		$this->option = $this->optionMapper->find($optionId);
167
		$this->acl->setPollId($this->option->getPollId())->request(Acl::PERMISSION_EDIT);
168
		$this->setOption($timestamp, $pollOptionText, $duration);
169
170
		$this->option = $this->optionMapper->update($this->option);
171
		$this->watchService->writeUpdate($this->option->getPollId(), Watch::OBJECT_OPTIONS);
172
		return $this->option;
173
	}
174
175
	/**
176
	 * Delete option
177
	 *
178
	 * @return Option
179
	 */
180
	public function delete(int $optionId): Option {
181
		$this->option = $this->optionMapper->find($optionId);
182
		$this->acl->setPollId($this->option->getPollId())->request(Acl::PERMISSION_EDIT);
183
		$this->optionMapper->delete($this->option);
184
		$this->watchService->writeUpdate($this->option->getPollId(), Watch::OBJECT_OPTIONS);
185
186
		return $this->option;
187
	}
188
189
	/**
190
	 * Switch option confirmation
191
	 *
192
	 * @return Option
193
	 */
194
	public function confirm(int $optionId): Option {
195
		$this->option = $this->optionMapper->find($optionId);
196
		$this->acl->setPollId($this->option->getPollId())->request(Acl::PERMISSION_EDIT);
197
198
		$this->option->setConfirmed($this->option->getConfirmed() ? 0 : time());
199
		$this->option = $this->optionMapper->update($this->option);
200
		$this->watchService->writeUpdate($this->option->getPollId(), Watch::OBJECT_OPTIONS);
201
		return $this->option;
202
	}
203
204
	/**
205
	 * Make a sequence of date poll options
206
	 * @param int $optionId
207
	 * @param int $step - The step for creating the sequence
208
	 * @param string $unit - The timeunit (year, month, ...)
209
	 * @param int $amount - Number of sequence items to create
210
	 *
211
	 * @return Option[]
212
	 *
213
	 * @psalm-return array<array-key, Option>
214
	 */
215
	public function sequence(int $optionId, int $step, string $unit, int $amount): array {
216
		$baseDate = new DateTime;
217
		$this->option = $this->optionMapper->find($optionId);
218
		$this->acl->setPollId($this->option->getPollId())->request(Acl::PERMISSION_EDIT);
219
220
		if ($this->pollMapper->find($this->acl->getPollId())->getType() !== Poll::TYPE_DATE) {
221
			throw new InvalidPollTypeException('Only allowed in date polls');
222
		}
223
224
225
		if ($step === 0) {
226
			return $this->optionMapper->findByPoll($this->option->getPollId());
227
		}
228
229
		$baseDate->setTimestamp($this->option->getTimestamp());
230
231
		for ($i = 0; $i < $amount; $i++) {
232
			$clonedOption = new Option();
233
			$clonedOption->setPollId($this->option->getPollId());
234
			$clonedOption->setDuration($this->option->getDuration());
235
			$clonedOption->setConfirmed(0);
236
			$clonedOption->setTimestamp($baseDate->modify($step . ' ' . $unit)->getTimestamp());
237
			$clonedOption->setOrder($clonedOption->getTimestamp());
238
			$clonedOption->setPollOptionText($baseDate->format('c'));
239
			try {
240
				$this->optionMapper->insert($clonedOption);
241
			} catch (UniqueConstraintViolationException $e) {
242
				\OC::$server->getLogger()->warning('skip adding ' . $baseDate->format('c') . 'for pollId' . $this->option->getPollId() . '. Option alredy exists.');
243
			}
244
		}
245
		$this->watchService->writeUpdate($this->option->getPollId(), Watch::OBJECT_OPTIONS);
246
		return $this->optionMapper->findByPoll($this->option->getPollId());
247
	}
248
249
	/**
250
	 * Shift all date options
251
	 * @param int $pollId
252
	 * @param int $step - The step for creating the sequence
253
	 * @param string $unit - The timeunit (year, month, ...)
254
	 *
255
	 * @return Option[]
256
	 *
257
	 * @psalm-return array<array-key, Option>
258
	 */
259
	public function shift(int $pollId, int $step, string $unit): array {
260
		$this->acl->setPollId($pollId)->request(Acl::PERMISSION_EDIT);
261
		if ($this->pollMapper->find($pollId)->getType() !== Poll::TYPE_DATE) {
262
			throw new InvalidPollTypeException('Only allowed in date polls');
263
		}
264
265
		$this->options = $this->optionMapper->findByPoll($pollId);
266
		$shiftedDate = new DateTime;
267
268
		if ($step > 0) {
269
			// avoid contraint errors
270
			$this->options = array_reverse($this->options);
271
		}
272
273
		foreach ($this->options as $option) {
274
			$shiftedDate->setTimestamp($option->getTimestamp());
275
			$option->setTimestamp($shiftedDate->modify($step . ' ' . $unit)->getTimestamp());
276
			$this->optionMapper->update($option);
277
		}
278
279
		return $this->optionMapper->findByPoll($pollId);
280
	}
281
282
	/**
283
	 * Copy options from $fromPoll to $toPoll
284
	 *
285
	 * @return Option[]
286
	 *
287
	 * @psalm-return array<array-key, Option>
288
	 */
289
	public function clone(int $fromPollId, int $toPollId): array {
290
		$this->acl->setPollId($fromPollId);
291
292
		foreach ($this->optionMapper->findByPoll($fromPollId) as $origin) {
293
			$option = new Option();
294
			$option->setPollId($toPollId);
295
			$option->setConfirmed(0);
296
			$option->setPollOptionText($origin->getPollOptionText());
297
			$option->setTimestamp($origin->getTimestamp());
298
			$option->setDuration($origin->getDuration());
299
			$option->setOrder($option->getOrder());
300
			$this->optionMapper->insert($option);
301
		}
302
303
		return $this->optionMapper->findByPoll($toPollId);
304
	}
305
306
	/**
307
	 * Reorder options with the order specified by $options
308
	 *
309
	 * @return Option[]
310
	 *
311
	 * @psalm-return array<array-key, Option>
312
	 */
313
	public function reorder(int $pollId, array $options): array {
314
		try {
315
			$this->poll = $this->pollMapper->find($pollId);
316
			$this->acl->setPoll($this->poll)->request(Acl::PERMISSION_EDIT);
317
318
			if ($this->poll->getType() === Poll::TYPE_DATE) {
319
				throw new BadRequestException('Not allowed in date polls');
320
			}
321
		} catch (DoesNotExistException $e) {
322
			throw new NotAuthorizedException;
323
		}
324
325
		$i = 0;
326
		foreach ($options as $option) {
327
			$this->option = $this->optionMapper->find($option['id']);
328
			if ($pollId === intval($this->option->getPollId())) {
329
				$this->option->setOrder(++$i);
330
				$this->optionMapper->update($this->option);
331
			}
332
		}
333
334
		$this->watchService->writeUpdate($pollId, Watch::OBJECT_OPTIONS);
335
		return $this->optionMapper->findByPoll($pollId);
336
	}
337
338
	/**
339
	 * Change order for $optionId and reorder the options
340
	 *
341
	 * @return Option[]
342
	 *
343
	 * @psalm-return array<array-key, Option>
344
	 */
345
	public function setOrder(int $optionId, int $newOrder): array {
346
		try {
347
			$this->option = $this->optionMapper->find($optionId);
348
			$this->poll = $this->pollMapper->find($this->option->getPollId());
349
			$this->acl->setPoll($this->poll)->request(Acl::PERMISSION_EDIT);
350
351
			if ($this->poll->getType() === Poll::TYPE_DATE) {
352
				throw new InvalidPollTypeException('Not allowed in date polls');
353
			}
354
		} catch (DoesNotExistException $e) {
355
			throw new NotAuthorizedException;
356
		}
357
358
		if ($newOrder < 1) {
359
			$newOrder = 1;
360
		} elseif ($newOrder > $this->getHighestOrder($this->poll->getId())) {
361
			$newOrder = $this->getHighestOrder($this->poll->getId());
362
		}
363
364
		foreach ($this->optionMapper->findByPoll($this->poll->getId()) as $option) {
365
			$option->setOrder($this->moveModifier($this->option->getOrder(), $newOrder, $option->getOrder()));
366
			$this->optionMapper->update($option);
367
		}
368
369
		$this->watchService->writeUpdate($this->option->getPollId(), Watch::OBJECT_OPTIONS);
370
		return $this->optionMapper->findByPoll($this->option->getPollId());
371
	}
372
373
	/**
374
	 * moveModifier - evaluate new order depending on the old and
375
	 * the new position of a moved array item
376
	 * @param int $moveFrom - old position of the moved item
377
	 * @param int $moveTo - target posotion of the moved item
378
	 * @param int $currentPosition - current position of the current item
379
	 *
380
	 * @return int - The modified new new position of the current item
381
	 */
382
	private function moveModifier(int $moveFrom, int $moveTo, int $currentPosition): int {
383
		$moveModifier = 0;
384
		if ($moveFrom < $currentPosition && $currentPosition <= $moveTo) {
385
			// moving forward
386
			$moveModifier = -1;
387
		} elseif ($moveTo <= $currentPosition && $currentPosition < $moveFrom) {
388
			//moving backwards
389
			$moveModifier = 1;
390
		} elseif ($moveFrom === $currentPosition) {
391
			return $moveTo;
392
		}
393
		return $currentPosition + $moveModifier;
394
	}
395
396
	/**
397
	 * Set option entities validated
398
	 *
399
	 * @return void
400
	 */
401
	private function setOption(int $timestamp = 0, ?string $pollOptionText = '', ?int $duration = 0): void {
402
		$this->poll = $this->pollMapper->find($this->option->getPollId());
403
404
		if ($this->poll->getType() === Poll::TYPE_DATE) {
405
			$this->option->setTimestamp($timestamp);
406
			$this->option->setOrder($timestamp);
407
			$this->option->setDuration($duration);
408
			if ($duration > 0) {
409
				$this->option->setPollOptionText(date('c', $timestamp) . ' - ' . date('c', $timestamp + $duration));
410
			} else {
411
				$this->option->setPollOptionText(date('c', $timestamp));
412
			}
413
		} else {
414
			$this->option->setPollOptionText($pollOptionText);
415
		}
416
	}
417
418
	/**
419
	 * Get all voteOptionTexts of the options, the user opted in
420
	 * with yes or maybe
421
	 *
422
	 * @return array
423
	 */
424
	private function getUsersVotes() {
425
		// Thats an ugly solution, but for now, it seems to work
426
		// Optimization proposals are welcome
427
428
		// First: Find votes, where the user voted yes or maybe
429
		$userId = $this->acl->getUserId();
430
		$exceptVotes = array_filter($this->votes, function ($vote) use ($userId) {
431
			return $vote->getUserId() === $userId && in_array($vote->getVoteAnswer(), ['yes', 'maybe']);
432
		});
433
434
		// Second: Extract only the vote option texts to an array
435
		return array_values(array_map(function ($vote) {
436
			return $vote->getVoteOptionText();
437
		}, $exceptVotes));
438
	}
439
440
	/**
441
	 * Remove booked up options, because they are not votable
442
	 *
443
	 * @return void
444
	 */
445
	private function filterBookedUp() {
446
		$exceptVotes = $this->getUsersVotes();
447
		$this->options = array_filter($this->options, function ($option) use ($exceptVotes) {
448
			return (!$option->getIsBookedUp() || in_array($option->getPollOptionText(), $exceptVotes));
449
		});
450
	}
451
452
	/**
453
	 * Calculate the votes of each option and determines if the option is booked up
454
	 * - unvoted counts as no
455
	 * - realNo reports the actually opted out votes
456
	 *
457
	 * @return void
458
	 */
459
	private function calculateVotes() {
460
		foreach ($this->options as $option) {
461
			$option->yes = count(
462
				array_filter($this->votes, function ($vote) use ($option) {
463
					return ($vote->getVoteOptionText() === $option->getPollOptionText()
464
						&& $vote->getVoteAnswer() === 'yes') ;
465
				})
466
			);
467
468
			$option->realNo = count(
469
				array_filter($this->votes, function ($vote) use ($option) {
470
					return ($vote->getVoteOptionText() === $option->getPollOptionText()
471
						&& $vote->getVoteAnswer() === 'no');
472
				})
473
			);
474
475
			$option->maybe = count(
476
				array_filter($this->votes, function ($vote) use ($option) {
477
					return ($vote->getVoteOptionText() === $option->getPollOptionText()
478
						&& $vote->getVoteAnswer() === 'maybe');
479
				})
480
			);
481
482
			$option->isBookedUp = $this->poll->getOptionLimit() ? $this->poll->getOptionLimit() <= $option->yes : false;
483
484
			// remove details, if the results shall be hidden
485
			if (!$this->acl->isAllowed(Acl::PERMISSION_SEE_RESULTS)) {
486
				$option->yes = 0;
487
				$option->no = 0;
488
				$option->maybe = 0;
489
				$option->realNo = 0;
490
			} else {
491
				$option->no = $this->countParticipants - $option->maybe - $option->yes;
492
			}
493
		}
494
	}
495
496
	/**
497
	 * Calculate the rank of each option based on the
498
	 * yes and maybe votes and recognize equal ranked options
499
	 *
500
	 * @return void
501
	 */
502
	private function calculateRanks() {
503
		// sort array by yes and maybe votes
504
		usort($this->options, function ($a, $b) {
505
			$diff = $b->yes - $a->yes;
506
			return ($diff !== 0) ? $diff : $b->maybe - $a->maybe;
507
		});
508
509
		// calculate the rank
510
		$count = count($this->options);
511
		for ($i = 0; $i < $count; $i++) {
512
			if ($i > 0 && $this->options[$i]->yes === $this->options[$i - 1]->yes && $this->options[$i]->maybe === $this->options[$i - 1]->maybe) {
513
				$this->options[$i]->rank = $this->options[$i - 1]->rank;
514
			} else {
515
				$this->options[$i]->rank = $i + 1;
516
			}
517
		}
518
519
		// restore original order
520
		usort($this->options, function ($a, $b) {
521
			return $a->getOrder() - $b->getOrder();
522
		});
523
	}
524
525
	/**
526
	 * Get the highest order number in $pollId
527
	 * Return Highest order number
528
	 *
529
	 * @return int
530
	 */
531
	private function getHighestOrder(int $pollId): int {
532
		$highestOrder = 0;
533
		foreach ($this->optionMapper->findByPoll($pollId) as $option) {
534
			$highestOrder = ($option->getOrder() > $highestOrder) ? $option->getOrder() : $highestOrder;
535
		}
536
		return $highestOrder;
537
	}
538
}
539