Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 99
0.00% covered (danger)
0.00%
0 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
ElectionTallier
0.00% covered (danger)
0.00%
0 / 99
0.00% covered (danger)
0.00%
0 / 8
1260
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 setupTalliers
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 execute
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
20
 addRecord
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
72
 getJSONResult
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 loadJSONResult
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 getHtmlResult
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
42
 getTextResult
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
42
1<?php
2
3namespace MediaWiki\Extension\SecurePoll\Talliers;
4
5use MediaWiki\Extension\SecurePoll\Ballots\Ballot;
6use MediaWiki\Extension\SecurePoll\Context;
7use MediaWiki\Extension\SecurePoll\Crypt\Crypt;
8use MediaWiki\Extension\SecurePoll\Entities\Election;
9use MediaWiki\Extension\SecurePoll\Entities\Question;
10use MediaWiki\Extension\SecurePoll\Exceptions\InvalidDataException;
11use MediaWiki\Extension\SecurePoll\Store\Store;
12use MediaWiki\Extension\SecurePoll\VoteRecord;
13use MediaWiki\Html\Html;
14use MediaWiki\Logger\LoggerFactory;
15use MediaWiki\Status\Status;
16
17/**
18 * A helper class for tallying a whole election (with multiple questions).
19 * Most of the functionality is contained in the Tallier subclasses
20 * which operate on a single question at a time.
21 *
22 * A convenience function for accessing this class is
23 * Election::tally().
24 */
25class ElectionTallier {
26    /** @var Ballot|null */
27    public $ballot;
28    /** @var Context */
29    public $context;
30    /** @var Crypt|false */
31    public $crypt;
32    /** @var Election */
33    public $election;
34    /** @var Question[]|null */
35    public $questions;
36    /** @var Tallier[] */
37    public $talliers = [];
38    /** @var string[] */
39    public $comments = [];
40
41    /**
42     * Constructor.
43     * @param Context $context
44     * @param Election $election
45     */
46    public function __construct( $context, $election ) {
47        $this->context = $context;
48        $this->election = $election;
49    }
50
51    /**
52     * Set up a Tallier of the appropriate type for every question
53     * @throws InvalidDataException
54     */
55    protected function setupTalliers() {
56        $questions = $this->election->getQuestions();
57        $this->talliers = [];
58        $tallyType = $this->election->getTallyType();
59        foreach ( $questions as $question ) {
60            $tallier = $this->context->newTallier( $tallyType, $this, $question );
61            if ( !$tallier ) {
62                throw new InvalidDataException( 'Invalid tally type' );
63            }
64            $this->talliers[$question->getId()] = $tallier;
65        }
66    }
67
68    /**
69     * Do the tally. Returns a Status object. On success, the value property
70     * of the status will be an array of Tallier objects, which can
71     * be queried for results information.
72     * @return Status
73     */
74    public function execute() {
75        $store = $this->context->getStore();
76        $this->crypt = $this->election->getCrypt();
77        $this->ballot = $this->election->getBallot();
78        $this->setupTalliers();
79
80        // T288366 Tallies fail on beta/prod with little visibility
81        // Add logging to gain more context into where it fails
82        LoggerFactory::getInstance( 'AdHocDebug' )->info(
83            'Starting queued election tally',
84            [
85                'electionId' => $this->election->getId(),
86            ]
87        );
88
89        $status = $store->callbackValidVotes(
90            $this->election->getId(),
91            [
92                $this,
93                'addRecord'
94            ]
95        );
96
97        if ( $this->crypt ) {
98            // Delete temporary files
99            $this->crypt->cleanup();
100        }
101
102        if ( !$status->isOK() ) {
103            return $status;
104        }
105
106        foreach ( $this->talliers as $tallier ) {
107            $tallier->finishTally();
108        }
109
110        return Status::newGood( $this->talliers );
111    }
112
113    /**
114     * Add a record. This is the callback function for Store::callbackValidVotes().
115     * On error, the Status object returned here will be passed through back to
116     * the caller of callbackValidVotes().
117     *
118     * @param Store $store
119     * @param string $record Encrypted, packed record.
120     * @return Status
121     */
122    public function addRecord( $store, $record ) {
123        # Decrypt and unpack
124        if ( $this->crypt ) {
125            $status = $this->crypt->decrypt( $record );
126            if ( !$status->isOK() ) {
127                return $status;
128            }
129            $record = $status->value;
130        }
131        $status = VoteRecord::readBlob( $record );
132        if ( !$status->isOK() ) {
133            return $status;
134        }
135        /** @var VoteRecord $voteRecord */
136        $voteRecord = $status->value;
137        $scores = $this->ballot->unpackRecord( $voteRecord->getBallotData() );
138
139        # Add the record to the underlying question-specific tallier objects
140        foreach ( $this->election->getQuestions() as $question ) {
141            $qid = $question->getId();
142            if ( !isset( $scores[$qid] ) ) {
143                return Status::newFatal( 'securepoll-tally-error' );
144            }
145            if ( !$this->talliers[$qid]->addVote( $scores[$qid] ) ) {
146                return Status::newFatal( 'securepoll-tally-error' );
147            }
148        }
149
150        if ( $voteRecord->getComment() !== '' ) {
151            $this->comments[] = $voteRecord->getComment();
152        }
153
154        return Status::newGood();
155    }
156
157    /**
158     * Get a simple array structure representing results for this tally. Should
159     * only be called after execute().
160     * @return array
161     */
162    public function getJSONResult() {
163        $data = [
164            'type' => $this->election->getTallyType(),
165            'results' => [],
166        ];
167        foreach ( $this->election->getQuestions() as $question ) {
168            $data['results'][ $question->getId() ] = $this->talliers[ $question->getId() ]->getJSONResult();
169        }
170        if ( $this->comments ) {
171            $data['comments'] = $this->comments;
172        }
173        return $data;
174    }
175
176    /**
177     * Restores results from getJSONResult
178     * @param array{results:array} $data
179     */
180    public function loadJSONResult( $data ) {
181        $this->setupTalliers();
182        foreach ( $data['results'] as $questionid => $questiondata ) {
183            $this->talliers[$questionid]->loadJSONResult( $questiondata );
184        }
185        foreach ( ( $data['comments'] ?? [] ) as $comment ) {
186            if ( is_string( $comment ) ) {
187                $this->comments[] = $comment;
188            }
189        }
190    }
191
192    /**
193     * Get HTML formatted results for this tally. Should only be called after
194     * execute().
195     * @return string
196     */
197    public function getHtmlResult() {
198        $s = '';
199        foreach ( $this->election->getQuestions() as $question ) {
200            if ( $s !== '' ) {
201                $s .= "<hr/>\n";
202            }
203            $tallier = $this->talliers[$question->getId()];
204            $s .= $tallier->getHtmlResult();
205        }
206
207        if ( count( $this->comments ) ) {
208            $s .= Html::element( 'h2', [],
209                wfMessage( 'securepoll-tally-comments' )->text() ) . "\n";
210            $first = true;
211            foreach ( $this->comments as $comment ) {
212                if ( $first ) {
213                    $first = false;
214                } else {
215                    $s .= "<hr/>\n";
216                }
217                $s .= nl2br( htmlspecialchars( $comment ) ) . "\n";
218            }
219        }
220
221        return $s;
222    }
223
224    /**
225     * Get text formatted results for this tally. Should only be called after
226     * execute().
227     * @return string
228     */
229    public function getTextResult() {
230        $s = '';
231        foreach ( $this->election->getQuestions() as $question ) {
232            if ( $s !== '' ) {
233                $s .= "\n";
234            }
235            $tallier = $this->talliers[$question->getId()];
236            $s .= $tallier->getTextResult();
237        }
238
239        if ( count( $this->comments ) ) {
240            $s .= "\n\n" . wfMessage( 'securepoll-tally-comments' )->text() . "\n\n";
241            $first = true;
242            foreach ( $this->comments as $comment ) {
243                if ( $first ) {
244                    $first = false;
245                } else {
246                    $s .= "\n" . str_repeat( '-', 80 ) . "\n";
247                }
248                $s .= $comment;
249            }
250        }
251
252        return $s;
253    }
254}