Completed
Push — master ( cd44b6...7febf5 )
by MusikAnimal
03:41 queued 01:27
created

AdminStats::groupAdminStatsByUsername()   B

Complexity

Conditions 4
Paths 4

Size

Total Lines 32
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 32
rs 8.5806
c 0
b 0
f 0
cc 4
eloc 14
nc 4
nop 2
1
<?php
2
/**
3
 * This file contains only the AdminStats class.
4
 */
5
6
namespace Xtools;
7
8
use Symfony\Component\DependencyInjection\Container;
9
use DateTime;
10
11
/**
12
 * AdminStats returns information about users with administrative
13
 * rights on a given wiki.
14
 */
15
class AdminStats extends Model
16
{
17
    /** @var Project Project associated with this AdminStats instance. */
18
    protected $project;
19
20
    /** @var string[] Keyed by user name, values are arrays containing actions and counts. */
21
    protected $adminStats;
22
23
    /**
24
     * Keys are user names, values are their abbreviated user groups.
25
     * If abbreviations are turned on, this will instead be a string of the abbreviated
26
     * user groups, separated by slashes.
27
     * @var string[]|string
28
     */
29
    protected $adminsAndGroups;
30
31
    /** @var int Number of admins (sysops) who haven't made any actions within the time period. */
32
    protected $adminsWithoutActions = 0;
33
34
    /** @var int Number of actual admins (sysops), not users with admin-like actions. */
35
    protected $numSysops = 0;
36
37
    /** @var int Start of time period as UTC timestamp */
38
    protected $start;
39
40
    /** @var int End of time period as UTC timestamp */
41
    protected $end;
42
43
    /**
44
     * TopEdits constructor.
45
     * @param Project $project
46
     * @param int $start as UTC timestamp.
47
     * @param int $end as UTC timestamp.
48
     */
49
    public function __construct(Project $project, $start = null, $end = null)
50
    {
51
        $this->project = $project;
52
        $this->start = $start;
53
        $this->end = $end;
54
    }
55
56
    /**
57
     * Get users of the project that are capable of making 'admin actions',
58
     * keyed by user name with abbreviations for the user groups as the values.
59
     * @param  string $abbreviate If set, the keys of the result with be a string containing
60
     *   abbreviated versions of their user groups, such as 'A' instead of administrator,
61
     *   'CU' instead of CheckUser, etc. If $abbreviate is false, the keys of the result
62
     *   will be an array of the full-named user groups.
63
     * @see Project::getAdmins()
64
     * @return string[]
65
     */
66
    public function getAdminsAndGroups($abbreviate = true)
67
    {
68
        if ($this->adminsAndGroups) {
69
            return $this->adminsAndGroups;
70
        }
71
72
        /**
73
         * Each user group that is considered capable of making 'admin actions'.
74
         * @var string[]
75
         */
76
        $adminGroups = $this->getRepository()->getAdminGroups($this->project);
1 ignored issue
show
Bug introduced by
It seems like you code against a specific sub-type and not the parent class Xtools\Repository as the method getAdminGroups() does only exist in the following sub-classes of Xtools\Repository: Xtools\AdminStatsRepository. Maybe you want to instanceof check for one of these explicitly?

Let’s take a look at an example:

abstract class User
{
    /** @return string */
    abstract public function getPassword();
}

class MyUser extends User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different sub-classes of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the parent class:

    abstract class User
    {
        /** @return string */
        abstract public function getPassword();
    
        /** @return string */
        abstract public function getDisplayName();
    }
    
Loading history...
77
78
        /** @var array Keys are the usernames, values are thier user groups. */
79
        $admins = $this->project->getUsersInGroups($adminGroups);
80
81
        if ($abbreviate === false) {
82
            return $admins;
83
        }
84
85
        /**
86
         * Keys are the database-stored names, values are the abbreviations.
87
         * FIXME: i18n this somehow.
88
         * @var string[]
89
         */
90
        $userGroupAbbrMap = [
91
            'sysop' => 'A',
92
            'bureaucrat' => 'B',
93
            'steward' => 'S',
94
            'checkuser' => 'CU',
95
            'oversight' => 'OS',
96
            'bot' => 'Bot',
97
        ];
98
99
        foreach ($admins as $admin => $groups) {
100
            $abbrGroups = [];
101
102
            // Keep track of actual number of sysops.
103
            if (in_array('sysop', $groups)) {
104
                $this->numSysops++;
105
            }
106
107
            foreach ($groups as $group) {
0 ignored issues
show
Bug introduced by
The expression $groups of type string is not traversable.
Loading history...
108
                if (isset($userGroupAbbrMap[$group])) {
109
                    $abbrGroups[] = $userGroupAbbrMap[$group];
110
                }
111
            }
112
113
            // Make 'A' (admin) come before 'CU' (CheckUser), etc.
114
            sort($abbrGroups);
115
116
            $this->adminsAndGroups[$admin] = implode('/', $abbrGroups);
117
        }
118
119
        return $this->adminsAndGroups;
120
    }
121
122
    /**
123
     * The number of days we're spanning between the start and end date.
124
     * @return int
125
     */
126
    public function numDays()
127
    {
128
        return ($this->end - $this->start) / 60 / 60 / 24;
129
    }
130
131
    /**
132
     * Get the array of statistics for each qualifying user. This may be called
133
     * ahead of self::getStats() so certain class-level properties will be supplied
134
     * (such as self::numUsers(), which is called in the view before iterating
135
     * over the master array of statistics).
136
     * @param boolean $abbreviateGroups If set, the 'groups' list will be
137
     *   a string with abbreivated user groups names, as opposed to an array
138
     *   of full-named user groups.
139
     * @return string[]
140
     */
141
    public function prepareStats($abbreviateGroups = true)
142
    {
143
        if (isset($this->adminStats)) {
144
            return $this->adminStats;
145
        }
146
147
        // UTC to YYYYMMDDHHMMSS.
148
        $startDb = date('Ymd000000', $this->start);
149
        $endDb = date('Ymd235959', $this->end);
150
151
        $stats = $this->getRepository()->getStats($this->project, $startDb, $endDb);
1 ignored issue
show
Bug introduced by
It seems like you code against a specific sub-type and not the parent class Xtools\Repository as the method getStats() does only exist in the following sub-classes of Xtools\Repository: Xtools\AdminStatsRepository. Maybe you want to instanceof check for one of these explicitly?

Let’s take a look at an example:

abstract class User
{
    /** @return string */
    abstract public function getPassword();
}

class MyUser extends User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different sub-classes of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the parent class:

    abstract class User
    {
        /** @return string */
        abstract public function getPassword();
    
        /** @return string */
        abstract public function getDisplayName();
    }
    
Loading history...
152
153
        // Group by username.
154
        $stats = $this->groupAdminStatsByUsername($stats, $abbreviateGroups);
155
156
        $this->adminStats = $stats;
0 ignored issues
show
Documentation Bug introduced by
It seems like $stats of type array<integer,string|arr...r","groups":"string"}>> is incompatible with the declared type array<integer,string> of property $adminStats.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
157
        return $this->adminStats;
158
    }
159
160
    /**
161
     * Get the master array of statistics for each qualifying user.
162
     * @param boolean $abbreviateGroups If set, the 'groups' list will be
163
     *   a string with abbreivated user groups names, as opposed to an array
164
     *   of full-named user groups.
165
     * @return string[]
166
     */
167
    public function getStats($abbreviateGroups = true)
168
    {
169
        if (isset($this->adminStats)) {
170
            $this->adminStats = $this->prepareStats($abbreviateGroups);
0 ignored issues
show
Documentation Bug introduced by
It seems like $this->prepareStats($abbreviateGroups) of type array<integer,string|arr...r","groups":"string"}>> is incompatible with the declared type array<integer,string> of property $adminStats.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
171
        }
172
        return $this->adminStats;
173
    }
174
175
    /**
176
     * Given the data returned by AdminStatsRepository::getStats,
177
     * return the stats keyed by user name, adding in a key/value for user groups.
178
     * @param  string[] $data As retrieved by AdminStatsRepository::getStats
179
     * @param boolean $abbreviateGroups If set, the 'groups' list will be
180
     *   a string with abbreivated user groups names, as opposed to an array
181
     *   of full-named user groups.
182
     * @return string[] Stats keyed by user name.
183
     * Functionality covered in test for self::getStats().
184
     * @codeCoverageIgnore
185
     */
186
    private function groupAdminStatsByUsername($data, $abbreviateGroups = true)
187
    {
188
        $adminsAndGroups = $this->getAdminsAndGroups($abbreviateGroups);
189
        $users = [];
190
191
        foreach ($data as $datum) {
192
            $username = $datum['user_name'];
193
194
            // Push to array containing all users with admin actions.
195
            // We also want numerical values to be integers.
196
            $users[$username] = array_map('intval', $datum);
197
198
            // Push back username which was casted to an integer.
199
            $users[$username]['user_name'] = $username;
200
201
            // Set the 'groups' property with the user groups they belong to (if any),
202
            // going off of self::getAdminsAndGroups().
203
            if (isset($adminsAndGroups[$username])) {
204
                $users[$username]['groups'] = $adminsAndGroups[$username];
205
206
                // Remove from actual admin list so later we can re-populate with zeros.
207
                unset($adminsAndGroups[$username]);
208
            } else {
209
                $users[$username]['groups'] = $abbreviateGroups ? '' : [];
210
            }
211
        }
212
213
        // Push any inactive admins back to $users with zero values.
214
        $users = $this->fillInactiveAdmins($users, $adminsAndGroups);
0 ignored issues
show
Bug introduced by
It seems like $adminsAndGroups defined by $this->getAdminsAndGroups($abbreviateGroups) on line 188 can also be of type string; however, Xtools\AdminStats::fillInactiveAdmins() does only seem to accept array<integer,string>, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
215
216
        return $users;
217
    }
218
219
    /**
220
     * Fill in inactive admins (no actions within time period), given the list of
221
     * remaining $adminsAndGroups from processing within self::groupAdminStatsByUsername().
222
     * @param  string[] $users As computed by self::groupAdminStatsByUsername().
223
     * @param  string[] $adminsAndGroups Remaining from self::getAdminsAndGroups().
224
     * @return string[]
225
     * @codeCoverageIgnore
226
     */
227
    private function fillInactiveAdmins($users, $adminsAndGroups)
228
    {
229
        foreach ($adminsAndGroups as $username => $groups) {
230
            $users[$username] = [
231
                'user_name' => $username,
232
                'delete' => 0,
233
                'restore' => 0,
234
                'block' => 0,
235
                'unblock' => 0,
236
                'protect' => 0,
237
                'unprotect' => 0,
238
                'rights' => 0,
239
                'import' => 0,
240
                'total' => 0,
241
                'groups' => $groups,
242
            ];
243
            $this->adminsWithoutActions++;
244
        }
245
246
        return $users;
247
    }
248
249
    /**
250
     * Get the formatted start date.
251
     * @return string
252
     */
253
    public function getStart()
254
    {
255
        return date('Y-m-d', $this->start);
256
    }
257
258
    /**
259
     * Get the formatted end date.
260
     * @return string
261
     */
262
    public function getEnd()
263
    {
264
        return date('Y-m-d', $this->end);
265
    }
266
267
    /**
268
     * Get the total number of admins (users currently with qualifying permissions).
269
     * @return int
270
     */
271
    public function numAdmins()
272
    {
273
        return $this->numSysops;
274
    }
275
276
    /**
277
     * Get the total number of users we're reporting as having made admin actions.
278
     * @return int
279
     */
280
    public function numUsers()
281
    {
282
        return count($this->adminStats);
283
    }
284
285
    /**
286
     * Number of admins who did make actions within the time period.
287
     * @return int
288
     */
289
    public function getNumAdminsWithActions()
290
    {
291
        return $this->numAdmins() - $this->adminsWithoutActions;
292
    }
293
294
    /**
295
     * Number of admins who did not make any actions within the time period.
296
     * @return int
297
     */
298
    public function getNumAdminsWithoutActions()
299
    {
300
        return $this->adminsWithoutActions;
301
    }
302
}
303