mercuto-client 0.2.8__py3-none-any.whl → 0.3.0a0__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.

Potentially problematic release.


This version of mercuto-client might be problematic. Click here for more details.

Files changed (37) hide show
  1. mercuto_client/__init__.py +2 -24
  2. mercuto_client/_authentication.py +72 -0
  3. mercuto_client/_tests/test_ingester/test_parsers.py +67 -67
  4. mercuto_client/_tests/test_mocking/__init__.py +0 -0
  5. mercuto_client/_tests/test_mocking/conftest.py +13 -0
  6. mercuto_client/_tests/test_mocking/test_mock_identity.py +8 -0
  7. mercuto_client/acl.py +16 -10
  8. mercuto_client/client.py +53 -779
  9. mercuto_client/exceptions.py +5 -1
  10. mercuto_client/ingester/__main__.py +1 -1
  11. mercuto_client/ingester/mercuto.py +15 -16
  12. mercuto_client/ingester/parsers/__init__.py +3 -3
  13. mercuto_client/ingester/parsers/campbell.py +2 -2
  14. mercuto_client/ingester/parsers/generic_csv.py +5 -5
  15. mercuto_client/ingester/parsers/worldsensing.py +4 -3
  16. mercuto_client/mocks/__init__.py +92 -0
  17. mercuto_client/mocks/_utility.py +69 -0
  18. mercuto_client/mocks/mock_data.py +402 -0
  19. mercuto_client/mocks/mock_fatigue.py +30 -0
  20. mercuto_client/mocks/mock_identity.py +188 -0
  21. mercuto_client/modules/__init__.py +19 -0
  22. mercuto_client/modules/_util.py +18 -0
  23. mercuto_client/modules/core.py +674 -0
  24. mercuto_client/modules/data.py +623 -0
  25. mercuto_client/modules/fatigue.py +189 -0
  26. mercuto_client/modules/identity.py +254 -0
  27. mercuto_client/{ingester/util.py → util.py} +27 -11
  28. {mercuto_client-0.2.8.dist-info → mercuto_client-0.3.0a0.dist-info}/METADATA +10 -3
  29. mercuto_client-0.3.0a0.dist-info/RECORD +41 -0
  30. mercuto_client/_tests/test_mocking.py +0 -93
  31. mercuto_client/_util.py +0 -13
  32. mercuto_client/mocks.py +0 -203
  33. mercuto_client/types.py +0 -409
  34. mercuto_client-0.2.8.dist-info/RECORD +0 -30
  35. {mercuto_client-0.2.8.dist-info → mercuto_client-0.3.0a0.dist-info}/WHEEL +0 -0
  36. {mercuto_client-0.2.8.dist-info → mercuto_client-0.3.0a0.dist-info}/licenses/LICENSE +0 -0
  37. {mercuto_client-0.2.8.dist-info → mercuto_client-0.3.0a0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,674 @@
1
+ import mimetypes
2
+ import os
3
+ from datetime import datetime, timedelta
4
+ from typing import TYPE_CHECKING, Any, Literal, Optional
5
+
6
+ from pydantic import TypeAdapter
7
+
8
+ from . import _PayloadType
9
+ from ._util import BaseModel, serialise_timedelta
10
+
11
+ if TYPE_CHECKING:
12
+ from ..client import MercutoClient
13
+
14
+
15
+ class ProjectStatus(BaseModel):
16
+ last_ping: Optional[str]
17
+ ip_address: Optional[str]
18
+
19
+
20
+ class Project(BaseModel):
21
+ code: str
22
+ name: str
23
+ project_number: str
24
+ active: bool
25
+ description: str
26
+ latitude: Optional[float]
27
+ longitude: Optional[float]
28
+ timezone: str
29
+ display_timezone: Optional[str]
30
+ tenant: str
31
+ status: ProjectStatus
32
+ commission_date: datetime
33
+
34
+
35
+ class WidgetConfig(BaseModel):
36
+ type: str
37
+ config: dict[Any, Any]
38
+
39
+
40
+ class WidgetColumn(BaseModel):
41
+ size: Optional[str | int]
42
+ widget: WidgetConfig
43
+
44
+
45
+ class WidgetRow(BaseModel):
46
+ columns: list[WidgetColumn]
47
+ height: int
48
+ title: str
49
+ breakpoint: Optional[str]
50
+
51
+
52
+ class Dashboard(BaseModel):
53
+ icon: Optional[str]
54
+ name: Optional[str]
55
+ banner_image: Optional[str]
56
+ widgets: Optional[list[WidgetRow]]
57
+ fullscreen: Optional[bool]
58
+
59
+
60
+ class Dashboards(BaseModel):
61
+ dashboards: list[Dashboard]
62
+
63
+
64
+ class ProjectEventDetection(BaseModel):
65
+ enabled: bool
66
+ datatables: list[str]
67
+ max_duration: timedelta
68
+ max_files: int
69
+ maximise: bool
70
+ overlap_period: timedelta
71
+ split_interval_cron: Optional[str]
72
+
73
+
74
+ class ItemCode(BaseModel):
75
+ code: str
76
+
77
+
78
+ class EventTag(BaseModel):
79
+ tag: str
80
+ value: Any | None
81
+
82
+
83
+ class Object(BaseModel):
84
+ code: str
85
+ mime_type: str
86
+ size_bytes: int
87
+ name: str
88
+ event: ItemCode | None
89
+ project: ItemCode
90
+ access_url: str | None
91
+ access_expires: datetime | None
92
+
93
+
94
+ class Event(BaseModel):
95
+ code: str
96
+ project: ItemCode
97
+ start_time: datetime
98
+ end_time: datetime
99
+ objects: list[Object]
100
+ tags: list[EventTag]
101
+
102
+
103
+ UserContactMethod = Literal['EMAIL', 'SMS']
104
+
105
+
106
+ class ContactGroup(BaseModel):
107
+ project: str
108
+ code: str
109
+ label: str
110
+ users: dict[str, list[UserContactMethod]]
111
+
112
+
113
+ class Condition(BaseModel):
114
+ code: str
115
+ source: str
116
+ description: str
117
+ upper_exclusive_bound: Optional[float]
118
+ lower_inclusive_bound: Optional[float]
119
+ neutral_position: float
120
+
121
+
122
+ class AlertConfiguration(BaseModel):
123
+ code: str
124
+ project: str
125
+ label: str
126
+ conditions: list[Condition]
127
+ contact_group: Optional[ContactGroup]
128
+ retrigger_interval: Optional[datetime]
129
+
130
+
131
+ class AlertLogConditionEntry(BaseModel):
132
+ condition: Condition
133
+ start_value: float
134
+ start_time: str
135
+ start_percentile: float
136
+
137
+ peak_value: float
138
+ peak_time: str
139
+ peak_percentile: float
140
+
141
+ end_value: float
142
+ end_time: str
143
+ end_percentile: float
144
+
145
+
146
+ class AlertLogComment(BaseModel):
147
+ user_code: str
148
+ comment: str
149
+ created_at: str
150
+
151
+
152
+ class AlertLog(BaseModel):
153
+ code: str
154
+ project: str
155
+ event: Optional[str]
156
+ acknowledged: bool
157
+ fired_at: str
158
+ configuration: str
159
+ conditions: list[AlertLogConditionEntry]
160
+ comments: list[AlertLogComment]
161
+
162
+
163
+ class AlertSummary(BaseModel):
164
+ alerts: list[AlertLog]
165
+ total: int
166
+
167
+
168
+ class Healthcheck(BaseModel):
169
+ ephemeral_warehouse: str
170
+ ephemeral_document_store: str
171
+ cache: str
172
+ database: str
173
+
174
+
175
+ class DeviceType(BaseModel):
176
+ code: str
177
+ description: str
178
+ manufacturer: str
179
+ model_number: str
180
+
181
+
182
+ class DeviceChannel(BaseModel):
183
+ channel: str
184
+ field: str
185
+
186
+
187
+ class Device(BaseModel):
188
+ code: str
189
+ project: ItemCode
190
+ label: str
191
+ location_description: Optional[str]
192
+ device_type: DeviceType
193
+ groups: list[str]
194
+ channels: list[DeviceChannel]
195
+
196
+
197
+ class EventAggregate(BaseModel):
198
+ aggregate: Literal["max", "greatest", "min", "median", "abs-max", "mean", "rms", "peak-to-peak", "daf"]
199
+ enabled: bool = True
200
+ options: Optional[dict[str, Any]] = None
201
+
202
+
203
+ class Camera(BaseModel):
204
+ code: str
205
+ project: str
206
+ label: str
207
+
208
+
209
+ class Video(BaseModel):
210
+ code: str
211
+ project: str
212
+ camera: str | None
213
+ start_time: str
214
+ end_time: str
215
+ mime_type: str
216
+ size_bytes: int
217
+ name: str
218
+ event: str | None
219
+ access_url: str | None
220
+ access_expires: str
221
+
222
+
223
+ class Image(BaseModel):
224
+ code: str
225
+ project: str
226
+ camera: str | None
227
+ timestamp: str | None
228
+ mime_type: str
229
+ size_bytes: int
230
+ name: str
231
+ event: str | None
232
+ access_url: str | None
233
+ access_expires: str
234
+
235
+
236
+ class ScheduledReport(BaseModel):
237
+ code: str
238
+ project: str
239
+ label: str
240
+ revision: str
241
+ schedule: Optional[str]
242
+ contact_group: Optional[str]
243
+ last_scheduled: Optional[str]
244
+
245
+
246
+ class ScheduledReportLog(BaseModel):
247
+ code: str
248
+ report: str
249
+ scheduled_start: Optional[str]
250
+ actual_start: str
251
+ actual_finish: Optional[str]
252
+ status: Literal['IN_PROGRESS', 'COMPLETED', 'FAILED']
253
+ message: Optional[str]
254
+ access_url: Optional[str]
255
+ mime_type: Optional[str]
256
+ filename: Optional[str]
257
+
258
+
259
+ class ReportSourceCodeRevision(BaseModel):
260
+ code: str
261
+ revision_date: datetime
262
+ description: str
263
+ source_code_url: str
264
+
265
+
266
+ _ProjectListAdapter = TypeAdapter(list[Project])
267
+ _EventsListAdapter = TypeAdapter(list[Event])
268
+ _DevicesListAdapter = TypeAdapter(list[Device])
269
+ _DeviceTypeListAdapter = TypeAdapter(list[DeviceType])
270
+ _ImageListAdapter = TypeAdapter(list[Image])
271
+ _VideoListAdapter = TypeAdapter(list[Video])
272
+ _CameraListAdapter = TypeAdapter(list[Camera])
273
+ _ContactGroupListAdapter = TypeAdapter(list[ContactGroup])
274
+ _ScheduledReportListAdapter = TypeAdapter(list[ScheduledReport])
275
+ _ScheduledReportLogListAdapter = TypeAdapter(list[ScheduledReportLog])
276
+
277
+
278
+ class MercutoCoreService:
279
+ def __init__(self, client: 'MercutoClient') -> None:
280
+ self._client = client
281
+
282
+ def healthcheck(self) -> Healthcheck:
283
+ r = self._client._http_request("/healthcheck", "GET")
284
+ return Healthcheck.model_validate_json(r.text)
285
+
286
+ # Projects
287
+
288
+ def get_project(self, code: str) -> Project:
289
+ if len(code) == 0:
290
+ raise ValueError("Project code must not be empty")
291
+ r = self._client._http_request(f'/projects/{code}', 'GET')
292
+ return Project.model_validate_json(r.text)
293
+
294
+ def list_projects(self) -> list[Project]:
295
+ r = self._client._http_request('/projects', 'GET')
296
+ return _ProjectListAdapter.validate_json(r.text)
297
+
298
+ def create_project(self, name: str, project_number: str, description: str, tenant: str,
299
+ timezone: str,
300
+ latitude: Optional[float] = None,
301
+ longitude: Optional[float] = None) -> Project:
302
+
303
+ payload: _PayloadType = {
304
+ 'name': name,
305
+ 'project_number': project_number,
306
+ 'description': description,
307
+ 'tenant_code': tenant,
308
+ 'timezone': timezone,
309
+ }
310
+ if latitude is not None:
311
+ payload['latitude'] = latitude
312
+ if longitude is not None:
313
+ payload['longitude'] = longitude
314
+
315
+ r = self._client._http_request('/projects', 'PUT', json=payload)
316
+ return Project.model_validate_json(r.text)
317
+
318
+ def ping_project(self, project: str, ip_address: str) -> None:
319
+ self._client._http_request(f'/projects/{project}/ping', 'POST', json={'ip_address': ip_address})
320
+
321
+ def create_dashboard(self, project_code: str, dashboards: Dashboards) -> None:
322
+ json = dashboards.model_dump()
323
+ self._client._http_request(f'/projects/{project_code}/dashboard', 'POST', json=json)
324
+
325
+ def set_project_event_detection(self, project: str, datatables: list[str]) -> ProjectEventDetection:
326
+ if len(datatables) == 0:
327
+ raise ValueError('At least one datatable must be provided to enable event detection')
328
+
329
+ params: _PayloadType = {
330
+ "enabled": True,
331
+ "datatables": datatables
332
+ }
333
+ r = self._client._http_request(f'/projects/{project}/event-detection', 'POST', json=params)
334
+ return ProjectEventDetection.model_validate_json(r.text)
335
+
336
+ # EVENTS
337
+
338
+ def create_event(self, project: str, start_time: datetime, end_time: datetime) -> Event:
339
+ if start_time.tzinfo is None or end_time.tzinfo is None:
340
+ raise ValueError("Timestamp must be timezone aware")
341
+
342
+ json: _PayloadType = {
343
+ 'project': project,
344
+ 'start_time': start_time.isoformat(),
345
+ 'end_time': end_time.isoformat(),
346
+ }
347
+ r = self._client._http_request('/events', 'PUT', json=json)
348
+ return Event.model_validate_json(r.text)
349
+
350
+ def list_events(self, project: str) -> list[Event]:
351
+ params: _PayloadType = {'project_code': project}
352
+ r = self._client._http_request('/events', 'GET', params=params)
353
+ return _EventsListAdapter.validate_json(r.text)
354
+
355
+ def get_event(self, event: str) -> Event:
356
+ r = self._client._http_request(f'/events/{event}', 'GET')
357
+ return Event.model_validate_json(r.text)
358
+
359
+ def delete_event(self, event: str) -> None:
360
+ self._client._http_request(f'/events/{event}', 'DELETE')
361
+
362
+ def get_nearest_event(
363
+ self,
364
+ project_code: str,
365
+ to: datetime,
366
+ maximum_delta: timedelta | None = None,
367
+ ) -> Event:
368
+ params: _PayloadType = {
369
+ 'project_code': project_code,
370
+ 'to': to.isoformat(),
371
+ }
372
+ if maximum_delta is not None:
373
+ params['maximum_delta'] = serialise_timedelta(maximum_delta)
374
+
375
+ r = self._client._http_request('/events/nearest', 'GET', params=params)
376
+ return Event.model_validate_json(r.text)
377
+
378
+ def set_event_aggregates(self, project: str, aggregates: list[EventAggregate]) -> None:
379
+ self._client._http_request('/aggregates', 'PUT',
380
+ json=[agg.model_dump(mode='json') for agg in aggregates], # type: ignore
381
+ params={'project_code': project})
382
+
383
+ # ALERTS
384
+
385
+ def get_condition(self, code: str) -> Condition:
386
+ r = self._client._http_request(f'/alerts/conditions/{code}', 'GET')
387
+ return Condition.model_validate_json(r.text)
388
+
389
+ def create_condition(self, source: str, description: str, *,
390
+ lower_bound: Optional[float] = None,
391
+ upper_bound: Optional[float] = None,
392
+ neutral_position: float = 0) -> Condition:
393
+ json: _PayloadType = {
394
+ 'source_channel_code': source,
395
+ 'description': description,
396
+ 'neutral_position': neutral_position
397
+ }
398
+ if lower_bound is not None:
399
+ json['lower_inclusive_bound'] = lower_bound
400
+ if upper_bound is not None:
401
+ json['upper_exclusive_bound'] = upper_bound
402
+ r = self._client._http_request('/alerts/conditions', 'PUT', json=json)
403
+ return Condition.model_validate_json(r.text)
404
+
405
+ def create_alert_configuration(self, label: str,
406
+ conditions: list[str],
407
+ contact_group: Optional[str] = None) -> AlertConfiguration:
408
+ json: _PayloadType = {
409
+ 'label': label,
410
+ 'conditions': conditions,
411
+
412
+ }
413
+ if contact_group is not None:
414
+ json['contact_group'] = contact_group
415
+ r = self._client._http_request('/alerts/configurations', 'PUT', json=json)
416
+ return AlertConfiguration.model_validate_json(r.text)
417
+
418
+ def get_alert_configuration(self, code: str) -> AlertConfiguration:
419
+ r = self._client._http_request(f'/alerts/configurations/{code}', 'GET')
420
+ return AlertConfiguration.model_validate_json(r.text)
421
+
422
+ def list_alert_logs(
423
+ self,
424
+ project: str | None = None,
425
+ configuration: str | None = None,
426
+ channels: list[str] | None = None,
427
+ start_time: datetime | str | None = None,
428
+ end_time: datetime | str | None = None,
429
+ limit: int = 10,
430
+ offset: int = 0,
431
+ latest_only: bool = False,
432
+ ) -> AlertSummary:
433
+ params: _PayloadType = {
434
+ 'limit': limit,
435
+ 'offset': offset,
436
+ 'latest_only': latest_only,
437
+ }
438
+
439
+ if project is not None:
440
+ params['project'] = project
441
+ if configuration is not None:
442
+ params['configuration_code'] = configuration
443
+ if channels is not None:
444
+ params['channels'] = channels
445
+ if start_time is not None:
446
+ params['start_time'] = start_time.isoformat() if isinstance(start_time, datetime) else start_time
447
+ if end_time is not None:
448
+ params['end_time'] = end_time.isoformat() if isinstance(end_time, datetime) else end_time
449
+
450
+ r = self._client._http_request('/alerts/logs', 'GET', params=params)
451
+ return AlertSummary.model_validate_json(r.text)
452
+
453
+ # DEVICES
454
+
455
+ def list_device_types(self) -> list[DeviceType]:
456
+ r = self._client._http_request('/devices/types', 'GET')
457
+ return _DeviceTypeListAdapter.validate_json(r.text)
458
+
459
+ def create_device_type(self, description: str, manufacturer: str, model_number: str) -> DeviceType:
460
+ json: _PayloadType = {
461
+ 'description': description,
462
+ 'manufacturer': manufacturer,
463
+ 'model_number': model_number
464
+ }
465
+ r = self._client._http_request('/devices/types', 'PUT', json=json)
466
+ return DeviceType.model_validate_json(r.text)
467
+
468
+ def list_devices(self, project_code: str, limit: int, offset: int) -> list[Device]:
469
+ params: _PayloadType = {
470
+ 'project_code': project_code,
471
+ 'limit': limit,
472
+ 'offset': offset
473
+ }
474
+ r = self._client._http_request('/devices', 'GET', params=params)
475
+ return _DevicesListAdapter.validate_json(r.text)
476
+
477
+ def get_device(self, device_code: str) -> Device:
478
+ r = self._client._http_request(f'/devices/{device_code}', 'GET')
479
+ return Device.model_validate_json(r.text)
480
+
481
+ def create_device(self,
482
+ project_code: str,
483
+ label: str,
484
+ device_type_code: str,
485
+ groups: list[str],
486
+ location_description: Optional[str] = None,
487
+ channels: Optional[list[DeviceChannel]] = None) -> Device:
488
+ json: _PayloadType = {
489
+ 'project_code': project_code,
490
+ 'label': label,
491
+ 'device_type_code': device_type_code,
492
+ 'groups': groups,
493
+ }
494
+ if location_description is not None:
495
+ json['location_description'] = location_description
496
+ if channels is not None:
497
+ json['channels'] = [channel.model_dump(mode='json') for channel in channels] # type: ignore[assignment]
498
+ r = self._client._http_request('/devices', 'PUT', json=json)
499
+ return Device.model_validate_json(r.text)
500
+
501
+ # MEDIA
502
+
503
+ def list_cameras(self, project: str) -> list[Camera]:
504
+ params: _PayloadType = {}
505
+ params['project_code'] = project
506
+ r = self._client._http_request('/media/cameras', 'GET', params=params)
507
+ return _CameraListAdapter.validate_json(r.text)
508
+
509
+ def list_videos(self, project: Optional[str] = None, event: Optional[str] = None, camera: Optional[str] = None) -> list[Video]:
510
+ params: _PayloadType = {}
511
+ if project is not None:
512
+ params['project'] = project
513
+ if event is not None:
514
+ params['event'] = event
515
+ if camera is not None:
516
+ params['camera'] = camera
517
+ r = self._client._http_request('/media/videos', 'GET', params=params)
518
+ return _VideoListAdapter.validate_json(r.text)
519
+
520
+ def get_video(self, code: str) -> Video:
521
+ r = self._client._http_request(f'/media/videos/{code}', 'GET')
522
+ return Video.model_validate_json(r.text)
523
+
524
+ def list_images(self, project: Optional[str] = None, event: Optional[str] = None, camera: Optional[str] = None) -> list[Image]:
525
+ params = {}
526
+ if project is not None:
527
+ params['project'] = project
528
+ if event is not None:
529
+ params['event'] = event
530
+ if camera is not None:
531
+ params['camera'] = camera
532
+ r = self._client._http_request('/media/images', 'GET', params=params)
533
+ return _ImageListAdapter.validate_json(r.text)
534
+
535
+ def get_image(self, code: str) -> Image:
536
+ r = self._client._http_request(f'/media/images/{code}', 'GET')
537
+ return Image.model_validate_json(r.text)
538
+
539
+ def upload_image(self, project: str, file: str, event: Optional[str] = None,
540
+ camera: Optional[str] = None, timestamp: Optional[datetime] = None,
541
+ filename: Optional[str] = None) -> Image:
542
+ if timestamp is not None and timestamp.tzinfo is None:
543
+ raise ValueError("Timestamp must be timezone aware")
544
+
545
+ mimetype, _ = mimetypes.guess_type(file, strict=False)
546
+ if mimetype is None or not mimetype.startswith('image/'):
547
+ raise ValueError(f"File {file} is not an image")
548
+
549
+ if os.stat(file).st_size > 5_000_000:
550
+ raise ValueError(f"File {file} is too large")
551
+
552
+ if filename is None:
553
+ filename = os.path.basename(file)
554
+
555
+ params: _PayloadType = {}
556
+ params['project'] = project
557
+ if event is not None:
558
+ params['event'] = event
559
+ if camera is not None:
560
+ params['camera'] = camera
561
+ if timestamp is not None:
562
+ params['timestamp'] = timestamp.isoformat()
563
+
564
+ with open(file, 'rb') as f:
565
+ r = self._client._http_request('/media/images', 'PUT',
566
+ params=params,
567
+ files={
568
+ 'file': (filename, f, mimetype)
569
+ })
570
+ return Image.model_validate_json(r.text)
571
+
572
+ def upload_video(self, project: str, file: str,
573
+ start_time: datetime, end_time: datetime,
574
+ event: Optional[str] = None,
575
+ filename: Optional[str] = None) -> Video:
576
+ if start_time.tzinfo is None:
577
+ raise ValueError("Timestamp must be timezone aware")
578
+ if end_time.tzinfo is None:
579
+ raise ValueError("Timestamp must be timezone aware")
580
+
581
+ mimetype, _ = mimetypes.guess_type(file, strict=False)
582
+ if mimetype is None or not mimetype.startswith('video/'):
583
+ raise ValueError(f"File {file} is not a video")
584
+
585
+ if os.stat(file).st_size > 5_000_000:
586
+ raise ValueError(f"File {file} is too large")
587
+
588
+ if filename is None:
589
+ filename = os.path.basename(file)
590
+
591
+ params: _PayloadType = {}
592
+ params['project'] = project
593
+ params['start_time'] = start_time.isoformat()
594
+ params['end_time'] = end_time.isoformat()
595
+ if event is not None:
596
+ params['event'] = event
597
+
598
+ with open(file, 'rb') as f:
599
+ r = self._client._http_request('/media/videos', 'PUT',
600
+ params=params,
601
+ files={
602
+ 'file': (filename, f, mimetype)
603
+ })
604
+ return Video.model_validate_json(r.text)
605
+
606
+ # Contacts
607
+
608
+ def list_contact_groups(self, project: Optional[str] = None) -> list[ContactGroup]:
609
+ params: _PayloadType = {}
610
+ if project is not None:
611
+ params['project'] = project
612
+ r = self._client._http_request('/notifications/contact_groups', 'GET', params=params)
613
+ return _ContactGroupListAdapter.validate_json(r.text)
614
+
615
+ def get_contact_group(self, code: str) -> ContactGroup:
616
+ r = self._client._http_request(f'/notifications/contact_groups/{code}', 'GET')
617
+ return ContactGroup.model_validate_json(r.text)
618
+
619
+ def create_contact_group(self, project: str, label: str, users: dict[str, list[UserContactMethod]]) -> ContactGroup:
620
+ r = self._client._http_request('/notifications/contact_groups', 'PUT',
621
+ json={
622
+ 'project': project,
623
+ 'label': label,
624
+ 'users': users
625
+ })
626
+ return ContactGroup.model_validate_json(r.text)
627
+
628
+ # Reports
629
+ def list_reports(self, project: Optional[str] = None) -> list['ScheduledReport']:
630
+ params: _PayloadType = {}
631
+ if project is not None:
632
+ params['project'] = project
633
+ r = self._client._http_request('/reports/scheduled', 'GET', params=params)
634
+ return _ScheduledReportListAdapter.validate_json(r.text)
635
+
636
+ def create_report(self, project: str, label: str, schedule: str, revision: str,
637
+ api_key: Optional[str] = None, contact_group: Optional[str] = None) -> ScheduledReport:
638
+ json: _PayloadType = {
639
+ 'project': project,
640
+ 'label': label,
641
+ 'schedule': schedule,
642
+ 'revision': revision,
643
+ 'execution_role_api_key': api_key,
644
+ 'contact_group': contact_group
645
+ }
646
+ r = self._client._http_request('/reports/scheduled', 'PUT', json=json)
647
+ return ScheduledReport.model_validate_json(r.text)
648
+
649
+ def generate_report(self, report: str, timestamp: datetime, mark_as_scheduled: bool = False) -> ScheduledReportLog:
650
+ r = self._client._http_request(f'/reports/scheduled/{report}/generate', 'PUT', json={
651
+ 'timestamp': timestamp.isoformat(),
652
+ 'mark_as_scheduled': mark_as_scheduled
653
+ })
654
+ return ScheduledReportLog.model_validate_json(r.text)
655
+
656
+ def list_report_logs(self, report: str, project: Optional[str] = None) -> list[ScheduledReportLog]:
657
+ params: _PayloadType = {}
658
+ if project is not None:
659
+ params['project'] = project
660
+ r = self._client._http_request(f'/reports/scheduled/{report}/logs', 'GET', params=params)
661
+ return _ScheduledReportLogListAdapter.validate_json(r.text)
662
+
663
+ def get_report_log(self, report: str, log: str) -> ScheduledReportLog:
664
+ r = self._client._http_request(f'/reports/scheduled/{report}/logs/{log}', 'GET')
665
+ return ScheduledReportLog.model_validate_json(r.text)
666
+
667
+ def create_report_revision(self, project: str, revision_date: datetime, description: str, source_code_data_url: str) -> ReportSourceCodeRevision:
668
+ json = {
669
+ 'revision_date': revision_date.isoformat(),
670
+ 'description': description,
671
+ 'source_code_data_url': source_code_data_url,
672
+ }
673
+ r = self._client._http_request('/reports/revisions', 'PUT', json=json, params={'project': project})
674
+ return ReportSourceCodeRevision.model_validate_json(r.text)