Completed
Push — master ( b22bd8...48645e )
by
unknown
07:05 queued 04:44
created

Calendar::withStatusOnTimestamps()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 11
rs 9.9
c 0
b 0
f 0
cc 1
nc 1
nop 1
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
    public function withStatus(Status $status): self
101
    {
102
        $clone = clone $this;
103
        $clone->status = $status;
104
        return $clone;
105
    }
106
107
    public function withStatusOnTimestamps(Status $status): self
108
    {
109
        $clone = clone $this;
110
        $clone->timestamps = \array_map(
111
            function (Timestamp $timestamp) use ($status) : Timestamp {
112
                return $timestamp->withStatus($status);
113
            },
114
            $clone->getTimestamps()
115
        );
116
        return $clone;
117
    }
118
119
    public function getStatus(): Status
120
    {
121
        return $this->status;
122
    }
123
124
    public function getType(): CalendarType
125
    {
126
        return CalendarType::fromNative($this->type);
127
    }
128
129
    public function serialize(): array
130
    {
131
        $serializedTimestamps = array_map(
132
            function (Timestamp $timestamp) {
133
                return $timestamp->serialize();
134
            },
135
            $this->timestamps
136
        );
137
138
        $serializedOpeningHours = array_map(
139
            function (OpeningHour $openingHour) {
140
                return $openingHour->serialize();
141
            },
142
            $this->openingHours
143
        );
144
145
        $calendar = [
146
            'type' => $this->type,
147
            'status' => $this->status->serialize(),
148
        ];
149
150
        empty($this->startDate) ?: $calendar['startDate'] = $this->startDate->format(DateTime::ATOM);
151
        empty($this->endDate) ?: $calendar['endDate'] = $this->endDate->format(DateTime::ATOM);
152
        empty($serializedTimestamps) ?: $calendar['timestamps'] = $serializedTimestamps;
153
        empty($serializedOpeningHours) ?: $calendar['openingHours'] = $serializedOpeningHours;
154
155
        return $calendar;
156
    }
157
158
    public static function deserialize(array $data): Calendar
159
    {
160
        $calendarType = CalendarType::fromNative($data['type']);
161
162
        // Backwards compatibility for serialized single or multiple calendar types that are missing timestamps but do
163
        // have a start and end date.
164
        $defaultTimeStamps = [];
165
        if ($calendarType->sameValueAs(CalendarType::SINGLE()) || $calendarType->sameValueAs(CalendarType::MULTIPLE())) {
166
            $defaultTimeStampStartDate = !empty($data['startDate']) ? self::deserializeDateTime($data['startDate']) : null;
167
            $defaultTimeStampEndDate = !empty($data['endDate']) ? self::deserializeDateTime($data['endDate']) : $defaultTimeStampStartDate;
168
            $defaultTimeStamp = $defaultTimeStampStartDate && $defaultTimeStampEndDate ? new Timestamp($defaultTimeStampStartDate, $defaultTimeStampEndDate) : null;
169
            $defaultTimeStamps = $defaultTimeStamp ? [$defaultTimeStamp] : [];
170
        }
171
172
        $calendar = new self(
173
            $calendarType,
174
            !empty($data['startDate']) ? self::deserializeDateTime($data['startDate']) : null,
175
            !empty($data['endDate']) ? self::deserializeDateTime($data['endDate']) : null,
176
            !empty($data['timestamps']) ? array_map(
177
                function ($timestamp) {
178
                    return Timestamp::deserialize($timestamp);
179
                },
180
                $data['timestamps']
181
            ) : $defaultTimeStamps,
182
            !empty($data['openingHours']) ? array_map(
183
                function ($openingHour) {
184
                    return OpeningHour::deserialize($openingHour);
185
                },
186
                $data['openingHours']
187
            ) : []
188
        );
189
190
        if (!empty($data['status'])) {
191
            $calendar->status = Status::deserialize($data['status']);
192
        }
193
194
        return $calendar;
195
    }
196
197
    /**
198
     * This deserialization function takes into account old data that might be missing a timezone.
199
     * It will fall back to creating a DateTime object and assume Brussels.
200
     * If this still fails an error will be thrown.
201
     */
202
    private static function deserializeDateTime(string $dateTimeData): DateTime
203
    {
204
        $dateTime = DateTime::createFromFormat(DateTime::ATOM, $dateTimeData);
205
206
        if ($dateTime === false) {
207
            $dateTime = DateTime::createFromFormat('Y-m-d\TH:i:s', $dateTimeData, new DateTimeZone('Europe/Brussels'));
208
209
            if (!$dateTime) {
210
                throw new InvalidArgumentException('Invalid date string provided for timestamp, ISO8601 expected!');
211
            }
212
        }
213
214
        return $dateTime;
215
    }
216
217
    public function getStartDate(): ?DateTimeInterface
218
    {
219
        $timestamps = $this->getTimestamps();
220
221
        if (empty($timestamps)) {
222
            return $this->startDate;
223
        }
224
225
        $startDate = null;
226
        foreach ($timestamps as $timestamp) {
227
            if ($startDate === null || $timestamp->getStartDate() < $startDate) {
228
                $startDate = $timestamp->getStartDate();
229
            }
230
        }
231
232
        return $startDate;
233
    }
234
235
    public function getEndDate(): ?DateTimeInterface
236
    {
237
        $timestamps = $this->getTimestamps();
238
239
        if (empty($timestamps)) {
240
            return $this->endDate;
241
        }
242
243
        $endDate = null;
244
        foreach ($this->getTimestamps() as $timestamp) {
245
            if ($endDate === null || $timestamp->getEndDate() > $endDate) {
246
                $endDate = $timestamp->getEndDate();
247
            }
248
        }
249
250
        return $endDate;
251
    }
