Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 224 |
|
0.00% |
0 / 24 |
CRAP | |
0.00% |
0 / 1 |
Election | |
0.00% |
0 / 224 |
|
0.00% |
0 / 24 |
5112 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
6 | |||
getMessageNames | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
2 | |||
getElection | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getChildren | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getStartDate | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getEndDate | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
isStarted | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
12 | |||
isFinished | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
12 | |||
getVotesCount | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
2 | |||
getBallot | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
getQualifiedStatus | |
0.00% |
0 / 65 |
|
0.00% |
0 / 1 |
870 | |||
isAdmin | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
hasVoted | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
2 | |||
allowChange | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getQuestions | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
12 | |||
getAuth | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
getLanguage | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getCrypt | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
getTallyType | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
dumpVotesToCallback | |
0.00% |
0 / 22 |
|
0.00% |
0 / 1 |
20 | |||
getConfXml | |
0.00% |
0 / 32 |
|
0.00% |
0 / 1 |
12 | |||
getPropertyDumpExclusion | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
6 | |||
tally | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
getTallyFromDb | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
6 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\SecurePoll\Entities; |
4 | |
5 | use InvalidArgumentException; |
6 | use MediaWiki\Extension\SecurePoll\Ballots\Ballot; |
7 | use MediaWiki\Extension\SecurePoll\Context; |
8 | use MediaWiki\Extension\SecurePoll\Crypt\Crypt; |
9 | use MediaWiki\Extension\SecurePoll\Exceptions\InvalidDataException; |
10 | use MediaWiki\Extension\SecurePoll\Talliers\ElectionTallier; |
11 | use MediaWiki\Extension\SecurePoll\User\Auth; |
12 | use MediaWiki\Extension\SecurePoll\User\Voter; |
13 | use MediaWiki\MediaWikiServices; |
14 | use MediaWiki\Status\Status; |
15 | use MediaWiki\User\User; |
16 | use Wikimedia\Rdbms\IDatabase; |
17 | use Xml; |
18 | |
19 | /** |
20 | * Class representing an *election*. The term is intended to include straw polls, |
21 | * surveys, etc. An election has one or more *questions* which voters answer. |
22 | * The *voters* submit their *votes*, which are later tallied to provide a result. |
23 | * An election runs only once and produces a single result. |
24 | * |
25 | * Each election has its own independent set of voters. Voters are created |
26 | * when the underlying user attempts to vote. A voter may vote more than once, |
27 | * unless the election disallows this, but only one of their votes is counted. |
28 | * |
29 | * Elections have a list of key/value pairs called properties, which are defined |
30 | * and used by various modules in order to configure the election. The properties, |
31 | * in order of the module that defines them, are as follows: |
32 | * |
33 | * Election |
34 | * min-edits |
35 | * Minimum number of edits needed to be qualified |
36 | * max-registration |
37 | * Latest acceptable registration date |
38 | * not-sitewide-blocked |
39 | * True if voters need to not have a sitewide block |
40 | * not-partial-blocked |
41 | * True if voters need to not have a partial block |
42 | * not-bot |
43 | * True if voters need to not have the bot permission |
44 | * need-group |
45 | * The name of an MW group voters need to be in |
46 | * need-list |
47 | * The name of a SecurePoll list voters need to be in |
48 | * need-central-list |
49 | * The name of a list in the CentralAuth database which is linked |
50 | * to globaluser.gu_id |
51 | * include-list |
52 | * The name of a SecurePoll list of voters who can vote regardless of the above |
53 | * exclude-list |
54 | * The name of a SecurePoll list of voters who may not vote regardless of the above |
55 | * admins |
56 | * A list of admin names, pipe separated |
57 | * disallow-change |
58 | * True if a voter is not allowed to change their vote |
59 | * encrypt-type |
60 | * The encryption module name |
61 | * not-centrally-blocked |
62 | * True if voters need to not be blocked on more than X projects |
63 | * central-block-threshold |
64 | * Number of blocks across projects that disqualify a user from voting. |
65 | * voter-privacy |
66 | * True to disable transparency features (public voter list and |
67 | * public encrypted record dump) in favour of preserving voter |
68 | * privacy. |
69 | * |
70 | * See the other module for documentation of the following. |
71 | * |
72 | * RemoteMWAuth |
73 | * remote-mw-script-path |
74 | * |
75 | * Ballot |
76 | * shuffle-questions |
77 | * shuffle-options |
78 | * |
79 | * GpgCrypt |
80 | * gpg-encrypt-key |
81 | * gpg-sign-key |
82 | * gpg-decrypt-key |
83 | * |
84 | * OpenSslCrypt |
85 | * openssl-encrypt-key |
86 | * openssl-sign-key |
87 | * openssl-decrypt-key |
88 | * openssl-verify-key |
89 | * |
90 | * VotePage |
91 | * jump-url |
92 | * jump-id |
93 | * return-url |
94 | */ |
95 | class Election extends Entity { |
96 | /** @var Question[]|null */ |
97 | public $questions; |
98 | /** @var Auth|null */ |
99 | public $auth; |
100 | /** @var Ballot|null */ |
101 | public $ballot; |
102 | /** @var string */ |
103 | public $id; |
104 | /** @var string */ |
105 | public $title; |
106 | /** @var string */ |
107 | public $ballotType; |
108 | /** @var string */ |
109 | public $tallyType; |
110 | /** @var string */ |
111 | public $primaryLang; |
112 | /** @var string */ |
113 | public $startDate; |
114 | /** @var string */ |
115 | public $endDate; |
116 | /** @var string */ |
117 | public $authType; |
118 | /** @var int */ |
119 | public $owner = 0; |
120 | |
121 | /** |
122 | * Constructor. |
123 | * |
124 | * Do not use this constructor directly, instead use |
125 | * Context::getElection(). |
126 | * @param Context $context |
127 | * @param array $info |
128 | */ |
129 | public function __construct( $context, $info ) { |
130 | parent::__construct( $context, 'election', $info ); |
131 | $this->id = $info['id']; |
132 | $this->title = $info['title']; |
133 | $this->ballotType = $info['ballot']; |
134 | $this->tallyType = $info['tally']; |
135 | $this->primaryLang = $info['primaryLang']; |
136 | $this->startDate = $info['startDate']; |
137 | $this->endDate = $info['endDate']; |
138 | $this->authType = $info['auth']; |
139 | if ( isset( $info['owner'] ) ) { |
140 | $this->owner = $info['owner']; |
141 | } |
142 | } |
143 | |
144 | /** |
145 | * Get a list of localisable message names. See Entity. |
146 | * @return array |
147 | */ |
148 | public function getMessageNames() { |
149 | return [ |
150 | 'title', |
151 | 'intro', |
152 | 'jump-text', |
153 | 'return-text', |
154 | 'unqualified-error', |
155 | 'comment-prompt' |
156 | ]; |
157 | } |
158 | |
159 | /** |
160 | * Get the election's parent election... hmm... |
161 | * @return Election |
162 | */ |
163 | public function getElection() { |
164 | return $this; |
165 | } |
166 | |
167 | /** |
168 | * Get a list of child entity objects. See Entity. |
169 | * @return array |
170 | */ |
171 | public function getChildren() { |
172 | return $this->getQuestions(); |
173 | } |
174 | |
175 | /** |
176 | * Get the start date in MW internal form. |
177 | * @return string |
178 | */ |
179 | public function getStartDate() { |
180 | return $this->startDate; |
181 | } |
182 | |
183 | /** |
184 | * Get the end date in MW internal form. |
185 | * @return string |
186 | */ |
187 | public function getEndDate() { |
188 | return $this->endDate; |
189 | } |
190 | |
191 | /** |
192 | * Returns true if the election has started. |
193 | * @param string|bool $ts The reference timestamp, or false for now. |
194 | * @return bool |
195 | */ |
196 | public function isStarted( $ts = false ) { |
197 | if ( $ts === false ) { |
198 | $ts = wfTimestampNow(); |
199 | } |
200 | |
201 | return !$this->startDate || $ts >= $this->startDate; |
202 | } |
203 | |
204 | /** |
205 | * Returns true if the election has finished. |
206 | * @param string|bool $ts The reference timestamp, or false for now. |
207 | * @return bool |
208 | */ |
209 | public function isFinished( $ts = false ) { |
210 | if ( $ts === false ) { |
211 | $ts = wfTimestampNow(); |
212 | } |
213 | |
214 | return $this->endDate && $ts >= $this->endDate; |
215 | } |
216 | |
217 | /** |
218 | * Returns number of votes from an election. |
219 | * @return string |
220 | */ |
221 | public function getVotesCount() { |
222 | $dbr = $this->context->getDB( DB_REPLICA ); |
223 | |
224 | return $dbr->newSelectQueryBuilder() |
225 | ->select( 'COUNT(*)' ) |
226 | ->from( 'securepoll_votes' ) |
227 | ->where( [ |
228 | 'vote_election' => $this->getId(), |
229 | 'vote_current' => 1, |
230 | 'vote_struck' => 0, |
231 | ] ) |
232 | ->caller( __METHOD__ ) |
233 | ->fetchField(); |
234 | } |
235 | |
236 | /** |
237 | * Get the ballot object for this election. |
238 | * @return Ballot |
239 | */ |
240 | public function getBallot() { |
241 | if ( !$this->ballot ) { |
242 | $this->ballot = $this->context->newBallot( $this->ballotType, $this ); |
243 | } |
244 | |
245 | return $this->ballot; |
246 | } |
247 | |
248 | /** |
249 | * Determine whether a voter would be qualified to vote in this election, |
250 | * based on the given associative array of parameters. |
251 | * @param array $params Associative array |
252 | * @return Status |
253 | */ |
254 | public function getQualifiedStatus( $params ) { |
255 | global $wgLang; |
256 | $props = $params['properties']; |
257 | $status = Status::newGood(); |
258 | |
259 | $lists = $props['lists'] ?? []; |
260 | $centralLists = $props['central-lists'] ?? []; |
261 | $includeList = $this->getProperty( 'include-list' ); |
262 | $excludeList = $this->getProperty( 'exclude-list' ); |
263 | |
264 | $includeUserGroups = explode( '|', $this->getProperty( 'allow-usergroups', '' ) ); |
265 | $inAllowedUserGroups = array_intersect( $includeUserGroups, $props['groups'] ); |
266 | |
267 | if ( $excludeList && in_array( $excludeList, $lists ) ) { |
268 | $status->fatal( 'securepoll-in-exclude-list' ); |
269 | } elseif ( ( $includeList && in_array( $includeList, $lists ) ) || |
270 | $inAllowedUserGroups ) { |
271 | // Good |
272 | } else { |
273 | // Edits |
274 | $minEdits = $this->getProperty( 'min-edits' ); |
275 | $edits = $props['edit-count'] ?? 0; |
276 | if ( $minEdits && $edits < $minEdits ) { |
277 | $status->fatal( |
278 | 'securepoll-too-few-edits', |
279 | $wgLang->formatNum( $minEdits ), |
280 | $wgLang->formatNum( $edits ) |
281 | ); |
282 | } |
283 | |
284 | // Registration date |
285 | $maxDate = $this->getProperty( 'max-registration' ); |
286 | $date = $props['registration'] ?? 0; |
287 | if ( $maxDate && $date > $maxDate ) { |
288 | $status->fatal( |
289 | 'securepoll-too-new', |
290 | $wgLang->date( $maxDate ), |
291 | $wgLang->date( $date ), |
292 | $wgLang->time( $maxDate ), |
293 | $wgLang->time( $date ) |
294 | ); |
295 | } |
296 | |
297 | // Blocked |
298 | $notAllowedSitewideBlocked = $this->getProperty( 'not-sitewide-blocked' ); |
299 | $notPartialBlocked = $this->getProperty( 'not-partial-blocked' ); |
300 | $isBlocked = !empty( $props['blocked'] ); |
301 | $isSitewideBlocked = $props['isSitewideBlocked']; |
302 | if ( $notAllowedSitewideBlocked && $isBlocked && $isSitewideBlocked ) { |
303 | $status->fatal( 'securepoll-blocked' ); |
304 | } elseif ( $notPartialBlocked && $isBlocked && !$isSitewideBlocked ) { |
305 | $status->fatal( 'securepoll-blocked-partial' ); |
306 | } |
307 | |
308 | // Centrally blocked on more than X projects |
309 | $notCentrallyBlocked = $this->getProperty( 'not-centrally-blocked' ); |
310 | $centralBlockCount = $props['central-block-count'] ?? 0; |
311 | $centralBlockThreshold = $this->getProperty( 'central-block-threshold', 1 ); |
312 | if ( $notCentrallyBlocked && $centralBlockCount >= $centralBlockThreshold ) { |
313 | $status->fatal( |
314 | 'securepoll-blocked-centrally', |
315 | $wgLang->formatNum( $centralBlockThreshold ) |
316 | ); |
317 | } |
318 | |
319 | // Bot |
320 | $notBot = $this->getProperty( 'not-bot' ); |
321 | $isBot = !empty( $props['bot'] ); |
322 | if ( $notBot && $isBot ) { |
323 | $status->fatal( 'securepoll-bot' ); |
324 | } |
325 | |
326 | // Groups |
327 | $needGroup = $this->getProperty( 'need-group' ); |
328 | $groups = $props['groups'] ?? []; |
329 | if ( $needGroup && !in_array( $needGroup, $groups ) ) { |
330 | $status->fatal( 'securepoll-not-in-group', $needGroup ); |
331 | } |
332 | |
333 | // Lists |
334 | $needList = $this->getProperty( 'need-list' ); |
335 | if ( $needList && !in_array( $needList, $lists ) ) { |
336 | $status->fatal( 'securepoll-not-in-list' ); |
337 | } |
338 | |
339 | $needCentralList = $this->getProperty( 'need-central-list' ); |
340 | if ( $needCentralList && !in_array( $needCentralList, $centralLists ) ) { |
341 | $status->fatal( 'securepoll-not-in-list' ); |
342 | } |
343 | } |
344 | |
345 | // Get custom error message and add it to the status's error messages |
346 | if ( !$status->isOK() ) { |
347 | $errorMsgText = $this->getMessage( 'unqualified-error' ); |
348 | if ( $errorMsgText !== '[unqualified-error]' && $errorMsgText !== '' ) { |
349 | // We create the message as a separate step so that possible wikitext in |
350 | // $errorMsgText gets parsed. |
351 | $errorMsg = wfMessage( 'securepoll-custom-unqualified', $errorMsgText ); |
352 | $status->error( $errorMsg ); |
353 | } |
354 | } |
355 | |
356 | return $status; |
357 | } |
358 | |
359 | /** |
360 | * Returns true if the user is an admin of the current election. |
361 | * @param User $user |
362 | * @return bool |
363 | */ |
364 | public function isAdmin( $user ) { |
365 | $admins = array_map( 'trim', explode( '|', $this->getProperty( 'admins' ) ) ); |
366 | |
367 | $userGroupManager = MediaWikiServices::getInstance()->getUserGroupManager(); |
368 | return in_array( $user->getName(), $admins ) |
369 | && in_array( 'electionadmin', $userGroupManager->getUserEffectiveGroups( $user ) ); |
370 | } |
371 | |
372 | /** |
373 | * Returns true if the voter has voted already. |
374 | * @param Voter $voter |
375 | * @return bool |
376 | */ |
377 | public function hasVoted( $voter ) { |
378 | $db = $this->context->getDB(); |
379 | $row = $db->newSelectQueryBuilder() |
380 | ->select( '1' ) |
381 | ->from( 'securepoll_votes' ) |
382 | ->where( [ |
383 | 'vote_election' => $this->getId(), |
384 | 'vote_voter' => $voter->getId(), |
385 | ] ) |
386 | ->caller( __METHOD__ ) |
387 | ->fetchRow(); |
388 | |
389 | return $row !== false; |
390 | } |
391 | |
392 | /** |
393 | * Returns true if the election allows voters to change their vote after it |
394 | * is initially cast. |
395 | * @return bool |
396 | */ |
397 | public function allowChange() { |
398 | return !$this->getProperty( 'disallow-change' ); |
399 | } |
400 | |
401 | /** |
402 | * Get the questions in this election |
403 | * @return Question[] |
404 | */ |
405 | public function getQuestions() { |
406 | if ( $this->questions === null ) { |
407 | $info = $this->context->getStore()->getQuestionInfo( $this->getId() ); |
408 | $this->questions = []; |
409 | foreach ( $info as $questionInfo ) { |
410 | $this->questions[] = $this->context->newQuestion( $questionInfo ); |
411 | } |
412 | } |
413 | |
414 | return $this->questions; |
415 | } |
416 | |
417 | /** |
418 | * Get the authorization object. |
419 | * @return Auth |
420 | */ |
421 | public function getAuth() { |
422 | if ( !$this->auth ) { |
423 | $this->auth = $this->context->newAuth( $this->authType ); |
424 | } |
425 | |
426 | return $this->auth; |
427 | } |
428 | |
429 | /** |
430 | * Get the primary language for this election. This language will be used as |
431 | * a default in the relevant places. |
432 | * @return string |
433 | */ |
434 | public function getLanguage() { |
435 | return $this->primaryLang; |
436 | } |
437 | |
438 | /** |
439 | * Get the cryptography module for this election, or false if none is |
440 | * defined. |
441 | * @return Crypt|false |
442 | * @throws InvalidDataException |
443 | */ |
444 | public function getCrypt() { |
445 | $type = $this->getProperty( 'encrypt-type', 'none' ); |
446 | try { |
447 | return $this->context->newCrypt( $type, $this ); |
448 | } catch ( InvalidArgumentException $e ) { |
449 | throw new InvalidDataException( 'Invalid encryption type' ); |
450 | } |
451 | } |
452 | |
453 | /** |
454 | * Get the tally type |
455 | * @return string |
456 | */ |
457 | public function getTallyType() { |
458 | return $this->tallyType; |
459 | } |
460 | |
461 | /** |
462 | * Call a callback function for each valid vote record, in random order. |
463 | * @param callable $callback |
464 | * @return Status |
465 | */ |
466 | public function dumpVotesToCallback( $callback ) { |
467 | $random = $this->context->getRandom(); |
468 | $status = $random->open(); |
469 | if ( !$status->isOK() ) { |
470 | return $status; |
471 | } |
472 | $db = $this->context->getDB(); |
473 | $res = $db->newSelectQueryBuilder() |
474 | ->select( '*' ) |
475 | ->from( 'securepoll_votes' ) |
476 | ->where( [ |
477 | 'vote_election' => $this->getId(), |
478 | 'vote_current' => 1, |
479 | 'vote_struck' => 0 |
480 | ] ) |
481 | ->caller( __METHOD__ ) |
482 | ->fetchResultSet(); |
483 | if ( $res->numRows() ) { |
484 | $order = $random->shuffle( range( 0, $res->numRows() - 1 ) ); |
485 | foreach ( $order as $i ) { |
486 | $res->seek( $i ); |
487 | call_user_func( $callback, $this, $res->fetchObject() ); |
488 | } |
489 | } |
490 | $random->close(); |
491 | |
492 | return Status::newGood(); |
493 | } |
494 | |
495 | /** |
496 | * Get an XML snippet describing the configuration of this object |
497 | * @param array $params |
498 | * @return string |
499 | */ |
500 | public function getConfXml( $params = [] ) { |
501 | $s = "<configuration>\n" . Xml::element( 'title', [], $this->title ) . "\n" . Xml::element( |
502 | 'ballot', |
503 | [], |
504 | $this->ballotType |
505 | ) . "\n" . Xml::element( 'tally', [], $this->tallyType ) . "\n" . Xml::element( |
506 | 'primaryLang', |
507 | [], |
508 | $this->primaryLang |
509 | ) . "\n" . Xml::element( |
510 | 'startDate', |
511 | [], |
512 | wfTimestamp( TS_ISO_8601, $this->startDate ) |
513 | ) . "\n" . Xml::element( |
514 | 'endDate', |
515 | [], |
516 | wfTimestamp( TS_ISO_8601, $this->endDate ) |
517 | ) . "\n" . $this->getConfXmlEntityStuff( $params ); |
518 | |
519 | // If we're making a jump dump, we need to add some extra properties, and |
520 | // override the auth type |
521 | if ( !empty( $params['jump'] ) ) { |
522 | $s .= Xml::element( 'auth', [], 'local' ) . "\n" . Xml::element( |
523 | 'property', |
524 | [ 'name' => 'jump-url' ], |
525 | $this->context->getSpecialTitle()->getCanonicalURL() |
526 | ) . "\n" . Xml::element( |
527 | 'property', |
528 | [ 'name' => 'jump-id' ], |
529 | (string)$this->getId() |
530 | ) . "\n"; |
531 | } else { |
532 | $s .= Xml::element( 'auth', [], $this->authType ) . "\n"; |
533 | } |
534 | |
535 | foreach ( $this->getQuestions() as $question ) { |
536 | $s .= $question->getConfXml( $params ); |
537 | } |
538 | $s .= "</configuration>\n"; |
539 | |
540 | return $s; |
541 | } |
542 | |
543 | /** |
544 | * Get property names which aren't included in an XML dump |
545 | * @param array $params |
546 | * @return array |
547 | */ |
548 | public function getPropertyDumpExclusion( $params = [] ) { |
549 | if ( empty( $params['private'] ) ) { |
550 | return [ |
551 | 'gpg-encrypt-key', |
552 | 'gpg-sign-key', |
553 | 'gpg-decrypt-key', |
554 | 'openssl-encrypt-key', |
555 | 'openssl-sign-key', |
556 | 'openssl-decrypt-key' |
557 | ]; |
558 | } else { |
559 | return []; |
560 | } |
561 | } |
562 | |
563 | /** |
564 | * Tally the valid votes for this election. |
565 | * Returns a Status object. On success, the value property will contain a |
566 | * ElectionTallier object. |
567 | * @return Status |
568 | */ |
569 | public function tally() { |
570 | $tallier = $this->context->newElectionTallier( $this ); |
571 | $status = $tallier->execute(); |
572 | if ( $status->isOK() ) { |
573 | return Status::newGood( $tallier ); |
574 | } else { |
575 | return $status; |
576 | } |
577 | } |
578 | |
579 | /** |
580 | * Get the stored tally results for this election. The caller can use the |
581 | * returned tallier to format the results in the desired way. |
582 | * |
583 | * @param IDatabase $dbr |
584 | * @return ElectionTallier|bool |
585 | */ |
586 | public function getTallyFromDb( $dbr ) { |
587 | $result = $dbr->newSelectQueryBuilder() |
588 | ->select( 'pr_value' ) |
589 | ->from( 'securepoll_properties' ) |
590 | ->where( [ |
591 | 'pr_entity' => $this->getId(), |
592 | 'pr_key' => [ |
593 | 'tally-result', |
594 | ], |
595 | ] ) |
596 | ->caller( __METHOD__ ) |
597 | ->fetchField(); |
598 | if ( !$result ) { |
599 | return false; |
600 | } |
601 | |
602 | $tallier = $this->context->newElectionTallier( $this ); |
603 | $tallier->loadJSONResult( json_decode( $result, true ) ); |
604 | return $tallier; |
605 | } |
606 | } |