smartconnect-client 1.11.1__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.
@@ -0,0 +1,552 @@
1
+ import json
2
+ import logging
3
+ import uuid
4
+ from datetime import datetime
5
+ from typing import List, Optional
6
+ from functools import wraps
7
+
8
+ import pytz
9
+ import httpx
10
+ from pydantic import parse_obj_as
11
+ from pydantic.main import BaseModel
12
+
13
+ from smartconnect import models, cache, smart_settings, data
14
+ from .exceptions import SMARTClientException, SMARTClientServerError, SMARTClientClientError, SMARTClientServerUnreachableError, SMARTClientUnauthorizedError
15
+ from .async_client import AsyncSmartClient
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ from smartconnect.models import SMARTRequest, SMARTResponse, Patrol, PatrolDataModel, DataModel, ConservationArea, \
20
+ ConfigurableDataModel, SmartConnectApiInfo
21
+
22
+ # Manually bump this.
23
+ __version__ = '1.11.0'
24
+
25
+ DEFAULT_TIMEOUT = (smart_settings.SMART_DEFAULT_CONNECT_TIMEOUT, smart_settings.SMART_DEFAULT_TIMEOUT)
26
+
27
+
28
+ def with_login_session():
29
+
30
+ def decorator(func):
31
+ @wraps(func)
32
+ def wrapper(self, *args, **kwargs):
33
+ self.ensure_login()
34
+ return func(self, *args, **kwargs)
35
+ return wrapper
36
+ return decorator
37
+ class SmartClient:
38
+
39
+ # TODO: Figure out how to specify timezone.
40
+ SMARTCONNECT_DATFORMAT = '%Y-%m-%dT%H:%M:%S'
41
+
42
+ def __init__(self, *, api=None, username=None, password=None, use_language_code='en', version="7.5",
43
+ read_timeout=None, connect_timeout=None):
44
+
45
+ self.api = api.rstrip('/') # trim trailing slash in case configured into portal with one
46
+ self.username = username
47
+ self.password = password
48
+ self.use_language_code=use_language_code
49
+ self.version=version
50
+
51
+ self.logger = logging.getLogger(SmartClient.__name__)
52
+ self.verify_ssl = smart_settings.SMART_SSL_VERIFY
53
+
54
+ # Configure httpx client with timeout and retries
55
+ self.max_retries = smart_settings.SMART_DEFAULT_CONNECT_RETRIES
56
+ transport = httpx.HTTPTransport(retries=self.max_retries)
57
+ # Use provided timeout values or fall back to environment variables
58
+ connect_timeout = connect_timeout if connect_timeout is not None else smart_settings.SMART_DEFAULT_CONNECT_TIMEOUT
59
+ data_timeout = read_timeout if read_timeout is not None else smart_settings.SMART_DEFAULT_TIMEOUT
60
+ timeout = httpx.Timeout(data_timeout, connect=connect_timeout, pool=connect_timeout)
61
+
62
+ self._session = httpx.Client(transport=transport, timeout=timeout, verify=self.verify_ssl)
63
+
64
+ def ensure_login(self):
65
+ '''
66
+ Login flow for SMART Connect. If the session already has a JSESSIONID cookie, it is assumed to be logged in.
67
+ '''
68
+ for k, v in self._session.cookies.items():
69
+ if k == 'JSESSIONID' and v:
70
+ return self._session
71
+
72
+ # Request the landing page to prime the session.
73
+ landing_page = self._session.get(f'{self.api}/connect/home')
74
+ if not landing_page.is_success:
75
+ raise SMARTClientServerUnreachableError(f"Failed to retrieve landing page {landing_page.url}. Status code: {landing_page.status_code}")
76
+
77
+ login_result = self._session.post(f'{self.api}/j_security_check',
78
+ data={"j_username": self.username, "j_password": self.password})
79
+
80
+ if login_result.is_success or login_result.is_redirect:
81
+ return self._session
82
+
83
+ if login_result.status_code == 401:
84
+ raise SMARTClientUnauthorizedError(f"Failed to login to SMART Connect {login_result.url}. Status code: {login_result.status_code}")
85
+
86
+ logger.error(f"Failed to login to SMART Connect {login_result.url}. Status code: {login_result.status_code}, {login_result.text[:250]}")
87
+ exception_class = SMARTClientClientError if login_result.is_client_error else SMARTClientServerError
88
+ raise exception_class(f"Failed to login to SMART Connect {login_result.url}. Status code: {login_result.status_code}")
89
+
90
+ @with_login_session()
91
+ def get_server_api_info(self):
92
+ cas = self._session.get(f'{self.api}/api/info',
93
+ headers={
94
+ 'accept': 'application/json',
95
+ })
96
+
97
+ if cas.is_success:
98
+ return SmartConnectApiInfo.parse_obj(cas.json())
99
+
100
+ @with_login_session()
101
+ def get_conservation_area(self, *, ca_uuid: str = None, force: bool = False):
102
+
103
+ cache_key = f"cache:smart-ca:{ca_uuid}:metadata"
104
+ if not force:
105
+ self.logger.info(f"Looking up CA cached at {cache_key}.")
106
+ try:
107
+ cached_data = cache.cache.get(cache_key)
108
+ if cached_data:
109
+ self.logger.info(f"Found CA cached at {cache_key}.")
110
+ conservation_area = ConservationArea.parse_raw(cached_data)
111
+ return conservation_area
112
+
113
+ self.logger.info(f"Cache miss for {cache_key}")
114
+ except:
115
+ self.logger.info(f"Cache miss/error for {cache_key}")
116
+ pass
117
+
118
+ try:
119
+ self.logger.info(
120
+ "Querying Smart Connect for CAs at endpoint: %s, username: %s",
121
+ self.api,
122
+ self.username,
123
+ )
124
+
125
+ for ca in self.get_conservation_areas():
126
+ if ca.uuid == uuid.UUID(ca_uuid):
127
+ conservation_area = ca
128
+ break
129
+ else:
130
+ logger.error(
131
+ f"Can't find a Conservation Area with UUID: {ca_uuid}"
132
+ )
133
+ conservation_area = None
134
+
135
+ if conservation_area:
136
+ self.logger.info(f"Caching CA metadata at {cache_key}")
137
+ cache.cache.set(
138
+ name=cache_key,
139
+ value=json.dumps(dict(conservation_area), default=str),
140
+ )
141
+
142
+ return conservation_area
143
+
144
+ except Exception as ex:
145
+ self.logger.exception(
146
+ f"Failed to get Conservation Areas", extra=dict(ca_uuid=ca_uuid)
147
+ )
148
+ raise SMARTClientException(f"Failed to get SMART Conservation Areas") from ex
149
+
150
+ @with_login_session()
151
+ def get_conservation_areas(self) -> List[models.ConservationArea]:
152
+ cas = self._session.get(f'{self.api}/api/conservationarea',
153
+ headers={
154
+ 'accept': 'application/json',
155
+ })
156
+
157
+ if cas.is_success:
158
+ cas = cas.json()
159
+
160
+ return [models.ConservationArea.parse_obj(ca) for ca in cas]
161
+
162
+ @with_login_session()
163
+ def list_configurable_datamodels(self, *, ca_uuid: str):
164
+
165
+ assert ca_uuid, "ca_uuid is required"
166
+ '''
167
+ Get metadata about the configurable data models for a given CA.
168
+ :param ca_uuid: The UUID of the CA to get the configurable data models for.
169
+ :return: Smart Connect response that contains the configurable data models.
170
+ '''
171
+ extra_dict = dict(ca_uuid=ca_uuid,
172
+ url=f'{self.api}/metadata/configurablemodel')
173
+
174
+ config_datamodels = self._session.get(f'{self.api}/api/metadata/configurablemodel',
175
+ params={"ca_uuid": ca_uuid},
176
+ headers={
177
+ 'accept': 'application/json',
178
+ })
179
+
180
+ self.logger.info(f'Get configurable data models for CA {ca_uuid} data model took {config_datamodels.elapsed.total_seconds()} seconds',
181
+ extra=dict(**extra_dict,
182
+ status_code=config_datamodels.status_code))
183
+
184
+ if not config_datamodels.is_success:
185
+ self.logger.error(
186
+ f'Failed to download configurable datamodels for CA {ca_uuid}. Status_code is: {config_datamodels.status_code}',
187
+ extra=dict(**extra_dict,
188
+ status_code=config_datamodels.status_code))
189
+ raise Exception('Failed to download Configurable Data Models')
190
+
191
+ cdms = config_datamodels.json()
192
+ return cdms
193
+
194
+ @with_login_session()
195
+ def download_configurable_datamodel(self, *, cm_uuid: str = None):
196
+
197
+ extra_dict = dict(cm_uuid=cm_uuid,
198
+ url=f'{self.api}/api/metadata/configurablemodel/{cm_uuid}')
199
+
200
+ config_datamodel = self._session.get(f'{self.api}/api/metadata/configurablemodel/{cm_uuid}',
201
+ headers={
202
+ 'accept': 'application/xml',
203
+ })
204
+
205
+ self.logger.info(f'Download configurable model {cm_uuid} data model took {config_datamodel.elapsed.total_seconds()} seconds',
206
+ extra=dict(**extra_dict,
207
+ status_code=config_datamodel.status_code))
208
+
209
+ if not config_datamodel.is_success:
210
+ self.logger.error(
211
+ f'Failed to download data model for configurable model {cm_uuid}. Status_code is: {config_datamodel.status_code}',
212
+ extra=dict(**extra_dict,
213
+ status_code=config_datamodel.status_code))
214
+ raise Exception('Failed to download Data Model')
215
+
216
+ cdm = ConfigurableDataModel(cm_uuid=cm_uuid, use_language_code=self.use_language_code)
217
+ cdm.load(config_datamodel.text)
218
+
219
+ return cdm
220
+
221
+ @with_login_session()
222
+ def get_configurable_data_model(self, *, cm_uuid: str = None, force: bool = False):
223
+ # TODO: version consideration
224
+ # TODO: Implement caching
225
+
226
+ ca_uuid = 'na'
227
+ cache_key = f'cache:smart-ca:{ca_uuid}:cdm:{cm_uuid}'
228
+
229
+ if not force:
230
+ try:
231
+ cached_data = cache.cache.get(cache_key)
232
+ if cached_data:
233
+
234
+ cm = ConfigurableDataModel(use_language_code=self.use_language_code)
235
+ cm.import_from_dict(json.loads(cached_data))
236
+
237
+ self.logger.debug(
238
+ f"Using cached SMART Configurable Data Model", extra={"cached_key": cache_key}
239
+ )
240
+ return cm
241
+
242
+ except Exception as ex:
243
+ logger.exception('Failed on reading configurable model from cache.', extra={'cache_key': cache_key})
244
+
245
+ # Re-download and cache.
246
+ ca_config_datamodel = self.download_configurable_datamodel(
247
+ cm_uuid=cm_uuid
248
+ )
249
+ cache.cache.set(cache_key, json.dumps(ca_config_datamodel.export_as_dict()))
250
+ return ca_config_datamodel
251
+
252
+ def get_data_model(self, *, ca_uuid: str = None, force: bool = False):
253
+
254
+ # CA Data Model is not available for versions below 7. Use a blank data model.
255
+ if self.version.startswith("6"):
256
+ blank_datamodel = DataModel()
257
+ blank_datamodel.load(data.BLANK_DATAMODEL_CONTENT)
258
+ return blank_datamodel
259
+
260
+ cache_key = f"cache:smart-ca:{ca_uuid}:datamodel"
261
+ if not force:
262
+ try:
263
+ cached_data = cache.cache.get(cache_key)
264
+ if cached_data:
265
+ dm = DataModel(use_language_code=self.use_language_code)
266
+ dm.import_from_dict(json.loads(cached_data))
267
+ self.logger.debug(
268
+ f"Using cached SMART Datamodel", extra={"cached_key": cache_key}
269
+ )
270
+ return dm
271
+
272
+ except Exception as ex:
273
+ logger.exception('Failed on reading data model from cache.', extra={'cache_key': cache_key})
274
+
275
+ logger.debug(f"Cache miss for SMART Datamodel", extra={"cached_key": cache_key})
276
+
277
+ try:
278
+ ca_datamodel = self.download_datamodel(
279
+ ca_uuid=ca_uuid
280
+ )
281
+ except Exception as e:
282
+ logger.exception(e)
283
+ raise SMARTClientException(f"Failed downloading SMART Datamodel for CA {ca_uuid}") from e
284
+
285
+ if ca_datamodel:
286
+ cache.cache.set(
287
+ name=cache_key,
288
+ value=json.dumps(ca_datamodel.export_as_dict()),
289
+ )
290
+
291
+ return ca_datamodel
292
+
293
+ @with_login_session()
294
+ def download_datamodel(self, *, ca_uuid: str = None):
295
+
296
+ extra_dict = dict(ca_uuid=ca_uuid,
297
+ url=f'{self.api}/api/metadata/datamodel/{ca_uuid}')
298
+
299
+ ca_datamodel = self._session.get(f'{self.api}/api/metadata/datamodel/{ca_uuid}',
300
+ headers={
301
+ 'accept': 'application/xml',
302
+ })
303
+
304
+ self.logger.info(f'Download CA {ca_uuid} data model took {ca_datamodel.elapsed.total_seconds()} seconds',
305
+ extra=dict(**extra_dict,
306
+ status_code=ca_datamodel.status_code))
307
+
308
+ if not ca_datamodel.is_success:
309
+ self.logger.error(
310
+ f'Failed to download data model for CA {ca_uuid}. Status_code is: {ca_datamodel.status_code}',
311
+ extra=dict(**extra_dict,
312
+ status_code=ca_datamodel.status_code))
313
+ raise Exception('Failed to download Data Model')
314
+
315
+ dm = DataModel(use_language_code=self.use_language_code)
316
+ dm.load(ca_datamodel.text)
317
+ return dm
318
+
319
+ def load_datamodel(self, *, filename=None):
320
+
321
+ with open(filename, 'r') as fi:
322
+ contents = fi.read()
323
+
324
+ dm = DataModel(use_language_code=self.use_language_code)
325
+ dm.load(contents)
326
+ return dm
327
+
328
+ @with_login_session()
329
+ def download_patrolmodel(self, *, ca_uuid: str = None):
330
+
331
+ ca_patrolmodel = self._session.get(f'{self.api}/api/metadata/patrol/{ca_uuid}',
332
+ headers={
333
+ 'accept': 'application/json',
334
+ })
335
+
336
+ self.logger.info('Downloaded CA Patrol Model. Status code is: %s', ca_patrolmodel.status_code)
337
+
338
+ if not ca_patrolmodel.is_success:
339
+ self.logger.error('Failed to download CA Patrol Model. Status_code is: %s', ca_patrolmodel.status_code)
340
+ raise Exception('Failed to download Patrol Model.')
341
+
342
+ pm = PatrolDataModel.parse_obj(ca_patrolmodel.json())
343
+ return pm
344
+
345
+ @with_login_session()
346
+ def download_missionmodel(self, *, ca_uuid: str = None):
347
+
348
+ ca_missionmodel = self._session.get(f'{self.api}/api/metadata/mission/{ca_uuid}',
349
+ headers={
350
+ 'accept': 'application/json',
351
+ })
352
+
353
+ self.logger.info('Downloaded CA Mission Model. Status code is: %s', ca_missionmodel.status_code)
354
+
355
+ if not ca_missionmodel.is_success:
356
+ self.logger.error('Failed to download CA Mission Model. Status_code is: %s', ca_missionmodel.status_code)
357
+ raise Exception('Failed to download Mission Model.')
358
+
359
+
360
+ with open('_missionmodel.json', 'w') as fo:
361
+ fo.write(ca_missionmodel.text)
362
+
363
+ @with_login_session()
364
+ def get_patrol(self, *, patrol_id=None):
365
+
366
+ response = self._session.get(f'{self.api}/api/query/custom/patrol',
367
+ params= {"client_patrol_uuid": patrol_id})
368
+
369
+ if response.is_success and len(response.json()) > 0:
370
+ patrol = parse_obj_as(List[Patrol],response.json())[0]
371
+ return patrol
372
+
373
+ @with_login_session()
374
+ def get_patrol_waypoints(self, *, patrol_id=None):
375
+
376
+ response = self._session.get(f'{self.api}/api/query/custom/waypoint/patrol',
377
+ params= {"client_patrol_uuid": patrol_id})
378
+
379
+ if response.is_success and len(response.json()) > 0:
380
+ smart_response = parse_obj_as(List[SMARTResponse],response.json())
381
+ return [item.properties.waypoint for item in smart_response]
382
+
383
+ @with_login_session()
384
+ def get_incident(self, *, incident_uuid=None):
385
+
386
+ url = f'{self.api}/api/query/custom/waypoint/incident'
387
+ response = self._session.get(url,
388
+ params={"client_incident_uuid": incident_uuid})
389
+
390
+ if not response.is_success:
391
+ logger.error("Failed lookup for incident %s", incident_uuid,
392
+ extra=dict(url=url,
393
+ response_content=response.content)
394
+ )
395
+
396
+ response_data = response.json()
397
+ if response_data and isinstance(response_data, list):
398
+ return parse_obj_as(List[SMARTResponse], response_data)
399
+
400
+ def generate_patrol_label(self, *, device_id=None, prefix='wildlife', ts=None):
401
+
402
+ ts = ts or datetime.now(tz=pytz.utc)
403
+
404
+ return '/'.join( (prefix, device_id, ts.strftime('%Y/%m')) )
405
+
406
+ @with_login_session()
407
+ def post_smart_request(self, *, json: str, ca_uuid: str = None):
408
+
409
+ url = f'{self.api}/api/data/{ca_uuid}'
410
+ response = self._session.post(url,
411
+ headers={'content-type': 'application/json'},
412
+ content=json)
413
+ if response.is_success:
414
+ logger.info("Posted request to SMART successfully")
415
+ if logger.isEnabledFor(logging.DEBUG):
416
+ logger.debug("SMART request succeeded", extra=dict(url=url,
417
+ ca_uuid=ca_uuid,
418
+ request=json,
419
+ response=response.content),
420
+ response_code=response.status_code)
421
+ else:
422
+ message = f"SMART request failed for {url} with response code {response.status_code}"
423
+ logger.exception(message, extra=dict(url=url,
424
+ ca_uuid=ca_uuid,
425
+ request=json,
426
+ response=response.content,
427
+ response_code=response.status_code)
428
+ )
429
+ raise SMARTClientException(message)
430
+
431
+ # Functions for quick testing
432
+ @with_login_session()
433
+ def add_patrol_trackpoint(self, *, ca_uuid: str = None, patrol_uuid: str = None, patrol_leg_uuid: str = None, x=None, y=None, timestamp=None):
434
+
435
+ track_point = {
436
+ "type": "Feature",
437
+ "geometry": {
438
+ "coordinates": [x, y],
439
+ "type": "Point"
440
+ },
441
+ "properties": {
442
+ "dateTime": timestamp.strftime(self.SMARTCONNECT_DATFORMAT),
443
+
444
+ "smartDataType": "patrol",
445
+ "smartFeatureType": "trackpoint/new",
446
+ "smartAttributes": {
447
+ "patrolUuid": patrol_uuid, # required
448
+ "patrolLegUuid": patrol_leg_uuid, # required
449
+ }
450
+ }
451
+ }
452
+
453
+ response = self._session.post(f'{self.api}/api/data/{ca_uuid}',
454
+ json=track_point)
455
+
456
+ if response.is_success:
457
+ print('All good mate!')
458
+ print(response.status_code, response.content)
459
+
460
+ @with_login_session()
461
+ def add_patrol_waypoint(self, *, ca_uuid: str = None, patrol_uuid: str = None, patrol_leg_uuid: str = None,
462
+ x=None, y=None, timestamp=None):
463
+
464
+ way_point = {
465
+ "type": "Feature",
466
+ "geometry": {
467
+ "coordinates": [x, y],
468
+ "type": "Point"
469
+ },
470
+ "properties": {
471
+ "dateTime": timestamp.strftime(self.SMARTCONNECT_DATFORMAT),
472
+
473
+ "smartDataType": "patrol",
474
+ "smartFeatureType": "waypoint/new",
475
+ "smartAttributes": {
476
+ "patrolUuid": patrol_uuid, # required
477
+ "patrolLegUuid": patrol_leg_uuid, # required
478
+ }
479
+ }
480
+ }
481
+
482
+ response = self._session.post(f'{self.api}/api/data/{ca_uuid}',
483
+ json=way_point)
484
+
485
+ if response.is_success:
486
+ print('All good mate!')
487
+ print(response.status_code, response.content)
488
+
489
+
490
+ # TODO: Depreciate can just use post_smart_request
491
+ @with_login_session()
492
+ def add_independent_incident(self, *, incident: models.SMARTRequest, ca_uuid: str = None):
493
+
494
+ response = self._session.post(f'{self.api}/api/data/{ca_uuid}', headers={'content-type': 'application/json'},
495
+ content=incident.json())
496
+
497
+ if response.is_success:
498
+ print('All good mate!')
499
+ print(response.content)
500
+ else:
501
+ logger.error('Failed posting independent incident. %s', response.text)
502
+
503
+ logger.debug(response.status_code, response.content)
504
+
505
+ @with_login_session()
506
+ def add_mission(self, *, ca_uuid: str = None):
507
+
508
+ present = datetime.now(tz=pytz.utc)
509
+
510
+ mission_uuid = str(uuid.uuid4())
511
+
512
+ print(f'Using Mission id: {mission_uuid}')
513
+ track_point = {
514
+ "type": "Feature",
515
+ "geometry": {
516
+ "coordinates": [11.41, -9.2],
517
+ "type": "Point"
518
+ },
519
+ "properties": {
520
+ "dateTime": present.strftime(self.SMARTCONNECT_DATFORMAT),
521
+
522
+ "smartDataType": "mission",
523
+ "smartFeatureType": "start",
524
+ "smartAttributes": {
525
+ 'missionId': 'wildilfe/2021/07/mission-00004',
526
+ "missionUuid": mission_uuid, # required
527
+ "survey": '3e6efdfdb3fe4929816ab1f735a80fad',
528
+ # "team": "communityteam1",
529
+ "objective": "Tracking wildlife", #required
530
+ "comment": "Tracking wildlife", #required
531
+ "isArmed": "false",
532
+ "transportType": "wildlife", #required
533
+ "mandate": "animaltracking", #required
534
+ # "number": -999,
535
+ # "test": "test attribute",
536
+ # "members": ['9b07b0d7155d44de93e8361fede22297'], # Required
537
+ # "leader": '9b07b0d7155d44de93e8361fede22297', # required
538
+ }
539
+ }
540
+ }
541
+
542
+
543
+ response = self._session.post(f'{self.api}/api/data/{ca_uuid}',
544
+ json=track_point)
545
+
546
+ if response.is_success:
547
+ print('All good mate!')
548
+ print(response.status_code, response.content)
549
+
550
+
551
+
552
+