Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 224
0.00% covered (danger)
0.00%
0 / 24
CRAP
0.00% covered (danger)
0.00%
0 / 1
Election
0.00% covered (danger)
0.00%
0 / 224
0.00% covered (danger)
0.00%
0 / 24
5112
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 getMessageNames
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 getElection
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getChildren
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getStartDate
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getEndDate
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isStarted
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 isFinished
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 getVotesCount
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 getBallot
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getQualifiedStatus
0.00% covered (danger)
0.00%
0 / 65
0.00% covered (danger)
0.00%
0 / 1
870
 isAdmin
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 hasVoted
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 allowChange
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getQuestions
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 getAuth
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getLanguage
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCrypt
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getTallyType
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 dumpVotesToCallback
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
20
 getConfXml
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
12
 getPropertyDumpExclusion
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 tally
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 getTallyFromDb
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace MediaWiki\Extension\SecurePoll\Entities;
4
5use InvalidArgumentException;
6use MediaWiki\Extension\SecurePoll\Ballots\Ballot;
7use MediaWiki\Extension\SecurePoll\Context;
8use MediaWiki\Extension\SecurePoll\Crypt\Crypt;
9use MediaWiki\Extension\SecurePoll\Exceptions\InvalidDataException;
10use MediaWiki\Extension\SecurePoll\Talliers\ElectionTallier;
11use MediaWiki\Extension\SecurePoll\User\Auth;
12use MediaWiki\Extension\SecurePoll\User\Voter;
13use MediaWiki\MediaWikiServices;
14use MediaWiki\Status\Status;
15use MediaWiki\User\User;
16use Wikimedia\Rdbms\IDatabase;
17use 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 */
95class 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}