Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
342 / 342
100.00% covered (success)
100.00%
21 / 21
CRAP
100.00% covered (success)
100.00%
1 / 1
ScheduleController
100.00% covered (success)
100.00%
342 / 342
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%
48 / 48
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}: 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                'url' => $schedule->name,
133                'shift_type_name' => Shifttype::find($schedule->shift_type)->name,
134                'shift_type_id' => $schedule->shift_type,
135                'need'       => $schedule->needed_from_shift_type ? 'from shift type' : 'from room',
136                'before' => $schedule->minutes_before,
137                'after' => $schedule->minutes_after,
138                'locations'  => $for->implode(', '),
139            ]
140        );
141
142        $this->addNotification('schedule.edit.success');
143
144        return redirect('/admin/schedule/load/' . $schedule->id);
145    }
146
147    protected function delete(ScheduleModel $schedule): Response
148    {
149        foreach ($schedule->scheduleShifts as $scheduleShift) {
150            // Only guid is needed here
151            $event = new Event(
152                $scheduleShift->guid,
153                0,
154                new Room(''),
155                '',
156                '',
157                '',
158                Carbon::now(),
159                '',
160                '',
161                '',
162                '',
163                new ConferenceTrack('')
164            );
165
166            $this->deleteEvent($event, $schedule);
167        }
168        $schedule->delete();
169
170        $this->log->info('Schedule {name} deleted', ['name' => $schedule->name]);
171        $this->addNotification('schedule.delete.success');
172        return redirect('/admin/schedule');
173    }
174
175    public function loadSchedule(Request $request): Response
176    {
177        try {
178            /**
179             * @var Event[] $newEvents
180             * @var Event[] $changeEvents
181             * @var Event[] $deleteEvents
182             * @var Room[] $newRooms
183             * @var int $shiftType
184             * @var ScheduleModel $scheduleModel
185             * @var Schedule $schedule
186             * @var int $minutesBefore
187             * @var int $minutesAfter
188             */
189            list(
190                $newEvents,
191                $changeEvents,
192                $deleteEvents,
193                $newRooms,
194                ,
195                $scheduleModel,
196                $schedule
197                ) = $this->getScheduleData($request);
198        } catch (ErrorException $e) {
199            $this->addNotification($e->getMessage(), NotificationType::ERROR);
200            return back();
201        }
202
203        return $this->response->withView(
204            'admin/schedule/load',
205            [
206                'schedule_id' => $scheduleModel->id,
207                'schedule' => $schedule,
208                'locations' => [
209                    'add' => $newRooms,
210                ],
211                'shifts' => [
212                    'add' => $newEvents,
213                    'update' => $changeEvents,
214                    'delete' => $deleteEvents,
215                ],
216            ]
217        );
218    }
219
220    public function importSchedule(Request $request): Response
221    {
222        try {
223            /**
224             * @var Event[] $newEvents
225             * @var Event[] $changeEvents
226             * @var Event[] $deleteEvents
227             * @var Room[] $newRooms
228             * @var int $shiftType
229             * @var ScheduleModel $schedule
230             */
231            list(
232                $newEvents,
233                $changeEvents,
234                $deleteEvents,
235                $newRooms,
236                $shiftType,
237                $schedule
238                ) = $this->getScheduleData($request);
239        } catch (ErrorException $e) {
240            $this->addNotification($e->getMessage(), NotificationType::ERROR);
241            return back();
242        }
243
244        $this->log->info('Started schedule "{name}" import', ['name' => $schedule->name]);
245
246        foreach ($newRooms as $room) {
247            $this->createLocation($room);
248        }
249
250        $locations = $this->getAllLocations();
251        foreach ($newEvents as $event) {
252            $this->createEvent(
253                $event,
254                $shiftType,
255                $locations
256                    ->where('name', $event->getRoom()->getName())
257                    ->first(),
258                $schedule
259            );
260        }
261
262        foreach ($changeEvents as $event) {
263            $this->updateEvent(
264                $event,
265                $shiftType,
266                $locations
267                    ->where('name', $event->getRoom()->getName())
268                    ->first(),
269                $schedule
270            );
271        }
272
273        foreach ($deleteEvents as $event) {
274            $this->deleteEvent($event, $schedule);
275        }
276
277        $schedule->touch();
278        $this->log->info('Ended schedule "{name}" import', ['name' => $schedule->name]);
279
280        $this->addNotification('schedule.import.success');
281        return redirect($this->url, 303);
282    }
283
284    protected function createLocation(Room $room): void
285    {
286        $location = new Location();
287        $location->name = $room->getName();
288        $location->save();
289
290        $this->log->info('Created schedule location "{location}"', ['location' => $room->getName()]);
291    }
292
293    protected function fireDeleteShiftEvents(Event $event, ScheduleModel $schedule): void
294    {
295        /** @var DatabaseCollection|ScheduleShift[] $scheduleShifts */
296        $scheduleShifts = ScheduleShift::where('guid', $event->getGuid())
297            ->where('schedule_id', $schedule->id)
298            ->get();
299
300        foreach ($scheduleShifts as $scheduleShift) {
301            event('shift.deleting', ['shift' => $scheduleShift->shift]);
302        }
303    }
304
305    protected function createEvent(Event $event, int $shiftTypeId, Location $location, ScheduleModel $schedule): void
306    {
307        $user = auth()->user();
308        $eventTimeZone = Carbon::now()->timezone;
309
310        $shift = new Shift();
311        $shift->title = $event->getTitle();
312        $shift->shift_type_id = $shiftTypeId;
313        $shift->start = $event->getDate()->copy()->timezone($eventTimeZone);
314        $shift->end = $event->getEndDate()->copy()->timezone($eventTimeZone);
315        $shift->location()->associate($location);
316        $shift->url = $event->getUrl() ?? '';
317        $shift->transaction_id = Uuid::uuidBy($schedule->id, '5c4ed01e');
318        $shift->createdBy()->associate($user);
319        $shift->save();
320
321        $scheduleShift = new ScheduleShift(['guid' => $event->getGuid()]);
322        $scheduleShift->schedule()->associate($schedule);
323        $scheduleShift->shift()->associate($shift);
324        $scheduleShift->save();
325
326        $this->log->info(
327            'Created schedule ({schedule}) shift: {shifttype} with title '
328            . '"{shift}" in "{location}" ({from} - {to}, {guid})',
329            [
330                'schedule' => $scheduleShift->schedule->name,
331                'shifttype' => $shift->shiftType->name,
332                'shift' => $shift->title,
333                'location' => $shift->location->name,
334                'from' => $shift->start->format('Y-m-d H:i'),
335                'to' => $shift->end->format('Y-m-d H:i'),
336                'guid' => $scheduleShift->guid,
337            ]
338        );
339    }
340
341    protected function updateEvent(Event $event, int $shiftTypeId, Location $location, ScheduleModel $schedule): void
342    {
343        $user = auth()->user();
344        $eventTimeZone = Carbon::now()->timezone;
345
346        /** @var ScheduleShift $scheduleShift */
347        $scheduleShift = ScheduleShift::whereGuid($event->getGuid())->where('schedule_id', $schedule->id)->first();
348        $shift = $scheduleShift->shift;
349        $oldShift = Shift::find($shift->id);
350        $shift->title = $event->getTitle();
351        $shift->shift_type_id = $shiftTypeId;
352        $shift->start = $event->getDate()->copy()->timezone($eventTimeZone);
353        $shift->end = $event->getEndDate()->copy()->timezone($eventTimeZone);
354        $shift->location()->associate($location);
355        $shift->url = $event->getUrl() ?? '';
356        $shift->updatedBy()->associate($user);
357        $shift->save();
358
359        $this->fireUpdateShiftUpdateEvent($oldShift, $shift);
360
361        $this->log->info(
362            'Updated schedule ({schedule}) shift: {shifttype} with title '
363            . '"{shift}" in "{location}" ({from} - {to}, {guid})',
364            [
365                'schedule' => $scheduleShift->schedule->name,
366                'shifttype' => $shift->shiftType->name,
367                'shift' => $shift->title,
368                'location' => $shift->location->name,
369                'from' => $shift->start->format('Y-m-d H:i'),
370                'to' => $shift->end->format('Y-m-d H:i'),
371                'guid' => $scheduleShift->guid,
372            ]
373        );
374    }
375
376    protected function deleteEvent(Event $event, ScheduleModel $schedule): void
377    {
378        /** @var ScheduleShift $scheduleShift */
379        $scheduleShift = ScheduleShift::whereGuid($event->getGuid())->where('schedule_id', $schedule->id)->first();
380        $shift = $scheduleShift->shift;
381
382        $this->fireDeleteShiftEvents($event, $schedule);
383        $shift->delete();
384        $scheduleShift->delete();
385
386        $this->log->info(
387            'Deleted schedule ({schedule}) shift: "{shift}" in {location} ({from} - {to}, {guid})',
388            [
389                'schedule' => $scheduleShift->schedule->name,
390                'shift' => $shift->title,
391                'location' => $shift->location->name,
392                'from' => $shift->start->format('Y-m-d H:i'),
393                'to' => $shift->end->format('Y-m-d H:i'),
394                'guid' => $scheduleShift->guid,
395            ]
396        );
397    }
398
399    protected function fireUpdateShiftUpdateEvent(Shift $oldShift, Shift $newShift): void
400    {
401        event('shift.updating', [
402            'shift' => $newShift,
403            'oldShift' => $oldShift,
404        ]);
405    }
406
407    /**
408     * @return Event[]|Room[]|Location[]
409     * @throws ErrorException
410     */
411    protected function getScheduleData(Request $request): array
412    {
413        $scheduleId = (int) $request->getAttribute('schedule_id');
414
415        /** @var ScheduleModel $scheduleModel */
416        $scheduleModel = ScheduleModel::findOrFail($scheduleId);
417
418        try {
419            $scheduleResponse = $this->guzzle->get($scheduleModel->url);
420        } catch (ConnectException | GuzzleException $e) {
421            $this->log->error('Exception during schedule request', ['exception' => $e]);
422            throw new ErrorException('schedule.import.request-error');
423        }
424
425        if ($scheduleResponse->getStatusCode() != 200) {
426            $this->log->warning(
427                'Problem during schedule request, got code {code}',
428                ['code' => $scheduleResponse->getStatusCode()]
429            );
430            throw new ErrorException('schedule.import.request-error');
431        }
432
433        $scheduleData = (string) $scheduleResponse->getBody();
434        if (!$this->parser->load($scheduleData)) {
435            $this->log->warning('Problem during schedule parsing');
436            throw new ErrorException('schedule.import.read-error');
437        }
438
439        $schedule = $this->parser->getSchedule();
440        $schedule = $this->patchSchedule($schedule);
441
442        $shiftType = $scheduleModel->shift_type;
443        $minutesBefore = $scheduleModel->minutes_before;
444        $minutesAfter = $scheduleModel->minutes_after;
445        $newRooms = $this->newRooms($schedule->getRooms());
446        return array_merge(
447            $this->shiftsDiff($schedule, $scheduleModel, $shiftType, $minutesBefore, $minutesAfter),
448            [$newRooms, $shiftType, $scheduleModel, $schedule, $minutesBefore, $minutesAfter]
449        );
450    }
451
452    /**
453     * @param Room[] $scheduleRooms
454     * @return Room[]
455     */
456    protected function newRooms(array $scheduleRooms): array
457    {
458        $newRooms = [];
459        $allLocations = $this->getAllLocations();
460
461        foreach ($scheduleRooms as $room) {
462            if ($allLocations->where('name', $room->getName())->count()) {
463                continue;
464            }
465
466            $newRooms[] = $room;
467        }
468
469        return $newRooms;
470    }
471
472    /**
473     * @return Event[]
474     */
475    protected function shiftsDiff(
476        Schedule $schedule,
477        ScheduleModel $scheduleModel,
478        int $shiftType,
479        int $minutesBefore,
480        int $minutesAfter
481    ): array {
482        /** @var Event[] $newEvents */
483        $newEvents = [];
484        /** @var Event[] $changeEvents */
485        $changeEvents = [];
486        /** @var Event[] $scheduleEvents */
487        $scheduleEvents = [];
488        /** @var Event[] $deleteEvents */
489        $deleteEvents = [];
490        $locations = $this->getAllLocations();
491        $eventTimeZone = Carbon::now()->timezone;
492
493        foreach ($schedule->getDays() as $day) {
494            foreach ($day->getRooms() as $room) {
495                if (!$scheduleModel->activeLocations->where('name', $room->getName())->count()) {
496                    continue;
497                }
498
499                foreach ($room->getEvents() as $event) {
500                    $scheduleEvents[$event->getGuid()] = $event;
501
502                    $event->getDate()->timezone($eventTimeZone)->subMinutes($minutesBefore);
503                    $event->getEndDate()->timezone($eventTimeZone)->addMinutes($minutesAfter);
504                    $event->setTitle(
505                        $event->getLanguage()
506                            ? sprintf('%s [%s]', $event->getTitle(), $event->getLanguage())
507                            : $event->getTitle()
508                    );
509                }
510            }
511        }
512
513        $scheduleEventsGuidList = array_keys($scheduleEvents);
514        $existingShifts = $this->getScheduleShiftsByGuid($scheduleModel, $scheduleEventsGuidList);
515        foreach ($existingShifts as $scheduleShift) {
516            $guid = $scheduleShift->guid;
517            $shift = $scheduleShift->shift;
518            $event = $scheduleEvents[$guid];
519            /** @var Location $location */
520            $location = $locations->where('name', $event->getRoom()->getName())->first();
521
522            if (
523                $shift->title != $event->getTitle()
524                || $shift->shift_type_id != $shiftType
525                || $shift->start != $event->getDate()
526                || $shift->end != $event->getEndDate()
527                || $shift->location_id != ($location->id ?? '')
528                || $shift->url != ($event->getUrl() ?? '')
529            ) {
530                $changeEvents[$guid] = $event;
531            }
532
533            unset($scheduleEvents[$guid]);
534        }
535
536        foreach ($scheduleEvents as $scheduleEvent) {
537            $newEvents[$scheduleEvent->getGuid()] = $scheduleEvent;
538        }
539
540        $scheduleShifts = $this->getScheduleShiftsWhereNotGuid($scheduleModel, $scheduleEventsGuidList);
541        foreach ($scheduleShifts as $scheduleShift) {
542            $event = $this->eventFromScheduleShift($scheduleShift);
543            $deleteEvents[$event->getGuid()] = $event;
544        }
545
546        return [$newEvents, $changeEvents, $deleteEvents];
547    }
548
549    protected function eventFromScheduleShift(ScheduleShift $scheduleShift): Event
550    {
551        $shift = $scheduleShift->shift;
552        $duration = $shift->start->diff($shift->end);
553
554        return new Event(
555            $scheduleShift->guid,
556            0,
557            new Room($shift->location->name),
558            $shift->title,
559            '',
560            'n/a',
561            $shift->start,
562            $shift->start->format('H:i'),
563            $duration->format('%H:%I'),
564            '',
565            '',
566            new ConferenceTrack('')
567        );
568    }
569
570    protected function patchSchedule(Schedule $schedule): Schedule
571    {
572        foreach ($schedule->getRooms() as $room) {
573            $room->patch('name', Str::substr($room->getName(), 0, 35));
574
575            foreach ($room->getEvents() as $event) {
576                $event->patch('title', Str::substr($event->getTitle(), 0, 255));
577                $event->patch('url', Str::substr((string) $event->getUrl(), 0, 255) ?: null);
578            }
579        }
580
581        return $schedule;
582    }
583
584    /**
585     * @return Location[]|Collection
586     */
587    protected function getAllLocations(): Collection | array
588    {
589        return Location::all();
590    }
591
592    /**
593     * @param string[] $events
594     *
595     * @return Collection|ScheduleShift[]
596     */
597    protected function getScheduleShiftsByGuid(ScheduleModel $schedule, array $events): Collection | array
598    {
599        return ScheduleShift::with('shift.location')
600            ->whereIn('guid', $events)
601            ->where('schedule_id', $schedule->id)
602            ->get();
603    }
604
605    /**
606     * @param string[] $events
607     * @return Collection|ScheduleShift[]
608     */
609    protected function getScheduleShiftsWhereNotGuid(ScheduleModel $schedule, array $events): Collection | array
610    {
611        return ScheduleShift::with('shift.location')
612            ->whereNotIn('guid', $events)
613            ->where('schedule_id', $schedule->id)
614            ->get();
615    }
616}