From 52f1963564d887712fd4f3432688a4dc80534b61 Mon Sep 17 00:00:00 2001 From: dmaone Date: Tue, 3 Aug 2021 13:28:46 -0700 Subject: [PATCH] Direct `ArchFXFlexibleDictionaryReport` upload (#8) `streamer/report` API endpoint requires very particular request structure, fully derivable from `ArchFXFlexibleDictionaryReport` object. Let's ensure request correctness by providing a dedicated API call. --- RELEASE.md | 4 +++ archfx_cloud/api/connection.py | 3 ++ archfx_cloud/reports/flexible_dictionary.py | 15 ++++++++ tests/test_api.py | 3 ++ tests/test_flexible_report.py | 39 ++++++++++++++++++++- version.py | 2 +- 6 files changed, 64 insertions(+), 2 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index 3263a2c..6ec1e81 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -2,6 +2,10 @@ All major changes in each released version of the archfx-cloud plugin are listed here. +## 0.12.0 + +- Support `ArchFXFlexibleDictionaryReport` direct upload + ## 0.11.0 - Support Django SimpleJWT authentication variety diff --git a/archfx_cloud/api/connection.py b/archfx_cloud/api/connection.py index 1b9a3cf..f042bbd 100644 --- a/archfx_cloud/api/connection.py +++ b/archfx_cloud/api/connection.py @@ -311,6 +311,9 @@ def refresh_token(self): self._destroy_tokens() return False + def __call__(self, id): + return self.resource_class(session=self.session, base_url=self.url(id)) + def __getattr__(self, item): """ Instead of raising an attribute error, the undefined attribute will diff --git a/archfx_cloud/reports/flexible_dictionary.py b/archfx_cloud/reports/flexible_dictionary.py index 3bbca9c..e5a1d33 100644 --- a/archfx_cloud/reports/flexible_dictionary.py +++ b/archfx_cloud/reports/flexible_dictionary.py @@ -1,6 +1,7 @@ """A flexible dictionary based report format suitable for msgpack and json serialization.""" import datetime +from io import BytesIO from typing import List, Union import pytz import msgpack @@ -115,6 +116,20 @@ def write(self, file_path: str): with open(file_path, "wb") as outfile: outfile.write(self.encode()) + def upload(self, cloud): + """Uploads this report into ArchFX cloud + + Args: + cloud: an instance of archfx_cloud.api.connection.Api. Must be authenticated. + + Returns: + int: The number of new readings that were accepted by the cloud as novel. + """ + return cloud("streamer/report").upload_fp( + ("report.mp", BytesIO(self.encode())), + timestamp=self.sent_timestamp, + )['count'] + def _encode_datetime(obj): """Pack a datetime into an isoformat string.""" diff --git a/tests/test_api.py b/tests/test_api.py index 5fa5594..5a604fa 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -142,6 +142,9 @@ def test_upload_fp_content_type(self, m): resp = api.test.upload_fp(BytesIO(b"test")) # "No mock address" means content-type is broken! self.assertEqual(resp['result'], 'ok') + request_body = m.request_history[0]._request.body + self.assertIn(b'Content-Disposition: form-data; name="file"; filename=', request_body) + @requests_mock.Mocker() def test_get_list(self, m): payload = { diff --git a/tests/test_flexible_report.py b/tests/test_flexible_report.py index bfe6b5b..d8acaac 100644 --- a/tests/test_flexible_report.py +++ b/tests/test_flexible_report.py @@ -1,9 +1,11 @@ -from datetime import datetime +from datetime import datetime, timezone import unittest import dateutil.parser import msgpack +import requests_mock +from archfx_cloud.api.connection import Api from archfx_cloud.reports.flexible_dictionary import ArchFXFlexibleDictionaryReport from archfx_cloud.reports.report import ArchFXDataPoint @@ -149,3 +151,38 @@ def test_report_usage(self): assert report_data[0].get('extra_data') == {'foo': 5, 'bar': 'foobar'} assert report_data[1].get('extra_data') == {'foo': 6, 'bar': 'foobar'} assert report_data[2].get('extra_data') == {} + + @requests_mock.Mocker() + def test_upload(self, m): + """Testing that we can upload a FlexibleDictionaryReport.""" + report = ArchFXFlexibleDictionaryReport.FromReadings( + device='d--1234', + data=[ + ArchFXDataPoint( + timestamp=datetime(2021, 1, 20, tzinfo=timezone.utc), + stream='0001-5030', + value=2.0, + summary_data={'foo': 5, 'bar': 'foobar'}, + raw_data=None, + reading_id=1000 + ), + ], + report_id=1003, + streamer=0xff, + sent_timestamp=datetime(2021, 1, 20, tzinfo=timezone.utc), + ) + sent_time_str = report.sent_timestamp.replace(":", "%3A").replace("+", "%2B") + m.post( + f"http://archfx.test/api/v1/streamer/report/?timestamp={sent_time_str}", + json={"count": 1}, + ) + + + api = Api(domain='http://archfx.test') + resp = report.upload(api) + self.assertEqual(resp, 1) + + request = m.request_history[0]._request + self.assertIn('multipart/form-data; boundary=', request.headers["Content-Type"]) + self.assertIn(b'Content-Disposition: form-data; name="file"; filename=', request.body) + self.assertIn(b'filename="report.mp"', request.body) # Check filename correctness diff --git a/version.py b/version.py index f5504c3..727f82d 100644 --- a/version.py +++ b/version.py @@ -1 +1 @@ -version = '0.11.0' +version = '0.12.0'