Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
158 / 158
100.00% covered (success)
100.00%
36 / 36
CRAP
100.00% covered (success)
100.00%
1 / 1
Stats
100.00% covered (success)
100.00%
158 / 158
100.00% covered (success)
100.00%
36 / 36
56
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 usersState
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
3
 usersInfo
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 forceActiveUsers
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 usersPronouns
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 email
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 currentlyWorkingUsers
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 vouchersQuery
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 vouchers
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 vouchersBuckets
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 goodies
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 tshirtSizes
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 languages
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 themes
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 licenses
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
2
 workSecondsQuery
n/a
0 / 0
n/a
0 / 0
4
 workSeconds
n/a
0 / 0
n/a
0 / 0
1
 workBuckets
n/a
0 / 0
n/a
0 / 0
1
 getBuckets
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 worklogSeconds
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 worklogBuckets
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 locations
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 shiftTypes
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 angelTypesSum
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 angelTypes
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
3
 shifts
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 announcements
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 comments
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 questions
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 faq
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 messages
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 sessions
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 oauth
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 databaseRead
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 databaseWrite
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 logEntries
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 passwordResets
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getQuery
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 raw
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Engelsystem\Controllers\Metrics;
6
7use Carbon\Carbon;
8use Engelsystem\Database\Database;
9use Engelsystem\Models\AngelType;
10use Engelsystem\Models\EventConfig;
11use Engelsystem\Models\Faq;
12use Engelsystem\Models\Location;
13use Engelsystem\Models\LogEntry;
14use Engelsystem\Models\Message;
15use Engelsystem\Models\News;
16use Engelsystem\Models\NewsComment;
17use Engelsystem\Models\OAuth;
18use Engelsystem\Models\Question;
19use Engelsystem\Models\Shifts\Shift;
20use Engelsystem\Models\Shifts\ShiftType;
21use Engelsystem\Models\User\License;
22use Engelsystem\Models\User\PasswordReset;
23use Engelsystem\Models\User\PersonalData;
24use Engelsystem\Models\User\Settings;
25use Engelsystem\Models\User\State;
26use Engelsystem\Models\User\User;
27use Engelsystem\Models\UserAngelType;
28use Engelsystem\Models\Worklog;
29use Illuminate\Contracts\Database\Query\Builder as BuilderContract;
30use Illuminate\Database\Eloquent\Builder;
31use Illuminate\Database\Query\Builder as QueryBuilder;
32use Illuminate\Database\Query\Expression as QueryExpression;
33use Illuminate\Support\Collection;
34
35class Stats
36{
37    public function __construct(protected Database $db)
38    {
39    }
40
41    /**
42     * The number of users that arrived/not arrived and/or did some work
43     *
44     * @param bool|null $working
45     */
46    public function usersState(bool $working = null, bool $arrived = true): int
47    {
48        $query = State::whereArrived($arrived);
49
50        if (!is_null($working)) {
51            $query
52                ->leftJoin('worklogs', 'worklogs.user_id', '=', 'users_state.user_id')
53                ->leftJoin('shift_entries', 'shift_entries.user_id', '=', 'users_state.user_id')
54                ->distinct();
55
56            $query->where(function ($query) use ($working): void {
57                /** @var QueryBuilder $query */
58                if ($working) {
59                    $query
60                        ->whereNotNull('shift_entries.shift_id')
61                        ->orWhereNotNull('worklogs.hours');
62
63                    return;
64                }
65
66                $query
67                    ->whereNull('shift_entries.shift_id')
68                    ->whereNull('worklogs.hours');
69            });
70        }
71
72        return $query->count('users_state.user_id');
73    }
74
75    public function usersInfo(): int
76    {
77        return State::query()
78            ->whereNotNull('user_info')
79            ->whereNot('user_info', '')
80            ->count();
81    }
82
83    public function forceActiveUsers(): int
84    {
85        return State::whereForceActive(true)->count();
86    }
87
88    public function usersPronouns(): int
89    {
90        return PersonalData::query()->where('pronoun', '!=', '')->count();
91    }
92
93    public function email(string $type): int
94    {
95        return match ($type) {
96            'system' => Settings::whereEmailShiftinfo(true)->count(),
97            'humans' => Settings::whereEmailHuman(true)->count(),
98            'goodie'  => Settings::whereEmailGoodie(true)->count(),
99            'news'   => Settings::whereEmailNews(true)->count(),
100            default  => 0,
101        };
102    }
103
104    /**
105     * The number of currently working users
106     *
107     * @param bool|null $freeloaded
108     */
109    public function currentlyWorkingUsers(bool $freeloaded = null): int
110    {
111        $query = User::query()
112            ->join('shift_entries', 'shift_entries.user_id', '=', 'users.id')
113            ->join('shifts', 'shifts.id', '=', 'shift_entries.shift_id')
114            ->where('shifts.start', '<=', Carbon::now())
115            ->where('shifts.end', '>', Carbon::now());
116
117        if (!is_null($freeloaded)) {
118            $query->where('shift_entries.freeloaded', '=', $freeloaded);
119        }
120
121        return $query->count();
122    }
123
124    protected function vouchersQuery(): Builder
125    {
126        return State::query();
127    }
128
129    public function vouchers(): int
130    {
131        return (int) $this->vouchersQuery()->sum('got_voucher');
132    }
133
134    public function vouchersBuckets(array $buckets): array
135    {
136        $return = [];
137        foreach ($buckets as $bucket) {
138            $query = $this->vouchersQuery();
139
140            if ($bucket !== '+Inf') {
141                $query->where('got_voucher', '<=', $bucket);
142            }
143
144            $return[$bucket] = $query->count('got_voucher');
145        }
146
147        return $return;
148    }
149
150    public function goodies(): int
151    {
152        return State::whereGotGoodie(true)->count();
153    }
154
155    public function tshirtSizes(): Collection
156    {
157        return PersonalData::query()
158            ->select(['shirt_size', $this->raw('COUNT(shirt_size) AS count')])
159            ->whereNotNull('shirt_size')
160            ->groupBy(['shirt_size'])
161            ->get();
162    }
163
164    public function languages(): Collection
165    {
166        return Settings::query()
167            ->select(['language', $this->raw('COUNT(language) AS count')])
168            ->groupBy(['language'])
169            ->get();
170    }
171
172    public function themes(): Collection
173    {
174        return Settings::query()
175            ->select(['theme', $this->raw('COUNT(theme) AS count')])
176            ->groupBy(['theme'])
177            ->get();
178    }
179
180    public function licenses(string $license, bool $confirmed = false): int
181    {
182        $mapping = [
183            'has_car'   => ['has_car', null],
184            'forklift' => ['drive_forklift', 'drive_confirmed'],
185            'car' => ['drive_car', 'drive_confirmed'],
186            '3.5t' => ['drive_3_5t', 'drive_confirmed'],
187            '7.5t' => ['drive_7_5t', 'drive_confirmed'],
188            '12t' => ['drive_12t', 'drive_confirmed'],
189            'ifsg_light' => ['ifsg_certificate_light', 'ifsg_confirmed'],
190            'ifsg' => ['ifsg_certificate', 'ifsg_confirmed'],
191        ];
192
193        $query = (new License())
194            ->getQuery()
195            ->where($mapping[$license][0], true);
196        if (!is_null($mapping[$license][1])) {
197            $query->where($mapping[$license][1], $confirmed);
198        }
199
200        return $query->count();
201    }
202
203    /**
204     * @param bool|null $done
205     * @param bool|null $freeloaded
206     *
207     * @codeCoverageIgnore because it is only used in functions that use TIMESTAMPDIFF
208     */
209    protected function workSecondsQuery(bool $done = null, bool $freeloaded = null): QueryBuilder
210    {
211        $query = $this
212            ->getQuery('shift_entries')
213            ->join('shifts', 'shifts.id', '=', 'shift_entries.shift_id');
214
215        if (!is_null($freeloaded)) {
216            $query->where('freeloaded', '=', $freeloaded);
217        }
218
219        if (!is_null($done)) {
220            $query->where('end', ($done ? '<' : '>='), Carbon::now());
221        }
222
223        return $query;
224    }
225
226    /**
227     * The amount of worked seconds
228     *
229     * @param bool|null $done
230     * @param bool|null $freeloaded
231     *
232     * @codeCoverageIgnore as TIMESTAMPDIFF is not implemented in SQLite
233     */
234    public function workSeconds(bool $done = null, bool $freeloaded = null): int
235    {
236        $query = $this->workSecondsQuery($done, $freeloaded);
237
238        return (int) $query->sum($this->raw('TIMESTAMPDIFF(MINUTE, start, end) * 60'));
239    }
240
241    /**
242     * The number of worked shifts
243     *
244     * @param bool|null $done
245     * @param bool|null $freeloaded
246     *
247     * @codeCoverageIgnore as TIMESTAMPDIFF is not implemented in SQLite
248     */
249    public function workBuckets(array $buckets, bool $done = null, bool $freeloaded = null): array
250    {
251        return $this->getBuckets(
252            $buckets,
253            $this->workSecondsQuery($done, $freeloaded),
254            'user_id',
255            'SUM(TIMESTAMPDIFF(MINUTE, start, end) * 60)',
256            'SUM(TIMESTAMPDIFF(MINUTE, start, end) * 60)'
257        );
258    }
259
260    protected function getBuckets(
261        array $buckets,
262        BuilderContract $basicQuery,
263        string $groupBy,
264        string $having,
265        string $count
266    ): array {
267        $return = [];
268
269        foreach ($buckets as $bucket) {
270            $query = clone $basicQuery;
271            $query->groupBy($groupBy);
272
273            if ($bucket !== '+Inf') {
274                $query->having($this->raw($having), '<=', $bucket);
275            }
276
277            $return[$bucket] = count($query->get($this->raw($count)));
278        }
279
280        return $return;
281    }
282
283    public function worklogSeconds(): int
284    {
285        return (int) Worklog::query()
286            ->sum($this->raw('hours * 60 * 60'));
287    }
288
289    public function worklogBuckets(array $buckets): array
290    {
291        return $this->getBuckets(
292            $buckets,
293            Worklog::query(),
294            'user_id',
295            'SUM(hours * 60 * 60)',
296            'SUM(hours * 60 * 60)'
297        );
298    }
299
300    public function locations(): int
301    {
302        return Location::query()
303            ->count();
304    }
305
306    public function shiftTypes(): int
307    {
308        return ShiftType::query()
309            ->count();
310    }
311
312    public function angelTypesSum(): int
313    {
314        return AngelType::query()->count();
315    }
316
317    public function angelTypes(): array
318    {
319        $angelTypes = [];
320        $rawAngelTypes = AngelType::query()->select(['id', 'name', 'restricted'])->orderBy('name')->get();
321        foreach ($rawAngelTypes as $angelType) {
322            $restricted = $angelType->restricted;
323            $userAngelTypeQuery = UserAngelType::query()
324                ->where('angel_type_id', $angelType->id);
325
326            $members = $userAngelTypeQuery->count();
327            $supporters = (clone $userAngelTypeQuery)->where('supporter', true)->count();
328            $confirmed = $members - $supporters;
329            $unconfirmed = 0;
330            if ($restricted) {
331                $confirmed = (clone $userAngelTypeQuery)->whereNotNull('confirm_user_id')->count() - $supporters;
332                $unconfirmed = $members - ($supporters + $confirmed);
333            }
334
335            $angelTypes[] = [
336                'name' => $angelType->name,
337                'restricted' => $restricted,
338                'unconfirmed' => $unconfirmed,
339                'supporters' => $supporters,
340                'confirmed' => $confirmed,
341            ];
342        }
343        return $angelTypes;
344    }
345
346    public function shifts(): int
347    {
348        return Shift::query()->count();
349    }
350
351    /**
352     * @param bool|null $meeting
353     */
354    public function announcements(bool $meeting = null): int
355    {
356        $query = is_null($meeting) ? News::query() : News::whereIsMeeting($meeting);
357
358        return $query->count();
359    }
360
361    public function comments(): int
362    {
363        return NewsComment::query()
364            ->count();
365    }
366
367    /**
368     * @param bool|null $answered
369     */
370    public function questions(bool $answered = null): int
371    {
372        $query = Question::query();
373        if (!is_null($answered)) {
374            if ($answered) {
375                $query->whereNotNull('answerer_id');
376            } else {
377                $query->whereNull('answerer_id');
378            }
379        }
380
381        return $query->count();
382    }
383
384    public function faq(): int
385    {
386        return Faq::query()->count();
387    }
388
389    public function messages(): int
390    {
391        return Message::query()->count();
392    }
393
394    public function sessions(): int
395    {
396        return $this
397            ->getQuery('sessions')
398            ->count();
399    }
400
401    public function oauth(): Collection
402    {
403        return OAuth::query()
404            ->select(['provider', $this->raw('COUNT(provider) AS count')])
405            ->groupBy(['provider'])
406            ->get();
407    }
408
409    public function databaseRead(): float
410    {
411        $start = microtime(true);
412
413        (new EventConfig())->findOrNew('last_metrics');
414
415        return microtime(true) - $start;
416    }
417
418    public function databaseWrite(): float
419    {
420        $config = (new EventConfig())->findOrNew('last_metrics');
421        $config
422            ->setAttribute('name', 'last_metrics')
423            ->setAttribute('value', new Carbon());
424
425        $start = microtime(true);
426
427        $config->save();
428
429        return microtime(true) - $start;
430    }
431
432    /**
433     * @param string|null $level
434     */
435    public function logEntries(string $level = null): int
436    {
437        $query = is_null($level) ? LogEntry::query() : LogEntry::whereLevel($level);
438
439        return $query->count();
440    }
441
442    public function passwordResets(): int
443    {
444        return PasswordReset::query()->count();
445    }
446
447    protected function getQuery(string $table): QueryBuilder
448    {
449        return $this->db
450            ->getConnection()
451            ->table($table);
452    }
453
454    protected function raw(mixed $value): QueryExpression
455    {
456        return $this->db->getConnection()->raw($value);
457    }
458}