From 4973dfc888c911dfea89c885cc487c212aafc867 Mon Sep 17 00:00:00 2001 From: chesnutcase Date: Wed, 19 Jun 2019 16:30:54 +0800 Subject: [PATCH 01/48] remove extra fields in default generalsettings --- elasticroute/defaults.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/elasticroute/defaults.py b/elasticroute/defaults.py index 22ddffb..11ee01f 100644 --- a/elasticroute/defaults.py +++ b/elasticroute/defaults.py @@ -3,9 +3,6 @@ generalSettings = { 'country': 'SG', 'timezone': 'Asia/Singapore', - 'map': 'OpenStreetMap', - 'map_api_key': None, - 'route_osm': 0, 'loading_time': 20, 'buffer': 40, 'service_time': 5, @@ -14,7 +11,6 @@ 'max_distance': None, 'max_stops': 30, 'max_runs': 1, - 'exclude_groups': None, 'avail_from': 900, 'avail_till': 1700, 'webhook_url': None, From 7ab5e9f49a0dfdae6621d577fa72e1d16b970e5c Mon Sep 17 00:00:00 2001 From: chesnutcase Date: Tue, 15 Oct 2019 03:15:06 +0800 Subject: [PATCH 02/48] update avail_from/to fields in generalSettings --- elasticroute/client_models.py | 8 ++++---- elasticroute/defaults.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/elasticroute/client_models.py b/elasticroute/client_models.py index b031467..1182ed6 100644 --- a/elasticroute/client_models.py +++ b/elasticroute/client_models.py @@ -42,9 +42,9 @@ def isfloatish(s): if isinstance(stop["lng"], str): self.stops[i]["lng"] = float(stop["lng"]) if stop["from"] is None or len(str(stop["from"]).strip()) == 0: - self.stops[i]["from"] = self.generalSettings["avail_from"] + self.stops[i]["from"] = self.generalSettings["from"] if stop["till"] is None or len(str(stop["till"]).strip()) == 0: - self.stops[i]["till"] = self.generalSettings["avail_till"] + self.stops[i]["till"] = self.generalSettings["till"] if stop["depot"] is None or len(str(stop["depot"]).strip()) == 0: self.stops[i]["depot"] = self.depots[0]["name"] for i in range(len(self.vehicles)): @@ -53,9 +53,9 @@ def isfloatish(s): self.vehicles[i] = Vehicle(vehicle) vehicle = self.vehicles[i] if vehicle["avail_from"] is None or len(str(vehicle["avail_from"])) == 0: - self.vehicles[i]["avail_from"] = self.generalSettings["avail_from"] + self.vehicles[i]["avail_from"] = self.generalSettings["from"] if vehicle["avail_till"] is None or len(str(vehicle["avail_till"])) == 0: - self.vehicles[i]["avail_till"] = self.generalSettings["avail_till"] + self.vehicles[i]["avail_till"] = self.generalSettings["till"] if vehicle["depot"] is None or len(str(vehicle["depot"]).strip()) == 0: self.vehicles[i]["depot"] = self.depots[0]["name"] diff --git a/elasticroute/defaults.py b/elasticroute/defaults.py index 11ee01f..e56a4ff 100644 --- a/elasticroute/defaults.py +++ b/elasticroute/defaults.py @@ -11,7 +11,7 @@ 'max_distance': None, 'max_stops': 30, 'max_runs': 1, - 'avail_from': 900, - 'avail_till': 1700, + 'from': 900, + 'till': 1700, 'webhook_url': None, } From 58b93d185ff7cd34394457a255cabc1d4e915b0d Mon Sep 17 00:00:00 2001 From: chesnutcase Date: Tue, 15 Oct 2019 10:00:20 +0800 Subject: [PATCH 03/48] add bean class and unit tests --- elasticroute/common.py | 54 +++++++++ elasticroute/warnings/bean.py | 9 ++ tests/unit/test_bean.py | 212 ++++++++++++++++++++++++++++++++++ 3 files changed, 275 insertions(+) create mode 100644 elasticroute/common.py create mode 100644 elasticroute/warnings/bean.py create mode 100644 tests/unit/test_bean.py diff --git a/elasticroute/common.py b/elasticroute/common.py new file mode 100644 index 0000000..54bbb36 --- /dev/null +++ b/elasticroute/common.py @@ -0,0 +1,54 @@ +import warnings +import copy +from .warnings.bean import NonStringKeyUsed + + +class Bean(): + default_data = {} + + def __init__(self, data={}): + # repair data + if not hasattr(self, 'data') or type(self.data) is not dict: + self.data = dict() + # recursively merge default datas + for c in self.__class__.mro()[::-1][1:]: + self.data = {**self.data, **c.default_data} + # repair modified data keys + if not hasattr(self, 'modified_data_keys') or type(self.modified_data_keys) is not set: + self.modified_data_keys = set() + self.modified_data_keys = set(self.default_data.keys()) + # finally, merge data items + for key, value in data.items(): + self[key] = value + + def __dict__(self): + return self.data + + def __getitem__(self, key): + if type(key) is not str: + warnings.warn(NonStringKeyUsed.message, NonStringKeyUsed, stacklevel=2) + return self.data[str(key)] + + def __setitem__(self, key, value): + if type(key) is not str: + warnings.warn(NonStringKeyUsed.message, NonStringKeyUsed, stacklevel=2) + if str(key) not in self.data or self.data[str(key)] != value: + self.modified_data_keys.add(str(key)) + self.data[str(key)] = value + + def __delitem__(self, key): + if type(key) is not str: + warnings.warn(NonStringKeyUsed.message, NonStringKeyUsed, stacklevel=2) + if str(key) in self.data: + self.modified_data_keys.add(str(key)) + del self.data[str(key)] + + def __iter__(self): + return iter(self.data.items()) + + def __str__(self): + return str(self.data) + + def __repr__(self): + return "{}({})".format(self.__class__, str(self.data)) + diff --git a/elasticroute/warnings/bean.py b/elasticroute/warnings/bean.py new file mode 100644 index 0000000..5bc6c0a --- /dev/null +++ b/elasticroute/warnings/bean.py @@ -0,0 +1,9 @@ +import warnings + + +class NonStringKeyUsed(UserWarning): + message = "WARNING: You tried to use non-string keys to access data members of this object. Keys are always converted to string in any data member access operation (using []), so this may have unexpected effects especially if you accidentally pass in a list or dictionary as the key. You might want to revise your code to ensure you only pass in strings." + + def __str__(self): + return self.message + pass diff --git a/tests/unit/test_bean.py b/tests/unit/test_bean.py new file mode 100644 index 0000000..e16b175 --- /dev/null +++ b/tests/unit/test_bean.py @@ -0,0 +1,212 @@ +# this module tests the bean abstract class functionality to ensure that all child classes function as expected +import unittest +import warnings +import pytest +from elasticroute.common import Bean +from elasticroute.warnings.bean import NonStringKeyUsed + + +class BeanTestConstructor(unittest.TestCase): + # this class tests various functionalities of the constructor + + # tests that, when super constructor is called at the child constructor tail, unset data attribute will be reset + def test_will_repair_data_attribute_when_unset(self): + class BadBeanBag(Bean): + def __init__(self, data={}): + super().__init__() + + bag = BadBeanBag() + self.assertEqual(dict(), bag.data) + + # tests that, when super constructor is called at the child constructor tail, non-set data attribute will be reset + def test_will_repair_data_attribute_when_not_dict(self): + class BadBeanBag(Bean): + def __init__(self, data={}): + self.data = None + super().__init__() + + bag = BadBeanBag() + self.assertEqual(dict(), bag.data) + + # tests that, when super constructor is called at the child constructor tail, unset modified_data_keys attribute will be reset + def test_will_repair_modified_data_keys_attribute_when_unset(self): + class BadBeanBag(Bean): + def __init__(self, data={}): + super().__init__() + + bag = BadBeanBag() + self.assertEqual(set(), bag.modified_data_keys) + + # tests that, when super constructor is called at the child constructor tail, non-set modified_data_keys attribute will be reset + def test_will_repair_modified_data_keys_attribute_when_not_dict(self): + class BadBeanBag(Bean): + def __init__(self, data={}): + self.modified_data_keys = None + super().__init__() + + bag = BadBeanBag() + self.assertEqual(set(), bag.modified_data_keys) + + # tests that the constructor accepts a dictionary to save as the data + def test_constructor_passes_dictionary_arg_to_data(self): + data = { + "hello": "world", + "apa": "ini" + } + b = Bean(data) + self.assertEqual(data, b.data) + + # tests that that the data that the constructor takes in is also logged to modified_data_keys + def test_constructor_touches_modified_data_keys(self): + data = { + "hello": "world", + "apa": "ini" + } + b = Bean(data) + self.assertEqual(set(data.keys()), b.modified_data_keys) + + # tests that when this class is subclassed, it will look for a default_data class attribute to fill as the default within the constructor + def test_subclass_can_inherit_defaults(self): + class BeanBag(Bean): + default_data = { + "hello": "world" + } + + b = BeanBag() + self.assertEqual("world", b["hello"]) + + # tests that arguments passed to bean subclass constructors can override the default data values specfied in the constructor + def test_subclass_constructor_can_override_defaults(self): + class BeanBag(Bean): + default_data = { + "hello": "world", + "apa": "ini" + } + data = { + "hello": "sekai" + } + b = BeanBag(data) + self.assertEqual("sekai", b["hello"]) + self.assertEqual("ini", b["apa"]) + + # tests that, if sub-subclassed, the defaults of the sub-subclass precede the defaults of the subclass + def test_sub_subclass_default_precedence(self): + + class BeanBag(Bean): + default_data = { + "hello": "world", + "apa": "ini" + } + + class BeanBagBag(BeanBag): + default_data = { + "hello": "sekai" + } + data = { + "foo": "bar" + } + b = BeanBagBag(data) + self.assertEqual("sekai", b["hello"]) + self.assertEqual("ini", b["apa"]) + self.assertEqual("bar", b["foo"]) + + +class BeanTestDataAccess(unittest.TestCase): + # this class tests various functionalities of the data access operator, i.e. bean[index] + + # tests that we can do bean[index] = value + def test_can_set_item(self): + b = Bean() + b["hello"] = "world" + self.assertIs("world", b.data["hello"]) + + # tests that we can do bean[index] to retrieve value + def test_can_get_item(self): + b = Bean() + b.data["hello"] = "world" + self.assertIs("world", b["hello"]) + + # tests that we can do del bean[index] to delete value entirely + def test_can_del_item(self): + b = Bean() + b.data["hello"] = "world" + del b["hello"] + self.assertNotIn("hello", b) + + # test keys are always treated as strings + @pytest.mark.filterwarnings("ignore::elasticroute.warnings.bean.NonStringKeyUsed") + def test_access_keys_are_treated_as_strings(self): + b = Bean() + b["420"] = "hello" + b[420] = "world" + self.assertIs("world", b["420"]) + self.assertIs("world", b[420]) + self.assertTrue(420 not in b.data) + + # tests that warning is issued when using non string keys for data access operator + def test_warning_raised_when_using_non_string_keys(self): + b = Bean() + b.data["999"] = "hello" + with pytest.warns(NonStringKeyUsed): + a = b[999] + with pytest.warns(NonStringKeyUsed): + b[420] = "world" + + # tests that setting keys will cause them to be added to modified_data_keys + def test_can_track_keys_that_were_changed(self): + b = Bean() + b["hello"] = "world" + self.assertIn("hello", b.modified_data_keys) + + # tests that deleting keys will cause them to be removed from modified_data_keys + def test_can_untrack_keys_that_were_deleted(self): + b = Bean() + b["hello"] = "world" + del b["hello"] + + +class BeanTestDataSerialization(unittest.TestCase): + # this class tests various functionalities of the bean's serialization methods + + # test that calling __dict__ simply returns the data + def test_dict(self): + data = { + "hello": "world", + "apa": "ini" + } + b = Bean(data) + self.assertEqual(data, b.__dict__()) + + # test that calling __str__ returns the string representation of self.data + def test_str(self): + data = { + "hello": "world", + "apa": "ini" + } + b = Bean(data) + self.assertEqual(str(data), str(b)) + + # test that calling repr creates a representative format of itself + def test_repr(self): + data = { + "hello": "world", + "apa": "ini" + } + b = Bean(data) + result = eval("bb", {'__builtins__': None}, {'bb': b}) + self.assertIs(Bean, type(result)) + self.assertEqual(b.data, result.data) + + # test that calling repr creates a representative format of itself, even if it is subclassed + def test_repr_subclass(self): + class BeanBag(Bean): + foo = "bar" + data = { + "hello": "world", + "apa": "ini" + } + b = BeanBag(data) + result = eval("bb", {'__builtins__': None}, {'bb': b}) + self.assertIs(BeanBag, type(result)) + self.assertEqual(b.data, result.data) + self.assertEqual("bar", result.foo) From 20d512654e6362b7547abf72ec797a264b81b7ff Mon Sep 17 00:00:00 2001 From: chesnutcase Date: Tue, 15 Oct 2019 11:22:54 +0800 Subject: [PATCH 04/48] add readonly capabilities and warnings for bean --- elasticroute/common.py | 16 ++++++++++-- elasticroute/warnings/bean.py | 8 +++++- tests/unit/test_bean.py | 48 ++++++++++++++++++++++++++++++++++- 3 files changed, 68 insertions(+), 4 deletions(-) diff --git a/elasticroute/common.py b/elasticroute/common.py index 54bbb36..c623547 100644 --- a/elasticroute/common.py +++ b/elasticroute/common.py @@ -1,18 +1,22 @@ import warnings -import copy -from .warnings.bean import NonStringKeyUsed +from .warnings.bean import NonStringKeyUsed, ResultKeyModified class Bean(): default_data = {} + result_data_keys = set() def __init__(self, data={}): + # throw exception if data doesn't look like a dict + if type(data) is not dict: + raise TypeError("Invalid data type received in constructor – expected dict, found{}".format(type(data))) # repair data if not hasattr(self, 'data') or type(self.data) is not dict: self.data = dict() # recursively merge default datas for c in self.__class__.mro()[::-1][1:]: self.data = {**self.data, **c.default_data} + self.result_data_keys = {*self.result_data_keys, *c.result_data_keys} # repair modified data keys if not hasattr(self, 'modified_data_keys') or type(self.modified_data_keys) is not set: self.modified_data_keys = set() @@ -32,10 +36,18 @@ def __getitem__(self, key): def __setitem__(self, key, value): if type(key) is not str: warnings.warn(NonStringKeyUsed.message, NonStringKeyUsed, stacklevel=2) + if str(key) in self.result_data_keys: + warnings.warn(ResultKeyModified.message, ResultKeyModified, stacklevel=2) + return if str(key) not in self.data or self.data[str(key)] != value: self.modified_data_keys.add(str(key)) self.data[str(key)] = value + def set_readonly_data(self, key, value): + if type(key) is not str: + warnings.warn(NonStringKeyUsed.message, NonStringKeyUsed, stacklevel=2) + self.data[str(key)] = value + def __delitem__(self, key): if type(key) is not str: warnings.warn(NonStringKeyUsed.message, NonStringKeyUsed, stacklevel=2) diff --git a/elasticroute/warnings/bean.py b/elasticroute/warnings/bean.py index 5bc6c0a..eef7607 100644 --- a/elasticroute/warnings/bean.py +++ b/elasticroute/warnings/bean.py @@ -6,4 +6,10 @@ class NonStringKeyUsed(UserWarning): def __str__(self): return self.message - pass + + +class ResultKeyModified(UserWarning): + message = "WARNING: You tried to modify a readonly data attribute using bean[readonly_index] = value. Doing so has no effect. If you really need to change the readonly attributes, please use the set_readonly_data method." + + def __str__(self): + return self.message diff --git a/tests/unit/test_bean.py b/tests/unit/test_bean.py index e16b175..5667b29 100644 --- a/tests/unit/test_bean.py +++ b/tests/unit/test_bean.py @@ -3,7 +3,7 @@ import warnings import pytest from elasticroute.common import Bean -from elasticroute.warnings.bean import NonStringKeyUsed +from elasticroute.warnings.bean import NonStringKeyUsed, ResultKeyModified class BeanTestConstructor(unittest.TestCase): @@ -164,6 +164,52 @@ def test_can_untrack_keys_that_were_deleted(self): b["hello"] = "world" del b["hello"] + # tests that in a subclass where result_data_keys are defined, warning is rasied when modification using these keys are attempted via setitem + def test_set_result_key_raises_warning(self): + class BeanBag(Bean): + result_data_keys = {"created_at", "updated_at"} + + data = { + "hello": "world", + "created_at": "today" + } + with pytest.warns(ResultKeyModified): + b = BeanBag(data) + + # tests that we can still set readonly result keys via set_readonly_data + def test_set_readonly_data(self): + class BeanBag(Bean): + result_data_keys = {"created_at", "updated_at"} + + data = { + "hello": "world" + } + b = BeanBag(data) + b.set_readonly_data("created_at", "today") + self.assertEqual("today", b.data["created_at"]) + + # tests that setting readonly data with non-string keys will also trigger warning + def test_set_readonly_data_with_non_string_keys_issues_warning(self): + class BeanBag(Bean): + result_data_keys = {"123", "updated_at"} + + b = BeanBag() + with pytest.warns(NonStringKeyUsed): + b.set_readonly_data(123, "456") + self.assertEqual("456", b.data["123"]) + + # tests we can still get readonly result keys with just getitem + def test_get_readonly_data(self): + class BeanBag(Bean): + result_data_keys = {"created_at", "updated_at"} + + data = { + "hello": "world" + } + b = BeanBag(data) + b.set_readonly_data("created_at", "today") + self.assertEqual("today", b["created_at"]) + class BeanTestDataSerialization(unittest.TestCase): # this class tests various functionalities of the bean's serialization methods From 0b9b273a79a05cfb70d461b91ac9f3704034ee1a Mon Sep 17 00:00:00 2001 From: chesnutcase Date: Tue, 15 Oct 2019 11:23:21 +0800 Subject: [PATCH 05/48] add data fields for all flavours of vehicles --- elasticroute/common.py | 21 +++++++++++++++++++++ elasticroute/dashboard.py | 9 +++++++++ elasticroute/routing.py | 9 +++++++++ 3 files changed, 39 insertions(+) create mode 100644 elasticroute/dashboard.py create mode 100644 elasticroute/routing.py diff --git a/elasticroute/common.py b/elasticroute/common.py index c623547..b3e78b0 100644 --- a/elasticroute/common.py +++ b/elasticroute/common.py @@ -64,3 +64,24 @@ def __str__(self): def __repr__(self): return "{}({})".format(self.__class__, str(self.data)) + +class Vehicle(Bean): + default_data = { + 'priority': None, + 'vehicle_types': None, + 'end_depot': None, + 'seating_capacity': None, + 'avail_from': 900, + 'avail_to': 1700, + 'volume_capacity': None, + 'service_radius': None, + 'buffer': None, + 'depot': None, + 'weight_capacity': None, + 'return_to_depot': None, + 'name': None, + } + + def __init__(self, data={}): + # the super constructor is called AFTER the defaults are set to allow overriding by the end user + super().__init__(data) diff --git a/elasticroute/dashboard.py b/elasticroute/dashboard.py new file mode 100644 index 0000000..4151f8e --- /dev/null +++ b/elasticroute/dashboard.py @@ -0,0 +1,9 @@ +from .common import Vehicle as BaseVehicle + + +class Vehicle(BaseVehicle): + # add dashboard only fields + # none atm + default_data = { + + } diff --git a/elasticroute/routing.py b/elasticroute/routing.py new file mode 100644 index 0000000..5173475 --- /dev/null +++ b/elasticroute/routing.py @@ -0,0 +1,9 @@ +from .common import Vehicle as BaseVehicle + + +class Vehicle(BaseVehicle): + # add routing-engine only fields + # none atm + default_data = { + + } From 1cadd6b292fb30054b0ad32b14b6acd34d046dc7 Mon Sep 17 00:00:00 2001 From: chesnutcase Date: Tue, 15 Oct 2019 11:38:23 +0800 Subject: [PATCH 06/48] add data fields for all flavours of stops --- elasticroute/common.py | 25 +++++++++++++++++++++++++ elasticroute/dashboard.py | 29 +++++++++++++++++++++++++++++ elasticroute/routing.py | 13 +++++++++++++ 3 files changed, 67 insertions(+) diff --git a/elasticroute/common.py b/elasticroute/common.py index b3e78b0..995fcbb 100644 --- a/elasticroute/common.py +++ b/elasticroute/common.py @@ -85,3 +85,28 @@ class Vehicle(Bean): def __init__(self, data={}): # the super constructor is called AFTER the defaults are set to allow overriding by the end user super().__init__(data) + + +class Stop(Bean): + default_data = { + 'priority': None, + 'postal_code': None, + 'preassign_to': None, + 'weight_load': None, + 'address': None, + 'volume_load': None, + 'depot': None, + 'lat': None, + 'lng': None, + 'seating_load': None, + 'name': None, + 'service_time': None, + 'vehicle_type': None, + } + result_data_keys = { + 'assign_to', + 'run', + 'eta', + 'sequence', + 'exception' + } diff --git a/elasticroute/dashboard.py b/elasticroute/dashboard.py index 4151f8e..343cf74 100644 --- a/elasticroute/dashboard.py +++ b/elasticroute/dashboard.py @@ -1,4 +1,5 @@ from .common import Vehicle as BaseVehicle +from .common import Stop as BaseStop class Vehicle(BaseVehicle): @@ -7,3 +8,31 @@ class Vehicle(BaseVehicle): default_data = { } + + +class Stop(BaseStop): + # add dashboard only fields + default_data = { + 'address_2': None, + 'group': None, + 'state': None, + 'time_window': None, + 'address_1': None, + 'city': None, + 'address_3': None, + 'load_id': None, + 'country': None, + } + + # add dashboard readonly fields + result_data_keys = { + 'plan_vehicle_type', + 'plan_depot', + 'plan_service_time' + 'sorted', + 'violations', + 'mapped_at', + 'planned_at', + 'created_at', + 'updated_at' + } diff --git a/elasticroute/routing.py b/elasticroute/routing.py index 5173475..be6535f 100644 --- a/elasticroute/routing.py +++ b/elasticroute/routing.py @@ -1,4 +1,5 @@ from .common import Vehicle as BaseVehicle +from .common import Stop as Stop class Vehicle(BaseVehicle): @@ -7,3 +8,15 @@ class Vehicle(BaseVehicle): default_data = { } + + +class Stop(BaseStop): + # add routing-engine only fields + default_data = { + 'from': 900, + 'till': 1700, + } + + # add routing-engine only readonly fields + # none atm + result_data_keys = {} From 786a7e8d3f1d9b7d17e6849d574ebf18454a5535 Mon Sep 17 00:00:00 2001 From: chesnutcase Date: Tue, 15 Oct 2019 11:50:52 +0800 Subject: [PATCH 07/48] remove constructor override in common vehicle --- elasticroute/common.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/elasticroute/common.py b/elasticroute/common.py index 995fcbb..2a10a94 100644 --- a/elasticroute/common.py +++ b/elasticroute/common.py @@ -82,10 +82,6 @@ class Vehicle(Bean): 'name': None, } - def __init__(self, data={}): - # the super constructor is called AFTER the defaults are set to allow overriding by the end user - super().__init__(data) - class Stop(Bean): default_data = { From 3908678aee8781ff70866871b22ff1a4cc717e29 Mon Sep 17 00:00:00 2001 From: chesnutcase Date: Thu, 17 Oct 2019 12:50:00 +0800 Subject: [PATCH 08/48] add data fields for all flavours of depots --- elasticroute/common.py | 10 ++++++++++ elasticroute/routing.py | 8 +++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/elasticroute/common.py b/elasticroute/common.py index 2a10a94..01e24d1 100644 --- a/elasticroute/common.py +++ b/elasticroute/common.py @@ -106,3 +106,13 @@ class Stop(Bean): 'sequence', 'exception' } + + +class Depot(Bean): + default_data = { + "name", + "lat", + "lng", + "address", + "postal_code" + } diff --git a/elasticroute/routing.py b/elasticroute/routing.py index be6535f..f78f04c 100644 --- a/elasticroute/routing.py +++ b/elasticroute/routing.py @@ -1,5 +1,6 @@ from .common import Vehicle as BaseVehicle -from .common import Stop as Stop +from .common import Stop as BaseStop +from .common import Depot as BaseDepot class Vehicle(BaseVehicle): @@ -20,3 +21,8 @@ class Stop(BaseStop): # add routing-engine only readonly fields # none atm result_data_keys = {} + + +class Depot(BaseDepot): + # no difference from base depot for now + pass From 641a8596c9dcbf54801f981bbb2aa0ec5a967de0 Mon Sep 17 00:00:00 2001 From: chesnutcase Date: Thu, 17 Oct 2019 12:50:35 +0800 Subject: [PATCH 09/48] add bean.get_full_default_data method and tests --- elasticroute/common.py | 8 ++++++++ tests/unit/test_bean.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/elasticroute/common.py b/elasticroute/common.py index 01e24d1..a2e5df4 100644 --- a/elasticroute/common.py +++ b/elasticroute/common.py @@ -64,6 +64,14 @@ def __str__(self): def __repr__(self): return "{}({})".format(self.__class__, str(self.data)) + @classmethod + def get_full_default_data(cls): + d = dict() + for c in cls.mro()[::-1][1:]: + if hasattr(c, "default_data"): + d = {**d, **c.default_data} + return d + class Vehicle(Bean): default_data = { diff --git a/tests/unit/test_bean.py b/tests/unit/test_bean.py index 5667b29..2266deb 100644 --- a/tests/unit/test_bean.py +++ b/tests/unit/test_bean.py @@ -256,3 +256,32 @@ class BeanBag(Bean): self.assertIs(BeanBag, type(result)) self.assertEqual(b.data, result.data) self.assertEqual("bar", result.foo) + + +class BeanTestMisc(unittest.TestCase): + # test misc functions of the bean + + # test whether sub-subclasses can view the entire default hierachy + def testCanSeeFullDefaultData(self): + class BeanBag(Bean): + default_data = { + "hello": "world", + "apa": "ini" + } + + class BeanBagBag(BeanBag): + default_data = { + "hello": "sekai" + } + b = BeanBagBag() + expected = { + "hello": "sekai", + "apa": "ini" + } + expected2 = { + "hello": "world", + "apa": "ini" + } + self.assertEqual(expected, b.get_full_default_data()) + self.assertEqual(expected, BeanBagBag.get_full_default_data()) + self.assertEqual(expected2, BeanBag.get_full_default_data()) From c1907d6dca10e189adc027e4e21331c613057f52 Mon Sep 17 00:00:00 2001 From: chesnutcase Date: Thu, 17 Oct 2019 15:03:26 +0800 Subject: [PATCH 10/48] add required_data_keys to bean --- elasticroute/common.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/elasticroute/common.py b/elasticroute/common.py index a2e5df4..ee2f73c 100644 --- a/elasticroute/common.py +++ b/elasticroute/common.py @@ -4,6 +4,7 @@ class Bean(): default_data = {} + required_data_keys = set() result_data_keys = set() def __init__(self, data={}): @@ -17,10 +18,11 @@ def __init__(self, data={}): for c in self.__class__.mro()[::-1][1:]: self.data = {**self.data, **c.default_data} self.result_data_keys = {*self.result_data_keys, *c.result_data_keys} + self.required_data_keys = {*self.required_data_keys, *c.required_data_keys} # repair modified data keys if not hasattr(self, 'modified_data_keys') or type(self.modified_data_keys) is not set: self.modified_data_keys = set() - self.modified_data_keys = set(self.default_data.keys()) + self.modified_data_keys = set(data.keys()) # finally, merge data items for key, value in data.items(): self[key] = value @@ -89,6 +91,9 @@ class Vehicle(Bean): 'return_to_depot': None, 'name': None, } + required_data_keys = { + 'name' + } class Stop(Bean): @@ -107,6 +112,12 @@ class Stop(Bean): 'service_time': None, 'vehicle_type': None, } + required_data_keys = { + 'name', + 'address', + 'lat', + 'lng' + } result_data_keys = { 'assign_to', 'run', From 044814628e3ed27f9ee5ded831d58ea293e3626e Mon Sep 17 00:00:00 2001 From: chesnutcase Date: Thu, 17 Oct 2019 15:03:59 +0800 Subject: [PATCH 11/48] add basic bean serializers and tests --- elasticroute/serializers.py | 80 +++++++++++++++++++++++++++ tests/unit/test_bean_serialization.py | 43 ++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 elasticroute/serializers.py create mode 100644 tests/unit/test_bean_serialization.py diff --git a/elasticroute/serializers.py b/elasticroute/serializers.py new file mode 100644 index 0000000..775eb79 --- /dev/null +++ b/elasticroute/serializers.py @@ -0,0 +1,80 @@ +from .common import Bean +from .common import Stop as BaseStop +from .dashboard import Stop as DashboardStop +from .routing import Stop as RoutingStop + + +class Serializer(): + def __init__(self, *, vanilla_keys_only=True, modified_keys_only=True): + self.vanilla_keys_only = vanilla_keys_only + self.modified_keys_only = modified_keys_only + + def to_dict(self, obj): + pass + + +class BeanSerializer(): + def __init__(self, *, vanilla_keys_only=True, modified_keys_only=True): + self.vanilla_keys_only = vanilla_keys_only + self.modified_keys_only = modified_keys_only + + def to_dict(self, obj): + if isinstance(obj, Bean): + # first get the normal dict + d = obj.__dict__() + + def should_include_entry(k, v, obj): + r = k in obj.required_data_keys + m = not self.modified_keys_only or k in obj.modified_data_keys + v = not self.vanilla_keys_only or k in obj.get_full_default_data().keys() + print(k, r, m, v) + return r or (m and v) + + return {k: v for (k, v) in d.items() if should_include_entry(k, v, obj)} + else: + return d + + +class StopSerializer(Serializer): + def to_dict(self, obj): + if isinstance(obj, BaseStop): + # the superclass is capable of processing this + return super().to_dict(obj) + elif type(obj) is dict: + d = obj + # decide whether to remove non vanilla keys + if self.vanilla_keys_only: + d = {k: v for (k, v) in d.items() if k in BaseStop.get_full_default_data().keys()} + return d + else: + raise TypeError("Invalid data type passed to to_dict: expected {} or dict, received {}".format(DashboardStop, type(obj))) + + +class DashboardStopSerializer(StopSerializer): + def to_dict(self, obj): + if type(obj) is DashboardStop: + # the superclass is capable of processing this + return super().to_dict(obj) + elif type(obj) is dict: + d = obj + # decide whether to remove non vanilla keys + if self.vanilla_keys_only: + d = {k: v for (k, v) in d.items() if k in DashboardStop.get_full_default_data().keys()} + return d + else: + raise TypeError("Invalid data type passed to to_dict: expected {} or dict, received {}".format(DashboardStop, type(obj))) + + +class RoutingStopSerializer(StopSerializer): + def to_dict(self, obj): + if type(obj) is RoutingStop: + # the superclass is capable of processing this + return super().to_dict(obj) + elif type(obj) is dict: + d = obj + # decide whether to remove non vanilla keys + if self.vanilla_keys_only: + d = {k: v for (k, v) in d.items() if k in RoutingStop.get_full_default_data().keys()} + return d + else: + raise TypeError("Invalid data type passed to to_dict: expected {} or dict, received {}".format(DashboardStop, type(obj))) diff --git a/tests/unit/test_bean_serialization.py b/tests/unit/test_bean_serialization.py new file mode 100644 index 0000000..1cf7237 --- /dev/null +++ b/tests/unit/test_bean_serialization.py @@ -0,0 +1,43 @@ +import unittest +import warnings +import pytest +from elasticroute.common import Bean +from elasticroute.routing import Stop +from elasticroute.serializers import BeanSerializer, StopSerializer + + +class BeanSerilizationTest(unittest.TestCase): + def test_default_settings_respect_only_keep_vanilla_keys_and_modified_keys(self): + return + + class BeanBag(Bean): + default_data = { + "hello": "world", + "apa": "ini" + } + b = BeanBag({ + "foo": "bar" + }) + s = BeanSerializer() + d = s.to_json(b) + self.assertEquals(BeanBag.default_data.keys(), d.keys()) + self.assertFalse("foo" in d.keys()) + + def test_stop(self): + data = { + "depot": "hello", + "apa": "ini" + } + expected_data = { + "name": None, + "address": None, + "lat": None, + "lng": None, + "depot": "hello" + } + stop = Stop(data) + s = StopSerializer() + print(stop.modified_data_keys) + print(stop.required_data_keys) + d = s.to_dict(stop) + self.assertEquals(expected_data, d) From 972fc27c6a6e71f2c160af385a2840f57a930ef1 Mon Sep 17 00:00:00 2001 From: chesnutcase Date: Thu, 17 Oct 2019 15:04:28 +0800 Subject: [PATCH 12/48] add safe .get retrieval method on bean --- elasticroute/common.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/elasticroute/common.py b/elasticroute/common.py index ee2f73c..d3590d4 100644 --- a/elasticroute/common.py +++ b/elasticroute/common.py @@ -35,6 +35,11 @@ def __getitem__(self, key): warnings.warn(NonStringKeyUsed.message, NonStringKeyUsed, stacklevel=2) return self.data[str(key)] + def get(self, key): + if type(key) is not str: + warnings.warn(NonStringKeyUsed.message, NonStringKeyUsed, stacklevel=2) + return self.data.get(str(key), None) + def __setitem__(self, key, value): if type(key) is not str: warnings.warn(NonStringKeyUsed.message, NonStringKeyUsed, stacklevel=2) From 4626c1ec02235ae5e215e722a731e4df192619f2 Mon Sep 17 00:00:00 2001 From: chesnutcase Date: Thu, 17 Oct 2019 15:05:09 +0800 Subject: [PATCH 13/48] add basic validators --- elasticroute/exceptions/validator.py | 14 +++++ elasticroute/validators.py | 86 ++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 elasticroute/exceptions/validator.py create mode 100644 elasticroute/validators.py diff --git a/elasticroute/exceptions/validator.py b/elasticroute/exceptions/validator.py new file mode 100644 index 0000000..6c9faff --- /dev/null +++ b/elasticroute/exceptions/validator.py @@ -0,0 +1,14 @@ +import sys +from pprint import pformat + + +class BadFieldError(Exception): + def __init__(self, message, dump=None): + self.message = message + self.dump = dump + + def __str__(self): + try: + return str(self.message) + str(self.dump) + except Exception as e: + return "Unexpected error:", sys.exc_info()[0] diff --git a/elasticroute/validators.py b/elasticroute/validators.py new file mode 100644 index 0000000..8ff7178 --- /dev/null +++ b/elasticroute/validators.py @@ -0,0 +1,86 @@ +from .exceptions.validator import BadFieldError + + +def not_null_or_ws_str(string): + if type(string) is not str: + return False + if string.strip() == "": + return False + return True + + +def floaty_number_or_string(input): + if type(input) is int or type(input) is float: + return True + elif type(input) is str: + if not not_null_or_ws_str(input): + return False + try: + f = float(str) + except ValueError, TypeError: + return False + return True + else: + return False + + +def inty_number_or_string(input): + if type(input) is int: + return True + elif type(input) is str: + if not not_null_or_ws_str(input): + return False + try: + f = float(str) + except ValueError, TypeError: + return False + return True + + +class Validator(): + + single_object_rules = { + + } + + @classmethod + def validate_object(cls, obj): + for rulename, rulefunction in cls.single_object_rules.items(): + if not rulefunction(obj): + raise BadFieldError("Validation Failed for Validator {}, rule: {}".format(cls, rulename)) + return True + + +class StopValidator(Validator): + + single_object_rules = { + "name is not null or empty string": lambda o: not_null_or_ws_str(o["name"]), + "either address or lat/lng is present": lambda o: not_null_or_ws_str(o["address"]) or (floaty_number_or_string(o["lat"]) and floaty_number_or_string(o["lng"])), + "if present, priority must be a valid whole number representation": lambda o: inty_number_or_string(o["priority"]) if o.get("priority") is not None else True, + "if present, preassign_to must be a valid string": lambda o: not_null_or_ws_str(o["preassign_to"]) if o.get("preassign_to") is not None else True, + "if present, weight_load must be a valid number representation": lambda o: floaty_number_or_string(o["weight_load"]) if o.get("weight_load") is not None else True, + "if present, volume_load must be a valid number representation": lambda o: floaty_number_or_string(o["volume_load"]) if o.get("volume_load") is not None else True, + "if present, seating_load must be a valid number representation": lambda o: floaty_number_or_string(o["seating_load"]) if o.get("seating_load") is not None else True, + "if present, depot must be a valid string": lambda o: not_null_or_ws_str(o["depot"]) if o.get("depot") is not None else True, + "if present, service time must be a valid whole number representation": lambda o: inty_number_or_string(o["service_time"]) if o.get("service_time") is not None else True, + "if present, vehicle_type must be a valid string": lambda o: not_null_or_ws_str(o["vehicle_type"]) if o.get("vehicle_type") is not None else True, + } + + +class VehicleValidator(Validator): + + single_object_rules = { + "name is not null or empty string": lambda o: not_null_or_ws_str(o["name"])", + "if present, priority must be a valid whole number representation": lambda o: inty_number_or_string(o["priority"]) if o.get("priority") is not None else True, + "if present, depot must be a valid string": lambda o: not_null_or_ws_str(o["depot"]) if o.get("depot") is not None else True, + "if present, end_depot must be a valid string": lambda o: not_null_or_ws_str(o["end_depot"]) if o.get("end_depot") is not None else True, + "if present, vehicle_types must be a list of strings": lambda o: (type(o["vehicle_types"]) is list and all([type(t) is str for t in o["vehicle_types"]])) if o.get("vehicle_types") is not None else True, + "if present, weight_capacity must be a valid number representation": lambda o: floaty_number_or_string(o["weight_capacity"]) if o.get("weight_capacity") is not None else True, + "if present, volume_capacity must be a valid number representation": lambda o: floaty_number_or_string(o["volume_capacity"]) if o.get("volume_capacity") is not None else True, + "if present, seating_capacity must be a valid number representation": lambda o: floaty_number_or_string(o["seating_capacity"]) if o.get("seating_capacity") is not None else True, + "if present, service_radius must be a valid whole number representation": lambda o: inty_number_or_string(o["service_radius"]) if o.get("service_radius") is not None else True, + "if present, buffer must be a valid whole number representation": lambda o: inty_number_or_string(o["buffer"]) if o.get("buffer") is not None else True, + "if present, return_to_depot must be a boolean": lambda o: type(o["return_to_depot"]) is bool if o.get("return_to_depot") is not None else True, + "if present, avail_from must be a valid whole number representation from 0 to 2359": lambda o: (is_inty_number_or_string(o["avail_from"]) and int(o["avail_from"]) >= 0 and int(o["avail_from"]) <= 2359) if o.get("avail_from") is not None else True, + "if present, avail_to must be a valid whole number representation from 0 to 2359": lambda o: (is_inty_number_or_string(o["avail_to"]) and int(o["avail_to"]) >= 0 and int(o["avail_to"]) <= 2359) if o.get("avail_to") is not None else True, + } From d33d8069ce370f114f9dbd4bd8b5c16873f565dd Mon Sep 17 00:00:00 2001 From: chesnutcase Date: Wed, 23 Oct 2019 05:48:55 +0800 Subject: [PATCH 14/48] add get_full_results_data method --- elasticroute/common.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/elasticroute/common.py b/elasticroute/common.py index d3590d4..d2b486e 100644 --- a/elasticroute/common.py +++ b/elasticroute/common.py @@ -79,6 +79,14 @@ def get_full_default_data(cls): d = {**d, **c.default_data} return d + @classmethod + def get_full_result_data_keys(cls): + d = dict() + for c in cls.mro()[::-1][1:]: + if hasattr(c, "result_data_keys"): + d = {*d, *c.result_data_keys} + return d + class Vehicle(Bean): default_data = { From 26c98b27ccb067fce00321fab8a790996f688955 Mon Sep 17 00:00:00 2001 From: chesnutcase Date: Wed, 23 Oct 2019 05:53:21 +0800 Subject: [PATCH 15/48] fixed wrong inheritance and remove print statement --- elasticroute/serializers.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/elasticroute/serializers.py b/elasticroute/serializers.py index 775eb79..dbfe429 100644 --- a/elasticroute/serializers.py +++ b/elasticroute/serializers.py @@ -27,7 +27,6 @@ def should_include_entry(k, v, obj): r = k in obj.required_data_keys m = not self.modified_keys_only or k in obj.modified_data_keys v = not self.vanilla_keys_only or k in obj.get_full_default_data().keys() - print(k, r, m, v) return r or (m and v) return {k: v for (k, v) in d.items() if should_include_entry(k, v, obj)} @@ -35,7 +34,7 @@ def should_include_entry(k, v, obj): return d -class StopSerializer(Serializer): +class StopSerializer(BeanSerializer): def to_dict(self, obj): if isinstance(obj, BaseStop): # the superclass is capable of processing this From 521dda77d8d276c07d69f700b4d6b8c23f2c9bf5 Mon Sep 17 00:00:00 2001 From: chesnutcase Date: Wed, 23 Oct 2019 05:55:54 +0800 Subject: [PATCH 16/48] move exceptions directory to errors because reasons --- elasticroute/{exceptions => errors}/validator.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename elasticroute/{exceptions => errors}/validator.py (100%) diff --git a/elasticroute/exceptions/validator.py b/elasticroute/errors/validator.py similarity index 100% rename from elasticroute/exceptions/validator.py rename to elasticroute/errors/validator.py From d231b33497565f60087f57f31934e686e2a98aa2 Mon Sep 17 00:00:00 2001 From: chesnutcase Date: Wed, 23 Oct 2019 10:16:25 +0800 Subject: [PATCH 17/48] fix syntax error in validator --- elasticroute/validators.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/elasticroute/validators.py b/elasticroute/validators.py index 8ff7178..fc0190a 100644 --- a/elasticroute/validators.py +++ b/elasticroute/validators.py @@ -16,8 +16,8 @@ def floaty_number_or_string(input): if not not_null_or_ws_str(input): return False try: - f = float(str) - except ValueError, TypeError: + float(str) + except (ValueError, TypeError): return False return True else: @@ -31,8 +31,8 @@ def inty_number_or_string(input): if not not_null_or_ws_str(input): return False try: - f = float(str) - except ValueError, TypeError: + float(str) + except (ValueError, TypeError): return False return True @@ -70,7 +70,7 @@ class StopValidator(Validator): class VehicleValidator(Validator): single_object_rules = { - "name is not null or empty string": lambda o: not_null_or_ws_str(o["name"])", + "name is not null or empty string": lambda o: not_null_or_ws_str(o["name"]), "if present, priority must be a valid whole number representation": lambda o: inty_number_or_string(o["priority"]) if o.get("priority") is not None else True, "if present, depot must be a valid string": lambda o: not_null_or_ws_str(o["depot"]) if o.get("depot") is not None else True, "if present, end_depot must be a valid string": lambda o: not_null_or_ws_str(o["end_depot"]) if o.get("end_depot") is not None else True, @@ -81,6 +81,6 @@ class VehicleValidator(Validator): "if present, service_radius must be a valid whole number representation": lambda o: inty_number_or_string(o["service_radius"]) if o.get("service_radius") is not None else True, "if present, buffer must be a valid whole number representation": lambda o: inty_number_or_string(o["buffer"]) if o.get("buffer") is not None else True, "if present, return_to_depot must be a boolean": lambda o: type(o["return_to_depot"]) is bool if o.get("return_to_depot") is not None else True, - "if present, avail_from must be a valid whole number representation from 0 to 2359": lambda o: (is_inty_number_or_string(o["avail_from"]) and int(o["avail_from"]) >= 0 and int(o["avail_from"]) <= 2359) if o.get("avail_from") is not None else True, - "if present, avail_to must be a valid whole number representation from 0 to 2359": lambda o: (is_inty_number_or_string(o["avail_to"]) and int(o["avail_to"]) >= 0 and int(o["avail_to"]) <= 2359) if o.get("avail_to") is not None else True, + "if present, avail_from must be a valid whole number representation from 0 to 2359": lambda o: (inty_number_or_string(o["avail_from"]) and int(o["avail_from"]) >= 0 and int(o["avail_from"]) <= 2359) if o.get("avail_from") is not None else True, + "if present, avail_to must be a valid whole number representation from 0 to 2359": lambda o: (inty_number_or_string(o["avail_to"]) and int(o["avail_to"]) >= 0 and int(o["avail_to"]) <= 2359) if o.get("avail_to") is not None else True, } From bab4f00549f3e484177fc94a9df945a3ae872f66 Mon Sep 17 00:00:00 2001 From: chesnutcase Date: Wed, 23 Oct 2019 10:16:57 +0800 Subject: [PATCH 18/48] add old_name property to DashboardStop --- elasticroute/dashboard.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/elasticroute/dashboard.py b/elasticroute/dashboard.py index 343cf74..c9247f3 100644 --- a/elasticroute/dashboard.py +++ b/elasticroute/dashboard.py @@ -36,3 +36,14 @@ class Stop(BaseStop): 'created_at', 'updated_at' } + + def __init__(self, data={}): + super().__init__(data) + self.__old_name = None + + def __setitem__(self, k, v): + if k == "name" and self["name"] != v: + self.__old_name = str(self["name"]) + super().__setitem__(k, v) + + old_name = property(lambda self: self.__old_name) From 3dd33f503a72e5cf68462a81f9f7fc2f5d90c891 Mon Sep 17 00:00:00 2001 From: chesnutcase Date: Wed, 23 Oct 2019 10:17:20 +0800 Subject: [PATCH 19/48] add stop deserializers --- elasticroute/deserializers.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 elasticroute/deserializers.py diff --git a/elasticroute/deserializers.py b/elasticroute/deserializers.py new file mode 100644 index 0000000..ab984cb --- /dev/null +++ b/elasticroute/deserializers.py @@ -0,0 +1,34 @@ +from .common import Bean +from .common import Stop as BaseStop +from .dashboard import Stop as DashboardStop +from .routing import Stop as RoutingStop + + +class Deserializer(): + def from_dict(self, d): + pass + + +class BeanDeserializer(Deserializer): + target_class = Bean + + def from_dict(self, d): + to_pass_to_constructor = {k: v for (k, v) in d.items() if k not in self.target_class.get_full_result_data_keys()} + result_items = {k: v for (k, v) in d.items() if k in self.target_class.get_full_result_data_keys()} + b = self.target_class(to_pass_to_constructor) + for k, v in result_items.items(): + b.set_readonly_data(k, v) + b.modified_data_keys = set() + return b + + +class StopDeserializer(BeanDeserializer): + target_class = BaseStop + + +class DashboardStopDeserializer(StopDeserializer): + target_class = DashboardStop + + +class RoutingStopDeserializer(StopDeserializer): + target_class = RoutingStop From b4c8535fb25e4a9dfdcc9a18e10c0a38ae2b9cb0 Mon Sep 17 00:00:00 2001 From: chesnutcase Date: Wed, 23 Oct 2019 10:19:04 +0800 Subject: [PATCH 20/48] add stop repository --- elasticroute/errors/repository.py | 14 +++ elasticroute/repositories.py | 203 ++++++++++++++++++++++++++++++ 2 files changed, 217 insertions(+) create mode 100644 elasticroute/errors/repository.py create mode 100644 elasticroute/repositories.py diff --git a/elasticroute/errors/repository.py b/elasticroute/errors/repository.py new file mode 100644 index 0000000..a7b40b5 --- /dev/null +++ b/elasticroute/errors/repository.py @@ -0,0 +1,14 @@ +import sys + + +class ERServiceException(Exception): + def __init__(self, message, dump=None, code=None): + self.message = message + self.dump = dump + self.code = code + + def __str__(self): + try: + return "Status Code {} from ER API: {}".format(self.code, self.message) + except Exception: + return "Unexpected error:", sys.exc_info()[0] diff --git a/elasticroute/repositories.py b/elasticroute/repositories.py new file mode 100644 index 0000000..bcd8b5e --- /dev/null +++ b/elasticroute/repositories.py @@ -0,0 +1,203 @@ +import requests +from datetime import datetime + +from .errors.repository import ERServiceException + + +def validate_date(date_text): + try: + if date_text != datetime.strptime(date_text, "%Y-%m-%d").strftime('%Y-%m-%d'): + raise ValueError + return True + except ValueError: + return False + + +class Repository(): + path = "" + client = None + + def __init__(self, serializer=None, client=None, deserializer=None): + self.serializer = serializer + self.deserializer = deserializer + self.client = client + self.request_args = {} + pass + + def resolve_create_path(self, obj): + return self.path + + def resolve_retrieve_path(self, obj): + return self.path + + def resolve_update_path(self, obj): + return self.path + + def resolve_delete_path(self, obj): + return self.path + + def resolve_default_headers(self): + headers = { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer {}'.format(self.client.api_key), + 'X-Client-Lib': 'Python' + } + return headers + + def resolve_create_headers(self, obj, **kwargs): + return self.resolve_default_headers() + + def resolve_retrieve_headers(self, obj, **kwargs): + return self.resolve_default_headers() + + def resolve_update_headers(self, obj, **kwargs): + return self.resolve_default_headers() + + def resolve_delete_headers(self, obj, **kwargs): + return self.resolve_default_headers() + + def resolve_create_body(self, obj, **kwargs): + return { + 'data': self.serializer.to_dict(obj) + } + + def resolve_retrieve_body(self, obj, **kwargs): + return {} + + def resolve_update_body(self, obj, **kwargs): + return { + 'data': self.serializer.to_dict(obj) + } + + def resolve_delete_body(self, obj, **kwargs): + return {} + + def create(self, obj, **kwargs): + if self.client.api_key is None or self.client.api_key.strip() == "": + raise ValueError("API Key is not set") + + response = requests.post(self.resolve_create_path(obj, **kwargs), + json=self.resolve_create_body(obj, **kwargs), + headers=self.resolve_create_headers(obj, **kwargs), + **self.request_args + ) + + if str(response.status_code)[0] != "2": + message = response.json().get("message") + raise ERServiceException(message, response.status_code, code=response.status_code) + + response_json = response.json() + + return self.deserializer.from_dict(response_json["data"]) + + def retrieve(self, name, **kwargs): + if self.client.api_key is None or self.client.api_key.strip() == "": + raise ValueError("API Key is not set") + + response = requests.get(self.resolve_retrieve_path(name, **kwargs), + json=self.resolve_retrieve_body(name, **kwargs), + headers=self.resolve_retrieve_headers(name, **kwargs), + **self.request_args + ) + + if str(response.status_code)[0] != "2": + message = response.json().get("message") + raise ERServiceException(message, response.status_code, code=response.status_code) + + response_json = response.json() + + return self.deserializer.from_dict(response_json["data"]) + + def update(self, obj, **kwargs): + if self.client.api_key is None or self.client.api_key.strip() == "": + raise ValueError("API Key is not set") + print(self.resolve_update_body(obj, **kwargs)) + response = requests.put(self.resolve_update_path(obj, **kwargs), + json=self.resolve_update_body(obj, **kwargs), + headers=self.resolve_update_headers(obj, **kwargs), + **self.request_args + ) + if str(response.status_code)[0] != "2": + message = response.json().get("message") + raise ERServiceException(message, response.status_code, code=response.status_code) + + response_json = response.json() + + return self.deserializer.from_dict(response_json["data"]) + + def delete(self, obj, **kwargs): + if self.client.api_key is None or self.client.api_key.strip() == "": + raise ValueError("API Key is not set") + + response = requests.put(self.resolve_delete_path(obj, **kwargs), + json=self.resolve_delete_body(obj, **kwargs), + headers=self.resolve_delete_headers(obj, **kwargs), + **self.request_args + ) + if str(response.status_code)[0] != "2": + message = response.json().get("message") + raise ERServiceException(message, response.status_code, code=response.status_code) + + json_data = response.json() + if json_data["message"] == "Deleted.": + return True + else: + return False + + +class StopRepository(Repository): + path = "stops" + + def resolve_create_path(self, obj, date=None): + pref_date = obj["date"] if obj.get("date") is not None else date + return "{}/{}/{}".format(self.client.endpoint, self.path, pref_date) + + def resolve_retrieve_path(self, name, date=None): + pref_date = date + return "{}/{}/{}/{}".format(self.client.endpoint, self.path, pref_date, name) + + def resolve_update_path(self, obj, date=None, old_name=None): + pref_date = obj["date"] if obj.get("date") is not None else date + pref_name = old_name if old_name is not None else obj.old_name if obj.old_name is not None and obj.old_name != "" else obj["name"] + print(old_name, obj.old_name, obj["name"], pref_name) + return "{}/{}/{}/{}".format(self.client.endpoint, self.path, pref_date, pref_name) + + def resolve_delete_path(self, obj, date=None): + pref_date = obj["date"] if obj.get("date") is not None else date + return "{}/{}/{}/{}".format(self.client.endpoint, self.path, pref_date, obj["name"]) + + def create(self, obj, *, date=None): + if date is None: + date = datetime.now().strftime("%Y-%m-%d") + else: + if not validate_date(date): + raise ValueError("Invalid Date Format!") + + return super().create(obj, date=date) + + def retrieve(self, name, *, date=None): + if date is None: + date = datetime.now().strftime("%Y-%m-%d") + else: + if not validate_date(date): + raise ValueError("Invalid Date Format!") + + return super().retrieve(name, date=date) + + def update(self, obj, *, date=None, old_name=None): + if date is None: + date = datetime.now().strftime("%Y-%m-%d") + else: + if not validate_date(date): + raise ValueError("Invalid Date Format!") + + return super().update(obj, date=date, old_name=old_name) + + def delete(self, name, *, date=None): + if date is None: + date = datetime.now().strftime("%Y-%m-%d") + else: + if not validate_date(date): + raise ValueError("Invalid Date Format!") + + return super().delete(name, date) From e31bdd0042cccbfbc396892e71b4b9c4bd0e07c4 Mon Sep 17 00:00:00 2001 From: chesnutcase Date: Wed, 23 Oct 2019 10:19:23 +0800 Subject: [PATCH 21/48] add dashboardclient --- elasticroute/clients.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 elasticroute/clients.py diff --git a/elasticroute/clients.py b/elasticroute/clients.py new file mode 100644 index 0000000..271507b --- /dev/null +++ b/elasticroute/clients.py @@ -0,0 +1,12 @@ +from .repositories import StopRepository +from .serializers import DashboardStopSerializer +from .deserializers import DashboardStopDeserializer + + +class DashboardClient(): + api_key = '' + endpoint = "https://app.elasticroute.com/api/v1/account" + + def __init__(self, api_key): + self.api_key = api_key + self.stops = StopRepository(serializer=DashboardStopSerializer(), client=self, deserializer=DashboardStopDeserializer()) From 636732b4acf589befcebfe52345d256b9787bb56 Mon Sep 17 00:00:00 2001 From: chesnutcase Date: Wed, 23 Oct 2019 10:36:19 +0800 Subject: [PATCH 22/48] hook validators into repositories --- elasticroute/clients.py | 3 ++- elasticroute/repositories.py | 13 ++++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/elasticroute/clients.py b/elasticroute/clients.py index 271507b..591a185 100644 --- a/elasticroute/clients.py +++ b/elasticroute/clients.py @@ -1,6 +1,7 @@ from .repositories import StopRepository from .serializers import DashboardStopSerializer from .deserializers import DashboardStopDeserializer +from .validators import StopValidator class DashboardClient(): @@ -9,4 +10,4 @@ class DashboardClient(): def __init__(self, api_key): self.api_key = api_key - self.stops = StopRepository(serializer=DashboardStopSerializer(), client=self, deserializer=DashboardStopDeserializer()) + self.stops = StopRepository(serializer=DashboardStopSerializer(), client=self, deserializer=DashboardStopDeserializer(), validator=StopValidator()) diff --git a/elasticroute/repositories.py b/elasticroute/repositories.py index bcd8b5e..4937b4f 100644 --- a/elasticroute/repositories.py +++ b/elasticroute/repositories.py @@ -17,9 +17,10 @@ class Repository(): path = "" client = None - def __init__(self, serializer=None, client=None, deserializer=None): + def __init__(self, serializer=None, client=None, deserializer=None, validator=None): self.serializer = serializer self.deserializer = deserializer + self.validator = validator self.client = client self.request_args = {} pass @@ -73,6 +74,7 @@ def resolve_delete_body(self, obj, **kwargs): return {} def create(self, obj, **kwargs): + self.validator.validate_object(obj) if self.client.api_key is None or self.client.api_key.strip() == "": raise ValueError("API Key is not set") @@ -109,6 +111,7 @@ def retrieve(self, name, **kwargs): return self.deserializer.from_dict(response_json["data"]) def update(self, obj, **kwargs): + self.validator.validate_object(obj) if self.client.api_key is None or self.client.api_key.strip() == "": raise ValueError("API Key is not set") print(self.resolve_update_body(obj, **kwargs)) @@ -125,13 +128,13 @@ def update(self, obj, **kwargs): return self.deserializer.from_dict(response_json["data"]) - def delete(self, obj, **kwargs): + def delete(self, name, **kwargs): if self.client.api_key is None or self.client.api_key.strip() == "": raise ValueError("API Key is not set") - response = requests.put(self.resolve_delete_path(obj, **kwargs), - json=self.resolve_delete_body(obj, **kwargs), - headers=self.resolve_delete_headers(obj, **kwargs), + response = requests.put(self.resolve_delete_path(name, **kwargs), + json=self.resolve_delete_body(name, **kwargs), + headers=self.resolve_delete_headers(name, **kwargs), **self.request_args ) if str(response.status_code)[0] != "2": From 1cee7bf753413346ebf82415af68d92095a8c9db Mon Sep 17 00:00:00 2001 From: chesnutcase Date: Wed, 23 Oct 2019 11:58:36 +0800 Subject: [PATCH 23/48] add vehicle serializers --- elasticroute/serializers.py | 51 ++++++++++++++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/elasticroute/serializers.py b/elasticroute/serializers.py index dbfe429..fc0c86b 100644 --- a/elasticroute/serializers.py +++ b/elasticroute/serializers.py @@ -1,7 +1,7 @@ from .common import Bean -from .common import Stop as BaseStop -from .dashboard import Stop as DashboardStop -from .routing import Stop as RoutingStop +from .common import Stop as BaseStop, Vehicle as BaseVehicle +from .dashboard import Stop as DashboardStop, Vehicle as DashboardVehicle +from .routing import Stop as RoutingStop, Vehicle as RoutingVehicle class Serializer(): @@ -77,3 +77,48 @@ def to_dict(self, obj): return d else: raise TypeError("Invalid data type passed to to_dict: expected {} or dict, received {}".format(DashboardStop, type(obj))) + + +class VehicleSerializer(BeanSerializer): + def to_dict(self, obj): + if isinstance(obj, BaseVehicle): + # the superclass is capable of processing this + return super().to_dict(obj) + elif type(obj) is dict: + d = obj + # decide whether to remove non vanilla keys + if self.vanilla_keys_only: + d = {k: v for (k, v) in d.items() if k in BaseVehicle.get_full_default_data().keys()} + return d + else: + raise TypeError("Invalid data type passed to to_dict: expected {} or dict, received {}".format(DashboardVehicle, type(obj))) + + +class DashboardVehicleSerializer(VehicleSerializer): + def to_dict(self, obj): + if type(obj) is DashboardVehicle: + # the superclass is capable of processing this + return super().to_dict(obj) + elif type(obj) is dict: + d = obj + # decide whether to remove non vanilla keys + if self.vanilla_keys_only: + d = {k: v for (k, v) in d.items() if k in DashboardVehicle.get_full_default_data().keys()} + return d + else: + raise TypeError("Invalid data type passed to to_dict: expected {} or dict, received {}".format(DashboardVehicle, type(obj))) + + +class RoutingVehicleSerializer(VehicleSerializer): + def to_dict(self, obj): + if type(obj) is RoutingVehicle: + # the superclass is capable of processing this + return super().to_dict(obj) + elif type(obj) is dict: + d = obj + # decide whether to remove non vanilla keys + if self.vanilla_keys_only: + d = {k: v for (k, v) in d.items() if k in RoutingVehicle.get_full_default_data().keys()} + return d + else: + raise TypeError("Invalid data type passed to to_dict: expected {} or dict, received {}".format(DashboardVehicle, type(obj))) From f1550e396e07c3bea836d9e7403af33e938a9c0d Mon Sep 17 00:00:00 2001 From: chesnutcase Date: Wed, 23 Oct 2019 11:58:56 +0800 Subject: [PATCH 24/48] add vehicle repository and add to client --- elasticroute/clients.py | 4 ++-- elasticroute/repositories.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/elasticroute/clients.py b/elasticroute/clients.py index 591a185..2139bfd 100644 --- a/elasticroute/clients.py +++ b/elasticroute/clients.py @@ -1,7 +1,7 @@ -from .repositories import StopRepository +from .repositories import StopRepository, VehicleRepository from .serializers import DashboardStopSerializer from .deserializers import DashboardStopDeserializer -from .validators import StopValidator +from .validators import StopValidator, VehicleValidator class DashboardClient(): diff --git a/elasticroute/repositories.py b/elasticroute/repositories.py index 4937b4f..333ebdc 100644 --- a/elasticroute/repositories.py +++ b/elasticroute/repositories.py @@ -204,3 +204,32 @@ def delete(self, name, *, date=None): raise ValueError("Invalid Date Format!") return super().delete(name, date) + + +class VehicleRepository(Repository): + path = "vehicles" + + def resolve_create_path(self, obj): + return "{}/{}".format(self.client.endpoint, self.path) + + def resolve_retrieve_path(self, name): + return "{}/{}/{}".format(self.client.endpoint, self.path, name) + + def resolve_update_path(self, obj, old_name=None): + pref_name = old_name if old_name is not None else obj.old_name if obj.old_name is not None and obj.old_name != "" else obj["name"] + return "{}/{}/{}".format(self.client.endpoint, self.path, pref_name) + + def resolve_delete_path(self, obj): + return "{}/{}/{}".format(self.client.endpoint, self.path, obj["name"]) + + def create(self, obj): + return super().create(obj) + + def retrieve(self, name): + return super().retrieve(name) + + def update(self, obj, *, old_name=None): + return super().update(obj, old_name=old_name) + + def delete(self, name): + return super().delete(name) From 07bc74f980533001f5f548b0132841ab44891dc0 Mon Sep 17 00:00:00 2001 From: chesnutcase Date: Wed, 23 Oct 2019 12:46:04 +0800 Subject: [PATCH 25/48] add vehicle deserializer --- elasticroute/deserializers.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/elasticroute/deserializers.py b/elasticroute/deserializers.py index ab984cb..80e60f8 100644 --- a/elasticroute/deserializers.py +++ b/elasticroute/deserializers.py @@ -1,7 +1,7 @@ from .common import Bean -from .common import Stop as BaseStop -from .dashboard import Stop as DashboardStop -from .routing import Stop as RoutingStop +from .common import Stop as BaseStop, Vehicle as BaseVehicle +from .dashboard import Stop as DashboardStop, Vehicle as DashboardVehicle +from .routing import Stop as RoutingStop, Vehicle as RoutingVehicle class Deserializer(): @@ -32,3 +32,15 @@ class DashboardStopDeserializer(StopDeserializer): class RoutingStopDeserializer(StopDeserializer): target_class = RoutingStop + + +class VehicleDeserializer(BeanDeserializer): + target_class = BaseVehicle + + +class DashboardVehicleDeserializer(VehicleDeserializer): + target_class = DashboardVehicle + + +class RoutingVehicleDeserializer(VehicleDeserializer): + target_class = RoutingVehicle From c56a588014c80c240841bc1e07c390de69846947 Mon Sep 17 00:00:00 2001 From: chesnutcase Date: Wed, 23 Oct 2019 12:46:19 +0800 Subject: [PATCH 26/48] fix wrong attribtue name in vehicle validation clause --- elasticroute/validators.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/elasticroute/validators.py b/elasticroute/validators.py index fc0190a..6abcb01 100644 --- a/elasticroute/validators.py +++ b/elasticroute/validators.py @@ -82,5 +82,5 @@ class VehicleValidator(Validator): "if present, buffer must be a valid whole number representation": lambda o: inty_number_or_string(o["buffer"]) if o.get("buffer") is not None else True, "if present, return_to_depot must be a boolean": lambda o: type(o["return_to_depot"]) is bool if o.get("return_to_depot") is not None else True, "if present, avail_from must be a valid whole number representation from 0 to 2359": lambda o: (inty_number_or_string(o["avail_from"]) and int(o["avail_from"]) >= 0 and int(o["avail_from"]) <= 2359) if o.get("avail_from") is not None else True, - "if present, avail_to must be a valid whole number representation from 0 to 2359": lambda o: (inty_number_or_string(o["avail_to"]) and int(o["avail_to"]) >= 0 and int(o["avail_to"]) <= 2359) if o.get("avail_to") is not None else True, + "if present, avail_till must be a valid whole number representation from 0 to 2359": lambda o: (inty_number_or_string(o["avail_till"]) and int(o["avail_till"]) >= 0 and int(o["avail_till"]) <= 2359) if o.get("avail_till") is not None else True, } From e58ebb311abac6f538aff53c1f38332cf64783b5 Mon Sep 17 00:00:00 2001 From: chesnutcase Date: Wed, 23 Oct 2019 13:05:33 +0800 Subject: [PATCH 27/48] fix vehicle data attributes --- elasticroute/common.py | 2 +- elasticroute/dashboard.py | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/elasticroute/common.py b/elasticroute/common.py index d2b486e..0d7d19a 100644 --- a/elasticroute/common.py +++ b/elasticroute/common.py @@ -95,7 +95,7 @@ class Vehicle(Bean): 'end_depot': None, 'seating_capacity': None, 'avail_from': 900, - 'avail_to': 1700, + 'avail_till': 1700, 'volume_capacity': None, 'service_radius': None, 'buffer': None, diff --git a/elasticroute/dashboard.py b/elasticroute/dashboard.py index c9247f3..32c0122 100644 --- a/elasticroute/dashboard.py +++ b/elasticroute/dashboard.py @@ -6,7 +6,25 @@ class Vehicle(BaseVehicle): # add dashboard only fields # none atm default_data = { + 'avail': None, + 'avail_fri': None, + 'avail_mon': None, + 'avail_wed': None, + 'avail_tue': None, + 'avail_thu': None, + 'groups': None, + 'break_time_window': None, + 'avail_sat': None, + 'zones': None, + 'unzoned': None, + 'all_groups': None, + 'avail_sun': None, + } + # add dashboard readonly fields + result_data_keys = { + 'created_at', + 'updated_at' } From 9ecede5032316942c3ebad1858c83f8bbd12ea47 Mon Sep 17 00:00:00 2001 From: chesnutcase Date: Wed, 23 Oct 2019 13:21:25 +0800 Subject: [PATCH 28/48] add vehicle repository to dashboardclient --- elasticroute/clients.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/elasticroute/clients.py b/elasticroute/clients.py index 2139bfd..d2459c2 100644 --- a/elasticroute/clients.py +++ b/elasticroute/clients.py @@ -1,6 +1,6 @@ from .repositories import StopRepository, VehicleRepository -from .serializers import DashboardStopSerializer -from .deserializers import DashboardStopDeserializer +from .serializers import DashboardStopSerializer, DashboardVehicleSerializer +from .deserializers import DashboardStopDeserializer, DashboardVehicleDeserializer from .validators import StopValidator, VehicleValidator @@ -11,3 +11,4 @@ class DashboardClient(): def __init__(self, api_key): self.api_key = api_key self.stops = StopRepository(serializer=DashboardStopSerializer(), client=self, deserializer=DashboardStopDeserializer(), validator=StopValidator()) + self.vehicles = VehicleRepository(serializer=DashboardVehicleSerializer(), client=self, deserializer=DashboardVehicleDeserializer(), validator=VehicleValidator()) From 721e240a9cc991d2363f8e66a18cac5ff7222593 Mon Sep 17 00:00:00 2001 From: chesnutcase Date: Wed, 23 Oct 2019 13:21:45 +0800 Subject: [PATCH 29/48] fix wrong target class in serializers --- elasticroute/serializers.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/elasticroute/serializers.py b/elasticroute/serializers.py index fc0c86b..cf63665 100644 --- a/elasticroute/serializers.py +++ b/elasticroute/serializers.py @@ -46,7 +46,7 @@ def to_dict(self, obj): d = {k: v for (k, v) in d.items() if k in BaseStop.get_full_default_data().keys()} return d else: - raise TypeError("Invalid data type passed to to_dict: expected {} or dict, received {}".format(DashboardStop, type(obj))) + raise TypeError("Invalid data type passed to to_dict: expected {} or dict, received {}".format(BaseStop, type(obj))) class DashboardStopSerializer(StopSerializer): @@ -76,7 +76,7 @@ def to_dict(self, obj): d = {k: v for (k, v) in d.items() if k in RoutingStop.get_full_default_data().keys()} return d else: - raise TypeError("Invalid data type passed to to_dict: expected {} or dict, received {}".format(DashboardStop, type(obj))) + raise TypeError("Invalid data type passed to to_dict: expected {} or dict, received {}".format(RoutingStop, type(obj))) class VehicleSerializer(BeanSerializer): @@ -91,7 +91,7 @@ def to_dict(self, obj): d = {k: v for (k, v) in d.items() if k in BaseVehicle.get_full_default_data().keys()} return d else: - raise TypeError("Invalid data type passed to to_dict: expected {} or dict, received {}".format(DashboardVehicle, type(obj))) + raise TypeError("Invalid data type passed to to_dict: expected {} or dict, received {}".format(BaseVehicle, type(obj))) class DashboardVehicleSerializer(VehicleSerializer): @@ -121,4 +121,4 @@ def to_dict(self, obj): d = {k: v for (k, v) in d.items() if k in RoutingVehicle.get_full_default_data().keys()} return d else: - raise TypeError("Invalid data type passed to to_dict: expected {} or dict, received {}".format(DashboardVehicle, type(obj))) + raise TypeError("Invalid data type passed to to_dict: expected {} or dict, received {}".format(RoutingVehicle, type(obj))) From 94ea8e0e9fcdf75612a88f4db999f25e77ce3d65 Mon Sep 17 00:00:00 2001 From: chesnutcase Date: Wed, 23 Oct 2019 13:36:06 +0800 Subject: [PATCH 30/48] delete old files --- conftest.py | 4 +- elasticroute/__init__.py | 6 - elasticroute/client_models.py | 166 ------ elasticroute/data_models.py | 264 --------- elasticroute/defaults.py | 17 - elasticroute/exceptions.py | 14 - elasticroute/utils.py | 9 - tests/integration/bigData.json | 811 -------------------------- tests/integration/test_simple_plan.py | 95 --- tests/unit/test_plan.py | 74 --- tests/unit/test_solution.py | 107 ---- tests/unit/test_stop_validation.py | 144 ----- tests/unit/test_vehicle_validation.py | 92 --- 13 files changed, 3 insertions(+), 1800 deletions(-) delete mode 100644 elasticroute/client_models.py delete mode 100644 elasticroute/data_models.py delete mode 100644 elasticroute/defaults.py delete mode 100644 elasticroute/exceptions.py delete mode 100644 elasticroute/utils.py delete mode 100644 tests/integration/bigData.json delete mode 100644 tests/integration/test_simple_plan.py delete mode 100644 tests/unit/test_plan.py delete mode 100644 tests/unit/test_solution.py delete mode 100644 tests/unit/test_stop_validation.py delete mode 100644 tests/unit/test_vehicle_validation.py diff --git a/conftest.py b/conftest.py index 1e9779f..98dbce7 100644 --- a/conftest.py +++ b/conftest.py @@ -3,6 +3,7 @@ import elasticroute import os +""" env_path = Path('tests') / '.env' load_dotenv(dotenv_path=env_path) @@ -10,4 +11,5 @@ elasticroute.defaults.BASE_URL = os.getenv("ELASTICROUTE_PATH") print("Default API Key registered as: {}".format(elasticroute.defaults.API_KEY)) -print("BASE URL registered as: {}".format(elasticroute.defaults.BASE_URL)) \ No newline at end of file +print("BASE URL registered as: {}".format(elasticroute.defaults.BASE_URL)) +""" diff --git a/elasticroute/__init__.py b/elasticroute/__init__.py index c836367..e69de29 100644 --- a/elasticroute/__init__.py +++ b/elasticroute/__init__.py @@ -1,6 +0,0 @@ -from .exceptions import BadFieldError -from .data_models import Depot, Stop, Vehicle -from . import defaults -from .client_models import Plan, Solution - -name = "elasticroute" diff --git a/elasticroute/client_models.py b/elasticroute/client_models.py deleted file mode 100644 index 1182ed6..0000000 --- a/elasticroute/client_models.py +++ /dev/null @@ -1,166 +0,0 @@ -from . import Stop -from . import Vehicle -from . import Depot -from . import BadFieldError -from . import defaults -from .utils import EREncoder -import requests -import json - - -class Plan(): - def __init__(self, data={}): - self.generalSettings = defaults.generalSettings - self.stops = [] - self.depots = [] - self.vehicles = [] - self.api_key = defaults.API_KEY - self.id = None - self.connection_type = "sync" - for key, value in data.items(): - setattr(self, key, value) - return - - def solve(self, *, request_options={}): - # helper function - def isfloatish(s): - s = str(s) - return s.replace('-', '', 1).replace('.', '', 1).isdigit() - if not isinstance(self.id, str) or len(self.id.strip()) == 0: - raise BadFieldError('You need to create an id for this plan!') - # validate stops - Stop.validateStops(self.stops, self.generalSettings) - Vehicle.validateVehicles(self.vehicles) - Depot.validateDepots(self.depots, self.generalSettings) - for i in range(len(self.stops)): - stop = self.stops[i] - if isinstance(self.stops[i], dict): - self.stops[i] = Stop(stop) - stop = self.stops[i] - if isinstance(stop["lat"], str): - self.stops[i]["lat"] = float(stop["lat"]) - if isinstance(stop["lng"], str): - self.stops[i]["lng"] = float(stop["lng"]) - if stop["from"] is None or len(str(stop["from"]).strip()) == 0: - self.stops[i]["from"] = self.generalSettings["from"] - if stop["till"] is None or len(str(stop["till"]).strip()) == 0: - self.stops[i]["till"] = self.generalSettings["till"] - if stop["depot"] is None or len(str(stop["depot"]).strip()) == 0: - self.stops[i]["depot"] = self.depots[0]["name"] - for i in range(len(self.vehicles)): - vehicle = self.vehicles[i] - if isinstance(self.vehicles[i], dict): - self.vehicles[i] = Vehicle(vehicle) - vehicle = self.vehicles[i] - if vehicle["avail_from"] is None or len(str(vehicle["avail_from"])) == 0: - self.vehicles[i]["avail_from"] = self.generalSettings["from"] - if vehicle["avail_till"] is None or len(str(vehicle["avail_till"])) == 0: - self.vehicles[i]["avail_till"] = self.generalSettings["till"] - if vehicle["depot"] is None or len(str(vehicle["depot"]).strip()) == 0: - self.vehicles[i]["depot"] = self.depots[0]["name"] - - params = {"c": "sync"} if self.connection_type == "sync" else {} - data = { - "generalSettings": self.generalSettings, - "stops": self.stops, - "vehicles": self.vehicles, - "depots": self.depots, - } - data = json.dumps(data, cls=EREncoder) - response = requests.post("{}/{}".format(defaults.BASE_URL, self.id), - params=params, - data=data, - headers={"Content-Type": "application/json", "Authorization": "Bearer {}".format(self.api_key or defaults.API_KEY)}, - **request_options - ) - if str(response.status_code)[0] in "45": - raise Exception("API Return HTTP Code {} : {}".format(response.status_code, response.text)) - response_obj = response.json() - solution = Solution() - solution.api_key = self.api_key - solution.raw_response_string = response.text - solution.raw_response_data = response_obj - solved_stops = [] - for received_stop in response_obj["data"]["details"]["stops"]: - solved_stop = Stop(received_stop) - solved_stops.append(solved_stop) - solution.stops = solved_stops - solved_depots = [] - for received_depot in response_obj["data"]["details"]["depots"]: - solved_depot = Depot(received_depot) - solved_depots.append(solved_depot) - solution.depots = solved_depots - solved_vehicles = [] - for received_vehicle in response_obj["data"]["details"]["vehicles"]: - solved_vehicle = Vehicle(received_vehicle) - solved_vehicles.append(solved_vehicle) - solution.vehicles = solved_vehicles - solution.plan_id = response_obj["data"]["plan_id"] - solution.progress = response_obj["data"]["progress"] - solution.status = response_obj["data"]["stage"] - solution.generalSettings = self.generalSettings - return solution - - def __dict__(self): - return { - "stops": self.stops, - "depots": self.depots, - "vehicles": self.vehicles, - "generalSettings": self.generalSettings - } - - -class Solution(): - def __init__(self, data={}): - self.api_key = None - self.plan_id = None - self.progress = None - self.status = None - self.raw_response_string = None - self.raw_response_data = None - self.depots = [] - self.stops = [] - self.vehicles = [] - self.generalSettings = None - for key, value in [(k, v) for (k, v) in data.items() if k in ["depots", "stops", "vehicles", "generalSettings", "status", "progress"]]: - if key in self.data: - self.data[key] = value - return - - def refresh(self, *, request_options={}): - if not isinstance(self.plan_id, str) or len(self.plan_id.strip()) == 0: - raise BadFieldError("You need to create an id for this plan!") - response = requests.get("{}/{}".format(defaults.BASE_URL, self.plan_id), - headers={"Content-Type": "application/json", "Authorization": "Bearer {}".format(self.api_key or defaults.API_KEY)}, - **request_options) - - if str(response.status_code)[0] in "45": - raise Exception("API Return HTTP Code {} : {}".format(response.status_code, response.text)) - response_obj = response.json() - - self.progress = response_obj["data"]["progress"] - self.status = response_obj["data"]["stage"] - self.data = response_obj["data"]["stage"] - self.raw_response_string = response.text - self.raw_response_data = response_obj - solved_stops = [] - for received_stop in response_obj["data"]["details"]["stops"]: - solved_stop = Stop(received_stop) - solved_stops.append(solved_stop) - self.stops = solved_stops - solved_depots = [] - for received_depot in response_obj["data"]["details"]["depots"]: - solved_depot = Depot(received_depot) - solved_depots.append(solved_depot) - self.depots = solved_depots - solved_vehicles = [] - for received_vehicle in response_obj["data"]["details"]["vehicles"]: - solved_vehicle = Vehicle(received_vehicle) - solved_vehicles.append(solved_vehicle) - self.vehicles = solved_vehicles - self.plan_id = response_obj["data"]["plan_id"] - - def get_unsolved_stops(self): - return [stop for stop in self.stops if isinstance(stop["exception"], str) and stop["exception"] != ""] - - unsolved_stops = property(get_unsolved_stops) diff --git a/elasticroute/data_models.py b/elasticroute/data_models.py deleted file mode 100644 index 1706f84..0000000 --- a/elasticroute/data_models.py +++ /dev/null @@ -1,264 +0,0 @@ -from . import BadFieldError - - -class Depot(): - def __init__(self, data={}): - self.data = { - "vehicle_type": None, - "depot": None, - "group": None, - "name": None, - "time_window": None, - "address": None, - "postal_code": None, - "weight_load": None, - "volume_load": None, - "seating_load": None, - "service_time": None, - "lat": None, - "lng": None, - "from": None, - "till": None, - "assign_to": None, - "run": None, - "sequence": None, - "eta": None, - "exception": None - } - for key, value in data.items(): - if key in self.data: - self.data[key] = value - return - - def __dict__(self): - return self.data - - def __getitem__(self, key): - return self.data[key] - - def __setitem__(self, key, value): - self.data[key] = value - - def __iter__(self): - return iter(self.data.items()) - - def __str__(self): - return str(self.data) - - def __repr__(self): - return str(self.data) - - @classmethod - def validateDepots(cls, depots, general_settings={}): - # helper function - def isfloatish(s): - s = str(s) - return s.replace('-', '', 1).replace('.', '', 1).isdigit() - for depot in depots: - if not isinstance(depot, cls) and not isinstance(depot, dict): - raise Exception("Depot must be either a Depot class or dict!") - if isinstance(depot, dict): - depot = Depot(depot) - # check depot.name - # required - if not isinstance(depot["name"], str) or depot["name"] == "": - raise BadFieldError("Depot name cannot be null", depot) - # max:255 - if len(depot["name"]) > 255: - raise BadFieldError("Depot name cannot be more than 255 chars", depot) - # distinct - duplicates = 0 - for sdepot in depots: - if sdepot["name"] == depot["name"]: - duplicates += 1 - if duplicates > 1: - raise BadFieldError("Depot name must be distinct", depot) - # check address/postcode/latlong - if not isfloatish(depot["lat"]) or not isfloatish(depot["lng"]): - # if no coordinates given, check address - if not isinstance(depot["address"], str) or depot["address"] == "": - # if no address given, check postcode and country - valid_countries = ["SG"] - if general_settings.get("country") not in valid_countries: - raise BadFieldError("Depot address and coordinates are not given", depot) - else: - if not isinstance(depot["postal_code"], str) or depot["postal_code"] == "": - raise BadFieldError("Depot address and coordinates are not given, and postcode is not present", depot) - - return True - - -class Stop(): - def __init__(self, data={}): - self.data = { - "vehicle_type": None, - "depot": None, - "group": None, - "name": None, - "time_window": None, - "address": None, - "postal_code": None, - "weight_load": None, - "volume_load": None, - "seating_load": None, - "service_time": None, - "lat": None, - "lng": None, - "from": None, - "till": None, - "assign_to": None, - "run": None, - "sequence": None, - "eta": None, - "exception": None - } - for key, value in data.items(): - if key in self.data: - self.data[key] = value - return - - def __dict__(self): - return self.data - - def __getitem__(self, key): - return self.data[key] - - def __setitem__(self, key, value): - self.data[key] = value - - def __iter__(self): - return iter(self.data.items()) - - def __str__(self): - return str(self.data) - - def __repr__(self): - return str(self.data) - - @classmethod - def validateStops(cls, stops, general_settings={}): - # helper function - def isfloatish(s): - s = str(s) - return s.replace('-', '', 1).replace('.', '', 1).isdigit() - # check stop - # min:2 - if len(stops) < 2: - raise BadFieldError("You must have at least two stops") - for stop in stops: - if not isinstance(stop, cls) and not isinstance(stop, dict): - raise Exception("Stop must be either a Stop class or dict!") - if isinstance(stop, dict): - stop = Stop(stop) - # check stop.name - # required - if not isinstance(stop["name"], str) or stop["name"] == "": - raise BadFieldError("Stop name cannot be null", stop) - # max:255 - if len(stop["name"]) > 255: - raise BadFieldError("Stop name cannot be more than 255 chars", stop) - # distinct - duplicates = 0 - for sstop in stops: - if sstop["name"] == stop["name"]: - duplicates += 1 - if duplicates > 1: - raise BadFieldError("Stop name must be distinct", stop) - # check address/postcode/latlong - if not isfloatish(stop["lat"]) or not isfloatish(stop["lng"]): - # if no coordinates given, check address - if not isinstance(stop["address"], str) or stop["address"] == "": - # if no address given, check postcode and country - valid_countries = ["SG"] - if general_settings.get("country") not in valid_countries: - raise BadFieldError("Stop address and coordinates are not given", stop) - else: - if not isinstance(stop["postal_code"], str) or stop["postal_code"] == "": - raise BadFieldError("Stop address and coordinates are not given, and postcode is not present", stop) - # check numeric|min:0|nullable for the following: - # weight_load, volume_load, seating_load, service_time - positiveNumericFields = ['weight_load', 'volume_load', 'seating_load', 'service_time'] - for field in positiveNumericFields: - if stop[field] is not None: - if not isfloatish(stop[field]): - raise BadFieldError(f"Stop {field} must be numeric", stop) - elif float(stop[field]) < 0: - raise BadFieldError(f"Stop {field} cannot be negative", stop) - - return True - - -class Vehicle(): - def __init__(self, data={}): - self.data = { - "depot": None, - "name": None, - "priority": 1, - "weight_capacity": None, - "volume_capacity": None, - "seating_capacity": None, - "buffer": None, - "avail_from": None, - "avail_till": None, - "return_to_depot": 1, - "vehicle_types": None, - } - for key, value in data.items(): - if key in self.data: - self.data[key] = value - return - - def __dict__(self): - return self.data - - def __getitem__(self, key): - return self.data[key] - - def __setitem__(self, key, value): - self.data[key] = value - - def __iter__(self): - return iter(self.data.items()) - - def __str__(self): - return str(self.data) - - def __repr__(self): - return str(self.data) - - @classmethod - def validateVehicles(cls, vehicles, general_settings={}): - # helper function - def isfloatish(s): - s = str(s) - return s.replace('-', '', 1).replace('.', '', 1).isdigit() - for vehicle in vehicles: - if not isinstance(vehicle, cls) and not isinstance(vehicle, dict): - raise Exception("Vehicle must be either a Vehicle class or dict!") - if isinstance(vehicle, dict): - vehicle = Vehicle(vehicle) - # check vehicle.name - # required - if not isinstance(vehicle["name"], str) or vehicle["name"] == "": - raise BadFieldError("Vehicle name cannot be null", vehicle) - # max:255 - if len(vehicle["name"]) > 255: - raise BadFieldError("Vehicle name cannot be more than 255 chars", vehicle) - # distinct - duplicates = 0 - for svehicle in vehicles: - if svehicle["name"] == vehicle["name"]: - duplicates += 1 - if duplicates > 1: - raise BadFieldError("Vehicle name must be distinct", vehicle) - # check numeric|min:0|nullable for the following: - # weight_capacity, volume_capacity, seating_capacity, service_time - positiveNumericFields = ['weight_capacity', 'volume_capacity', 'seating_capacity'] - for field in positiveNumericFields: - if vehicle[field] is not None: - if not isfloatish(vehicle[field]): - raise BadFieldError(f"Vehicle {field} must be numeric", vehicle) - elif float(vehicle[field]) < 0: - raise BadFieldError(f"Vehicle {field} cannot be negative", vehicle) - - return True diff --git a/elasticroute/defaults.py b/elasticroute/defaults.py deleted file mode 100644 index e56a4ff..0000000 --- a/elasticroute/defaults.py +++ /dev/null @@ -1,17 +0,0 @@ -BASE_URL = 'https://app.elasticroute.com/api/v1/plan' -API_KEY = None -generalSettings = { - 'country': 'SG', - 'timezone': 'Asia/Singapore', - 'loading_time': 20, - 'buffer': 40, - 'service_time': 5, - 'distance_unit': 'km', - 'max_time': None, - 'max_distance': None, - 'max_stops': 30, - 'max_runs': 1, - 'from': 900, - 'till': 1700, - 'webhook_url': None, -} diff --git a/elasticroute/exceptions.py b/elasticroute/exceptions.py deleted file mode 100644 index 6c9faff..0000000 --- a/elasticroute/exceptions.py +++ /dev/null @@ -1,14 +0,0 @@ -import sys -from pprint import pformat - - -class BadFieldError(Exception): - def __init__(self, message, dump=None): - self.message = message - self.dump = dump - - def __str__(self): - try: - return str(self.message) + str(self.dump) - except Exception as e: - return "Unexpected error:", sys.exc_info()[0] diff --git a/elasticroute/utils.py b/elasticroute/utils.py deleted file mode 100644 index 2c52907..0000000 --- a/elasticroute/utils.py +++ /dev/null @@ -1,9 +0,0 @@ -import json - -class EREncoder(json.JSONEncoder): - def default(self, o): - dict_function = getattr(o, "__dict__", None) - if callable(dict_function): - return o.__dict__() - # Let the base class default method raise the TypeError - return json.JSONEncoder.default(self, o) diff --git a/tests/integration/bigData.json b/tests/integration/bigData.json deleted file mode 100644 index f154a8a..0000000 --- a/tests/integration/bigData.json +++ /dev/null @@ -1,811 +0,0 @@ -{ - "stops": [{ - "vehicle_type": "Default", - "depot": "Kaki Bukit Warehouse", - "group": "noFeet", - "name": "TEST123", - "time_window": "Default", - "address": "321 Orchard Road #07-03 Orchard Shopping Centre, 238866, Singapore ", - "postal_code": null, - "weight_load": null, - "volume_load": null, - "seating_load": null, - "service_time": 5, - "lat": "1.30153213", - "lng": "103.83717013", - "from": 900, - "till": 1700 - }, { - "vehicle_type": "Default", - "depot": "Kaki Bukit Warehouse", - "group": "dd", - "name": "TEST124", - "time_window": "Default", - "address": "110 Turf Club Road, 288000, Singapore ", - "postal_code": null, - "weight_load": null, - "volume_load": null, - "seating_load": null, - "service_time": 5, - "lat": "1.33492219", - "lng": "103.79417006", - "from": 900, - "till": 1700 - }, { - "vehicle_type": "Default", - "depot": "Kaki Bukit Warehouse", - "group": "", - "name": "TEST126", - "time_window": "Default", - "address": "391 Orchard Road #22-06\/10 Ngee Ann City, 238872, Singapore ", - "postal_code": null, - "weight_load": null, - "volume_load": null, - "seating_load": null, - "service_time": 5, - "lat": "1.30254213", - "lng": "103.83417012", - "from": 900, - "till": 1700 - }, { - "vehicle_type": "Default", - "depot": "Kaki Bukit Warehouse", - "group": "", - "name": "TEST127", - "time_window": "Default", - "address": "77 Robinson Road Robinson 77, 068896, Singapore ", - "postal_code": null, - "weight_load": null, - "volume_load": null, - "seating_load": null, - "service_time": 5, - "lat": "1.27775209", - "lng": "103.84170145", - "from": 900, - "till": 1700 - }, { - "vehicle_type": "Default", - "depot": "Kaki Bukit Warehouse", - "group": "", - "name": "TEST128", - "time_window": "Default", - "address": "33 Tannery Lane 02-00 Hoesteel Industrial Building Singapore 347789, Singapore ", - "postal_code": null, - "weight_load": null, - "volume_load": null, - "seating_load": null, - "service_time": 5, - "lat": "1.32857218", - "lng": "103.87617019", - "from": 900, - "till": 1700 - }, { - "vehicle_type": "Default", - "depot": "Kaki Bukit Warehouse", - "group": "", - "name": "TEST129", - "time_window": "Default", - "address": "5064, 25 loyang crescent, 508988, Singapore ", - "postal_code": null, - "weight_load": null, - "volume_load": null, - "seating_load": null, - "service_time": 5, - "lat": "1.38132226", - "lng": "103.97017034", - "from": 900, - "till": 1700 - }, { - "vehicle_type": "Default", - "depot": "Kaki Bukit Warehouse", - "group": "", - "name": "TEST130", - "time_window": "Default", - "address": "Sheraton Towers Hotel 39 Scotts Road Singapore 228230, Singapore ", - "postal_code": null, - "weight_load": null, - "volume_load": null, - "seating_load": null, - "service_time": 5, - "lat": "1.31183215", - "lng": "103.83170126", - "from": 900, - "till": 1700 - }, { - "vehicle_type": "Default", - "depot": "Kaki Bukit Warehouse", - "group": "", - "name": "TEST131", - "time_window": "Default", - "address": "26 Clementi Loop #01-55 Clementi Camp, 129817, Singapore ", - "postal_code": null, - "weight_load": null, - "volume_load": null, - "seating_load": null, - "service_time": 5, - "lat": "1.32981218", - "lng": "103.76117000", - "from": 900, - "till": 1700 - }, { - "vehicle_type": "Default", - "depot": "Kaki Bukit Warehouse", - "group": "", - "name": "TEST132", - "time_window": "Default", - "address": "30 Jalan Buroh , zipcode: Singapore619486, Singapore ", - "postal_code": null, - "weight_load": null, - "volume_load": null, - "seating_load": null, - "service_time": 5, - "lat": "1.31332152", - "lng": "103.70169914", - "from": 900, - "till": 1700 - }, { - "vehicle_type": "Default", - "depot": "Kaki Bukit Warehouse", - "group": "", - "name": "TEST133", - "time_window": "Default", - "address": "13-09 ngee ann city tower b 391b orchard road, 238874, Singapore ", - "postal_code": null, - "weight_load": null, - "volume_load": null, - "seating_load": null, - "service_time": 5, - "lat": "1.30306213", - "lng": "103.83170122", - "from": 900, - "till": 1700 - }, { - "vehicle_type": "Default", - "depot": "Kaki Bukit Warehouse", - "group": "", - "name": "TEST134", - "time_window": "Default", - "address": "507 Yishun Industrial Parka, 768734, Singapore ", - "postal_code": null, - "weight_load": null, - "volume_load": null, - "seating_load": null, - "service_time": 5, - "lat": "1.44309236", - "lng": "103.83617013", - "from": 900, - "till": 1700 - }, { - "vehicle_type": "Default", - "depot": "Kaki Bukit Warehouse", - "group": "", - "name": "TEST135", - "time_window": "Default", - "address": "Bruderer 65 Loyang Way Singapore 508755, Singapore ", - "postal_code": null, - "weight_load": null, - "volume_load": null, - "seating_load": null, - "service_time": 5, - "lat": "1.38222265", - "lng": "103.97417035", - "from": 900, - "till": 1700 - }, { - "vehicle_type": "Default", - "depot": "Kaki Bukit Warehouse", - "group": "", - "name": "TEST136", - "time_window": "Default", - "address": "1 Tech Park Crescent Tuas Tech Park, 638131, Singapore ", - "postal_code": null, - "weight_load": null, - "volume_load": null, - "seating_load": null, - "service_time": 5, - "lat": "1.31171215", - "lng": "103.62916979", - "from": 900, - "till": 1700 - }, { - "vehicle_type": "Default", - "depot": "Kaki Bukit Warehouse", - "group": "", - "name": "TEST137", - "time_window": "Default", - "address": "28A Temple Street, 058573, Singapore ", - "postal_code": null, - "weight_load": null, - "volume_load": null, - "seating_load": null, - "service_time": 5, - "lat": "1.28284210", - "lng": "103.84417014", - "from": 900, - "till": 1700 - }, { - "vehicle_type": "Default", - "depot": "Kaki Bukit Warehouse", - "group": "", - "name": "TEST138", - "time_window": "Default", - "address": "71 Lor 23 Geylang #08-01, 388386, Singapore ", - "postal_code": null, - "weight_load": null, - "volume_load": null, - "seating_load": null, - "service_time": 5, - "lat": "1.31627216", - "lng": "103.88017020", - "from": 900, - "till": 1700 - }, { - "vehicle_type": "Default", - "depot": "Kaki Bukit Warehouse", - "group": "", - "name": "TEST139", - "time_window": "Default", - "address": "9 Temasek Boulevard #38-01 SUNTEC TOWER 2, 038989, Singapore ", - "postal_code": null, - "weight_load": null, - "volume_load": null, - "seating_load": null, - "service_time": 5, - "lat": "1.29558212", - "lng": "103.85817016", - "from": 900, - "till": 1700 - }, { - "vehicle_type": "Default", - "depot": "Kaki Bukit Warehouse", - "group": "", - "name": "TEST140", - "time_window": "Default", - "address": "Toa Payoh Lor 8 01-1477, 319074, Singapore ", - "postal_code": null, - "weight_load": null, - "volume_load": null, - "seating_load": null, - "service_time": 5, - "lat": "1.34394220", - "lng": "103.84317014", - "from": 900, - "till": 1700 - }, { - "vehicle_type": "Default", - "depot": "Kaki Bukit Warehouse", - "group": "", - "name": "TEST141", - "time_window": "Default", - "address": "21, Kaki Bt ViewKaki Bukit Techpark II Singapore 415957, Singapore ", - "postal_code": null, - "weight_load": null, - "volume_load": null, - "seating_load": null, - "service_time": 5, - "lat": "1.33537219", - "lng": "103.90817024", - "from": 900, - "till": 1700 - }, { - "vehicle_type": "Default", - "depot": "Kaki Bukit Warehouse", - "group": "", - "name": "TEST142", - "time_window": "Default", - "address": "1 Kaki Bukit Ave 6, Singapore 417883 ", - "postal_code": null, - "weight_load": null, - "volume_load": null, - "seating_load": null, - "service_time": 5, - "lat": "1.34143220", - "lng": "103.91217025", - "from": 900, - "till": 1700 - }, { - "vehicle_type": "Default", - "depot": "Kaki Bukit Warehouse", - "group": "", - "name": "TEST143", - "time_window": "Default", - "address": "750 Chai Chee Rd, #01-18 Viva Business Park, Singapore 469000 ", - "postal_code": null, - "weight_load": null, - "volume_load": null, - "seating_load": null, - "service_time": 5, - "lat": "1.32363217", - "lng": "103.92170266", - "from": 900, - "till": 1700 - }, { - "vehicle_type": "Default", - "depot": "Kaki Bukit Warehouse", - "group": "", - "name": "TEST144", - "time_window": "Default", - "address": "467 Changi Rd, S 419887 ", - "postal_code": null, - "weight_load": null, - "volume_load": null, - "seating_load": null, - "service_time": 5, - "lat": "1.31934216", - "lng": "103.91217025", - "from": 900, - "till": 1700 - }, { - "vehicle_type": "Default", - "depot": "Kaki Bukit Warehouse", - "group": "", - "name": "TEST145", - "time_window": "Default", - "address": "57B Jln Tua Kong, Singapore 457253 ", - "postal_code": null, - "weight_load": null, - "volume_load": null, - "seating_load": null, - "service_time": 5, - "lat": "1.31604216", - "lng": "103.92517027", - "from": 900, - "till": 1700 - }, { - "vehicle_type": "Default", - "depot": "Kaki Bukit Warehouse", - "group": "", - "name": "TEST146", - "time_window": "Default", - "address": "505 Bedok Reservoir Rd, Singapore 479269 ", - "postal_code": null, - "weight_load": null, - "volume_load": null, - "seating_load": null, - "service_time": 5, - "lat": "1.33795219", - "lng": "103.92017026", - "from": 900, - "till": 1700 - }, { - "vehicle_type": "Default", - "depot": "Kaki Bukit Warehouse", - "group": "", - "name": "TEST147", - "time_window": "Default", - "address": "21 Tampines Ave 1, Singapore 529757 ", - "postal_code": null, - "weight_load": null, - "volume_load": null, - "seating_load": null, - "service_time": 5, - "lat": "1.34545220", - "lng": "103.93317029", - "from": 900, - "till": 1700 - }, { - "vehicle_type": "Default", - "depot": "Kaki Bukit Warehouse", - "group": "", - "name": "TEST148", - "time_window": "Default", - "address": "180 Kitchener Rd, Singapore 208539 ", - "postal_code": null, - "weight_load": null, - "volume_load": null, - "seating_load": null, - "service_time": 5, - "lat": "1.31133215", - "lng": "103.85617016", - "from": 900, - "till": 1700 - }, { - "vehicle_type": "Default", - "depot": "Kaki Bukit Warehouse", - "group": "", - "name": "TEST149", - "time_window": "Default", - "address": "3 Temasek Blvd, Singapore 038983 ", - "postal_code": null, - "weight_load": null, - "volume_load": null, - "seating_load": null, - "service_time": 5, - "lat": "1.29605212", - "lng": "103.85817016", - "from": 900, - "till": 1700 - }, { - "vehicle_type": "Default", - "depot": "Kaki Bukit Warehouse", - "group": "", - "name": "TEST150", - "time_window": "Default", - "address": "1 St Andrew's Rd, Singapore 178957 ", - "postal_code": null, - "weight_load": null, - "volume_load": null, - "seating_load": null, - "service_time": 5, - "lat": "1.28978211", - "lng": "103.85117015", - "from": 900, - "till": 1700 - }, { - "vehicle_type": "Default", - "depot": "Kaki Bukit Warehouse", - "group": "", - "name": "TEST151", - "time_window": "Default", - "address": "9 Bishan Pl, Singapore 579837 ", - "postal_code": null, - "weight_load": null, - "volume_load": null, - "seating_load": null, - "service_time": 5, - "lat": "1.35049221", - "lng": "103.84817015", - "from": 900, - "till": 1700 - }, { - "vehicle_type": "Default", - "depot": "Kaki Bukit Warehouse", - "group": "", - "name": "TEST152", - "time_window": "Default", - "address": "290 Orchard Rd, Singapore 238859 ", - "postal_code": null, - "weight_load": null, - "volume_load": null, - "seating_load": null, - "service_time": 5, - "lat": "1.30389214", - "lng": "103.83517012", - "from": 900, - "till": 1700 - }, { - "vehicle_type": "Default", - "depot": "Kaki Bukit Warehouse", - "group": "", - "name": "TEST153", - "time_window": "Default", - "address": "815 Bukit Batok West Ave 5, Singapore 659085 ", - "postal_code": null, - "weight_load": null, - "volume_load": null, - "seating_load": null, - "service_time": 5, - "lat": "1.36758224", - "lng": "103.74169984", - "from": 900, - "till": 1700 - }, { - "vehicle_type": "Default", - "depot": "Kaki Bukit Warehouse", - "group": "", - "name": "TEST154", - "time_window": "Default", - "address": "1 Woodlands Rd, Singapore 677899 ", - "postal_code": null, - "weight_load": null, - "volume_load": null, - "seating_load": null, - "service_time": 5, - "lat": "1.38041226", - "lng": "103.76017000", - "from": 900, - "till": 1700 - }, { - "vehicle_type": "Default", - "depot": "Kaki Bukit Warehouse", - "group": "", - "name": "TEST155", - "time_window": "Default", - "address": "3 Gateway Dr, Singapore 608532 ", - "postal_code": null, - "weight_load": null, - "volume_load": null, - "seating_load": null, - "service_time": 5, - "lat": "1.33447219", - "lng": "103.74169972", - "from": 900, - "till": 1700 - }, { - "vehicle_type": "Default", - "depot": "Kaki Bukit Warehouse", - "group": "", - "name": "TEST157", - "time_window": "Default", - "address": "81 Choa Chu Kang Way, Singapore 688263 ", - "postal_code": null, - "weight_load": null, - "volume_load": null, - "seating_load": null, - "service_time": 5, - "lat": "1.38418227", - "lng": "103.73716996", - "from": 900, - "till": 1700 - }, { - "vehicle_type": "Default", - "depot": "Kaki Bukit Warehouse", - "group": "", - "name": "TEST158", - "time_window": "Default", - "address": "21 Jurong East Street 31, Singapore 609517 ", - "postal_code": null, - "weight_load": null, - "volume_load": null, - "seating_load": null, - "service_time": 5, - "lat": "1.34602221", - "lng": "103.73016995", - "from": 900, - "till": 1700 - }, { - "vehicle_type": "Default", - "depot": "Kaki Bukit Warehouse", - "group": "", - "name": "TEST159", - "time_window": "Default", - "address": "2 Jurong Hill, Singapore 628925 ", - "postal_code": null, - "weight_load": null, - "volume_load": null, - "seating_load": null, - "service_time": 5, - "lat": "1.32101216", - "lng": "103.70616991", - "from": 900, - "till": 1700 - }, { - "vehicle_type": "Default", - "depot": "Kaki Bukit Warehouse", - "group": "", - "name": "TEST160", - "time_window": "Default", - "address": "510 Upper Jurong Rd, Singapore 638365 ", - "postal_code": null, - "weight_load": null, - "volume_load": null, - "seating_load": null, - "service_time": 5, - "lat": "1.33264218", - "lng": "103.67816987", - "from": 900, - "till": 1700 - }, { - "vehicle_type": "Default", - "depot": "Kaki Bukit Warehouse", - "group": "", - "name": "TEST161", - "time_window": "Default", - "address": "51 Chinese Cemetery Path 4, Singapore 698932 ", - "postal_code": null, - "weight_load": null, - "volume_load": null, - "seating_load": null, - "service_time": 5, - "lat": "1.37438225", - "lng": "103.68716988", - "from": 900, - "till": 1700 - }, { - "vehicle_type": "Default", - "depot": "Kaki Bukit Warehouse", - "group": "", - "name": "TEST162", - "time_window": "Default", - "address": "21 Lower Kent Ridge Rd, Singapore 119077 ", - "postal_code": null, - "weight_load": null, - "volume_load": null, - "seating_load": null, - "service_time": 5, - "lat": "1.29711213", - "lng": "103.77717003", - "from": 900, - "till": 1700 - }, { - "vehicle_type": "Default", - "depot": "Kaki Bukit Warehouse", - "group": "", - "name": "TEST163", - "time_window": "Default", - "address": "469D Bukit Timah Rd, Singapore 259773 ", - "postal_code": null, - "weight_load": null, - "volume_load": null, - "seating_load": null, - "service_time": 5, - "lat": "1.31878216", - "lng": "103.81817010", - "from": 900, - "till": 1700 - }, { - "vehicle_type": "Default", - "depot": "Kaki Bukit Warehouse", - "group": "", - "name": "TEST164", - "time_window": "Default", - "address": "1384 Ang Mo Kio Ave 1 ", - "postal_code": null, - "weight_load": null, - "volume_load": null, - "seating_load": null, - "service_time": 5, - "lat": "1.36219223", - "lng": "103.84717014", - "from": 900, - "till": 1700 - }, { - "vehicle_type": "Default", - "depot": "Kaki Bukit Warehouse", - "group": "", - "name": "TEST165", - "time_window": "Default", - "address": "90 Yishun Central, Singapore 768828 ", - "postal_code": null, - "weight_load": null, - "volume_load": null, - "seating_load": null, - "service_time": 5, - "lat": "1.42381233", - "lng": "103.83170129", - "from": 900, - "till": 1700 - }, { - "vehicle_type": "Default", - "depot": "Kaki Bukit Warehouse", - "group": "", - "name": "TEST166", - "time_window": "Default", - "address": "604 Sembawang Rd, Singapore 758459 ", - "postal_code": null, - "weight_load": null, - "volume_load": null, - "seating_load": null, - "service_time": 5, - "lat": "1.44187236", - "lng": "103.82417011", - "from": 900, - "till": 1700 - }, { - "vehicle_type": "Default", - "depot": "Kaki Bukit Warehouse", - "group": "", - "name": "TEST167", - "time_window": "Default", - "address": "2 Woodlands Industrial Park E4, Singapore 757387 ", - "postal_code": null, - "weight_load": null, - "volume_load": null, - "seating_load": null, - "service_time": 5, - "lat": "1.45223790", - "lng": "103.79317006", - "from": 900, - "till": 1700 - }, { - "vehicle_type": "Default", - "depot": "Kaki Bukit Warehouse", - "group": "", - "name": "TEST168", - "time_window": "Default", - "address": "9 Woodlands Ave 9, Singapore 738964 ", - "postal_code": null, - "weight_load": null, - "volume_load": null, - "seating_load": null, - "service_time": 5, - "lat": "1.44379237", - "lng": "103.78417004", - "from": 900, - "till": 1700 - }, { - "vehicle_type": "Default", - "depot": "Kaki Bukit Warehouse", - "group": "", - "name": "TEST169", - "time_window": "Default", - "address": "249 Sembawang Road, Singapore 758352 ", - "postal_code": null, - "weight_load": null, - "volume_load": null, - "seating_load": null, - "service_time": 5, - "lat": "1.41752232", - "lng": "103.81517009", - "from": 900, - "till": 1700 - }, { - "vehicle_type": "Default", - "depot": "Kaki Bukit Warehouse", - "group": "", - "name": "TEST170", - "time_window": "Default", - "address": "1 Turf Club Avenue, Singapore 738078 ", - "postal_code": null, - "weight_load": null, - "volume_load": null, - "seating_load": null, - "service_time": 5, - "lat": "1.42271233", - "lng": "103.76170005", - "from": 900, - "till": 1700 - }, { - "vehicle_type": "Default", - "depot": "Kaki Bukit Warehouse", - "group": "", - "name": "TEST171", - "time_window": "Default", - "address": "1 Choa Chu Kang Street 53, Singapore 689236 ", - "postal_code": null, - "weight_load": null, - "volume_load": null, - "seating_load": null, - "service_time": 5, - "lat": "1.39134228", - "lng": "103.74716998", - "from": 900, - "till": 1700 - }], - "depots": [{ - "name": "Kaki Bukit Warehouse", - "address": "61 Kaki Bukit Avenue 1", - "lat": "1.33674219", - "lng": "103.91017025", - "default": 1, - "created_at": "2019-02-11 08:35:52", - "updated_at": "2019-02-11 08:35:52" - }], - "vehicles": [{ - "depot": "Kaki Bukit Warehouse", - "name": "GH1234H", - "priority": 10, - "weight_capacity": null, - "volume_capacity": null, - "seating_capacity": null, - "buffer": null, - "avail_from": 900, - "avail_till": 1700, - "return_to_depot": 1, - "created_at": "2019-02-11 08:36:06", - "updated_at": "2019-02-11 08:36:06", - "vehicle_types": ["Default"] - }, { - "depot": "Kaki Bukit Warehouse", - "name": "SGX8171C", - "priority": 10, - "weight_capacity": null, - "volume_capacity": null, - "seating_capacity": null, - "buffer": null, - "return_to_depot": 1, - "created_at": "2019-02-11 10:23:32", - "updated_at": "2019-02-11 10:23:32", - "vehicle_types": ["Default"] - }], - "rushHours": [], - "timeWindows": [{ - "id": 1, - "team_id": 1, - "name": "Default", - "from": 900, - "till": 1700, - "default": 1, - "created_at": "2019-02-11 08:35:29", - "updated_at": "2019-02-11 10:16:19" - }], - "vehicleTypes": [{ - "id": 1, - "team_id": 1, - "name": "Default", - "default": 1, - "default_setting": 0, - "created_at": "2019-02-11 08:35:29", - "updated_at": "2019-02-11 08:35:29" - }] -} \ No newline at end of file diff --git a/tests/integration/test_simple_plan.py b/tests/integration/test_simple_plan.py deleted file mode 100644 index 6617d67..0000000 --- a/tests/integration/test_simple_plan.py +++ /dev/null @@ -1,95 +0,0 @@ -import unittest -import os -import time -import elasticroute -from pathlib import Path -import json - - -class SimplePlanTest(unittest.TestCase): - @classmethod - def setup_class(cls): - if os.getenv("ELASTICROUTE_PROXY_ENABLED") == "true": - cls.options = { - "proxies": { - "https": os.getenv("ELASTICROUTE_PROXY_HOST") - }, - "verify": False - } - else: - cls.options = {} - - def testSimplePlan(self): - plan = elasticroute.Plan() - plan.id = "TestSimplePlan_{}".format(int(time.time())) - plan.stops = [ - { - 'name': 'SUTD', - 'address': '8 Somapah Road Singapore 487372', - }, - { - 'name': 'Changi Airport', - 'address': '80 Airport Boulevard (S)819642', - }, - { - 'name': 'Gardens By the Bay', - 'lat': '1.281407', - 'lng': '103.865770', - }, - { - 'name': 'Singapore Zoo', - 'lat': '1.404701', - 'lng': '103.790018', - }, - ] - plan.vehicles = [ - { - 'name': 'Van 1', - 'address': '61 Kaki Bukit Ave 1 #04-34, Shun Li Ind Park Singapore 417943', - 'avail_from': 900, - 'avail_till': 1200, - }, - { - 'name': 'Van 2', - 'address': '61 Kaki Bukit Ave 1 #04-34, Shun Li Ind Park Singapore 417943', - 'avail_from': 1200, - 'avail_till': 1400, - }, - ] - plan.depots = [ - { - 'name': 'Main Warehouse', - 'address': '61 Kaki Bukit Ave 1 #04-34, Shun Li Ind Park Singapore 417943', - 'postal_code': '417943', - } - ] - solution = plan.solve(request_options=self.__class__.options) - - self.assertIsInstance(solution, elasticroute.Solution) - self.assertIsInstance(solution.raw_response_string, str) - self.assertIsInstance(solution.raw_response_data, dict) - for stop in solution.stops: - self.assertIsInstance(stop, elasticroute.Stop) - for depot in solution.depots: - self.assertIsInstance(depot, elasticroute.Depot) - for vehicle in solution.vehicles: - self.assertIsInstance(vehicle, elasticroute.Vehicle) - self.assertEqual("planned", solution.raw_response_data["data"]["stage"]) - self.assertEqual("planned", solution.status) - self.assertEqual(100, solution.progress) - - def testAsyncPlan(self): - data_file_path = Path("tests/integration").absolute() / 'bigData.json' - test_data = json.loads(data_file_path.read_text()) - plan = elasticroute.Plan() - plan.id = "TestAsyncPlan_{}".format(int(time.time())) - plan.connection_type = "poll" - plan.stops = test_data["stops"] - plan.depots = test_data["depots"] - plan.vehicles = test_data["vehicles"] - solution = plan.solve(request_options=self.__class__.options) - self.assertEqual("submitted", solution.status) - while solution.status != "planned": - solution.refresh(request_options=self.__class__.options) - time.sleep(5) - self.assertTrue(len(solution.unsolved_stops) >= 0) diff --git a/tests/unit/test_plan.py b/tests/unit/test_plan.py deleted file mode 100644 index 0903a10..0000000 --- a/tests/unit/test_plan.py +++ /dev/null @@ -1,74 +0,0 @@ -import unittest -import time -import elasticroute -from elasticroute import Plan -from elasticroute import Depot -from elasticroute import BadFieldError -import os -import requests - - -class PlanValidationTest(unittest.TestCase): - @classmethod - def setup_class(cls): - if os.getenv("ELASTICROUTE_PROXY_ENABLED") == "true": - cls.options = { - "proxies": { - "https": os.getenv("ELASTICROUTE_PROXY_HOST") - }, - "verify": False - } - else: - cls.options = {} - - def testWillThrowExceptionWhenNoIdIsSet(self): - plan = Plan() - try: - plan.solve(request_options=self.__class__.options) - self.fail('No exception was thrown') - except Exception as ex: - self.assertRegex(str(ex), r'You need to create an id for this plan!.*') - pass - - def testWillThrowExceptionOnHTTPError(self): - # try to intentionally cause an HTTP Error by changing the baseURL - plan = Plan() - elasticroute.defaults.BASE_URL = 'https://example.com' - plan.id = 'TestPlan_' + str(int(time.time())) - depots = [Depot({ - 'name': 'Somewhere', - 'address': 'Somewhere' - })] - vehicles = [{ - 'name': 'Some vehicle' - }] - stops = [ - { - 'name': 'SUTD', - 'address': '8 Somapah Road Singapore 487372', - }, - { - 'name': 'Changi Airport', - 'address': '80 Airport Boulevard (S)819642', - }, - { - 'name': 'Gardens By the Bay', - 'lat': '1.281407', - 'lng': '103.865770', - }, - { - 'name': 'Singapore Zoo', - 'lat': '1.404701', - 'lng': '103.790018', - }, - ] - plan.depots = depots - plan.vehicles = vehicles - plan.stops = stops - try: - plan.solve(request_options=self.__class__.options) - self.fail("No exception was thrown") - except Exception as ex: - self.assertRegex(str(ex), r'API Return HTTP Code.*') - pass - elasticroute.defaults.BASE_URL = os.getenv("ELASTICROUTE_PATH") or 'https://app.elasticroute.com/api/v1/plan' diff --git a/tests/unit/test_solution.py b/tests/unit/test_solution.py deleted file mode 100644 index 7164f7e..0000000 --- a/tests/unit/test_solution.py +++ /dev/null @@ -1,107 +0,0 @@ -import unittest -import time -import elasticroute -from elasticroute import Plan -from elasticroute import Depot -from elasticroute import BadFieldError -import os - - -class SolutionValidationTest(unittest.TestCase): - @classmethod - def setup_class(cls): - if os.getenv("ELASTICROUTE_PROXY_ENABLED") == "true": - cls.options = { - "proxies": { - "https": os.getenv("ELASTICROUTE_PROXY_HOST") - }, - "verify": False - } - else: - cls.options = {} - - def testWillThrowExceptionWhenNoIdIsSet(self): - plan = Plan() - plan.id = 'TestPlan_' + str(int(time.time())) - depots = [Depot({ - 'name': 'Somewhere', - 'address': 'Somewhere' - })] - vehicles = [{ - 'name': 'Some vehicle' - }] - stops = [ - { - 'name': 'SUTD', - 'address': '8 Somapah Road Singapore 487372', - }, - { - 'name': 'Changi Airport', - 'address': '80 Airport Boulevard (S)819642', - }, - { - 'name': 'Gardens By the Bay', - 'lat': '1.281407', - 'lng': '103.865770', - }, - { - 'name': 'Singapore Zoo', - 'lat': '1.404701', - 'lng': '103.790018', - }, - ] - plan.depots = depots - plan.vehicles = vehicles - plan.stops = stops - solution = plan.solve(request_options=self.__class__.options) - solution.plan_id = "" - try: - solution.refresh() - self.fail("No exception was thrown") - except Exception as ex: - self.assertRegex(str(ex), r'You need to create an id for this plan!*') - pass - - def testWillThrowExceptionOnHTTPError(self): - # try to intentionally cause an HTTP Error by changing the baseURL - plan = Plan() - plan.id = 'TestPlan_2' + str(int(time.time())) - depots = [Depot({ - 'name': 'Somewhere', - 'address': 'Somewhere' - })] - vehicles = [{ - 'name': 'Some vehicle' - }] - stops = [ - { - 'name': 'SUTD', - 'address': '8 Somapah Road Singapore 487372', - }, - { - 'name': 'Changi Airport', - 'address': '80 Airport Boulevard (S)819642', - }, - { - 'name': 'Gardens By the Bay', - 'lat': '1.281407', - 'lng': '103.865770', - }, - { - 'name': 'Singapore Zoo', - 'lat': '1.404701', - 'lng': '103.790018', - }, - ] - plan.depots = depots - plan.vehicles = vehicles - plan.stops = stops - solution = plan.solve(request_options=self.__class__.options) - elasticroute.defaults.BASE_URL = "https://example.com" - try: - solution.refresh(request_options=self.__class__.options) - self.fail("No exception was thrown") - except Exception as ex: - self.assertRegex(str(ex), r'API Return HTTP Code.*') - pass - elasticroute.defaults.BASE_URL = os.getenv("ELASTICROUTE_PATH") or 'https://app.elasticroute.com/api/v1/plan' diff --git a/tests/unit/test_stop_validation.py b/tests/unit/test_stop_validation.py deleted file mode 100644 index f23be17..0000000 --- a/tests/unit/test_stop_validation.py +++ /dev/null @@ -1,144 +0,0 @@ -import unittest -import random -import time -import inspect -import hashlib -from elasticroute import Stop -from elasticroute import BadFieldError - - -class StopValidationTest(unittest.TestCase): - def createStop(self, testname=None): - stop = Stop() - seed = random.random() + time.time() - seed = str(seed) - stop["name"] = testname or inspect.stack()[1][3] + str(hashlib.md5(seed.encode("utf-8")).digest()) - testAddresses = ['61 Kaki Bukit Ave 1 #04-34, Shun Li Ind Park Singapore 417943', - '8 Somapah Road Singapore 487372', - '80 Airport Boulevard (S)819642', - '80 Mandai Lake Road Singapore 729826', - '10 Bayfront Avenue Singapore 018956', - '18 Marina Gardens Drive Singapore 018953', ] - stop["address"] = random.sample(testAddresses, 1)[0] - return stop - - def testMustHaveAtLeastTwoStops(self): - __METHOD__ = inspect.stack()[0][3] - stops = [self.createStop(__METHOD__ + '0')] - try: - self.assertTrue(Stop.validateStops(stops)) - self.fail('No exception was thrown') - except BadFieldError as ex: - self.assertRegex(str(ex), r'You must have at least two stops.*') - - stops = [self.createStop(__METHOD__ + '1'), - self.createStop(__METHOD__ + '2'), - ] - self.assertTrue(Stop.validateStops(stops)) - pass - - def testNamesMustBeDistinct(self): - __METHOD__ = inspect.stack()[0][3] - stops = [self.createStop(__METHOD__) for i in range(2)] - try: - self.assertTrue(Stop.validateStops(stops)) - self.fail('No exception was thrown') - except BadFieldError as ex: - self.assertRegex(str(ex), r'Stop name must be distinct.*') - - stops = [self.createStop(__METHOD__ + str(i)) for i in range(2)] - self.assertTrue(Stop.validateStops(stops)) - pass - - def testNamesCannotBeEmpty(self): - stops = [self.createStop()] - badStop = self.createStop() - badStop["name"] = "" - stops.append(badStop) - try: - self.assertTrue(Stop.validateStops(stops)) - self.fail('No exception was thrown') - except BadFieldError as ex: - self.assertRegex(str(ex), r'Stop name cannot be null.*') - pass - - def testNamesCannotBeLongerThan255Chars(self): - stops = [self.createStop(), self.createStop("LONG LOOOONG M" + ("A" * 255) + "AN")] - try: - self.assertTrue(Stop.validateStops(stops)) - self.fail('No exception was thrown') - except BadFieldError as ex: - self.assertRegex(str(ex), r'Stop name cannot be more than 255 chars.*') - pass - - def testCanPassCoordinatesOnly(self): - stop = Stop() - stop["name"] = "Something" - stop["address"] = None - stop["lat"] = 1.3368888888888888 - stop["lng"] = 103.91086111111112 - stops = [self.createStop(), stop] - self.assertTrue(Stop.validateStops(stops)) - pass - - def testPositiveNumericFields(self): - fields = ['weight_load', 'volume_load', 'seating_load', 'service_time'] - for field in fields: - badValues = [-0.5, '-9001.3'] - for badValue in badValues: - stops = [self.createStop()] - badStop = self.createStop() - badStop[field] = badValue - stops.append(badStop) - try: - self.assertTrue(Stop.validateStops(stops)) - self.fail('No exception thrown') - except BadFieldError as ex: - self.assertRegex(str(ex), r'Stop ' + field + r' cannot be negative.*') - - stops = [self.createStop(), self.createStop()] - stops[0][field] = "1.0" - stops[1][field] = "nani" - try: - self.assertTrue(Stop.validateStops(stops)) - self.fail('No exception thrown') - except BadFieldError as ex: - self.assertRegex(str(ex), r'Stop ' + field + r' must be numeric.*') - pass - - def testCanPassPostcodeForSupportedCountries(self): - badStop = Stop() - badStop["name"] = 'Nani' - badStop["postal_code"] = 'S417943' - stops = [self.createStop(), badStop] - mockGeneralSettings = { - 'country': 'SG', - } - self.assertTrue(Stop.validateStops(stops, mockGeneralSettings)) - - try: - self.assertTrue(Stop.validateStops(stops)) - self.fail('No exception was thrown') - except BadFieldError as ex: - self.assertRegex(str(ex), r'Stop address and coordinates are not given.*') - pass - - def testCannotPassNoFormsOfAddress(self, ): - badStop = Stop() - badStop["name"] = 'Nani' - stops = [self.createStop(), badStop] - mockGeneralSettings = { - 'country': 'SG', - } - try: - self.assertTrue(Stop.validateStops(stops)) - self.fail('No exception was thrown') - except BadFieldError as ex: - self.assertRegex(str(ex), r'Stop address and coordinates are not given.*') - - try: - self.assertTrue(Stop.validateStops(stops, mockGeneralSettings)) - self.fail('No exception was thrown') - except BadFieldError as ex: - self.assertRegex(str(ex), r'Stop address and coordinates are not given, and postcode is not present.*') - pass diff --git a/tests/unit/test_vehicle_validation.py b/tests/unit/test_vehicle_validation.py deleted file mode 100644 index b044297..0000000 --- a/tests/unit/test_vehicle_validation.py +++ /dev/null @@ -1,92 +0,0 @@ -import unittest -import random -import time -import inspect -import hashlib -from elasticroute import Vehicle -from elasticroute import BadFieldError - - -class VehicleValidationTest(unittest.TestCase): - def createVehicle(self, testname=None): - vehicle = Vehicle() - seed = random.random() + time.time() - seed = str(seed) - vehicle["name"] = testname or inspect.stack()[1][3] + str(hashlib.md5(seed.encode("utf-8")).digest()) - testAddresses = ['61 Kaki Bukit Ave 1 #04-34, Shun Li Ind Park Singapore 417943', - '8 Somapah Road Singapore 487372', - '80 Airport Boulevard (S)819642', - '80 Mandai Lake Road Singapore 729826', - '10 Bayfront Avenue Singapore 018956', - '18 Marina Gardens Drive Singapore 018953', ] - vehicle["address"] = random.sample(testAddresses, 1)[0] - return vehicle - - def testNamesMustBeDistinct(self): - __METHOD__ = inspect.stack()[0][3] - vehicles = [self.createVehicle(__METHOD__) for i in range(2)] - try: - self.assertTrue(Vehicle.validateVehicles(vehicles)) - self.fail('No exception was thrown') - except BadFieldError as ex: - self.assertRegex(str(ex), r'Vehicle name must be distinct.*') - - vehicles = [self.createVehicle(__METHOD__ + str(i)) for i in range(2)] - self.assertTrue(Vehicle.validateVehicles(vehicles)) - pass - - def testNamesCannotBeEmpty(self): - vehicles = [self.createVehicle()] - badVehicle = self.createVehicle() - badVehicle["name"] = "" - vehicles.append(badVehicle) - try: - self.assertTrue(Vehicle.validateVehicles(vehicles)) - self.fail('No exception was thrown') - except BadFieldError as ex: - self.assertRegex(str(ex), r'Vehicle name cannot be null.*') - pass - - def testNamesCannotBeLongerThan255Chars(self): - vehicles = [self.createVehicle(), self.createVehicle("LONG LOOOONG M" + ("A" * 255) + "AN")] - try: - self.assertTrue(Vehicle.validateVehicles(vehicles)) - self.fail('No exception was thrown') - except BadFieldError as ex: - self.assertRegex(str(ex), r'Vehicle name cannot be more than 255 chars.*') - pass - - def testCanPassCoordinatesOnly(self): - vehicle = Vehicle() - vehicle["name"] = "Something" - vehicle["address"] = None - vehicle["lat"] = 1.3368888888888888 - vehicle["lng"] = 103.91086111111112 - vehicles = [self.createVehicle(), vehicle] - self.assertTrue(Vehicle.validateVehicles(vehicles)) - pass - - def testPositiveNumericFields(self): - fields = ['weight_capacity', 'volume_capacity', 'seating_capacity'] - for field in fields: - badValues = [-0.5, '-9001.3'] - for badValue in badValues: - vehicles = [self.createVehicle()] - badVehicle = self.createVehicle() - badVehicle[field] = badValue - vehicles.append(badVehicle) - try: - self.assertTrue(Vehicle.validateVehicles(vehicles)) - self.fail('No exception thrown') - except BadFieldError as ex: - self.assertRegex(str(ex), r'Vehicle ' + field + r' cannot be negative.*') - - vehicles = [self.createVehicle(), self.createVehicle()] - vehicles[0][field] = "1.0" - vehicles[1][field] = "nani" - try: - self.assertTrue(Vehicle.validateVehicles(vehicles)) - self.fail('No exception thrown') - except BadFieldError as ex: - self.assertRegex(str(ex), r'Vehicle ' + field + r' must be numeric.*') - pass From 4ebf3ad225b12c572e8d21eef59fa112bc8be2da Mon Sep 17 00:00:00 2001 From: chesnutcase Date: Wed, 23 Oct 2019 13:59:28 +0800 Subject: [PATCH 31/48] delete more old files --- tests/unit/test_depot_validation.py | 104 ---------------------------- 1 file changed, 104 deletions(-) delete mode 100644 tests/unit/test_depot_validation.py diff --git a/tests/unit/test_depot_validation.py b/tests/unit/test_depot_validation.py deleted file mode 100644 index 3f3a65c..0000000 --- a/tests/unit/test_depot_validation.py +++ /dev/null @@ -1,104 +0,0 @@ -import unittest -import random -import time -import inspect -import hashlib -from elasticroute import Depot -from elasticroute import BadFieldError - - -class DepotValidationTest(unittest.TestCase): - def createDepot(self, testname=None): - depot = Depot() - seed = random.random() + time.time() - seed = str(seed) - depot["name"] = testname or inspect.stack()[1][3] + str(hashlib.md5(seed.encode("utf-8")).digest()) - testAddresses = ['61 Kaki Bukit Ave 1 #04-34, Shun Li Ind Park Singapore 417943', - '8 Somapah Road Singapore 487372', - '80 Airport Boulevard (S)819642', - '80 Mandai Lake Road Singapore 729826', - '10 Bayfront Avenue Singapore 018956', - '18 Marina Gardens Drive Singapore 018953', ] - depot["address"] = random.sample(testAddresses, 1)[0] - return depot - - def testNamesMustBeDistinct(self): - __METHOD__ = inspect.stack()[0][3] - depots = [self.createDepot(__METHOD__) for i in range(2)] - try: - self.assertTrue(Depot.validateDepots(depots)) - self.fail('No exception was thrown') - except BadFieldError as ex: - self.assertRegex(str(ex), r'Depot name must be distinct.*') - - depots = [self.createDepot(__METHOD__ + str(i)) for i in range(2)] - self.assertTrue(Depot.validateDepots(depots)) - pass - - def testNamesCannotBeEmpty(self): - depots = [self.createDepot()] - badDepot = self.createDepot() - badDepot["name"] = "" - depots.append(badDepot) - try: - self.assertTrue(Depot.validateDepots(depots)) - self.fail('No exception was thrown') - except BadFieldError as ex: - self.assertRegex(str(ex), r'Depot name cannot be null.*') - pass - - def testNamesCannotBeLongerThan255Chars(self): - depots = [self.createDepot(), self.createDepot("LONG LOOOONG M" + ("A" * 255) + "AN")] - try: - self.assertTrue(Depot.validateDepots(depots)) - self.fail('No exception was thrown') - except BadFieldError as ex: - self.assertRegex(str(ex), r'Depot name cannot be more than 255 chars.*') - pass - - def testCanPassCoordinatesOnly(self): - depot = Depot() - depot["name"] = "Something" - depot["address"] = None - depot["lat"] = 1.3368888888888888 - depot["lng"] = 103.91086111111112 - depots = [self.createDepot(), depot] - self.assertTrue(Depot.validateDepots(depots)) - pass - - def testCanPassPostcodeForSupportedCountries(self): - badDepot = Depot() - badDepot["name"] = 'Nani' - badDepot["postal_code"] = 'S417943' - depots = [self.createDepot(), badDepot] - mockGeneralSettings = { - 'country': 'SG', - } - self.assertTrue(Depot.validateDepots(depots, mockGeneralSettings)) - - try: - self.assertTrue(Depot.validateDepots(depots)) - self.fail('No exception was thrown') - except BadFieldError as ex: - self.assertRegex(str(ex), r'Depot address and coordinates are not given.*') - pass - - def testCannotPassNoFormsOfAddress(self, ): - badDepot = Depot() - badDepot["name"] = 'Nani' - depots = [self.createDepot(), badDepot] - mockGeneralSettings = { - 'country': 'SG', - } - try: - self.assertTrue(Depot.validateDepots(depots)) - self.fail('No exception was thrown') - except BadFieldError as ex: - self.assertRegex(str(ex), r'Depot address and coordinates are not given.*') - - try: - self.assertTrue(Depot.validateDepots(depots, mockGeneralSettings)) - self.fail('No exception was thrown') - except BadFieldError as ex: - self.assertRegex(str(ex), r'Depot address and coordinates are not given, and postcode is not present.*') - pass From b92017a4f95429a41d559b22adc1dcaa63077ba0 Mon Sep 17 00:00:00 2001 From: chesnutcase Date: Wed, 23 Oct 2019 13:59:48 +0800 Subject: [PATCH 32/48] add old_name attribute to dashboard vehicles as well --- elasticroute/dashboard.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/elasticroute/dashboard.py b/elasticroute/dashboard.py index 32c0122..9c37123 100644 --- a/elasticroute/dashboard.py +++ b/elasticroute/dashboard.py @@ -27,6 +27,17 @@ class Vehicle(BaseVehicle): 'updated_at' } + def __init__(self, data={}): + super().__init__(data) + self.__old_name = None + + def __setitem__(self, k, v): + if k == "name" and self["name"] != v: + self.__old_name = str(self["name"]) + super().__setitem__(k, v) + + old_name = property(lambda self: self.__old_name) + class Stop(BaseStop): # add dashboard only fields From 220b49b0adfdd7c63ad63f688bdc2250c8cce22c Mon Sep 17 00:00:00 2001 From: chesnutcase Date: Wed, 23 Oct 2019 14:00:14 +0800 Subject: [PATCH 33/48] add Plan object --- elasticroute/routing.py | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/elasticroute/routing.py b/elasticroute/routing.py index f78f04c..ff393cd 100644 --- a/elasticroute/routing.py +++ b/elasticroute/routing.py @@ -1,3 +1,5 @@ +from datetime import datetime + from .common import Vehicle as BaseVehicle from .common import Stop as BaseStop from .common import Depot as BaseDepot @@ -26,3 +28,41 @@ class Stop(BaseStop): class Depot(BaseDepot): # no difference from base depot for now pass + + +class Plan(): + generalSettings = { + "country": None, + "timezone": None, + "loading_time": None, + "buffer": None, + "service_time": None, + "depot_selection": None, + "depot_selection_radius": None, + "distance_unit": None, + "max_time": None, + "max_distance": None, + "max_stops": None, + "max_runs": None, + "from": None, + "till": None, + "webhook_url": None + } + + def __init__(self, date=None, stops=None, depots=None, vehicles=None, rushHours=None, generalSettings=None): + self.date = date if date is not None else datetime.now().strftime("%Y-%m-%d") + self.stops = stops if stops is not None else [] + self.depots = depots if depots is not None else [] + self.vehicles = vehicles if vehicles is not None else [] + self.rushHours = rushHours if rushHours is not None else [] + self.generalSettings = generalSettings if generalSettings is not None else self.__class__().generalSettings + + def __dict__(self): + return { + "date": self.date, + "stops": self.stops, + "depots": self.depots, + "vehicles": self.vehicles, + "rushHours": self.rushHours, + "generalSettings": self.generalSettings + } From 898228454dbdc042c63df48099909716d14029d7 Mon Sep 17 00:00:00 2001 From: chesnutcase Date: Wed, 23 Oct 2019 14:00:34 +0800 Subject: [PATCH 34/48] add BaseDepot and RoutingDepot serializers and deserializers --- elasticroute/deserializers.py | 12 ++++++-- elasticroute/serializers.py | 58 +++++++++++++++++++++++++++++++---- 2 files changed, 62 insertions(+), 8 deletions(-) diff --git a/elasticroute/deserializers.py b/elasticroute/deserializers.py index 80e60f8..a3509f5 100644 --- a/elasticroute/deserializers.py +++ b/elasticroute/deserializers.py @@ -1,7 +1,7 @@ from .common import Bean -from .common import Stop as BaseStop, Vehicle as BaseVehicle +from .common import Stop as BaseStop, Vehicle as BaseVehicle, Depot as BaseDepot from .dashboard import Stop as DashboardStop, Vehicle as DashboardVehicle -from .routing import Stop as RoutingStop, Vehicle as RoutingVehicle +from .routing import Stop as RoutingStop, Vehicle as RoutingVehicle, Depot as RoutingDepot class Deserializer(): @@ -44,3 +44,11 @@ class DashboardVehicleDeserializer(VehicleDeserializer): class RoutingVehicleDeserializer(VehicleDeserializer): target_class = RoutingVehicle + + +class DepotDeserializer(BeanDeserializer): + target_class = BaseDepot + + +class RoutingDepotDeserializer(DepotDeserializer): + target_class = RoutingDepot diff --git a/elasticroute/serializers.py b/elasticroute/serializers.py index cf63665..b261865 100644 --- a/elasticroute/serializers.py +++ b/elasticroute/serializers.py @@ -1,14 +1,12 @@ from .common import Bean -from .common import Stop as BaseStop, Vehicle as BaseVehicle +from .common import Stop as BaseStop, Vehicle as BaseVehicle, Depot as BaseDepot from .dashboard import Stop as DashboardStop, Vehicle as DashboardVehicle -from .routing import Stop as RoutingStop, Vehicle as RoutingVehicle +from .routing import Stop as RoutingStop, Vehicle as RoutingVehicle, Depot as RoutingDepot +from .routing import Plan -class Serializer(): - def __init__(self, *, vanilla_keys_only=True, modified_keys_only=True): - self.vanilla_keys_only = vanilla_keys_only - self.modified_keys_only = modified_keys_only +class Serializer(): def to_dict(self, obj): pass @@ -122,3 +120,51 @@ def to_dict(self, obj): return d else: raise TypeError("Invalid data type passed to to_dict: expected {} or dict, received {}".format(RoutingVehicle, type(obj))) + + +class DepotSerializer(BeanSerializer): + def to_dict(self, obj): + if isinstance(obj, BaseDepot): + # the superclass is capable of processing this + return super().to_dict(obj) + elif type(obj) is dict: + d = obj + # decide whether to remove non vanilla keys + if self.vanilla_keys_only: + d = {k: v for (k, v) in d.items() if k in BaseDepot.get_full_default_data().keys()} + return d + else: + raise TypeError("Invalid data type passed to to_dict: expected {} or dict, received {}".format(BaseDepot, type(obj))) + + +class RoutingDepotSerializer(DepotSerializer): + def to_dict(self, obj): + if type(obj) is RoutingDepot: + # the superclass is capable of processing this + return super().to_dict(obj) + elif type(obj) is dict: + d = obj + # decide whether to remove non vanilla keys + if self.vanilla_keys_only: + d = {k: v for (k, v) in d.items() if k in RoutingDepot.get_full_default_data().keys()} + return d + else: + raise TypeError("Invalid data type passed to to_dict: expected {} or dict, received {}".format(RoutingDepot, type(obj))) + + +class PlanSerializer(Serializer): + def __init__(self, *, stop_serializer=None, vehicle_serializer=None, depot_serializer=None): + self.stop_serializer = stop_serializer + self.vehicle_serializer = vehicle_serializer + self.depot_serializer = depot_serializer + + def to_dict(self, obj): + if type(obj) is Plan: + obj = obj.__dict__() + if type(obj) is not dict: + raise TypeError("Invalid data type passed to to_dict: expected {} or dict, received {}".format(Plan, type(obj))) + obj["stops"] = [self.stop_serializer.to_dict(s) for s in obj["stops"]] + obj["vehicles"] = [self.vehicle_serializer.to_dict(s) for s in obj["vehicles"]] + obj["depots"] = [self.depot_serializer.to_dict(s) for s in obj["depots"]] + + return obj From e1c40e5ce134d35cacdae0d498db52b4b5c33d0a Mon Sep 17 00:00:00 2001 From: chesnutcase Date: Wed, 23 Oct 2019 14:27:52 +0800 Subject: [PATCH 35/48] add is_valid_date helper function to validators modules --- elasticroute/validators.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/elasticroute/validators.py b/elasticroute/validators.py index 6abcb01..23ad811 100644 --- a/elasticroute/validators.py +++ b/elasticroute/validators.py @@ -1,3 +1,4 @@ +from datetime import datetime from .exceptions.validator import BadFieldError @@ -37,6 +38,15 @@ def inty_number_or_string(input): return True +def is_valid_date(date_text): + try: + if date_text != datetime.strptime(date_text, "%Y-%m-%d").strftime('%Y-%m-%d'): + raise ValueError + return True + except ValueError: + return False + + class Validator(): single_object_rules = { From 2652ede1fe6ea53722ce4aac9df494cee1d539ac Mon Sep 17 00:00:00 2001 From: chesnutcase Date: Wed, 23 Oct 2019 14:28:15 +0800 Subject: [PATCH 36/48] add basic Depot validator (needs extra work) --- elasticroute/validators.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/elasticroute/validators.py b/elasticroute/validators.py index 23ad811..1ee5efd 100644 --- a/elasticroute/validators.py +++ b/elasticroute/validators.py @@ -94,3 +94,12 @@ class VehicleValidator(Validator): "if present, avail_from must be a valid whole number representation from 0 to 2359": lambda o: (inty_number_or_string(o["avail_from"]) and int(o["avail_from"]) >= 0 and int(o["avail_from"]) <= 2359) if o.get("avail_from") is not None else True, "if present, avail_till must be a valid whole number representation from 0 to 2359": lambda o: (inty_number_or_string(o["avail_till"]) and int(o["avail_till"]) >= 0 and int(o["avail_till"]) <= 2359) if o.get("avail_till") is not None else True, } + + +class DepotValidator(Validator): + + single_object_rules = { + "name is not null or empty string": lambda o: not_null_or_ws_str(o["name"]), + "either address or lat/lng is present": lambda o: not_null_or_ws_str(o["address"]) or (floaty_number_or_string(o["lat"]) and floaty_number_or_string(o["lng"])), + } + From 2de4a39ed26f51ad2c4ffdbef4810ad755f8d5dd Mon Sep 17 00:00:00 2001 From: chesnutcase Date: Wed, 23 Oct 2019 14:33:02 +0800 Subject: [PATCH 37/48] add basic Plan validator (needs extra work) --- elasticroute/validators.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/elasticroute/validators.py b/elasticroute/validators.py index 1ee5efd..a1d023c 100644 --- a/elasticroute/validators.py +++ b/elasticroute/validators.py @@ -1,4 +1,6 @@ from datetime import datetime +from inspect import signature + from .exceptions.validator import BadFieldError @@ -103,3 +105,26 @@ class DepotValidator(Validator): "either address or lat/lng is present": lambda o: not_null_or_ws_str(o["address"]) or (floaty_number_or_string(o["lat"]) and floaty_number_or_string(o["lng"])), } + +class PlanValidator(Validator): + stop_validator = StopValidator + depot_validator = DepotValidator + vehicle_validator = VehicleValidator + + single_object_rules = { + "date is valid date": lambda o: is_valid_date(o["date"]), + "stops is a list of valid stop representations": lambda o, c: type(o["stops"]) is list and all([c.stop_validator.validate_object(s) for s in o["stops"]]), + "vehicles is a list of valid vehicle representations": lambda o, c: type(o["vehicles"]) is list and all([c.vehicle_validator.validate_object(s) for s in o["vehicles"]]), + "depots is a list of valid depot representations": lambda o, c: type(o["depots"]) is list and all([c.depot_validator.validate_object(s) for s in o["depots"]]), + } + + @classmethod + def validate_object(cls, obj): + for rulename, rulefunction in cls.single_object_rules.items(): + if len(signature(rulefunction).parameters) == 1: + if not rulefunction(obj): + raise BadFieldError("Validation Failed for Validator {}, rule: {}".format(cls, rulename)) + elif len(signature(rulefunction).parameters) == 2: + if not rulefunction(obj, cls): + raise BadFieldError("Validation Failed for Validator {}, rule: {}".format(cls, rulename)) + return True From d29c13a3dc2377de2cd8087e7a424b42bc96ad9b Mon Sep 17 00:00:00 2001 From: chesnutcase Date: Wed, 23 Oct 2019 14:39:12 +0800 Subject: [PATCH 38/48] add basic Plan deserializer (needs extra work) --- elasticroute/deserializers.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/elasticroute/deserializers.py b/elasticroute/deserializers.py index a3509f5..beb96e8 100644 --- a/elasticroute/deserializers.py +++ b/elasticroute/deserializers.py @@ -3,6 +3,8 @@ from .dashboard import Stop as DashboardStop, Vehicle as DashboardVehicle from .routing import Stop as RoutingStop, Vehicle as RoutingVehicle, Depot as RoutingDepot +from .routing import Plan + class Deserializer(): def from_dict(self, d): @@ -52,3 +54,19 @@ class DepotDeserializer(BeanDeserializer): class RoutingDepotDeserializer(DepotDeserializer): target_class = RoutingDepot + + +class PlanDeserializer(Deserializer): + def __init__(self, *, stop_deserializer=None, vehicle_deserializer=None, depot_deserializer=None): + self.stop_deserializer = stop_deserializer + self.vehicle_deserializer = vehicle_deserializer + self.depot_deserializer = depot_deserializer + + def from_dict(self, d): + p = Plan() + for k, v in d.items(): + setattr(p, k, v) + p.stops = [self.stop_deserializer.from_dict(s) for s in d["stops"]] + p.vehicles = [self.vehicle_deserializer.from_dict(v) for v in d["vehicles"]] + p.depots = [self.depot_deserializer.from_dict(de) for de in d["depots"]] + return p From 4039baf2d95bb912675eb23a1c3f951784afef5f Mon Sep 17 00:00:00 2001 From: chesnutcase Date: Wed, 23 Oct 2019 14:44:02 +0800 Subject: [PATCH 39/48] add __getitem__ and __setitem__ magic methods --- elasticroute/routing.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/elasticroute/routing.py b/elasticroute/routing.py index ff393cd..e6f7444 100644 --- a/elasticroute/routing.py +++ b/elasticroute/routing.py @@ -66,3 +66,11 @@ def __dict__(self): "rushHours": self.rushHours, "generalSettings": self.generalSettings } + + def __getitem__(self, k): + if k in ("date", "stops", "depots", "vehicles", "rushHours", "generalSettings"): + return getattr(self, k) + + def __setitem__(self, k, v): + if k in ("date", "stops", "depots", "vehicles", "rushHours", "generalSettings"): + return setattr(self, k, v) From f1166d3285653b498338e1ce55fea65b6df1ff70 Mon Sep 17 00:00:00 2001 From: chesnutcase Date: Wed, 23 Oct 2019 14:51:02 +0800 Subject: [PATCH 40/48] use plan_id attribute instead of name --- elasticroute/routing.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/elasticroute/routing.py b/elasticroute/routing.py index e6f7444..5863074 100644 --- a/elasticroute/routing.py +++ b/elasticroute/routing.py @@ -49,7 +49,8 @@ class Plan(): "webhook_url": None } - def __init__(self, date=None, stops=None, depots=None, vehicles=None, rushHours=None, generalSettings=None): + def __init__(self, plan_id, date=None, stops=None, depots=None, vehicles=None, rushHours=None, generalSettings=None): + self.plan_id = plan_id self.date = date if date is not None else datetime.now().strftime("%Y-%m-%d") self.stops = stops if stops is not None else [] self.depots = depots if depots is not None else [] @@ -59,6 +60,7 @@ def __init__(self, date=None, stops=None, depots=None, vehicles=None, rushHours= def __dict__(self): return { + "plan_id": self.plan_id, "date": self.date, "stops": self.stops, "depots": self.depots, @@ -68,9 +70,9 @@ def __dict__(self): } def __getitem__(self, k): - if k in ("date", "stops", "depots", "vehicles", "rushHours", "generalSettings"): + if k in ("plan_id", "date", "stops", "depots", "vehicles", "rushHours", "generalSettings"): return getattr(self, k) def __setitem__(self, k, v): - if k in ("date", "stops", "depots", "vehicles", "rushHours", "generalSettings"): + if k in ("plan_id", "date", "stops", "depots", "vehicles", "rushHours", "generalSettings"): return setattr(self, k, v) From 692c7ad6d4a41514b188d6445c4737c8f01fe265 Mon Sep 17 00:00:00 2001 From: chesnutcase Date: Wed, 23 Oct 2019 14:51:21 +0800 Subject: [PATCH 41/48] add PlanRepository --- elasticroute/repositories.py | 43 ++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/elasticroute/repositories.py b/elasticroute/repositories.py index 333ebdc..c8f521c 100644 --- a/elasticroute/repositories.py +++ b/elasticroute/repositories.py @@ -233,3 +233,46 @@ def update(self, obj, *, old_name=None): def delete(self, name): return super().delete(name) + + +class PlanRepository(Repository): + path = "" + + def resolve_create_path(self, obj): + return "{}/{}".format(self.client.endpoint, obj["plan_id"]) + + def resolve_retrieve_path(self, plan_id): + return "{}/{}".format(self.client.endpoint, plan_id) + + def resolve_update_path(self, obj): + return "{}/{}".format(self.client.endpoint) + + def resolve_delete_path(self, obj): + return "{}/{}".format(self.client.endpoint, obj["plan_id"]) + + def resolve_stop_path(self, obj): + return "{}/{}/stop".format(self.client.endpoint, obj["plan_id"]) + + def resolve_stop_headers(self, obj): + return self.resolve_default_headers() + + def resolve_stop_body(self, obj): + return {} + + def stop(self, obj, **kwargs): + if self.client.api_key is None or self.client.api_key.strip() == "": + raise ValueError("API Key is not set") + + response = requests.post(self.resolve_stop_path(obj, **kwargs), + json=self.resolve_stop_body(obj, **kwargs), + headers=self.resolve_stop_headers(obj, **kwargs), + **self.request_args + ) + + if str(response.status_code)[0] != "2": + message = response.json().get("message") + raise ERServiceException(message, response.status_code, code=response.status_code) + + response_json = response.json() + + return response_json From a8ad939a1af414c4d4474052732b2319124308f4 Mon Sep 17 00:00:00 2001 From: chesnutcase Date: Wed, 23 Oct 2019 15:01:49 +0800 Subject: [PATCH 42/48] fix wrong import --- elasticroute/validators.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/elasticroute/validators.py b/elasticroute/validators.py index a1d023c..c8c858a 100644 --- a/elasticroute/validators.py +++ b/elasticroute/validators.py @@ -1,7 +1,7 @@ from datetime import datetime from inspect import signature -from .exceptions.validator import BadFieldError +from .errors.validator import BadFieldError def not_null_or_ws_str(string): From 70a1c0c072b52e642f61acb91da8cc3595df590a Mon Sep 17 00:00:00 2001 From: chesnutcase Date: Wed, 23 Oct 2019 15:02:33 +0800 Subject: [PATCH 43/48] add routing engine api client --- elasticroute/clients.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/elasticroute/clients.py b/elasticroute/clients.py index d2459c2..4a646b5 100644 --- a/elasticroute/clients.py +++ b/elasticroute/clients.py @@ -3,6 +3,11 @@ from .deserializers import DashboardStopDeserializer, DashboardVehicleDeserializer from .validators import StopValidator, VehicleValidator +from .repositories import PlanRepository +from .serializers import RoutingStopSerializer, RoutingVehicleSerializer, RoutingDepotSerializer, PlanSerializer +from .deserializers import RoutingStopDeserializer, RoutingVehicleDeserializer, RoutingDepotDeserializer, PlanDeserializer +from .validators import PlanValidator + class DashboardClient(): api_key = '' @@ -12,3 +17,14 @@ def __init__(self, api_key): self.api_key = api_key self.stops = StopRepository(serializer=DashboardStopSerializer(), client=self, deserializer=DashboardStopDeserializer(), validator=StopValidator()) self.vehicles = VehicleRepository(serializer=DashboardVehicleSerializer(), client=self, deserializer=DashboardVehicleDeserializer(), validator=VehicleValidator()) + + +class RoutingClient(): + api_key = '' + endpoint = "https://app.elasticroute.com/api/v1/plan" + + def __init__(self, api_key): + self.api_key = api_key + plan_serializer = PlanSerializer(stop_serializer=RoutingStopSerializer(), vehicle_serializer=RoutingVehicleSerializer(), depot_serializer=RoutingDepotSerializer()) + plan_deserializer = PlanDeserializer(stop_deserializer=RoutingStopDeserializer(), vehicle_deserializer=RoutingVehicleDeserializer(), depot_deserializer=RoutingDepotDeserializer()) + self.plans = PlanRepository(serializer=plan_serializer, client=self, deserializer=plan_deserializer, validator=PlanValidator()) From e6a386d7d3d0ba0a62e98934656d1e25898badab Mon Sep 17 00:00:00 2001 From: chesnutcase Date: Wed, 23 Oct 2019 15:23:01 +0800 Subject: [PATCH 44/48] change repository to use obj instead of name for DELETE method --- elasticroute/repositories.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/elasticroute/repositories.py b/elasticroute/repositories.py index c8f521c..b46f46d 100644 --- a/elasticroute/repositories.py +++ b/elasticroute/repositories.py @@ -128,15 +128,15 @@ def update(self, obj, **kwargs): return self.deserializer.from_dict(response_json["data"]) - def delete(self, name, **kwargs): + def delete(self, obj, **kwargs): if self.client.api_key is None or self.client.api_key.strip() == "": raise ValueError("API Key is not set") - response = requests.put(self.resolve_delete_path(name, **kwargs), - json=self.resolve_delete_body(name, **kwargs), - headers=self.resolve_delete_headers(name, **kwargs), - **self.request_args - ) + response = requests.delete(self.resolve_delete_path(obj, **kwargs), + json=self.resolve_delete_body(obj, **kwargs), + headers=self.resolve_delete_headers(obj, **kwargs), + **self.request_args + ) if str(response.status_code)[0] != "2": message = response.json().get("message") raise ERServiceException(message, response.status_code, code=response.status_code) @@ -196,14 +196,14 @@ def update(self, obj, *, date=None, old_name=None): return super().update(obj, date=date, old_name=old_name) - def delete(self, name, *, date=None): + def delete(self, obj, *, date=None): if date is None: date = datetime.now().strftime("%Y-%m-%d") else: if not validate_date(date): raise ValueError("Invalid Date Format!") - return super().delete(name, date) + return super().delete(obj, date) class VehicleRepository(Repository): @@ -231,8 +231,8 @@ def retrieve(self, name): def update(self, obj, *, old_name=None): return super().update(obj, old_name=old_name) - def delete(self, name): - return super().delete(name) + def delete(self, obj): + return super().delete(obj) class PlanRepository(Repository): From 52f0c70437a3517055dd88414710e610490c6859 Mon Sep 17 00:00:00 2001 From: chesnutcase Date: Wed, 23 Oct 2019 16:22:12 +0800 Subject: [PATCH 45/48] add start_plan and stop_plan methods --- elasticroute/repositories.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/elasticroute/repositories.py b/elasticroute/repositories.py index b46f46d..80844c5 100644 --- a/elasticroute/repositories.py +++ b/elasticroute/repositories.py @@ -205,6 +205,38 @@ def delete(self, obj, *, date=None): return super().delete(obj, date) + def start_planning(self, date=None): + if date is None: + date = datetime.now().strftime("%Y-%m-%d") + else: + if not validate_date(date): + raise ValueError("Invalid Date Format!") + + url = "{}/{}/{}/plan".format(self.client.endpoint, self.path, date) + response = requests.post(url, json={}, headers=self.resolve_default_headers(None)) + + if str(response.status_code)[0] != "2": + message = response.json().get("message") + raise ERServiceException(message, response.status_code, code=response.status_code) + + return True + + def stop_planning(self, date=None): + if date is None: + date = datetime.now().strftime("%Y-%m-%d") + else: + if not validate_date(date): + raise ValueError("Invalid Date Format!") + + url = "{}/{}/{}/plan/stop".format(self.client.endpoint, self.path, date) + response = requests.post(url, json={}, headers=self.resolve_default_headers(None)) + + if str(response.status_code)[0] != "2": + message = response.json().get("message") + raise ERServiceException(message, response.status_code, code=response.status_code) + + return True + class VehicleRepository(Repository): path = "vehicles" From 9537ac3d436bf42475cfba3f1febf874f8ab967c Mon Sep 17 00:00:00 2001 From: chesnutcase Date: Wed, 23 Oct 2019 16:22:24 +0800 Subject: [PATCH 46/48] update readme --- README.md | 238 ++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 159 insertions(+), 79 deletions(-) diff --git a/README.md b/README.md index 77a54b4..ed2346a 100644 --- a/README.md +++ b/README.md @@ -13,116 +13,208 @@ You have a fleet of just 10 vehicles to serve 500 spots in the city. Some vehicl You don't need to. Just throw us a list of stops, vehicles and depots and we will do the heavy lifting for you. _Routing as a Service!_ -**BETA RELASE:** ElasticRoute is completely free-to-use until 30th April 2020! Register for an account [here](https://www.elasticroute.com/) +## Preamble -## Quick Start Guide +We offer two API's: The Dashboard API, for developers looking to integrate their existing system with our [ElasticRoute Dashboard](https://www.elasticroute.com/); and the Routing Engine API, for developers looking to solve the Vehicle Routing Problem in a headless environment. The Routing Engine API is only available by request, while the Dashboard API is generally available. Read more [here](https://www.elasticroute.com/routing-engine-api-documentation/). + +**Backwards-compatibility notice:** Due to significant overhauls in the backend API, major version 2 of this library is _not_ compatible with code written to work with version 1 of this library. + +## Quick Start Guide (Dashboard API) Install with pip: pip install elasticroute -In your code, set your default API Key (this can be retrieved from the dashboard of the [web application](https://app.elasticroute.com)): +Create an instance of `DashboardClient`, passing your API key to the constructor. The API Key can be retrieved from the dashboard of the [web application](https://app.elasticroute.com)). + +```python +from elasticroute.clients import DashboardClient + +dashboard = DashboardClient("YOUR_API_KEY_HERE") +``` + +You can then programmatically create stops to appear on your Dashboard: + +```python +from elasticroute.dashboard import Stop + +stop = Stop() +stop["name"] = "Changi Airport" +stop["address"] = "80 Airport Boulevard (S)819642" + +dashboard.stops.create(stop) +``` + +Data attributes of models in this library are accessed and modified using the index operator `[]`. You can get/set any attributes listed in [this page](https://www.elasticroute.com/dashboard-api-documentation/) (under _Field Headers and Description_) that are not marked as **Result** or **Readonly**. Keys passed to the index operator **must** be strings. Passing non-string keys or attempting to modify readonly attributes will trigger a warning. + +By default, this creates a stop on today's date. Change the date by passing the `date` keyword argument: + +```python +dashboard.stops.create(stop, date="2019-01-01") +``` + +Date strings must follow the `YYYY-MM-DD` format. + +All CRUD operations are available for stops with the following method signatures: + +```python +dashboard.stops.create(stop) +dashboard.stops.retrieve(stop_name) +dashboard.stops.update(stop) +dashboard.stops.delete(stop) +``` + +All methods accept the `date` keyword argument. The `create` method throws an exception (`elasticroute.errors.repository.ERServiceException`) if a stop with an existing name already exists on the same day, while the `retrieve`, `update` and `delete` methods will throw an exception if a stop with the given name does not exist on that day. + +CRUD operations are also available for Vehicles: + +```python +from elasticroute.dashboard import Vehicle + +vehicle = Vehicle() +vehicle["name"] = "Morning shift driver" +vehicle["avail_from"] = 900 +vehicle["avail_to"] = 1200 + +dashboard.vehicles.create(vehicle) +dashboard.vehicles.retrieve(vehicle_name) +dashboard.vehicles.update(vehicle) +dashboard.vehicles.delete(vehicle) +``` + +Like for stops, the `create` method throws `elasticroute.errors.repository.ERServiceException` if a vehicle with the same name already exists on the same account, while `retrieve`, `update`, `delete` methods will throw an exception if a vehicle with the given name does not yet exist in the account. + +Unlike stops, vehicles are not bound by date, and are present across all dates. + +Both stops and vehicles accept a dictionary in their constructor that automatically sets their corresponding data attributes. + +The library helps you check for invalid values before requests are sent to the server. For instance, setting a vehicle's `avail_to` data attribute to `2500` will trigger a `elasticroute.errors.validator.BadFieldError` when performing any CRUD operations. +Currently, the Dashboard API is unable to perform CRUD operations on depots. Since the details of depots are likely not going to be changed frequently, please configure (using the web application) all the depots that your team has before using this library to perform plans. + +### Programmatically starting the planning process + +Once you have created more than one stop for the day (and created a starting depot via the web application), you can remotely start and stop the planning process: ```python -import elasticroute as er -er.defaults.API_KEY = "my_super_secret_key" + # where dashboard is an instance of elasticroute.clients.DashboardClient and date is a string in YYYY-MM-DD format + dashboard.stops.start_planning(date) + dashboard.stops.stop_planning(date) ``` +## Quick Start Guide (Routing Engine API) + +The Routing Engine API is only available by request; please get in touch with us if you require our headless routing capabilities. Attempting to use the Routing Engine API with an unauthorized API Key will result in your requests being rejected. + +If you haven't already, install this library: -Create a new `Plan` object and givt it a name/id: + pip install elasticroute>=2.0.0 + +Create an instance of `RoutingClient`, passing your API key in the constructor: ```python -plan = er.Plan() -plan.id = "my_first_plan" +from elasticroute.clients import RoutingClient + +router = RoutingClient("YOUR_API_KEY_HERE") +``` + +Create a new `Plan` object: + +```python +from elasticroute.routing import Plan + +plan = Plan("some-unique-id") ``` -Give us an array of stops: +Give us a list of stops: ```python +from elasticroute.routing import Stop plan.stops = [ - { + Stop({ "name": "Changi Airport", "address": "80 Airport Boulevard (S)819642", - }, - { + }), + Stop({ "name": "Gardens By the Bay", "lat": "1.281407", "lng": "103.865770", - }, + }), # add more stops! # both human-readable addresses and machine-friendly coordinates work! ] ``` -Give us an array of your available vehicles: +Give us a list of your available vehicles: ```python +from elasticroute.routing import Vehicle plan.vehicles = [ - { + Vehicle({ "name": "Van 1" - }, - { + }), + Vehicle({ "name": "Van 2" - }, + }), ] ``` -Give us an array of depots (warehouses): +Give us a list of depots (warehouses): ```python +from elasticroute.routing import Depot plan.depots = [ - { + Depot({ "name": "Main Warehouse", "address": "61 Kaki Bukit Ave 1 #04-34, Shun Li Ind Park Singapore 417943", - }, + }), ] ``` -Set your country and timezone (for accurate geocoding): +Set your country and timezone: ```python plan.generalSettings["country"] = "SG" plan.generalSettings["timezone"] = "Asia/Singapore" ``` -Call `solve()` and save the result to a variable: +Use the client to submit the plan: ```python -solution = plan.solve() +plan = router.plans.create(plan) ``` -Inspect the solution! +The planning process is asynchronous as it takes some time to complete. Persist the value of the plan id you used earlier, and retrieve it in a separate process at a later time: ```python -for stop in solution.stops: - print("Stop {} will be served by {} at time {}".format(stop["name"], stop["assign_to"], stop["eta"])) +plan = router.plans.retrieve(plan_id) ``` -Quick notes: +`plan.status` should give you `"planned"` when the process is complete. Inspect the solution: -- The individual stops, vehicles and depots can be passed into the `Plan` as either dictionaries or instances of `elasticroute.Stop`, `elasticroute.Vehicle` and `elasticroute.Depot` respectively. Respective properties are the same as the dictionary keys. -- Solving a plan returns you an instance of `elasticroute.Solution`, that has mostly the same properties as `elasticroute.Plan` but not the same functions (see advanced usage) -- Unlike when creating `Plan`'s, `Solution.stops|vehicles|depots` returns you instances of `elasticroute.Stop`, `elasticroute.Vehicle` and `elasticroute.Depot` accordingly instead of dictionaries. +```python +for stop in plan.stops: + print("Stop {} will be served by {} at time {}".format(stop["name"], stop["assign_to"], stop["eta"])) +``` ## Advanced Usage ### Setting time constraints -Time constraints for Stops and Vehicles can be set with the `from` and `till` keys of `elasticroute.Stop` and `elasticroute.Vehicle`: +Time constraints for Stops and Vehicles can be set with the `from` and `till` keys of `elasticroute.common.Stop`, and the `avail_from` and `avail_to` keys of `elasticroute.common.Vehicle`: ```python -morning_only_stop = er.Stop() +morning_only_stop = Stop() morning_only_stop["name"] = "Morning Delivery 1" morning_only_stop["from"] = 900 morning_only_stop["till"] = 1200 # add address and add to plan... -morning_shift_van = er.Vehicle() +morning_shift_van = Vehicle() morning_shift_van["name"] = "Morning Shift 1" -morning_shift_van["from"] = 900 -morning_shift_van["till"] - 1200 -# add to plan and solve... +morning_shift_van["avail_from"] = 900 +morning_shift_van["avail_till"] - 1200 +# add to plan and solve, or upload to dashboard using DashboardClient ``` -Not specifying the `from` and `till` keys of either class would result it being defaulted to `avail_from` and `avail_to` keys in the `elasticroute.defaults.generalSettings` dictionary, which in turn defaults to `500` and `1700`. +`elasticroute.common.Stop` is the parent class of `elasticroute.routing.Stop` and `elasticroute.dashboard.Stop`; Vehicles work in a similar manner ### Setting home depots @@ -130,61 +222,49 @@ A "home depot" can be set for both Stops and Vehicles. A depot for stops indicat By default, for every stop and vehicle, if the depot field is not specified we will assume it to be the first depot. ```python -common_stop = er.Stop() +common_stop = Stop() common_stop["name"] = "Normal Delivery 1" common_stop["depot"] = "Main Warehouse" -# set stop address and add to plan... -rare_stop = er.Stop() +# set stop address +rare_stop = Stop() rare_stop["name"] = "Uncommon Delivery 1" rare_stop["depot"] = "Auxillary Warehouse" -# set stop address and add to plan... -plan.vehicles = [ - { - "name": "Main Warehouse Van", - "depot": "Main Warehouse" - }, - { - "name": "Auxillary Warehouse Van", - "depot": "Auxillary Warehouse" - } -] +# set stop address +main_warehouse_van = Vehicle({ + "name": "Main Warehouse Van", + "depot": "Main Warehouse" +}) +aux_warehouse_van = Vehicle({ + "name": "Auxillary Warehouse Van", + "depot": "Auxillary Warehouse" +}) + +# if using DashboardClient: +dashboard.stops.create(common_stop) +dashboard.stops.create(rare_stop) +dashboard.vehicles.create(main_warehouse_van) +dashboard.vehicles.create(aux_warehouse_van) + +# if using RoutingClient: +plan = Plan("my_plan") +plan.stops = [common_stop, rare_stop] +plan.vehicles = [main_warehouse_van, aux_warehouse_van] plan.depots = [ - { + Depot({ "name": "Main Warehouse", "address": "Somewhere" - }, - { + }), + Depot({ "name": "Auxillary Warehouse", "address": "Somewhere else" - } + }) ] -# solve and get results... +router.plans.create(plan) ``` -**IMPORTANT:** The value of the `depot` fields MUST correspond to a matching `elasticroute.Depot` in the same plan with the same name! +For this to work, there must be a corresponding depot with the same name in the dashboard (if using `DashboardClient`) or in the same plan (if using `RoutingClient`) ### Setting load constraints Each vehicle can be set to have a cumulative maximum weight, volume and (non-cumulative) seating capacity which can be used to determine how many stops it can serve before it has to return to the depot. Conversely, each stop can also be assigned weight, volume and seating loads. The keys are `weight_load`, `volume_load`, `seating_load` for Stops and `weight_capacity`, `volume_capacity` and `seating_capacity` for Vehicles. - -### Alternative connection types (for large datasets) - -By default, all requests are made in a _synchronous_ manner. Most small to medium-sized datasets can be solved in less than 10 seconds, but for production uses you probably may one to close the HTTP connection first and poll for updates in the following manner: - -```python -import time - -plan = er.Plan() -plan.connection_type = "poll"; -# do the usual stuff -solution = plan.solve() -while solution.status != "planned": - solution.refresh() - time.sleep(2) - # or do some threading or promise -``` - -Setting the `connection_type` to `"poll"` will cause the server to return you a response immediately after parsing the request data. You can monitor the status with the `status` and `progress` properties while fetching updates with the `refresh()` method. - -In addition, setting the `connectionType` to `"webhook"` will also cause the server to post a copy of the response to your said webhook. The exact location of the webhook can be specified with the `webhook` property of `Plan` objects. From a77186a6c56b84d040bef194241921d75a107b32 Mon Sep 17 00:00:00 2001 From: chesnutcase Date: Wed, 23 Oct 2019 16:24:48 +0800 Subject: [PATCH 47/48] bump version and exclude tests --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 0a034b6..ffc4012 100644 --- a/setup.py +++ b/setup.py @@ -5,14 +5,14 @@ setuptools.setup( name="elasticroute", - version="1.0.2", + version="2.0.0", author="Detrack", author_email="chester@detack.com", description="Free, intelligent routing for your logistics – now on Python", long_description=long_description, long_description_content_type="text/markdown", url="https://github.com/detrack/elasticroute-python", - packages=setuptools.find_packages(), + packages=setuptools.find_packages(exclude=("tests")), classifiers=[ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", From d4e43c1129ab903bec78e91fecf949b55f155def Mon Sep 17 00:00:00 2001 From: chesnutcase Date: Wed, 23 Oct 2019 16:53:26 +0800 Subject: [PATCH 48/48] remove tests/test_module.py --- tests/test_module.py | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 tests/test_module.py diff --git a/tests/test_module.py b/tests/test_module.py deleted file mode 100644 index a88409f..0000000 --- a/tests/test_module.py +++ /dev/null @@ -1,7 +0,0 @@ -import unittest -import elasticroute - - -class ModuleTest(unittest.TestCase): - def test_module_name(self): - self.assertEqual(elasticroute.name, "elasticroute")