Completed
Pull Request — master (#489)
by
unknown
02:44
created

Calendar::determineCorrectTopStatusForProjection()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 19

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 19
rs 9.6333
c 0
b 0
f 0
cc 3
nc 3
nop 0
1
<?php
2
3
namespace CultuurNet\UDB3;
4
5
use Broadway\Serializer\SerializableInterface;
6
use CultuurNet\UDB3\Calendar\OpeningHour;
7
use CultuurNet\UDB3\Event\ValueObjects\Status;
8
use CultuurNet\UDB3\Event\ValueObjects\StatusType;
9
use CultuurNet\UDB3\Model\ValueObject\Calendar\Calendar as Udb3ModelCalendar;
10
use CultuurNet\UDB3\Model\ValueObject\Calendar\CalendarWithDateRange;
11
use CultuurNet\UDB3\Model\ValueObject\Calendar\CalendarWithOpeningHours;
12
use CultuurNet\UDB3\Model\ValueObject\Calendar\CalendarWithSubEvents;
13
use CultuurNet\UDB3\Model\ValueObject\Calendar\OpeningHours\OpeningHour as Udb3ModelOpeningHour;
14
use CultuurNet\UDB3\Model\ValueObject\Calendar\SubEvent;
15
use DateTime;
16
use DateTimeInterface;
17
use DateTimeZone;
18
use InvalidArgumentException;
19
20
final class Calendar implements CalendarInterface, JsonLdSerializableInterface, SerializableInterface
21
{
22
    /**
23
     * @var CalendarType
24
     */
25
    protected $type;
26
27
    /**
28
     * @var DateTimeInterface
29
     */
30
    protected $startDate;
31
32
    /**
33
     * @var DateTimeInterface
34
     */
35
    protected $endDate;
36
37
    /**
38
     * @var Timestamp[]
39
     */
40
    protected $timestamps = [];
41
42
    /**
43
     * @var OpeningHour[]
44
     */
45
    protected $openingHours = [];
46
47
    /**
48
     * @var Status
49
     */
50
    protected $status;
51
52
    /**
53
     * @param CalendarType $type
54
     * @param DateTimeInterface|null $startDate
55
     * @param DateTimeInterface|null $endDate
56
     * @param Timestamp[] $timestamps
57
     * @param OpeningHour[] $openingHours
58
     */
59
    public function __construct(
60
        CalendarType $type,
61
        ?DateTimeInterface $startDate = null,
62
        ?DateTimeInterface $endDate = null,
63
        array $timestamps = [],
64
        array $openingHours = []
65
    ) {
66
        if (empty($timestamps) && ($type->is(CalendarType::SINGLE()) || $type->is(CalendarType::MULTIPLE()))) {
67
            throw new \UnexpectedValueException('A single or multiple calendar should have timestamps.');
68
        }
69
70
        if (($startDate === null || $endDate === null) && $type->is(CalendarType::PERIODIC())) {
71
            throw new \UnexpectedValueException('A period should have a start- and end-date.');
72
        }
73
74
        foreach ($timestamps as $timestamp) {
75
            if (!is_a($timestamp, Timestamp::class)) {
76
                throw new \InvalidArgumentException('Timestamps should have type TimeStamp.');
77
            }
78
        }
79
80
        foreach ($openingHours as $openingHour) {
81
            if (!is_a($openingHour, OpeningHour::class)) {
82
                throw new \InvalidArgumentException('OpeningHours should have type OpeningHour.');
83
            }
84
        }
85
86
        $this->type = $type->toNative();
87
        $this->startDate = $startDate;
88
        $this->endDate = $endDate;
89
        $this->openingHours = $openingHours;
90
91
        usort($timestamps, function (Timestamp $timestamp, Timestamp $otherTimestamp) {
92
            return $timestamp->getStartDate() <=> $otherTimestamp->getStartDate();
93
        });
94
95
        $this->timestamps = $timestamps;
96
97
        $this->status = new Status($this->deriveStatusTypeFromSubEvents(), []);
98
    }
99
100
    /**
101
     * Set $updateStatusInTimestamps to true when setting a new top status that should also be applied to all subEvents.
102
     * Set it to false when constructing a Calendar object that's deserialized from JSON and you want to set the raw
103
     * status without affecting the status in the subEvents.
104
     */
105
    public function withStatus(Status $status, bool $updateStatusInTimestamps = true): self
106
    {
107
        $clone = clone $this;
108
109
        $clone->status = $status;
110
111
        if ($updateStatusInTimestamps) {
112
            $clone->timestamps = \array_map(
113
                function (Timestamp $timestamp) use ($status) : Timestamp {
114
                    return $timestamp->withStatus($status);
115
                },
116
                $clone->getTimestamps()
117
            );
118
        }
119
120
        return $clone;
121
    }
122
123
    public function getStatus(): Status
124
    {
125
        return $this->status;
126
    }
127
128
    public function getType(): CalendarType
129
    {
130
        return CalendarType::fromNative($this->type);
131
    }
132
133
    public function serialize(): array
134
    {
135
        $serializedTimestamps = array_map(
136
            function (Timestamp $timestamp) {
137
                return $timestamp->serialize();
138
            },
139
            $this->timestamps
140
        );
141
142
        $serializedOpeningHours = array_map(
143
            function (OpeningHour $openingHour) {
144
                return $openingHour->serialize();
145
            },
146
            $this->openingHours
147
        );
148
149
        $calendar = [
150
            'type' => $this->type,
151
            'status' => $this->status->serialize(),
152
        ];
153
154
        empty($this->startDate) ?: $calendar['startDate'] = $this->startDate->format(DateTime::ATOM);
155
        empty($this->endDate) ?: $calendar['endDate'] = $this->endDate->format(DateTime::ATOM);
156
        empty($serializedTimestamps) ?: $calendar['timestamps'] = $serializedTimestamps;
157
        empty($serializedOpeningHours) ?: $calendar['openingHours'] = $serializedOpeningHours;
158
159
        return $calendar;
160
    }
161
162
    public static function deserialize(array $data): Calendar
163
    {
164
        $calendarType = CalendarType::fromNative($data['type']);
165
166
        // Backwards compatibility for serialized single or multiple calendar types that are missing timestamps but do
167
        // have a start and end date.
168
        $defaultTimeStamps = [];
169
        if ($calendarType->sameValueAs(CalendarType::SINGLE()) || $calendarType->sameValueAs(CalendarType::MULTIPLE())) {
170
            $defaultTimeStampStartDate = !empty($data['startDate']) ? self::deserializeDateTime($data['startDate']) : null;
171
            $defaultTimeStampEndDate = !empty($data['endDate']) ? self::deserializeDateTime($data['endDate']) : $defaultTimeStampStartDate;
172
            $defaultTimeStamp = $defaultTimeStampStartDate && $defaultTimeStampEndDate ? new Timestamp($defaultTimeStampStartDate, $defaultTimeStampEndDate) : null;
173
            $defaultTimeStamps = $defaultTimeStamp ? [$defaultTimeStamp] : [];
174
        }
175
176
        $calendar = new self(
177
            $calendarType,
178
            !empty($data['startDate']) ? self::deserializeDateTime($data['startDate']) : null,
179
            !empty($data['endDate']) ? self::deserializeDateTime($data['endDate']) : null,
180
            !empty($data['timestamps']) ? array_map(
181
                function ($timestamp) {
182
                    return Timestamp::deserialize($timestamp);
183
                },
184
                $data['timestamps']
185
            ) : $defaultTimeStamps,
186
            !empty($data['openingHours']) ? array_map(
187
                function ($openingHour) {
188
                    return OpeningHour::deserialize($openingHour);
189
                },
190
                $data['openingHours']
191
            ) : []
192
        );
193
194
        if (!empty($data['status'])) {
195
            $calendar->status = Status::deserialize($data['status']);
196
        }
197
198
        return $calendar;
199
    }
200
201
    /**
202
     * This deserialization function takes into account old data that might be missing a timezone.
203
     * It will fall back to creating a DateTime object and assume Brussels.
204
     * If this still fails an error will be thrown.
205
     */
206
    private static function deserializeDateTime(string $dateTimeData): DateTime
207
    {
208
        $dateTime = DateTime::createFromFormat(DateTime::ATOM, $dateTimeData);
209
210
        if ($dateTime === false) {
211
            $dateTime = DateTime::createFromFormat('Y-m-d\TH:i:s', $dateTimeData, new DateTimeZone('Europe/Brussels'));
212
213
            if (!$dateTime) {
214
                throw new InvalidArgumentException('Invalid date string provided for timestamp, ISO8601 expected!');
215
            }
216
        }
217
218
        return $dateTime;
219
    }
220
221
    public function getStartDate(): ?DateTimeInterface
222
    {
223
        $timestamps = $this->getTimestamps();
224
225
        if (empty($timestamps)) {
226
            return $this->startDate;
227
        }
228
229
        $startDate = null;
230
        foreach ($timestamps as $timestamp) {
231
            if ($startDate === null || $timestamp->getStartDate() < $startDate) {
232
                $startDate = $timestamp->getStartDate();
233
            }
234
        }
235
236
        return $startDate;
237
    }
238
239
    public function getEndDate(): ?DateTimeInterface
240
    {
241
        $timestamps = $this->getTimestamps();
242
243
        if (empty($timestamps)) {
244
            return $this->endDate;
245
        }
246
247
        $endDate = null;
248
        foreach ($this->getTimestamps() as $timestamp) {
249
            if ($endDate === null || $timestamp->getEndDate() > $endDate) {
250
                $endDate = $timestamp->getEndDate();
251
            }
252
        }
253
254
        return $endDate;
255
    }
256
257
    /**
258
     * @return array|OpeningHour[]
259
     */
260
    public function getOpeningHours(): array
261
    {
262
        return $this->openingHours;
263
    }
264
265
    /**
266
     * @return array|Timestamp[]
267
     */
268
    public function getTimestamps(): array
269
    {
270
        return $this->timestamps;
271
    }
272
273
    private function deriveStatusTypeFromSubEvents(): StatusType
274
    {
275
        $statusTypeCounts = [];
276
        $statusTypeCounts[StatusType::available()->toNative()] = 0;
277
        $statusTypeCounts[StatusType::temporarilyUnavailable()->toNative()] = 0;
278
        $statusTypeCounts[StatusType::unavailable()->toNative()] = 0;
279
280
        foreach ($this->timestamps as $timestamp) {
281
            ++$statusTypeCounts[$timestamp->getStatus()->getType()->toNative()];
282
        }
283
284
        if ($statusTypeCounts[StatusType::available()->toNative()] > 0) {
285
            return StatusType::available();
286
        }
287
288
        if ($statusTypeCounts[StatusType::temporarilyUnavailable()->toNative()] > 0) {
289
            return StatusType::temporarilyUnavailable();
290
        }
291
292
        if ($statusTypeCounts[StatusType::unavailable()->toNative()] > 0) {
293
            return StatusType::unavailable();
294
        }
295
296
        // This extra return is needed for events with calendar type of permanent or periodic.
297
        return StatusType::available();
298
    }
299
300
    public function toJsonLd(): array
301
    {
302
        $jsonLd = [];
303
304
        $jsonLd['calendarType'] = $this->getType()->toNative();
305
306
        $startDate = $this->getStartDate();
307
        $endDate = $this->getEndDate();
308
        if ($startDate !== null) {
309
            $jsonLd['startDate'] = $startDate->format(DateTime::ATOM);
310
        }
311
        if ($endDate !== null) {
312
            $jsonLd['endDate'] = $endDate->format(DateTime::ATOM);
313
        }
314
315
        $jsonLd['status'] = $this->determineCorrectTopStatusForProjection()->serialize();
316
317
        $timestamps = $this->getTimestamps();
318
        if (!empty($timestamps)) {
319
            $jsonLd['subEvent'] = [];
320
            foreach ($timestamps as $timestamp) {
321
                $jsonLd['subEvent'][] = $timestamp->toJsonLd();
322
            }
323
        }
324
325
        $openingHours = $this->getOpeningHours();
326
        if (!empty($openingHours)) {
327
            $jsonLd['openingHours'] = [];
328
            foreach ($openingHours as $openingHour) {
329
                $jsonLd['openingHours'][] = $openingHour->serialize();
330
            }
331
        }
332
333
        return $jsonLd;
334
    }
335
336
    public function sameAs(Calendar $otherCalendar): bool
337
    {
338
        return $this->toJsonLd() === $otherCalendar->toJsonLd();
339
    }
340
341
    public static function fromUdb3ModelCalendar(Udb3ModelCalendar $calendar): Calendar
342
    {
343
        $type = CalendarType::fromNative($calendar->getType()->toString());
344
345
        $startDate = null;
346
        $endDate = null;
347
        $timestamps = [];
348
        $openingHours = [];
349
350
        if ($calendar instanceof CalendarWithDateRange) {
351
            $startDate = $calendar->getStartDate();
352
            $endDate = $calendar->getEndDate();
353
        }
354
355
        if ($calendar instanceof CalendarWithSubEvents) {
356
            $timestamps = array_map(
357
                function (SubEvent $subEvent) {
358
                    return Timestamp::fromUdb3ModelSubEvent($subEvent);
359
                },
360
                $calendar->getSubEvents()->toArray()
361
            );
362
        }
363
364
        if ($calendar instanceof CalendarWithOpeningHours) {
365
            $openingHours = array_map(
366
                function (Udb3ModelOpeningHour $openingHour) {
367
                    return OpeningHour::fromUdb3ModelOpeningHour($openingHour);
368
                },
369
                $calendar->getOpeningHours()->toArray()
370
            );
371
        }
372
373
        return new self($type, $startDate, $endDate, $timestamps, $openingHours);
374
    }
375
376
    /**
377
     * If the calendar has subEvents (timestamps), and a status manually set through an import or full calendar update
378
     * through the API, the top status might be incorrect.
379
     * For example the top status can not be Available if one of the subEvents is Unavailable.
380
     * However we want to be flexible in what we accept from API clients since otherwise they will have to implement a
381
     * lot of (new) logic to make sure the top status they're sending is correct.
382
     * So we accept the top status as-is, and correct it during projection.
383
     * That way if the correction is bugged, we can always fix it and replay it with the original data.
384
     */
385
    private function determineCorrectTopStatusForProjection(): Status
386
    {
387
        // If the calendar has no subEvents, the top level status is always valid.
388
        if (empty($this->timestamps)) {
389
            return $this->status;
390
        }
391
392
        // If the calendar has subEvents, the top level status is valid if it is the same type as the type derived from
393
        // the subEvents. In that case return $this->status so we include the top-level reason (if it has one).
394
        $expectedStatusType = $this->deriveStatusTypeFromSubEvents();
395
        if ($this->status->getType()->equals($expectedStatusType)) {
396
            return $this->status;
397
        }
398
399
        // If the top-level status is invalid compared to the status type derived from the subEvents, return the
400
        // expected status type without any reason. (If the top level status had a reason it's probably not applicable
401
        // for the new status type.)
402
        return new Status($expectedStatusType, []);
403
    }
404
}
405