Skip to content

Commit d38f034

Browse files
authored
Feature/event attachments (#37)
* Default content type for file attachments is now set in the code; Minor documentation fixes. * Adding support for event attachments; Minor documentation fixes. * Added event handling sample. * Fix username determination from HTTPBasicAuth. * Added integration tests for event attachment handling. * Added event attachment handling to the Events API. * Update CHANGELOG.md
1 parent 2b9dac2 commit d38f034

File tree

5 files changed

+284
-18
lines changed

5 files changed

+284
-18
lines changed

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# Changelog
22

3+
* Added support for event attachment handling.
4+
35
* Adding support for bulk operations.
46

57
## Version 1.6.1
@@ -8,7 +10,7 @@
810

911
## Version 1.6
1012

11-
* Added API support for Notification 2.0 subscriptions and tokens.
13+
* Added API support for Notification 2.0 subscriptions and tokens.
1214

1315
* Added new package c8y_tk for additional features.
1416

c8y_api/_base_api.py

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ def __init__(self, base_url: str, tenant_id: str, username: str = None, password
4343
tenant_id (str): The ID of the tenant to connect to
4444
username (str): Username
4545
password (str): User password
46-
tfa_token (str): Currently valid two factor authorization token
46+
tfa_token (str): Currently valid two-factor authorization token
4747
auth (AuthBase): Authentication details
4848
application_key (str): Application ID to include in requests
4949
(for billing/metering purposes).
@@ -83,7 +83,7 @@ def prepare_request(self, method: str, resource: str,
8383
Args:
8484
method (str): One of 'GET', 'POST', 'PUT', 'DELETE'
8585
resource (str): Path to the HTTP resource
86-
json (dict): JSON body (nested dict) to send witht he request
86+
json (dict): JSON body (nested dict) to send with the request
8787
additional_headers (dict): Additional non-standard headers to
8888
include in the request
8989
@@ -106,7 +106,7 @@ def get(self, resource: str, params: dict = None, accept: str = None, ordered: b
106106
resource (str): Resource path
107107
params (dict): Additional request parameters
108108
accept (str|None): Custom Accept header to use (default is
109-
application/json). Specify '' to sent no Accept header.
109+
application/json). Specify '' to send no Accept header.
110110
ordered (bool): Whether the result JSON needs to be ordered
111111
(default is False)
112112
@@ -166,7 +166,7 @@ def post(self, resource: str, json: dict, accept: str = None, content_type: str
166166
resource (str): Resource path
167167
json (dict): JSON body (nested dict)
168168
accept (str|None): Custom Accept header to use (default is
169-
application/json). Specify '' to sent no Accept header.
169+
application/json). Specify '' to send no Accept header.
170170
content_type (str|None): Custom Content-Type header to use
171171
(default is application/json)
172172
@@ -192,8 +192,8 @@ def post(self, resource: str, json: dict, accept: str = None, content_type: str
192192
return r.json()
193193
return {}
194194

195-
def post_file(self, resource: str, file: str | BinaryIO, object: dict = None,
196-
accept: str = None, content_type: str = 'application/octet-stream'):
195+
def post_file(self, resource: str, file: str | BinaryIO, object: dict = None, # noqa (object)
196+
accept: str = None, content_type: str = None):
197197
"""Generic HTTP POST wrapper.
198198
199199
Used for posting binary data, i.e. creating binary objects in Cumulocity.
@@ -203,7 +203,7 @@ def post_file(self, resource: str, file: str | BinaryIO, object: dict = None,
203203
file (str|BinaryIO): File-like object or a file path
204204
object (dict): File metadata, stored within Cumulocity
205205
accept (str|None): Custom Accept header to use (default is
206-
application/json). Specify '' to sent no Accept header.
206+
application/json). Specify '' to send no Accept header.
207207
content_type (str): Content type of the file sent
208208
(default is application/octet-stream)
209209
@@ -247,7 +247,7 @@ def put(self, resource: str, json: dict, params: dict = None,
247247
json (dict): JSON body (nested dict)
248248
params (dict): Additional request parameters
249249
accept (str|None): Custom Accept header to use (default is
250-
application/json). Specify '' to sent no Accept header.
250+
application/json). Specify '' to send no Accept header.
251251
content_type (str|None): Custom Content-Type header to use
252252
(default is application/json)
253253
@@ -274,7 +274,7 @@ def put(self, resource: str, json: dict, params: dict = None,
274274
return {}
275275

276276
def put_file(self, resource: str, file: str | BinaryIO,
277-
accept: str = None, content_type: str = 'application/octet-stream'):
277+
accept: str = None, content_type: str = None):
278278
"""Generic HTTP PUT wrapper.
279279
280280
Used for put'ing binary data, i.e. updating binaries in Cumulocity.
@@ -283,7 +283,7 @@ def put_file(self, resource: str, file: str | BinaryIO,
283283
resource (str): Resource path
284284
file (str|BinaryIO): File-like object or a file path
285285
accept (str|None): Custom Accept header to use (default is
286-
application/json). Specify '' to sent no Accept header.
286+
application/json). Specify '' to send no Accept header.
287287
content_type (str): Content type of the file sent
288288
(default is application/octet-stream)
289289
@@ -357,7 +357,8 @@ def _resolve_username_from_auth(cls, auth: AuthBase):
357357
and the username resolved from the payload.
358358
"""
359359
if isinstance(auth, HTTPBasicAuth):
360-
return auth.username
360+
# the username may contain the tenant ID (<tenant ID>/<username>)
361+
return auth.username.split('/')[-1]
361362
if isinstance(auth, HTTPBearerAuth):
362363
return JWT(auth.token).username
363364
raise ValueError(f"Unexpected AuthBase instance: {auth.__class__}. Unable to resolve username.")

c8y_api/model/events.py

Lines changed: 132 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from __future__ import annotations
1111

1212
from datetime import datetime, timedelta
13-
from typing import Generator, List
13+
from typing import Generator, List, BinaryIO
1414

1515
from c8y_api._base_api import CumulocityRestApi
1616
from c8y_api.model._base import CumulocityResource, SimpleObject, ComplexObject
@@ -28,7 +28,7 @@ class Event(ComplexObject):
2828
"""
2929

3030
_resource = '/event/events'
31-
# _accept can remain default
31+
_accept = 'application/vnd.com.nsn.cumulocity.event+json'
3232
_parser = ComplexObjectParser({
3333
'type': 'type',
3434
'time': 'time',
@@ -37,8 +37,8 @@ class Event(ComplexObject):
3737
'updated_time': 'lastUpdated',
3838
}, ['source'])
3939

40-
def __init__(self, c8y: CumulocityRestApi = None, type: str = None, time: str | datetime = None,
41-
source: str = None, text: str = None, **kwargs): # noqa (type)
40+
def __init__(self, c8y: CumulocityRestApi = None, type: str = None, time: str | datetime = None, # noqa (type)
41+
source: str = None, text: str = None, **kwargs):
4242
"""Create a new Event object.
4343
4444
Args:
@@ -91,6 +91,9 @@ def updated_datetime(self) -> datetime:
9191
"""
9292
return super()._to_datetime(self.updated_time)
9393

94+
def _build_attachment_path(self) -> str:
95+
return super()._build_object_path() + '/binaries'
96+
9497
@classmethod
9598
def from_json(cls, json: dict) -> Event:
9699
# (no doc update required)
@@ -146,6 +149,69 @@ def apply_to(self, other_id: str) -> Event:
146149
"""
147150
return super()._apply_to(other_id)
148151

152+
def has_attachment(self) -> bool:
153+
"""Check whether the event has a binary attachment.
154+
155+
Event objects that have an attachment feature a `c8y_IsBinary`
156+
fragment. This function checks the presence of that fragment.
157+
158+
Note: This does not query the database. Hence, the information might
159+
be outdated if a binary was attached _after_ the event object was
160+
last read from the database.
161+
162+
Returns:
163+
True if the event object has an attachment, False otherwise.
164+
"""
165+
return 'c8y_IsBinary' in self
166+
167+
def download_attachment(self) -> bytes:
168+
"""Read the binary attachment.
169+
170+
Returns:
171+
The event's binary attachment as bytes.
172+
"""
173+
super()._assert_c8y()
174+
super()._assert_id()
175+
return self.c8y.get_file(self._build_attachment_path())
176+
177+
def create_attachment(self, file: str|BinaryIO, content_type: str = None) -> dict:
178+
"""Create the binary attachment.
179+
180+
Args:
181+
file (str|BinaryIO): File-like object or a file path
182+
content_type (str): Content type of the file sent
183+
(default is application/octet-stream)
184+
185+
Returns:
186+
Attachment details as JSON object (dict).
187+
"""
188+
super()._assert_c8y()
189+
super()._assert_id()
190+
return self.c8y.post_file(self._build_attachment_path(), file,
191+
accept='application/json', content_type=content_type)
192+
193+
def update_attachment(self, file: str|BinaryIO, content_type: str = None) -> dict:
194+
"""Update the binary attachment.
195+
196+
Args:
197+
file (str|BinaryIO): File-like object or a file path
198+
content_type (str): Content type of the file sent
199+
(default is application/octet-stream)
200+
201+
Returns:
202+
Attachment details as JSON object (dict).
203+
"""
204+
super()._assert_c8y()
205+
super()._assert_id()
206+
return self.c8y.put_file(self._build_attachment_path(), file,
207+
accept='application/json', content_type=content_type)
208+
209+
def delete_attachment(self):
210+
"""Remove the binary attachment."""
211+
super()._assert_c8y()
212+
super()._assert_id()
213+
self.c8y.delete(self._build_attachment_path())
214+
149215

150216
class Events(CumulocityResource):
151217
"""Provides access to the Events API.
@@ -159,6 +225,17 @@ class Events(CumulocityResource):
159225
def __init__(self, c8y):
160226
super().__init__(c8y, '/event/events')
161227

228+
def build_attachment_path(self, event_id: str) -> str:
229+
"""Build the attachment path of a specific event.
230+
231+
Args:
232+
event_id (int|str): Database ID of the event
233+
234+
Returns:
235+
The relative path to the event attachment within Cumulocity.
236+
"""
237+
return super().build_object_path(event_id) + '/binaries'
238+
162239
def get(self, event_id: str) -> Event: # noqa (id)
163240
"""Retrieve a specific object from the database.
164241
@@ -184,7 +261,7 @@ def select(self, type: str = None, source: str = None, fragment: str = None, #
184261
fetched from the database as long there is a consumer for them.
185262
186263
All parameters are considered to be filters, limiting the result set
187-
to objects which meet the filters specification. Filters can be
264+
to objects which meet the filter's specification. Filters can be
188265
combined (within reason).
189266
190267
Args:
@@ -286,7 +363,7 @@ def delete_by(self, type: str = None, source: str = None, fragment: str = None,
286363
"""Query the database and delete matching events.
287364
288365
All parameters are considered to be filters, limiting the result set
289-
to objects which meet the filters specification. Filters can be
366+
to objects which meet the filter's specification. Filters can be
290367
combined (within reason).
291368
292369
Args:
@@ -306,3 +383,52 @@ def delete_by(self, type: str = None, source: str = None, fragment: str = None,
306383
# remove &page_number= from the end
307384
query = base_query[:base_query.rindex('&')]
308385
self.c8y.delete(query)
386+
387+
def create_attachment(self, event_id: str, file: str|BinaryIO, content_type: str = None) -> dict:
388+
"""Add an event's binary attachment.
389+
390+
Args:
391+
event_id (str): The database ID of the event
392+
file (str|BinaryIO): File-like object or a file path
393+
content_type (str): Content type of the file sent
394+
(default is application/octet-stream)
395+
396+
Returns:
397+
Attachment details as JSON object (dict).
398+
"""
399+
return self.c8y.post_file(self.build_attachment_path(event_id), file,
400+
accept='application/json', content_type=content_type)
401+
402+
def update_attachment(self, event_id: str, file: str|BinaryIO, content_type: str = None) -> dict:
403+
"""Update an event's binary attachment.
404+
405+
Args:
406+
event_id (str): The database ID of the event
407+
file (str|BinaryIO): File-like object or a file path
408+
content_type (str): Content type of the file sent
409+
(default is application/octet-stream)
410+
411+
Returns:
412+
Attachment details as JSON object (dict).
413+
"""
414+
return self.c8y.put_file(self.build_attachment_path(event_id), file,
415+
accept='application/json', content_type=content_type)
416+
417+
def download_attachment(self, event_id: str) -> bytes:
418+
"""Read an event's binary attachment.
419+
420+
Args:
421+
event_id (str): The database ID of the event
422+
423+
Returns:
424+
The event's binary attachment as bytes.
425+
"""
426+
return self.c8y.get_file(self.build_attachment_path(event_id))
427+
428+
def delete_attachment(self, event_id: str):
429+
"""Remove an event's binary attachment.
430+
431+
Args:
432+
event_id (str): The database ID of the event
433+
"""
434+
self.c8y.delete(self.build_attachment_path(event_id))

0 commit comments

Comments
 (0)