Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
100.00% |
158 / 158 |
|
100.00% |
36 / 36 |
CRAP | |
100.00% |
1 / 1 |
Stats | |
100.00% |
158 / 158 |
|
100.00% |
36 / 36 |
56 | |
100.00% |
1 / 1 |
__construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
usersState | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
3 | |||
usersInfo | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
forceActiveUsers | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
usersPronouns | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
|
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | ||||
currentlyWorkingUsers | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
2 | |||
vouchersQuery | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
vouchers | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
vouchersBuckets | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
3 | |||
goodies | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
tshirtSizes | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
languages | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
themes | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
licenses | |
100.00% |
16 / 16 |
|
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% |
8 / 8 |
|
100.00% |
1 / 1 |
3 | |||
worklogSeconds | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
worklogBuckets | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
locations | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
shiftTypes | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
angelTypesSum | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
angelTypes | |
100.00% |
21 / 21 |
|
100.00% |
1 / 1 |
3 | |||
shifts | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
announcements | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
comments | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
questions | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
faq | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
messages | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
sessions | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
oauth | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
databaseRead | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
databaseWrite | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
logEntries | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
passwordResets | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getQuery | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
raw | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 |
1 | <?php |
2 | |
3 | declare(strict_types=1); |
4 | |
5 | namespace Engelsystem\Controllers\Metrics; |
6 | |
7 | use Carbon\Carbon; |
8 | use Engelsystem\Database\Database; |
9 | use Engelsystem\Models\AngelType; |
10 | use Engelsystem\Models\EventConfig; |
11 | use Engelsystem\Models\Faq; |
12 | use Engelsystem\Models\Location; |
13 | use Engelsystem\Models\LogEntry; |
14 | use Engelsystem\Models\Message; |
15 | use Engelsystem\Models\News; |
16 | use Engelsystem\Models\NewsComment; |
17 | use Engelsystem\Models\OAuth; |
18 | use Engelsystem\Models\Question; |
19 | use Engelsystem\Models\Shifts\Shift; |
20 | use Engelsystem\Models\Shifts\ShiftType; |
21 | use Engelsystem\Models\User\License; |
22 | use Engelsystem\Models\User\PasswordReset; |
23 | use Engelsystem\Models\User\PersonalData; |
24 | use Engelsystem\Models\User\Settings; |
25 | use Engelsystem\Models\User\State; |
26 | use Engelsystem\Models\User\User; |
27 | use Engelsystem\Models\UserAngelType; |
28 | use Engelsystem\Models\Worklog; |
29 | use Illuminate\Contracts\Database\Query\Builder as BuilderContract; |
30 | use Illuminate\Database\Eloquent\Builder; |
31 | use Illuminate\Database\Query\Builder as QueryBuilder; |
32 | use Illuminate\Database\Query\Expression as QueryExpression; |
33 | use Illuminate\Support\Collection; |
34 | |
35 | class 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 | } |