hyperping 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
hyperping/models.py ADDED
@@ -0,0 +1,769 @@
1
+ """Pydantic models for Hyperping API requests and responses."""
2
+
3
+ from datetime import UTC, datetime
4
+ from enum import IntEnum, StrEnum
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
8
+
9
+
10
+ class HttpMethod(StrEnum):
11
+ """HTTP methods supported by Hyperping monitors."""
12
+
13
+ GET = "GET"
14
+ POST = "POST"
15
+ PUT = "PUT"
16
+ PATCH = "PATCH"
17
+ DELETE = "DELETE"
18
+ HEAD = "HEAD"
19
+
20
+
21
+ class MonitorFrequency(IntEnum):
22
+ """Monitor check frequencies in seconds."""
23
+
24
+ SECONDS_10 = 10
25
+ SECONDS_20 = 20
26
+ SECONDS_30 = 30
27
+ MINUTES_1 = 60
28
+ MINUTES_2 = 120
29
+ MINUTES_3 = 180
30
+ MINUTES_5 = 300
31
+ MINUTES_10 = 600
32
+ MINUTES_30 = 1800
33
+ HOURS_1 = 3600
34
+ HOURS_6 = 21600
35
+ HOURS_12 = 43200
36
+ HOURS_24 = 86400
37
+
38
+
39
+ class MonitorTimeout(IntEnum):
40
+ """Monitor request timeout options in seconds."""
41
+
42
+ SECONDS_5 = 5
43
+ SECONDS_10 = 10
44
+ SECONDS_15 = 15
45
+ SECONDS_20 = 20
46
+
47
+
48
+ class Region(StrEnum):
49
+ """Hyperping monitoring regions.
50
+
51
+ Combined from official Hyperping API documentation and real API responses.
52
+ """
53
+
54
+ # Europe
55
+ PARIS = "paris"
56
+ FRANKFURT = "frankfurt"
57
+ AMSTERDAM = "amsterdam"
58
+ LONDON = "london"
59
+
60
+ # Asia Pacific
61
+ SINGAPORE = "singapore"
62
+ SYDNEY = "sydney"
63
+ TOKYO = "tokyo"
64
+ SEOUL = "seoul"
65
+ MUMBAI = "mumbai"
66
+ BANGALORE = "bangalore"
67
+
68
+ # Americas
69
+ VIRGINIA = "virginia"
70
+ CALIFORNIA = "california"
71
+ SAN_FRANCISCO = "sanfrancisco"
72
+ OREGON = "oregon"
73
+ NYC = "nyc"
74
+ TORONTO = "toronto"
75
+ SAO_PAULO = "saopaulo"
76
+
77
+ # Middle East / Africa
78
+ BAHRAIN = "bahrain"
79
+ CAPE_TOWN = "capetown"
80
+
81
+
82
+ # Default regions (common subset for balanced global coverage)
83
+ DEFAULT_REGIONS = [
84
+ Region.PARIS,
85
+ Region.FRANKFURT,
86
+ Region.AMSTERDAM,
87
+ Region.LONDON,
88
+ Region.SINGAPORE,
89
+ Region.SYDNEY,
90
+ Region.TOKYO,
91
+ Region.VIRGINIA,
92
+ ]
93
+
94
+
95
+ class MonitorProtocol(StrEnum):
96
+ """Monitor protocol types."""
97
+
98
+ HTTP = "http"
99
+ PORT = "port"
100
+ ICMP = "icmp"
101
+ DNS = "dns"
102
+
103
+
104
+ class DnsRecordType(StrEnum):
105
+ """DNS record types supported by Hyperping DNS monitors."""
106
+
107
+ A = "A"
108
+ AAAA = "AAAA"
109
+ CNAME = "CNAME"
110
+ MX = "MX"
111
+ NS = "NS"
112
+ TXT = "TXT"
113
+ SOA = "SOA"
114
+ SRV = "SRV"
115
+ CAA = "CAA"
116
+ PTR = "PTR"
117
+
118
+
119
+ class RequestHeader(BaseModel):
120
+ """HTTP header for monitor requests.
121
+
122
+ API format: [{"name": "Header-Name", "value": "header-value"}]
123
+ """
124
+
125
+ name: str = Field(..., description="Header name")
126
+ value: str = Field(..., description="Header value")
127
+
128
+
129
+ class LocalizedText(BaseModel):
130
+ """Localized text supporting multiple languages.
131
+
132
+ Used for incident/maintenance titles and descriptions.
133
+ API format: {"en": "English text", "fr": "French text"}
134
+ """
135
+
136
+ model_config = ConfigDict(extra="allow", frozen=True)
137
+
138
+ en: str = Field(..., description="English text (required)")
139
+ fr: str | None = Field(default=None, description="French text")
140
+ de: str | None = Field(default=None, description="German text")
141
+ es: str | None = Field(default=None, description="Spanish text")
142
+
143
+ @classmethod
144
+ def from_string(cls, text: str) -> "LocalizedText":
145
+ """Create LocalizedText from a simple string (English only)."""
146
+ return cls(en=text)
147
+
148
+
149
+ class MonitorBase(BaseModel):
150
+ """Base model for monitor data.
151
+
152
+ Field names match the official Hyperping API (snake_case).
153
+ """
154
+
155
+ model_config = ConfigDict(use_enum_values=True, populate_by_name=True)
156
+
157
+ name: str = Field(..., min_length=1, max_length=255, description="Monitor display name")
158
+ url: str = Field(..., description="URL to monitor")
159
+ protocol: MonitorProtocol = Field(
160
+ default=MonitorProtocol.HTTP,
161
+ description="Monitor protocol: http, port, or icmp",
162
+ )
163
+ http_method: HttpMethod = Field(
164
+ default=HttpMethod.GET,
165
+ alias="http_method",
166
+ description="HTTP method",
167
+ )
168
+ check_frequency: int = Field(
169
+ default=30,
170
+ alias="check_frequency",
171
+ description="Check frequency in seconds",
172
+ )
173
+ regions: list[str] = Field(
174
+ default_factory=lambda: [r.value for r in DEFAULT_REGIONS],
175
+ description="Monitoring regions",
176
+ )
177
+ request_headers: list[RequestHeader] = Field(
178
+ default_factory=list,
179
+ alias="request_headers",
180
+ description="Custom HTTP headers [{name, value}]",
181
+ )
182
+ request_body: str | None = Field(
183
+ default=None,
184
+ alias="request_body",
185
+ description="Request body for POST/PUT/PATCH",
186
+ )
187
+ follow_redirects: bool = Field(
188
+ default=True,
189
+ alias="follow_redirects",
190
+ description="Follow HTTP redirects",
191
+ )
192
+ expected_status_code: str = Field(
193
+ default="2xx",
194
+ alias="expected_status_code",
195
+ description="Expected status code (e.g., '200' or '2xx')",
196
+ )
197
+ required_keyword: str | None = Field(
198
+ default=None,
199
+ alias="required_keyword",
200
+ description="Required keyword in response body",
201
+ )
202
+ paused: bool = Field(default=False, description="Whether monitor is paused")
203
+ port: int | None = Field(default=None, description="Port number (for port protocol)")
204
+ alerts_wait: int | None = Field(
205
+ default=None,
206
+ alias="alerts_wait",
207
+ description="Seconds to wait before alerting",
208
+ )
209
+ escalation_policy: str | None = Field(
210
+ default=None,
211
+ alias="escalation_policy",
212
+ description="Escalation policy UUID",
213
+ )
214
+
215
+ # DNS-specific fields (only used when protocol="dns")
216
+ dns_record_type: str | None = Field(
217
+ default=None,
218
+ alias="dns_record_type",
219
+ description="DNS record type (A, AAAA, CNAME, MX, etc.)",
220
+ )
221
+ dns_nameserver: str | None = Field(
222
+ default=None,
223
+ alias="dns_nameserver",
224
+ description="Custom nameserver to query (e.g. 8.8.8.8)",
225
+ )
226
+ dns_expected_answer: str | None = Field(
227
+ default=None,
228
+ alias="dns_expected_answer",
229
+ description="Expected DNS answer to match against",
230
+ )
231
+
232
+ @field_validator("escalation_policy", mode="before")
233
+ @classmethod
234
+ def coerce_escalation_policy(cls, v: object) -> str | None:
235
+ """Accept both plain UUID strings and {uuid, name} dicts from the API."""
236
+ if isinstance(v, dict):
237
+ return v.get("uuid")
238
+ return v # type: ignore[return-value]
239
+
240
+ # Helper methods for backward compatibility
241
+ def get_headers_dict(self) -> dict[str, str]:
242
+ """Get headers as a dictionary for convenience."""
243
+ return {h.name: h.value for h in self.request_headers}
244
+
245
+ @staticmethod
246
+ def _remap_legacy_fields(data: dict[str, Any]) -> dict[str, Any]:
247
+ """Remap legacy field names to current API field names."""
248
+ if "frequency" in data and "check_frequency" not in data:
249
+ data["check_frequency"] = data.pop("frequency")
250
+ if "method" in data and "http_method" not in data:
251
+ data["http_method"] = data.pop("method")
252
+ if "body" in data and "request_body" not in data:
253
+ data["request_body"] = data.pop("body")
254
+ if "headers" in data and "request_headers" not in data:
255
+ headers = data.pop("headers")
256
+ if isinstance(headers, dict):
257
+ data["request_headers"] = [{"name": k, "value": v} for k, v in headers.items()]
258
+ else:
259
+ data["request_headers"] = headers
260
+ if "expected_status" in data and "expected_status_code" not in data:
261
+ data["expected_status_code"] = str(data.pop("expected_status"))
262
+ return data
263
+
264
+
265
+ class MonitorCreate(MonitorBase):
266
+ """Model for creating a new monitor.
267
+
268
+ All fields from MonitorBase are available. Required: name, url, protocol.
269
+ """
270
+
271
+ def __init__(self, **data: Any) -> None:
272
+ MonitorBase._remap_legacy_fields(data)
273
+ super().__init__(**data)
274
+
275
+
276
+ class MonitorUpdate(BaseModel):
277
+ """Model for updating an existing monitor (all fields optional)."""
278
+
279
+ model_config = ConfigDict(use_enum_values=True, populate_by_name=True)
280
+
281
+ name: str | None = Field(default=None, min_length=1, max_length=255)
282
+ url: str | None = None
283
+ protocol: MonitorProtocol | None = None
284
+ http_method: HttpMethod | None = Field(default=None, alias="http_method")
285
+ check_frequency: int | None = Field(default=None, alias="check_frequency")
286
+ regions: list[str] | None = None
287
+ request_headers: list[RequestHeader] | None = Field(default=None, alias="request_headers")
288
+ request_body: str | None = Field(default=None, alias="request_body")
289
+ follow_redirects: bool | None = Field(default=None, alias="follow_redirects")
290
+ expected_status_code: str | None = Field(default=None, alias="expected_status_code")
291
+ required_keyword: str | None = Field(default=None, alias="required_keyword")
292
+ paused: bool | None = None
293
+ port: int | None = None
294
+ alerts_wait: int | None = Field(default=None, alias="alerts_wait")
295
+ escalation_policy: str | None = Field(default=None, alias="escalation_policy")
296
+ dns_record_type: str | None = Field(default=None, alias="dns_record_type")
297
+ dns_nameserver: str | None = Field(default=None, alias="dns_nameserver")
298
+ dns_expected_answer: str | None = Field(default=None, alias="dns_expected_answer")
299
+
300
+
301
+ class Monitor(MonitorBase):
302
+ """Model for a monitor response from Hyperping API."""
303
+
304
+ model_config = ConfigDict(extra="ignore", populate_by_name=True, frozen=True)
305
+
306
+ uuid: str = Field(..., description="Monitor unique identifier (mon_xxx)")
307
+ project_uuid: str | None = Field(default=None, alias="projectUuid")
308
+
309
+ # Override paused from MonitorBase (it's in response, not just create)
310
+ paused: bool = Field(default=False, description="Whether monitor is paused")
311
+ down: bool = Field(default=False, description="Whether monitor is currently down")
312
+
313
+ def __init__(self, **data: Any) -> None:
314
+ # Handle both uuid and monitorUuid (legacy alias)
315
+ if "monitorUuid" in data and "uuid" not in data:
316
+ data["uuid"] = data.pop("monitorUuid")
317
+
318
+ # Apply shared legacy field remapping
319
+ MonitorBase._remap_legacy_fields(data)
320
+
321
+ # Handle API returning headers as dict sometimes
322
+ if "request_headers" in data and isinstance(data["request_headers"], dict):
323
+ data["request_headers"] = [
324
+ {"name": k, "value": v} for k, v in data["request_headers"].items()
325
+ ]
326
+
327
+ # Handle API returning null for optional fields
328
+ if "request_headers" in data and data["request_headers"] is None:
329
+ data["request_headers"] = []
330
+ if "http_method" in data and data["http_method"] is None:
331
+ del data["http_method"] # Use MonitorBase default (GET)
332
+ if "expected_status_code" in data:
333
+ if data["expected_status_code"] is None:
334
+ del data["expected_status_code"] # Use MonitorBase default ("2xx")
335
+ elif isinstance(data["expected_status_code"], int):
336
+ data["expected_status_code"] = str(data["expected_status_code"])
337
+
338
+ super().__init__(**data)
339
+
340
+
341
+ class ReportPeriod(BaseModel):
342
+ """Time period for a monitor report."""
343
+
344
+ from_date: str = Field(..., alias="from", description="Start date ISO 8601")
345
+ to_date: str = Field(..., alias="to", description="End date ISO 8601")
346
+
347
+ model_config = ConfigDict(populate_by_name=True, frozen=True)
348
+
349
+
350
+ class OutageDetail(BaseModel):
351
+ """Details about a specific outage."""
352
+
353
+ start_date: str = Field(..., alias="startDate")
354
+ end_date: str = Field(..., alias="endDate")
355
+
356
+ model_config = ConfigDict(populate_by_name=True, frozen=True, extra="ignore")
357
+
358
+
359
+ class OutageStats(BaseModel):
360
+ """Statistics about outages in a report period."""
361
+
362
+ count: int = Field(default=0, description="Total number of outages")
363
+ total_downtime: int = Field(
364
+ default=0, alias="totalDowntime", description="Total downtime in seconds"
365
+ )
366
+ total_downtime_formatted: str = Field(
367
+ default="", alias="totalDowntimeFormatted", description="Human-readable downtime"
368
+ )
369
+ longest_outage: int = Field(
370
+ default=0, alias="longestOutage", description="Longest outage in seconds"
371
+ )
372
+ longest_outage_formatted: str = Field(
373
+ default="", alias="longestOutageFormatted", description="Human-readable longest outage"
374
+ )
375
+ details: list[OutageDetail] = Field(default_factory=list, description="Outage details")
376
+
377
+ model_config = ConfigDict(populate_by_name=True, frozen=True)
378
+
379
+
380
+ class MonitorReport(BaseModel):
381
+ """Model for monitor uptime report from v2 API.
382
+
383
+ API: GET /v2/reporting/monitor-reports?period=30d
384
+ """
385
+
386
+ model_config = ConfigDict(extra="ignore", populate_by_name=True, frozen=True)
387
+
388
+ uuid: str = Field(..., description="Monitor UUID")
389
+ name: str = Field(..., description="Monitor name")
390
+ protocol: str = Field(..., description="Monitor protocol")
391
+ period: ReportPeriod = Field(..., description="Report time period")
392
+ sla: float = Field(..., description="SLA percentage (e.g., 99.184)")
393
+ outages: OutageStats = Field(..., description="Outage statistics")
394
+ mttr: int = Field(default=0, description="Mean time to recovery in seconds")
395
+ mttr_formatted: str = Field(
396
+ default="0s", alias="mttrFormatted", description="MTTR human-readable"
397
+ )
398
+
399
+
400
+ class MonitorListResponse(BaseModel):
401
+ """Response model for list monitors endpoint."""
402
+
403
+ model_config = ConfigDict(extra="ignore", frozen=True)
404
+
405
+ monitors: list[Monitor] = Field(default_factory=list)
406
+ total: int = Field(default=0)
407
+
408
+
409
+ class APIErrorResponse(BaseModel):
410
+ """Model for API error responses."""
411
+
412
+ model_config = ConfigDict(extra="ignore", frozen=True)
413
+
414
+ error: str = Field(default="Unknown error")
415
+ message: str | None = None
416
+ details: list[dict[str, Any]] | None = None
417
+
418
+
419
+ # ==================== Incident Models ====================
420
+
421
+
422
+ class IncidentType(StrEnum):
423
+ """Incident type values from v3 API."""
424
+
425
+ OUTAGE = "outage"
426
+ INCIDENT = "incident"
427
+
428
+
429
+ class IncidentUpdateType(StrEnum):
430
+ """Incident update type values from v3 API."""
431
+
432
+ INVESTIGATING = "investigating"
433
+ IDENTIFIED = "identified"
434
+ UPDATE = "update"
435
+ MONITORING = "monitoring"
436
+ RESOLVED = "resolved"
437
+
438
+
439
+ class IncidentUpdate(BaseModel):
440
+ """Model for an incident update from v3 API."""
441
+
442
+ model_config = ConfigDict(extra="ignore", populate_by_name=True, frozen=True)
443
+
444
+ uuid: str = Field(..., description="Update UUID")
445
+ date: str = Field(..., description="Update timestamp ISO 8601")
446
+ text: LocalizedText = Field(..., description="Localized update text")
447
+ type: str = Field(..., description="Update type")
448
+
449
+
450
+ class AddIncidentUpdateRequest(BaseModel):
451
+ """Model for adding an update to an incident.
452
+
453
+ API: POST /v3/incidents/{uuid}/updates
454
+ """
455
+
456
+ model_config = ConfigDict(use_enum_values=True, populate_by_name=True)
457
+
458
+ text: LocalizedText = Field(..., description="Localized update text")
459
+ type: IncidentUpdateType = Field(..., description="Update type")
460
+ date: str = Field(..., description="Update date ISO 8601")
461
+
462
+
463
+ class IncidentCreate(BaseModel):
464
+ """Model for creating a new incident via v3 API.
465
+
466
+ API: POST /v3/incidents
467
+ """
468
+
469
+ model_config = ConfigDict(use_enum_values=True, populate_by_name=True)
470
+
471
+ title: LocalizedText = Field(..., description="Localized incident title")
472
+ text: LocalizedText = Field(..., description="Localized incident message")
473
+ type: IncidentType = Field(
474
+ default=IncidentType.INCIDENT,
475
+ description="Incident type: outage or incident",
476
+ )
477
+ affected_components: list[str] = Field(
478
+ default_factory=list,
479
+ alias="affectedComponents",
480
+ description="Affected component UUIDs",
481
+ )
482
+ statuspages: list[str] = Field(
483
+ ...,
484
+ description="Status page UUIDs to display incident on (required)",
485
+ )
486
+ date: str | None = Field(
487
+ default=None,
488
+ description="Incident date ISO 8601 (optional)",
489
+ )
490
+
491
+
492
+ class IncidentUpdateRequest(BaseModel):
493
+ """Model for updating an existing incident via v3 API.
494
+
495
+ API: PUT /v3/incidents/{uuid}
496
+ """
497
+
498
+ model_config = ConfigDict(use_enum_values=True, populate_by_name=True)
499
+
500
+ title: LocalizedText | None = None
501
+ type: IncidentType | None = None
502
+ affected_components: list[str] | None = Field(default=None, alias="affectedComponents")
503
+ statuspages: list[str] | None = None
504
+
505
+
506
+ class Incident(BaseModel):
507
+ """Model for an incident response from v3 API.
508
+
509
+ API: GET /v3/incidents, GET /v3/incidents/{uuid}
510
+ """
511
+
512
+ model_config = ConfigDict(extra="ignore", populate_by_name=True, frozen=True)
513
+
514
+ uuid: str = Field(..., description="Incident UUID (inci_xxx)")
515
+ date: str | None = Field(default=None, description="Incident date ISO 8601")
516
+ title: LocalizedText = Field(..., description="Localized incident title")
517
+ text: LocalizedText | None = Field(default=None, description="Localized incident message")
518
+ type: str = Field(..., description="Incident type: outage or incident")
519
+ affected_components: list[str] = Field(
520
+ default_factory=list,
521
+ alias="affectedComponents",
522
+ description="Affected component UUIDs",
523
+ )
524
+ statuspages: list[str] = Field(
525
+ default_factory=list,
526
+ description="Status page UUIDs",
527
+ )
528
+ updates: list[IncidentUpdate] = Field(default_factory=list)
529
+
530
+ @property
531
+ def is_resolved(self) -> bool:
532
+ """Check if incident has a resolved update."""
533
+ return any(u.type == IncidentUpdateType.RESOLVED.value for u in self.updates)
534
+
535
+ @property
536
+ def title_en(self) -> str:
537
+ """Get English title for convenience."""
538
+ return self.title.en
539
+
540
+ @property
541
+ def text_en(self) -> str:
542
+ """Get English text for convenience."""
543
+ return self.text.en if self.text else ""
544
+
545
+
546
+ # Legacy aliases for backward compatibility (used by _incidents_mixin.py)
547
+ IncidentStatus = IncidentUpdateType # Old name -> new name
548
+ IncidentUpdateCreate = AddIncidentUpdateRequest # Old name -> new name
549
+
550
+
551
+ # ==================== Maintenance Models ====================
552
+
553
+
554
+ class NotificationOption(StrEnum):
555
+ """Maintenance notification options."""
556
+
557
+ SCHEDULED = "scheduled"
558
+ IMMEDIATE = "immediate"
559
+
560
+
561
+ class MaintenanceCreate(BaseModel):
562
+ """Model for creating a maintenance window via v1 API.
563
+
564
+ API: POST /v1/maintenance-windows
565
+ """
566
+
567
+ model_config = ConfigDict(use_enum_values=True, populate_by_name=True)
568
+
569
+ name: str = Field(..., min_length=1, max_length=255, description="Internal name")
570
+ start_date: str = Field(
571
+ ...,
572
+ alias="start_date",
573
+ description="Start date ISO 8601 (e.g., 2025-05-18T14:30:00Z)",
574
+ )
575
+ end_date: str = Field(
576
+ ...,
577
+ alias="end_date",
578
+ description="End date ISO 8601 (e.g., 2025-05-18T15:30:00Z)",
579
+ )
580
+ monitors: list[str] = Field(
581
+ ...,
582
+ description="Array of monitor UUIDs affected by maintenance",
583
+ )
584
+ statuspages: list[str] = Field(
585
+ default_factory=list,
586
+ description="Array of status page UUIDs to display maintenance on",
587
+ )
588
+ title: LocalizedText | None = Field(
589
+ default=None,
590
+ description="Localized public title",
591
+ )
592
+ text: LocalizedText | None = Field(
593
+ default=None,
594
+ description="Localized public description",
595
+ )
596
+ notification_option: NotificationOption | None = Field(
597
+ default=None,
598
+ alias="notificationOption",
599
+ description="When to notify: scheduled or immediate",
600
+ )
601
+ notification_minutes: int | None = Field(
602
+ default=None,
603
+ alias="notificationMinutes",
604
+ description="Minutes before start to send notification",
605
+ )
606
+
607
+
608
+ class MaintenanceUpdate(BaseModel):
609
+ """Model for updating a maintenance window via v1 API.
610
+
611
+ API: PUT /v1/maintenance-windows/{uuid}
612
+ """
613
+
614
+ model_config = ConfigDict(use_enum_values=True, populate_by_name=True)
615
+
616
+ name: str | None = Field(default=None, min_length=1, max_length=255)
617
+ start_date: str | None = Field(default=None, alias="start_date")
618
+ end_date: str | None = Field(default=None, alias="end_date")
619
+ monitors: list[str] | None = None
620
+
621
+
622
+ class Maintenance(BaseModel):
623
+ """Model for a maintenance window response from v1 API.
624
+
625
+ API: GET /v1/maintenance-windows, GET /v1/maintenance-windows/{uuid}
626
+ """
627
+
628
+ model_config = ConfigDict(extra="ignore", populate_by_name=True, frozen=True)
629
+
630
+ uuid: str = Field(..., description="Maintenance UUID (mw_xxx)")
631
+ name: str = Field(..., description="Internal name")
632
+ title: LocalizedText | None = Field(default=None, description="Localized public title")
633
+ text: LocalizedText | None = Field(default=None, description="Localized public description")
634
+
635
+ @field_validator("title", "text", mode="before")
636
+ @classmethod
637
+ def coerce_empty_localized_text(cls, v: object) -> object:
638
+ """Convert empty dicts {} to None — the API returns {} for unset titles."""
639
+ if isinstance(v, dict) and not v:
640
+ return None
641
+ return v
642
+
643
+ start_date: str | None = Field(default=None, alias="start_date")
644
+ end_date: str | None = Field(default=None, alias="end_date")
645
+ timezone: str = Field(default="UTC", description="Timezone")
646
+ monitors: list[str] = Field(default_factory=list, description="Affected monitor UUIDs")
647
+ statuspages: list[str] = Field(default_factory=list, description="Status page UUIDs")
648
+ bulk_uuid: str | None = Field(default=None, alias="bulkUuid")
649
+ created_by: str | None = Field(default=None, alias="createdBy")
650
+ created_at: str | None = Field(default=None, alias="createdAt")
651
+ notification_option: str | None = Field(default=None, alias="notificationOption")
652
+ notification_minutes: int | None = Field(default=None, alias="notificationMinutes")
653
+
654
+ def is_active(self, at_time: datetime | None = None) -> bool:
655
+ """Check if maintenance is currently active.
656
+
657
+ Args:
658
+ at_time: Time to check (defaults to now)
659
+
660
+ Returns:
661
+ True if maintenance window is currently active
662
+ """
663
+ if at_time is None:
664
+ at_time = datetime.now(UTC)
665
+
666
+ if self.start_date and self.end_date:
667
+ from datetime import datetime as dt
668
+
669
+ try:
670
+ start = dt.fromisoformat(self.start_date.replace("Z", "+00:00"))
671
+ end = dt.fromisoformat(self.end_date.replace("Z", "+00:00"))
672
+ # Make at_time timezone-aware if needed
673
+ if at_time.tzinfo is None:
674
+ at_time = at_time.replace(tzinfo=UTC)
675
+ return start <= at_time <= end
676
+ except (ValueError, AttributeError):
677
+ return False
678
+
679
+ return False
680
+
681
+ def affects_monitor(self, monitor_uuid: str) -> bool:
682
+ """Check if this maintenance affects a specific monitor.
683
+
684
+ Args:
685
+ monitor_uuid: Monitor UUID to check
686
+
687
+ Returns:
688
+ True if monitor is affected by this maintenance
689
+ """
690
+ return monitor_uuid in self.monitors
691
+
692
+ @property
693
+ def title_en(self) -> str | None:
694
+ """Get English title for convenience."""
695
+ return self.title.en if self.title else None
696
+
697
+ @property
698
+ def text_en(self) -> str | None:
699
+ """Get English text for convenience."""
700
+ return self.text.en if self.text else None
701
+
702
+
703
+ # ==================== Status Page Models ====================
704
+
705
+
706
+ class StatusPage(BaseModel):
707
+ """Model for a status page response from v2 API.
708
+
709
+ API: GET /v2/statuspages, GET /v2/statuspages/{uuid}
710
+ """
711
+
712
+ model_config = ConfigDict(extra="ignore", populate_by_name=True, frozen=True)
713
+
714
+ uuid: str = Field(..., description="Status page UUID")
715
+ name: str = Field(..., description="Status page display name")
716
+ subdomain: str = Field(..., description="Status page subdomain")
717
+ custom_domain: str | None = Field(
718
+ default=None, alias="customDomain", description="Custom domain"
719
+ )
720
+ public: bool = Field(default=True, description="Whether the page is publicly accessible")
721
+ monitors: list[str] = Field(
722
+ default_factory=list, description="Monitor UUIDs shown on this page"
723
+ )
724
+
725
+
726
+ class StatusPageCreate(BaseModel):
727
+ """Model for creating a new status page.
728
+
729
+ API: POST /v2/statuspages
730
+ """
731
+
732
+ model_config = ConfigDict(use_enum_values=True, populate_by_name=True)
733
+
734
+ name: str = Field(..., min_length=1, max_length=255, description="Status page display name")
735
+ subdomain: str = Field(..., description="Status page subdomain")
736
+ custom_domain: str | None = Field(
737
+ default=None, alias="customDomain", description="Custom domain"
738
+ )
739
+ public: bool = Field(default=True, description="Whether the page is publicly accessible")
740
+ monitors: list[str] = Field(
741
+ default_factory=list, description="Monitor UUIDs shown on this page"
742
+ )
743
+
744
+
745
+ class StatusPageUpdate(BaseModel):
746
+ """Model for updating an existing status page (all fields optional).
747
+
748
+ API: PUT /v2/statuspages/{uuid}
749
+ """
750
+
751
+ model_config = ConfigDict(use_enum_values=True, populate_by_name=True)
752
+
753
+ name: str | None = Field(default=None, min_length=1, max_length=255)
754
+ subdomain: str | None = None
755
+ custom_domain: str | None = Field(default=None, alias="customDomain")
756
+ public: bool | None = None
757
+ monitors: list[str] | None = None
758
+
759
+
760
+ class StatusPageSubscriber(BaseModel):
761
+ """Model for a status page subscriber.
762
+
763
+ API: GET /v2/statuspages/{uuid}/subscribers
764
+ """
765
+
766
+ model_config = ConfigDict(extra="ignore", populate_by_name=True, frozen=True)
767
+
768
+ id: str = Field(..., description="Subscriber ID")
769
+ email: str = Field(..., description="Subscriber email address")