252
253
    /**
254
     * @return array|OpeningHour[]
255
     */
256
    public function getOpeningHours(): array
257
    {
258
        return $this->openingHours;
259
    }
260
261
    /**
262
     * @return array|Timestamp[]
263
     */
264
    public function getTimestamps(): array
265
    {
266
        return $this->timestamps;
267
    }
268
269
    private function deriveStatusTypeFromSubEvents(): StatusType
270
    {
271
        $statusTypeCounts = [];
272
        $statusTypeCounts[StatusType::available()->toNative()] = 0;
273
        $statusTypeCounts[StatusType::temporarilyUnavailable()->toNative()] = 0;
274
        $statusTypeCounts[StatusType::unavailable()->toNative()] = 0;
275
276
        foreach ($this->timestamps as $timestamp) {
277
            ++$statusTypeCounts[$timestamp->getStatus()->getType()->toNative()];
278
        }
279
280
        if ($statusTypeCounts[StatusType::available()->toNative()] > 0) {
281
            return StatusType::available();
282
        }
283
284
        if ($statusTypeCounts[StatusType::temporarilyUnavailable()->toNative()] > 0) {
285
            return StatusType::temporarilyUnavailable();
286
        }
287
288
        if ($statusTypeCounts[StatusType::unavailable()->toNative()] > 0) {
289
            return StatusType::unavailable();
290
        }
291
292
        // This extra return is needed for events with calendar type of permanent or periodic.
293
        return StatusType::available();
294
    }
295
296
    public function toJsonLd(): array
297
    {
298
        $jsonLd = [];
299
300
        $jsonLd['calendarType'] = $this->getType()->toNative();
301
302
        $startDate = $this->getStartDate();
303
        $endDate = $this->getEndDate();
304
        if ($startDate !== null) {
305
            $jsonLd['startDate'] = $startDate->format(DateTime::ATOM);
306
        }
307
        if ($endDate !== null) {
308
            $jsonLd['endDate'] = $endDate->format(DateTime::ATOM);
309
        }
310
311
        $jsonLd['status'] = $this->determineCorrectTopStatusForProjection()->serialize();
312
313
        $timestamps = $this->getTimestamps();
314
        if (!empty($timestamps)) {
315
            $jsonLd['subEvent'] = [];
316
            foreach ($timestamps as $timestamp) {
317
                $jsonLd['subEvent'][] = $timestamp->toJsonLd();
318
            }
319
        }
320
321
        $openingHours = $this->getOpeningHours();
322
        if (!empty($openingHours)) {
323
            $jsonLd['openingHours'] = [];
324
            foreach ($openingHours as $openingHour) {
325
                $jsonLd['openingHours'][] = $openingHour->serialize();
326
            }
327
        }
328
329
        return $jsonLd;
330
    }
331
332
    public function sameAs(Calendar $otherCalendar): bool
333
    {
334
        return $this->toJsonLd() === $otherCalendar->toJsonLd();
335
    }
336
337
    public static function fromUdb3ModelCalendar(Udb3ModelCalendar $calendar): Calendar
338
    {
339
        $type = CalendarType::fromNative($calendar->getType()->toString());
340
341
        $startDate = null;
342
        $endDate = null;
343
        $timestamps = [];
344
        $openingHours = [];
345
346
        if ($calendar instanceof CalendarWithDateRange) {
347
            $startDate = $calendar->getStartDate();
348
            $endDate = $calendar->getEndDate();
349
        }
350
351
        if ($calendar instanceof CalendarWithSubEvents) {
352
            $timestamps = array_map(
353
                function (SubEvent $subEvent) {
354
                    return Timestamp::fromUdb3ModelSubEvent($subEvent);
355
                },
356
                $calendar->getSubEvents()->toArray()
357
            );
358
        }
359
360
        if ($calendar instanceof CalendarWithOpeningHours) {
361
            $openingHours = array_map(
362
                function (Udb3ModelOpeningHour $openingHour) {
363
                    return OpeningHour::fromUdb3ModelOpeningHour($openingHour);
364
                },
365
                $calendar->getOpeningHours()->toArray()
366
            );
367
        }
368
369
        return new self($type, $startDate, $endDate, $timestamps, $openingHours);
370
    }
371
372
    /**
373
     * If the calendar has subEvents (timestamps), and a status manually set through an import or full calendar update
374
     * through the API, the top status might be incorrect.
375
     * For example the top status can not be Available if all the subEvents are Unavailable or TemporarilyUnavailable.
376
     * However we want to be flexible in what we accept from API clients since otherwise they will have to implement a
377
     * lot of (new) logic to make sure the top status they're sending is correct.
378
     * So we accept the top status as-is, and correct it during projection.
379
     * That way if the correction is bugged, we can always fix it and replay it with the original data.
380
     */
381
    private function determineCorrectTopStatusForProjection(): Status
382
    {
383
        // If the calendar has no subEvents, the top level status is always valid.
384
        if (empty($this->timestamps)) {
385
            return $this->status;
386
        }
387
388
        // If the calendar has subEvents, the top level status is valid if it is the same type as the type derived from
389
        // the subEvents. In that case return $this->status so we include the top-level reason (if it has one).
390
        $expectedStatusType = $this->deriveStatusTypeFromSubEvents();
391
        if ($this->status->getType()->equals($expectedStatusType)) {
392
            return $this->status;
393
        }
394
395
        // If the top-level status is invalid compared to the status type derived from the subEvents, return the
396
        // expected status type without any reason. (If the top level status had a reason it's probably not applicable
397
        // for the new status type.)
398
        return new Status($expectedStatusType, []);
399
    }
400
}
401