Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
343 / 343
100.00% covered (success)
100.00%
21 / 21
CRAP
100.00% covered (success)
100.00%
1 / 1
ScheduleController
100.00% covered (success)
100.00%
343 / 343
100.00% covered (success)
100.00%
21 / 21
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
 index
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 edit
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 save
100.00% covered (success)
100.00%
49 / 49
100.00% covered (success)
100.00%
1 / 1
6
 delete
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
2
 loadSchedule
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
2
 importSchedule
100.00% covered (success)
100.00%
39 / 39
100.00% covered (success)
100.00%
1 / 1
6
 createLocation
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 fireDeleteShiftEvents
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 createEvent
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
1
 updateEvent
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
1
 deleteEvent
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
1
 fireUpdateShiftUpdateEvent
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getScheduleData
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
4
 newRooms
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 shiftsDiff
100.00% covered (success)
100.00%
41 / 41
100.00% covered (success)
100.00%
1 / 1
15
 eventFromScheduleShift
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
1
 patchSchedule
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 getAllLocations
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getScheduleShiftsByGuid
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getScheduleShiftsWhereNotGuid
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Engelsystem\Controllers\Admin;
6
7use Engelsystem\Controllers\BaseController;
8use Engelsystem\Controllers\HasUserNotifications;
9use Engelsystem\Controllers\NotificationType;
10use Engelsystem\Helpers\Carbon;
11use Engelsystem\Helpers\Schedule\ConferenceTrack;
12use Engelsystem\Helpers\Schedule\Event;
13use Engelsystem\Helpers\Schedule\Room;
14use Engelsystem\Helpers\Schedule\Schedule;
15use Engelsystem\Helpers\Schedule\XmlParser;
16use Engelsystem\Helpers\Uuid;
17use Engelsystem\Http\Request;
18use Engelsystem\Http\Response;
19use Engelsystem\Models\Location;
20use Engelsystem\Models\Shifts\Schedule as ScheduleModel;
21use Engelsystem\Models\Shifts\ScheduleShift;
22use Engelsystem\Models\Shifts\Shift;
23use Engelsystem\Models\Shifts\ShiftType;
24use ErrorException;
25use GuzzleHttp\Client as GuzzleClient;
26use GuzzleHttp\Exception\ConnectException;
27use GuzzleHttp\Exception\GuzzleException;
28use Illuminate\Database\Connection as DatabaseConnection;
29use Illuminate\Database\Eloquent\Collection as DatabaseCollection;
30use Illuminate\Support\Collection;
31use Illuminate\Support\Str;
32use Psr\Log\LoggerInterface;
33
34class ScheduleController extends BaseController
35{
36    use HasUserNotifications;
37
38    protected array $permissions = [
39        'schedule.import',
40    ];
41
42    protected string $url = '/admin/schedule';
43
44    public function __construct(
45        protected Response $response,
46        protected GuzzleClient $guzzle,
47        protected XmlParser $parser,
48        protected DatabaseConnection $db,
49        protected LoggerInterface $log
50    ) {
51    }
52
53    public function index(): Response
54    {
55        return $this->response->withView(
56            'admin/schedule/index',
57            [
58                'is_index' => true,
59                'schedules' => ScheduleModel::all()->loadCount('shifts'),
60            ]
61        );
62    }
63
64    public function edit(Request $request): Response
65    {
66        $scheduleId = $request->getAttribute('schedule_id'); // optional
67        $schedule = ScheduleModel::findOrNew($scheduleId);
68
69        return $this->response->withView(
70            'admin/schedule/edit',
71            [
72                'schedule' => $schedule,
73                'shift_types' => ShiftType::all()->sortBy('name')->pluck('name', 'id'),
74                'locations'   => Location::all()->sortBy('name')->pluck('name', 'id'),
75            ]
76        );
77    }
78
79    public function save(Request $request): Response
80    {
81        $scheduleId = $request->getAttribute('schedule_id'); // optional
82
83        /** @var ScheduleModel $schedule */
84        $schedule = ScheduleModel::findOrNew($scheduleId);
85
86        if ($request->request->has('delete')) {
87            return $this->delete($schedule);
88        }
89
90        $locationsList = Location::all()->pluck('id');
91        $locationsValidation = [];
92        foreach ($locationsList as $id) {
93            $locationsValidation['location_' . $id] = 'optional|checked';
94        }
95
96        $data = $this->validate($request, [
97            'name' => 'required|max:255',
98            'url' => 'required',
99            'shift_type' => 'required|int',
100            'needed_from_shift_type' => 'optional|checked',
101            'minutes_before' => 'int',
102            'minutes_after' => 'int',
103        ] + $locationsValidation);
104        ShiftType::findOrFail($data['shift_type']);
105
106        $schedule->name = $data['name'];
107        $schedule->url = $data['url'];
108        $schedule->shift_type = $data['shift_type'];
109        $schedule->needed_from_shift_type = (bool) $data['needed_from_shift_type'];
110        $schedule->minutes_before = $data['minutes_before'];
111        $schedule->minutes_after = $data['minutes_after'];
112
113        $schedule->save();
114        $schedule->activeLocations()->detach();
115
116        $for = new Collection();
117        foreach ($locationsList as $id) {
118            if (!$data['location_' . $id]) {
119                continue;
120            }
121
122            $location = Location::find($id);
123            $schedule->activeLocations()->attach($location);
124            $for[] = $location->name;
125        }
126
127        $this->log->info(
128            'Schedule {name} ({id}): Url {url}, Shift Type {shift_type_name} ({shift_type_id}), ({need}), '
129            . 'minutes before/after {before}/{after}, for: {locations}',
130            [
131                'name' => $schedule->name,
132                'id' => $schedule->id,
133                'url' => $schedule->url,
134                'shift_type_name' => Shifttype::find($schedule->shift_type)->name,
135                'shift_type_id' => $schedule->shift_type,
136                'need'       => $schedule->needed_from_shift_type ? 'from shift type' : 'from location',
137                'before' => $schedule->minutes_before,
138                'after' => $schedule->minutes_after,
139                'locations'  => $for->implode(', '),
140            ]
141        );
142
143        $this->addNotification('schedule.edit.success');
144
145        return redirect('/admin/schedule/load/' . $schedule->id);
146    }
147
148    protected function delete(ScheduleModel $schedule): Response
149    {
150        foreach ($schedule->scheduleShifts as $scheduleShift) {
151            // Only guid is needed here
152            $event = new Event(
153                $scheduleShift->guid,
154                0,
155                new Room(''),
156                '',
157                '',
158                '',
159                Carbon::now(),
160                '',
161                '',
162                '',
163                '',
164                new ConferenceTrack('')
165            );
166
167            $this->deleteEvent($event, $schedule);
168        }
169        $schedule->delete();
170
171        $this->log->info('Schedule {name} deleted', ['name' => $schedule->name]);
172        $this->addNotification('schedule.delete.success');
173        return redirect('/admin/schedule');
174    }
175
176    public function loadSchedule(Request $request): Response
177    {
178        try {
179            /**
180             * @var Event[] $newEvents
181             * @var Event[] $changeEvents
182             * @var Event[] $deleteEvents
183             * @var Room[] $newRooms
184             * @var int $shiftType
185             * @var ScheduleModel $scheduleModel
186             * @var Schedule $schedule
187             * @var int $minutesBefore
188             * @var int $minutesAfter
189             */
190            list(
191                $newEvents,
192                $changeEvents,
193                $deleteEvents,
194                $newRooms,
195                ,
196                $scheduleModel,
197                $schedule
198                ) = $this->getScheduleData($request);
199        } catch (ErrorException $e) {
200            $this->addNotification($e->getMessage(), NotificationType::ERROR);
201            return back();
202        }
203
204        return $this->response->withView(
205            'admin/schedule/load',
206            [
207                'schedule_id' => $scheduleModel->id,
208                'schedule' => $schedule,
209                'locations' => [
210                    'add' => $newRooms,
211                ],
212                'shifts' => [
213                    'add' => $newEvents,
214                    'update' => $changeEvents,
215                    'delete' => $deleteEvents,
216                ],
217            ]
218        );
219    }
220
221    public function importSchedule(Request $request): Response
222    {
223        try {
224            /**
225             * @var Event[] $newEvents
226             * @var Event[] $changeEvents
227             * @var Event[] $deleteEvents
228             * @var Room[] $newRooms
229             * @var int $shiftType
230             * @var ScheduleModel $schedule
231             */
232            list(
233                $newEvents,
234                $changeEvents,
235                $deleteEvents,
236                $newRooms,
237                $shiftType,
238                $schedule
239                ) = $this->getScheduleData($request);
240        } catch (ErrorException $e) {
241            $this->addNotification($e->getMessage(), NotificationType::ERROR);
242            return back();
243        }
244
245        $this->log->info('Started schedule "{name}" import', ['name' => $schedule->name]);
246
247        foreach ($newRooms as $room) {
248            $this->createLocation($room);
249        }
250
251        $locations = $this->getAllLocations();
252        foreach ($newEvents as $event) {
253            $this->createEvent(
254                $event,
255                $shiftType,
256                $locations
257                    ->where('name', $event->getRoom()->getName())
258                    ->first(),
259                $schedule
260            );
261        }
262
263        foreach ($changeEvents as $event) {
264            $this->updateEvent(
265                $event,
266                $shiftType,
267                $locations
268                    ->where('name', $event->getRoom()->getName())
269                    ->first(),
270                $schedule
271            );
272        }
273
274        foreach ($deleteEvents as $event) {
275            $this->deleteEvent($event, $schedule);
276        }
277
278        $schedule->touch();
279        $this->log->info('Ended schedule "{name}" import', ['name' => $schedule->name]);
280
281        $this->addNotification('schedule.import.success');
282        return redirect($this->url, 303);
283    }
284
285    protected function createLocation(Room $room): void
286    {
287        $location = new Location();
288        $location->name = $room->getName();
289        $location->save();
290
291        $this->log->info('Created schedule location "{location}"', ['location' => $room->getName()]);
292    }
293
294    protected function fireDeleteShiftEvents(Event $event, ScheduleModel $schedule): void
295    {
296        /** @var DatabaseCollection|ScheduleShift[] $scheduleShifts */
297        $scheduleShifts = ScheduleShift::where('guid', $event->getGuid())
298            ->where('schedule_id', $schedule->id)
299            ->get();
300
301        foreach ($scheduleShifts as $scheduleShift) {
302            event('shift.deleting', ['shift' => $scheduleShift->shift]);
303        }
304    }
305
306    protected function createEvent(Event $event, int $shiftTypeId, Location $location, ScheduleModel $schedule): void
307    {
308        $user = auth()->user();
309        $eventTimeZone = Carbon::now()->timezone;
310
311        $shift = new Shift();
312        $shift->title = $event->getTitle();
313        $shift->shift_type_id = $shiftTypeId;
314        $shift->start = $event->getDate()->copy()->timezone($eventTimeZone);
315        $shift->end = $event->getEndDate()->copy()->timezone($eventTimeZone);
316        $shift->location()->associate($location);
317        $shift->url = $event->getUrl() ?? '';
318        $shift->transaction_id = Uuid::uuidBy($schedule->id, '5c4ed01e');
319        $shift->createdBy()->associate($user);
320        $shift->save();
321
322        $scheduleShift = new ScheduleShift(['guid' => $event->getGuid()]);
323        $scheduleShift->schedule()->associate($schedule);
324        $scheduleShift->shift()->associate($shift);
325        $scheduleShift->save();
326
327        $this->log->info(
328            'Created schedule ({schedule}) shift: {shifttype} with title '
329            . '"{shift}" in "{location}" ({from} - {to}, {guid})',
330            [
331                'schedule' => $scheduleShift->schedule->name,
332                'shifttype' => $shift->shiftType->name,
333                'shift' => $shift->title,
334                'location' => $shift->location->name,
335                'from' => $shift->start->format('Y-m-d H:i'),
336                'to' => $shift->end->format('Y-m-d H:i'),
337                'guid' => $scheduleShift->guid,
338            ]
339        );
340    }
341
342    protected function updateEvent(Event $event, int $shiftTypeId, Location $location, ScheduleModel $schedule): void
343    {
344        $user = auth()->user();
345        $eventTimeZone = Carbon::now()->timezone;
346
347        /** @var ScheduleShift $scheduleShift */
348        $scheduleShift = ScheduleShift::whereGuid($event->getGuid())->where('schedule_id', $schedule->id)->first();
349        $shift = $scheduleShift->shift;
350        $oldShift = Shift::find($shift->id);
351        $shift->title = $event->getTitle();
352        $shift->shift_type_id = $shiftTypeId;
353        $shift->start = $event->getDate()->copy()->timezone($eventTimeZone);
354        $shift->end = $event->getEndDate()->copy()->timezone($eventTimeZone);
355        $shift->location()->associate($location);
356        $shift->url = $event->getUrl() ?? '';
357        $shift->updatedBy()->associate($user);
358        $shift->save();
359
360        $this->fireUpdateShiftUpdateEvent($oldShift, $shift);
361
362        $this->log->info(
363            'Updated schedule ({schedule}) shift: {shifttype} with title '
364            . '"{shift}" in "{location}" ({from} - {to}, {guid})',
365            [
366                'schedule' => $scheduleShift->schedule->name,
367                'shifttype' => $shift->shiftType->name,
368                'shift' => $shift->title,
369                'location' => $shift->location->name,
370                'from' => $shift->start->format('Y-m-d H:i'),
371                'to' => $shift->end->format('Y-m-d H:i'),
372                'guid' => $scheduleShift->guid,
373            ]
374        );
375    }
376
377    protected function deleteEvent(Event $event, ScheduleModel $schedule): void
378    {
379        /** @var ScheduleShift $scheduleShift */
380        $scheduleShift = ScheduleShift::whereGuid($event->getGuid())->where('schedule_id', $schedule->id)->first();
381        $shift = $scheduleShift->shift;
382
383        $this->fireDeleteShiftEvents($event, $schedule);
384        $shift->delete();
385        $scheduleShift->delete();
386
387        $this->log->info(
388            'Deleted schedule ({schedule}) shift: "{shift}" in {location} ({from} - {to}, {guid})',
389            [
390                'schedule' => $scheduleShift->schedule->name,
391                'shift' => $shift->title,
392                'location' => $shift->location->name,
393                'from' => $shift->start->format('Y-m-d H:i'),
394                'to' => $shift->end->format('Y-m-d H:i'),
395                'guid' => $scheduleShift->guid,
396            ]
397        );
398    }
399
400    protected function fireUpdateShiftUpdateEvent(Shift $oldShift, Shift $newShift): void
401    {
402        event('shift.updating', [
403            'shift' => $newShift,
404            'oldShift' => $oldShift,
405        ]);
406    }
407
408    /**
409     * @return Event[]|Room[]|Location[]
410     * @throws ErrorException
411     */
412    protected function getScheduleData(Request $request): array
413    {
414        $scheduleId = (int) $request->getAttribute('schedule_id');
415
416        /** @var ScheduleModel $scheduleModel */
417        $scheduleModel = ScheduleModel::findOrFail($scheduleId);
418
419        try {
420            $scheduleResponse = $this->guzzle->get($scheduleModel->url);
421        } catch (ConnectException | GuzzleException $e) {
422            $this->log->error('Exception during schedule request', ['exception' => $e]);
423            throw new ErrorException('schedule.import.request-error');
424        }
425
426        if ($scheduleResponse->getStatusCode() != 200) {
427            $this->log->warning(
428                'Problem during schedule request, got code {code}',
429                ['code' => $scheduleResponse->getStatusCode()]
430            );
431            throw new ErrorException('schedule.import.request-error');
432        }
433
434        $scheduleData = (string) $scheduleResponse->getBody();
435        if (!$this->parser->load($scheduleData)) {
436            $this->log->warning('Problem during schedule parsing');
437            throw new ErrorException('schedule.import.read-error');
438        }
439
440        $schedule = $this->parser->getSchedule();
441        $schedule = $this->patchSchedule($schedule);
442
443        $shiftType = $scheduleModel->shift_type;
444        $minutesBefore = $scheduleModel->minutes_before;
445        $minutesAfter = $scheduleModel->minutes_after;
446        $newRooms = $this->newRooms($schedule->getRooms());
447        return array_merge(
448            $this->shiftsDiff($schedule, $scheduleModel, $shiftType, $minutesBefore, $minutesAfter),
449            [$newRooms, $shiftType, $scheduleModel, $schedule, $minutesBefore, $minutesAfter]
450        );
451    }
452
453    /**
454     * @param Room[] $scheduleRooms
455     * @return Room[]
456     */
457    protected function newRooms(array $scheduleRooms): array
458    {
459        $newRooms = [];
460        $allLocations = $this->getAllLocations();
461
462        foreach ($scheduleRooms as $room) {
463            if ($allLocations->where('name', $room->getName())->count()) {
464                continue;
465            }
466
467            $newRooms[] = $room;
468        }
469
470        return $newRooms;
471    }
472
473    /**
474     * @return Event[]
475     */
476    protected function shiftsDiff(
477        Schedule $schedule,
478        ScheduleModel $scheduleModel,
479        int $shiftType,
480        int $minutesBefore,
481        int $minutesAfter
482    ): array {
483        /** @var Event[] $newEvents */
484        $newEvents = [];
485        /** @var Event[] $changeEvents */
486        $changeEvents = [];
487        /** @var Event[] $scheduleEvents */
488        $scheduleEvents = [];
489        /** @var Event[] $deleteEvents */
490        $deleteEvents = [];
491        $locations = $this->getAllLocations();
492        $eventTimeZone = Carbon::now()->timezone;
493
494        foreach ($schedule->getDays() as $day) {
495            foreach ($day->getRooms() as $room) {
496                if (!$scheduleModel->activeLocations->where('name', $room->getName())->count()) {
497                    continue;
498                }
499
500                foreach ($room->getEvents() as $event) {
501                    $scheduleEvents[$event->getGuid()] = $event;
502
503                    $event->getDate()->timezone($eventTimeZone)->subMinutes($minutesBefore);
504                    $event->getEndDate()->timezone($eventTimeZone)->addMinutes($minutesAfter);
505                    $event->setTitle(
506                        $event->getLanguage()
507                            ? sprintf('%s [%s]', $event->getTitle(), $event->getLanguage())
508                            : $event->getTitle()
509                    );
510                }
511            }
512        }
513
514        $scheduleEventsGuidList = array_keys($scheduleEvents);
515        $existingShifts = $this->getScheduleShiftsByGuid($scheduleModel, $scheduleEventsGuidList);
516        foreach ($existingShifts as $scheduleShift) {
517            $guid = $scheduleShift->guid;
518            $shift = $scheduleShift->shift;
519            $event = $scheduleEvents[$guid];
520            /** @var Location $location */
521            $location = $locations->where('name', $event->getRoom()->getName())->first();
522
523            if (
524                $shift->title != $event->getTitle()
525                || $shift->shift_type_id != $shiftType
526                || $shift->start != $event->getDate()
527                || $shift->end != $event->getEndDate()
528                || $shift->location_id != ($location->id ?? '')
529                || $shift->url != ($event->getUrl() ?? '')
530            ) {
531                $changeEvents[$guid] = $event;
532            }
533
534            unset($scheduleEvents[$guid]);
535        }
536
537        foreach ($scheduleEvents as $scheduleEvent) {
538            $newEvents[$scheduleEvent->getGuid()] = $scheduleEvent;
539        }
540
541        $scheduleShifts = $this->getScheduleShiftsWhereNotGuid($scheduleModel, $scheduleEventsGuidList);
542        foreach ($scheduleShifts as $scheduleShift) {
543            $event = $this->eventFromScheduleShift($scheduleShift);
544            $deleteEvents[$event->getGuid()] = $event;
545        }
546
547        return [$newEvents, $changeEvents, $deleteEvents];
548    }
549
550    protected function eventFromScheduleShift(ScheduleShift $scheduleShift): Event
551    {
552        $shift = $scheduleShift->shift;
553        $duration = $shift->start->diff($shift->end);
554
555        return new Event(
556            $scheduleShift->guid,
557            0,
558            new Room($shift->location->name),
559            $shift->title,
560            '',
561            'n/a',
562            $shift->start,
563            $shift->start->format('H:i'),
564            $duration->format('%H:%I'),
565            '',
566            '',
567            new ConferenceTrack('')
568        );
569    }
570
571    protected function patchSchedule(Schedule $schedule): Schedule
572    {
573        foreach ($schedule->getAllRooms() as $room) {
574            $room->patch('name', Str::substr($room->getName(), 0, 35));
575
576            foreach ($room->getEvents() as $event) {
577                $event->patch('title', Str::substr($event->getTitle(), 0, 255));
578                $event->patch('url', Str::substr((string) $event->getUrl(), 0, 255) ?: null);
579            }
580        }
581
582        return $schedule;
583    }
584
585    /**
586     * @return Location[]|Collection
587     */
588    protected function getAllLocations(): Collection | array
589    {
590        return Location::all();
591    }
592
593    /**
594     * @param string[] $events
595     *
596     * @return Collection|ScheduleShift[]
597     */
598    protected function getScheduleShiftsByGuid(ScheduleModel $schedule, array $events): Collection | array
599    {
600        return ScheduleShift::with('shift.location')
601            ->whereIn('guid', $events)
602            ->where('schedule_id', $schedule->id)
603            ->get();
604    }
605
606    /**
607     * @param string[] $events
608     * @return Collection|ScheduleShift[]
609     */
610    protected function getScheduleShiftsWhereNotGuid(ScheduleModel $schedule, array $events): Collection | array
611    {
612        return ScheduleShift::with('shift.location')
613            ->whereNotIn('guid', $events)
614            ->where('schedule_id', $schedule->id)
615            ->get();
616    }
617}