Compare commits
19 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
af4fd6fb92 | ||
|
0134ed4641 | ||
|
df2664283c | ||
|
b668b0c2e6 | ||
|
06a329274d | ||
|
596675ec27 | ||
|
b3bcce9203 | ||
|
f9acba3ec4 | ||
|
7b32dcabba | ||
|
f21dc2b9dc | ||
|
1e49fd05b5 | ||
|
736ec62d3b | ||
|
0e4c24046b | ||
|
82d2e069ef | ||
|
6e9dafe516 | ||
|
7ac32e7d0f | ||
|
6d9f724088 | ||
|
298ff345c6 | ||
|
1d66928c2a |
2
.coveragerc
Normal file
2
.coveragerc
Normal file
|
@ -0,0 +1,2 @@
|
|||
[run]
|
||||
include=urbantz/*
|
|
@ -1,16 +1,40 @@
|
|||
image: python:3.7
|
||||
stages:
|
||||
- lint
|
||||
- test
|
||||
- deploy
|
||||
|
||||
flake8:
|
||||
stage: lint
|
||||
variables:
|
||||
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
|
||||
|
||||
before_script:
|
||||
- pip install .[dev]
|
||||
cache:
|
||||
paths:
|
||||
- .cache/pip
|
||||
- venv/
|
||||
|
||||
before_script:
|
||||
- pip install virtualenv
|
||||
- virtualenv venv
|
||||
- source venv/bin/activate
|
||||
- pip install .[dev]
|
||||
|
||||
tests:
|
||||
stage: test
|
||||
coverage: '/TOTAL[\s\d]+\s(\d+%)/'
|
||||
script:
|
||||
- coverage run setup.py test
|
||||
- coverage report
|
||||
- codecov
|
||||
|
||||
flake8:
|
||||
stage: test
|
||||
script:
|
||||
- flake8
|
||||
|
||||
doc8:
|
||||
stage: test
|
||||
script:
|
||||
- doc8
|
||||
|
||||
deploy-pypi:
|
||||
stage: deploy
|
||||
when: manual
|
||||
|
@ -20,7 +44,7 @@ deploy-pypi:
|
|||
name: pypi
|
||||
url: https://pypi.org/project/pyurbantz
|
||||
|
||||
before_script:
|
||||
script:
|
||||
- pip install twine setuptools wheel
|
||||
- echo "[distutils]" > ~/.pypirc
|
||||
- echo "index-servers =" >> ~/.pypirc
|
||||
|
@ -29,7 +53,6 @@ deploy-pypi:
|
|||
- echo "repository=https://upload.pypi.org/legacy/" >> ~/.pypirc
|
||||
- echo "username=$PYPI_DEPLOY_USERNAME" >> ~/.pypirc
|
||||
- echo "password=$PYPI_DEPLOY_PASSWORD" >> ~/.pypirc
|
||||
script:
|
||||
- python setup.py sdist bdist_wheel
|
||||
- twine upload dist/* -r pypi
|
||||
|
||||
|
@ -42,7 +65,7 @@ deploy-testpypi:
|
|||
name: testpypi
|
||||
url: https://test.pypi.org/project/pyurbantz
|
||||
|
||||
before_script:
|
||||
script:
|
||||
- pip install twine setuptools wheel
|
||||
- echo "[distutils]" > ~/.pypirc
|
||||
- echo "index-servers =" >> ~/.pypirc
|
||||
|
@ -51,7 +74,6 @@ deploy-testpypi:
|
|||
- echo "repository=https://test.pypi.org/legacy/" >> ~/.pypirc
|
||||
- echo "username=$PYPI_DEPLOY_USERNAME" >> ~/.pypirc
|
||||
- echo "password=$PYPI_DEPLOY_PASSWORD" >> ~/.pypirc
|
||||
script:
|
||||
- python setup.py sdist bdist_wheel
|
||||
- twine upload dist/* -r testpypi
|
||||
|
||||
|
@ -64,8 +86,6 @@ pages:
|
|||
paths:
|
||||
- public
|
||||
|
||||
before_script:
|
||||
- pip install .[dev]
|
||||
script:
|
||||
- cd docs
|
||||
- make html
|
||||
|
|
|
@ -7,6 +7,12 @@ Deliveries
|
|||
.. automodule:: urbantz.delivery
|
||||
:members:
|
||||
|
||||
Delivery items
|
||||
--------------
|
||||
|
||||
.. automodule:: urbantz.items
|
||||
:members:
|
||||
|
||||
Exceptions
|
||||
----------
|
||||
|
||||
|
@ -16,5 +22,8 @@ Exceptions
|
|||
Helper classes
|
||||
--------------
|
||||
|
||||
.. automodule:: urbantz.base
|
||||
:members:
|
||||
|
||||
.. automodule:: urbantz.utils
|
||||
:members:
|
||||
|
|
|
@ -83,7 +83,10 @@ html_theme = 'alabaster'
|
|||
# further. For a list of options available for each theme, see the
|
||||
# documentation.
|
||||
#
|
||||
# html_theme_options = {}
|
||||
html_theme_options = {
|
||||
'description': 'UrbanTZ API client',
|
||||
'fixed_sidebar': 'true',
|
||||
}
|
||||
|
||||
# Add any paths that contain custom static files (such as style sheets) here,
|
||||
# relative to this directory. They are copied after the builtin static files,
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
Contributing
|
||||
============
|
||||
|
||||
Contributions to the project are greatly appreciated.
|
||||
Contributions to the project are greatly appreciated.
|
||||
|
||||
Bugs and suggestions
|
||||
--------------------
|
||||
|
@ -27,9 +27,40 @@ recommended::
|
|||
mkvirtualenv -a . pyurbantz
|
||||
pip install -e .[dev]
|
||||
|
||||
This will clone the repository, create a virtual environment named ``pyurbantz``,
|
||||
then tell pip to let the package be editable (``-e``). The ``[dev]`` suffix adds
|
||||
the extra requirements useful for development.
|
||||
This will clone the repository, create a virtual environment named
|
||||
``pyurbantz``, then tell pip to let the package be editable (``-e``).
|
||||
The ``[dev]`` suffix adds the extra requirements useful for development.
|
||||
|
||||
Unit tests
|
||||
^^^^^^^^^^
|
||||
|
||||
Unit tests use the standard ``unittest`` package; you may run them using the
|
||||
standard ``setup.py`` command::
|
||||
|
||||
./setup.py test
|
||||
|
||||
Tests coverage
|
||||
^^^^^^^^^^^^^^
|
||||
|
||||
I aim for 100% coverage on all of my Python packages whenever I add unit
|
||||
tests to them; this package is no exception. CI checks use the `coverage`_
|
||||
Python package and `codecov`_ to check for test coverage. To get test coverage
|
||||
data locally, run::
|
||||
|
||||
coverage run setup.py test
|
||||
|
||||
You may then get a short coverage summary in your terminal::
|
||||
|
||||
coverage report
|
||||
|
||||
Or generate an HTML report in a ``htmlcov`` folder, which can be browsed
|
||||
offline using your favorite web browser and shows line by line coverage::
|
||||
|
||||
coverage html
|
||||
|
||||
If you are having issues reaching 100% coverage, try to still add some tests,
|
||||
and mention your issues when creating a pull request to the
|
||||
`GitLab repository`_.
|
||||
|
||||
Linting
|
||||
^^^^^^^
|
||||
|
@ -45,9 +76,12 @@ The documentation you are reading is generated by the `Sphinx`_ tool.
|
|||
The text files that hold the documentation's contents are written in
|
||||
`reStructuredText`_ and are available under the ``/docs`` folder of the
|
||||
`GitLab repository`_.
|
||||
They are also subject to linting using the ``doc8`` tool.
|
||||
|
||||
.. _submit an issue: https://gitlab.com/Lucidiot/pyurbantz/issues/new
|
||||
.. _virtualenvwrapper: https://virtualenvwrapper.readthedocs.io
|
||||
.. _coverage: https://coverage.readthedocs.io/
|
||||
.. _codecov: https://codecov.io/gl/Lucidiot/pyurbantz
|
||||
.. _GitLab repository: https://gitlab.com/Lucidiot/pyurbantz
|
||||
.. _Sphinx: http://www.sphinx-doc.org/
|
||||
.. _reStructuredText: http://www.sphinx-doc.org/en/master/usage/restructuredtext/basics.html
|
||||
|
|
|
@ -1,2 +1,5 @@
|
|||
flake8>=3.5
|
||||
doc8>=0.8
|
||||
Sphinx>=1.8.1
|
||||
coverage>=4.5
|
||||
codecov>=2.0
|
||||
|
|
5
setup.cfg
Normal file
5
setup.cfg
Normal file
|
@ -0,0 +1,5 @@
|
|||
[flake8]
|
||||
exclude = .git,__pycache__,docs,*.pyc,venv
|
||||
|
||||
[doc8]
|
||||
ignore-path=**/*.txt,*.txt,*.egg-info,docs/_build,venv,.git
|
1
setup.py
Normal file → Executable file
1
setup.py
Normal file → Executable file
|
@ -1,3 +1,4 @@
|
|||
#!/usr/bin/env python3
|
||||
from setuptools import setup, find_packages
|
||||
|
||||
|
||||
|
|
152
urbantz/base.py
Normal file
152
urbantz/base.py
Normal file
|
@ -0,0 +1,152 @@
|
|||
from abc import ABC, abstractmethod
|
||||
from math import radians, cos, sin, asin, sqrt, trunc, floor, ceil
|
||||
|
||||
EARTH_RADIUS_KM = 6371
|
||||
|
||||
|
||||
class JSONSerializable(ABC):
|
||||
|
||||
@abstractmethod
|
||||
def toJSON(self):
|
||||
"""
|
||||
Convert this instance to a JSON-serializable type.
|
||||
|
||||
:returns: An object that can be serialized to JSON.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def fromJSON(cls, payload):
|
||||
"""
|
||||
Get an instance of the class from parsed JSON data.
|
||||
|
||||
:param payload: A JSON parsed payload.
|
||||
:type payload: dict or list or str or int or float or bool or None
|
||||
:returns: An instance of the class.
|
||||
"""
|
||||
|
||||
|
||||
class Coordinates(JSONSerializable):
|
||||
"""
|
||||
A helper class for GPS coordinates.
|
||||
"""
|
||||
|
||||
def __init__(self, lat=0, lng=0):
|
||||
"""
|
||||
:param float lat: Latitude in decimal degrees.
|
||||
:param float lng: Longitude in decimal degrees.
|
||||
"""
|
||||
self.lat = lat
|
||||
self.lng = lng
|
||||
|
||||
def __repr__(self):
|
||||
return '{}(lat={}, lng={})'.format(
|
||||
self.__class__.__name__, self.lat, self.lng)
|
||||
|
||||
def __str__(self):
|
||||
return ', '.join(map(str, tuple(self)))
|
||||
|
||||
def __hash__(self):
|
||||
return hash(tuple(self))
|
||||
|
||||
def __len__(self):
|
||||
return 2
|
||||
|
||||
def __length_hint__(self):
|
||||
return len(self)
|
||||
|
||||
def __iter__(self):
|
||||
return iter((self.lat, self.lng))
|
||||
|
||||
def __eq__(self, other):
|
||||
return hasattr(other, 'lat') and hasattr(other, 'lng') and \
|
||||
tuple(self) == tuple(other)
|
||||
|
||||
def __add__(self, other):
|
||||
if not hasattr(other, 'lat') or not hasattr(other, 'lng'):
|
||||
return NotImplemented
|
||||
return self.__class__(
|
||||
lat=self.lat + other.lat,
|
||||
lng=self.lng + other.lng,
|
||||
)
|
||||
|
||||
def __sub__(self, other):
|
||||
if not hasattr(other, 'lat') or not hasattr(other, 'lng'):
|
||||
return NotImplemented
|
||||
return self.__class__(
|
||||
lat=self.lat - other.lat,
|
||||
lng=self.lng - other.lng,
|
||||
)
|
||||
|
||||
def __pos__(self):
|
||||
return self
|
||||
|
||||
def __neg__(self):
|
||||
return self.__class__(lat=-self.lat, lng=-self.lng)
|
||||
|
||||
def __abs__(self):
|
||||
return self.__class__(lat=abs(self.lat), lng=abs(self.lng))
|
||||
|
||||
def __round__(self, ndigits=None):
|
||||
return self.__class__(
|
||||
lat=round(self.lat, ndigits),
|
||||
lng=round(self.lng, ndigits),
|
||||
)
|
||||
|
||||
def __trunc__(self):
|
||||
return self.__class__(lat=trunc(self.lat), lng=trunc(self.lng))
|
||||
|
||||
def __floor__(self):
|
||||
return self.__class__(lat=floor(self.lat), lng=floor(self.lng))
|
||||
|
||||
def __ceil__(self):
|
||||
return self.__class__(lat=ceil(self.lat), lng=ceil(self.lng))
|
||||
|
||||
def to_radians(self):
|
||||
"""
|
||||
Convert to a ``(lat, lng)`` tuple in radians.
|
||||
|
||||
:returns: Coordinates in radians.
|
||||
:rtype: tuple(float, float)
|
||||
"""
|
||||
return tuple(map(radians, self))
|
||||
|
||||
def distance(self, other):
|
||||
"""
|
||||
Compute Haversine distance between two coordinates in meters.
|
||||
|
||||
:param other: Another pair of coordinates to compute distance against.
|
||||
:type other: Coordinates
|
||||
:returns: Distance between the two coordinates, in meters.
|
||||
:rtype: float
|
||||
"""
|
||||
if not hasattr(other, 'to_radians'):
|
||||
raise NotImplementedError(
|
||||
'Distance requires a to_radians() method on both coordinates')
|
||||
lat1, lon1 = self.to_radians()
|
||||
lat2, lon2 = other.to_radians()
|
||||
dlon, dlat = lon2 - lon1, lat2 - lat1
|
||||
a = sin(dlat / 2) ** 2 + cos(lat1) * cos(lat2) * sin(dlon / 2) ** 2
|
||||
return EARTH_RADIUS_KM * 2000 * asin(sqrt(a))
|
||||
|
||||
def toJSON(self):
|
||||
"""
|
||||
Convert to UrbanTZ JSON geometry
|
||||
|
||||
:returns: UrbanTZ-compatible JSON geometry data
|
||||
:rtype: list(float)
|
||||
"""
|
||||
return [self.lng, self.lat]
|
||||
|
||||
@classmethod
|
||||
def fromJSON(cls, geometry):
|
||||
"""
|
||||
Get a Coordinates instance from parsed UrbanTZ JSON geometry data.
|
||||
|
||||
:param geometry: Parsed UrbanTZ geometry data: a list holding
|
||||
``[lng, lat]`` in decimal degrees.
|
||||
:type geometry: list(float)
|
||||
:returns: A Coordinates instance.
|
||||
:rtype: urbantz.base.Coordinates
|
||||
"""
|
||||
return cls(lng=geometry[0], lat=geometry[1])
|
|
@ -1,23 +1,41 @@
|
|||
from datetime import datetime
|
||||
from urbantz.utils import Coordinates
|
||||
from datetime import datetime, timedelta
|
||||
from urbantz.base import JSONSerializable, Coordinates
|
||||
from urbantz.utils import DictObject
|
||||
from urbantz.items import LogEntry, Item
|
||||
from urbantz.exceptions import APIError
|
||||
import requests
|
||||
|
||||
DATE_FORMAT = '%Y-%m-%dT%H:%M:%S.%fZ'
|
||||
|
||||
class Delivery(object):
|
||||
|
||||
class Location(DictObject):
|
||||
"""
|
||||
A delivery destination.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if 'location' not in self:
|
||||
return
|
||||
point = self.pop('location')['geometry']
|
||||
self['coordinates'] = Coordinates.fromJSON(point)
|
||||
|
||||
|
||||
class Delivery(JSONSerializable):
|
||||
"""
|
||||
A UrbanTZ delivery with a unique ID.
|
||||
"""
|
||||
|
||||
def __init__(self, id):
|
||||
def __init__(self, tracking_code=None):
|
||||
"""
|
||||
:param str id: A delivery ID.
|
||||
:param tracking_code: A delivery public tracking code.
|
||||
:type tracking_code: str or None
|
||||
"""
|
||||
self.id = id
|
||||
self.tracking_code = tracking_code
|
||||
"""
|
||||
The delivery ID.
|
||||
The delivery public tracking code.
|
||||
|
||||
:type: str
|
||||
:type: str or None
|
||||
"""
|
||||
|
||||
self.last_updated = None
|
||||
|
@ -28,22 +46,169 @@ class Delivery(object):
|
|||
:type: datetime or None
|
||||
"""
|
||||
|
||||
self.payload = None
|
||||
"""
|
||||
Latest parsed JSON payload from the API. Is None if data has never
|
||||
been fetched from the API or loaded via :meth:`Delivery.use`.
|
||||
|
||||
:type: dict or None
|
||||
"""
|
||||
|
||||
self.id = None
|
||||
"""
|
||||
Identifier for this delivery.
|
||||
|
||||
:type: str or None
|
||||
"""
|
||||
|
||||
self.date = None
|
||||
"""
|
||||
Date of the delivery.
|
||||
|
||||
:type: date or None
|
||||
"""
|
||||
|
||||
self.task_id = None
|
||||
"""
|
||||
Task identifier for this delivery.
|
||||
|
||||
:type: str or None
|
||||
"""
|
||||
|
||||
self.platform_id = None
|
||||
"""
|
||||
Platform identifier for this delivery.
|
||||
|
||||
:type: str or None
|
||||
"""
|
||||
|
||||
self.driver_id = None
|
||||
"""
|
||||
Driver identifier for this delivery.
|
||||
|
||||
:type: str or None
|
||||
"""
|
||||
|
||||
self.round_id = None
|
||||
"""
|
||||
Driver round identifier for this delivery.
|
||||
|
||||
:type: str or None
|
||||
"""
|
||||
|
||||
self.instructions = None
|
||||
"""
|
||||
Delivery instructions given to the driver.
|
||||
|
||||
:type: str or None
|
||||
"""
|
||||
|
||||
self.progress = None
|
||||
"""
|
||||
The delivery order progress.
|
||||
|
||||
:type: str or None
|
||||
"""
|
||||
|
||||
self.status = None
|
||||
"""
|
||||
The delivery status.
|
||||
|
||||
:type: str or None
|
||||
"""
|
||||
|
||||
self.arrival_time = None
|
||||
"""
|
||||
Estimated or actual arrival time.
|
||||
|
||||
:type: datetime or None
|
||||
"""
|
||||
|
||||
self.eta_margin = None
|
||||
"""
|
||||
Margin, in minutes, for the estimated time of arrival.
|
||||
|
||||
:type: int or None
|
||||
"""
|
||||
|
||||
self.eta_rounding = None
|
||||
"""
|
||||
Rounding, in minutes, for the estimated time of arrival.
|
||||
|
||||
:type: int or None
|
||||
"""
|
||||
|
||||
self.time_window = None
|
||||
"""
|
||||
Planned delivery time window (start and end datetimes)
|
||||
|
||||
:type: list(datetime) or None
|
||||
"""
|
||||
|
||||
self.position = None
|
||||
"""
|
||||
Coordinates of the delivery truck's position.
|
||||
|
||||
:type: urbantz.utils.Coordinates
|
||||
:type: urbantz.base.Coordinates or None
|
||||
"""
|
||||
|
||||
self.destination = None
|
||||
"""
|
||||
Coordinates of the delivery destination.
|
||||
The delivery's destination.
|
||||
|
||||
:type: urbantz.utils.Coordinates
|
||||
:type: urbantz.delivery.Location or None
|
||||
"""
|
||||
|
||||
self.recipient = None
|
||||
"""
|
||||
Informations about the recipient (name, language, phone number, etc.)
|
||||
Does not contain the destination location information.
|
||||
|
||||
:type: urbantz.utils.DictObject or None
|
||||
"""
|
||||
|
||||
self.features = None
|
||||
"""
|
||||
Dictionary of booleans indicating which features of the UrbanTZ
|
||||
tracking software are enabled on this delivery.
|
||||
For example, ``consumerModifyInstructions`` will indicate whether
|
||||
the client is allowed to update the delivery instructions after the
|
||||
driver departs for its round.
|
||||
|
||||
:type: urbantz.utils.DictObject or None
|
||||
"""
|
||||
|
||||
self.items = None
|
||||
"""
|
||||
List of delivery items.
|
||||
|
||||
:type: list(urbantz.items.Item)
|
||||
"""
|
||||
|
||||
self.logs = None
|
||||
"""
|
||||
List of status update logs for the delivery.
|
||||
|
||||
:type: list(urbantz.items.LogEntry)
|
||||
"""
|
||||
|
||||
self.theme = None
|
||||
"""
|
||||
Front-end theming information for the delivery tracking page.
|
||||
|
||||
:type: urbantz.utils.DictObject or None
|
||||
"""
|
||||
|
||||
self.template = None
|
||||
"""
|
||||
Front-end template information for the delivery tracking page.
|
||||
|
||||
:type: urbantz.utils.DictObject or None
|
||||
"""
|
||||
|
||||
def __repr__(self):
|
||||
return '{}({})'.format(self.__class__.__name__, self.id)
|
||||
return '{}({})'.format(
|
||||
self.__class__.__name__, repr(self.tracking_code))
|
||||
|
||||
@property
|
||||
def api_url(self):
|
||||
|
@ -52,25 +217,111 @@ class Delivery(object):
|
|||
|
||||
:type: str
|
||||
"""
|
||||
return 'https://backend.urbantz.com/public/task/tracking/' + self.id
|
||||
return 'https://backend.urbantz.com/public/task/tracking/{}'.format(
|
||||
self.tracking_code)
|
||||
|
||||
@property
|
||||
def eta(self):
|
||||
"""
|
||||
Estimated time of arrival: start and end datetimes, computed from the
|
||||
arrival time and rounding settings
|
||||
|
||||
:type: list(datetime) or None
|
||||
"""
|
||||
if not (self.eta_margin and self.eta_rounding and self.arrival_time):
|
||||
return
|
||||
start = self.arrival_time - timedelta(minutes=self.eta_margin / 2)
|
||||
start -= timedelta(
|
||||
minutes=start.minute % self.eta_rounding,
|
||||
seconds=start.second,
|
||||
microseconds=start.microsecond,
|
||||
)
|
||||
end = start + timedelta(minutes=self.eta_margin)
|
||||
return [start, end]
|
||||
|
||||
def update(self):
|
||||
"""
|
||||
Fetch the latest delivery information from the API.
|
||||
|
||||
:raises urbantz.exceptions.APIError: If the API returned a JSON error.
|
||||
:raises requests.exceptions.HTTPError: If the response has an
|
||||
HTTP 4xx or 5xx code.
|
||||
:raises urbantz.exceptions.APIError: If the API returned an error.
|
||||
HTTP 4xx or 5xx code, or an empty payload.
|
||||
"""
|
||||
resp = requests.get(self.api_url)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
data = {}
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if 'error' in data:
|
||||
raise APIError(data['error'])
|
||||
|
||||
self.position = Coordinates.fromJSON(data['position'])
|
||||
self.destination = Coordinates.fromJSON(
|
||||
data['location']['location']['geometry'])
|
||||
resp.raise_for_status()
|
||||
if not data:
|
||||
# If requests does not raise anything and there is no data,
|
||||
# raise our own error
|
||||
raise APIError({'message': 'API returned an empty payload'})
|
||||
|
||||
self.use(data)
|
||||
|
||||
# TODO: See if the payload holds a last update value
|
||||
self.last_updated = datetime.now()
|
||||
|
||||
def use(self, payload):
|
||||
"""
|
||||
Use a parsed JSON payload to update the properties.
|
||||
|
||||
:param dict payload: A parsed JSON payload from the API.
|
||||
"""
|
||||
self.payload = payload
|
||||
self.id = self.payload['_id']
|
||||
self.date = datetime.strptime(
|
||||
self.payload['date'][:10], "%Y-%m-%d").date()
|
||||
self.task_id = self.payload['taskId']
|
||||
self.platform_id = self.payload['platform']
|
||||
self.driver_id = self.payload['driver']
|
||||
self.round_id = self.payload['round']
|
||||
self.by = self.payload['by']
|
||||
self.instructions = self.payload['instructions']
|
||||
self.progress = self.payload['progress']
|
||||
self.status = self.payload['status']
|
||||
|
||||
self.arrival_time = datetime.strptime(
|
||||
self.payload['arriveTime'], DATE_FORMAT)
|
||||
self.eta_margin = self.payload['eta']['margin']
|
||||
self.eta_rounding = self.payload['eta']['rounding']
|
||||
self.time_window = [
|
||||
datetime.strptime(
|
||||
self.payload['timeWindow']['start'],
|
||||
DATE_FORMAT,
|
||||
),
|
||||
datetime.strptime(
|
||||
self.payload['timeWindow']['stop'],
|
||||
DATE_FORMAT,
|
||||
),
|
||||
]
|
||||
|
||||
self.position = Coordinates.fromJSON(self.payload['position'])
|
||||
self.destination = Location.fromJSON(self.payload['location'])
|
||||
self.recipient = DictObject(self.payload['contact'])
|
||||
self.features = DictObject(self.payload['features'])
|
||||
self.template = DictObject(self.payload['template'])
|
||||
self.theme = DictObject(self.payload['theme'])
|
||||
self.logs = list(map(LogEntry.fromJSON, self.payload['log']))
|
||||
self.items = list(map(Item.fromJSON, self.payload['items']))
|
||||
|
||||
@classmethod
|
||||
def fromJSON(cls, payload):
|
||||
"""
|
||||
Create a Delivery instance from an existing payload.
|
||||
|
||||
:param payload: A parsed JSON payload.
|
||||
:type payload: dict
|
||||
"""
|
||||
instance = cls()
|
||||
instance.use(payload)
|
||||
return instance
|
||||
|
||||
def toJSON(self):
|
||||
return self.payload
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
class APIError(Exception):
|
||||
from urbantz.base import JSONSerializable
|
||||
|
||||
|
||||
class APIError(JSONSerializable, Exception):
|
||||
"""
|
||||
An error returned by the UrbanTZ API.
|
||||
This does not include HTTP errors.
|
||||
|
@ -13,7 +16,18 @@ class APIError(Exception):
|
|||
self.code = error.get('code')
|
||||
|
||||
def __repr__(self):
|
||||
return "<APIError '{}'>".format(str(self))
|
||||
return "<APIError {}>".format(repr(str(self)))
|
||||
|
||||
def __str__(self):
|
||||
return self.message or 'Unknown error'
|
||||
|
||||
def __eq__(self, other):
|
||||
return isinstance(other, self.__class__) and \
|
||||
self.code == other.code and self.message == other.message
|
||||
|
||||
@classmethod
|
||||
def fromJSON(cls, payload):
|
||||
return cls(payload)
|
||||
|
||||
def toJSON(self):
|
||||
return {'message': self.message, 'code': self.code}
|
||||
|
|
132
urbantz/items.py
Normal file
132
urbantz/items.py
Normal file
|
@ -0,0 +1,132 @@
|
|||
from datetime import datetime
|
||||
from urbantz.base import JSONSerializable
|
||||
from urbantz.utils import DictObject
|
||||
|
||||
|
||||
class LogEntry(DictObject):
|
||||
"""
|
||||
A log entry for a delivery or a delivery item that logs status changes.
|
||||
|
||||
- ``id`` : Unique identifier of the log entry
|
||||
- ``by`` : Unique identifier of the author of the change. Nullable.
|
||||
- ``when`` : Date/time of the log entry as a ISO 8601 string
|
||||
- ``to`` : Updated status
|
||||
"""
|
||||
|
||||
@property
|
||||
def datetime(self):
|
||||
"""
|
||||
Datetime for the log entry.
|
||||
"""
|
||||
from urbantz.delivery import DATE_FORMAT
|
||||
return datetime.strptime(self.when, DATE_FORMAT)
|
||||
|
||||
|
||||
class Item(JSONSerializable):
|
||||
"""
|
||||
Describes a delivery item.
|
||||
"""
|
||||
|
||||
def __init__(self, payload):
|
||||
self.payload = payload
|
||||
"""
|
||||
The original JSON payload for the item.
|
||||
|
||||
:type: dict
|
||||
"""
|
||||
|
||||
self.id = self.payload['_id']
|
||||
"""
|
||||
Unique identifier of the delivery item.
|
||||
|
||||
:type: str
|
||||
"""
|
||||
|
||||
self.type = self.payload['type']
|
||||
"""
|
||||
Type of the delivery item. Types vary with each company.
|
||||
|
||||
:type: str
|
||||
"""
|
||||
|
||||
self.barcode = self.payload['barcode']
|
||||
"""
|
||||
Barcode of the delivery item. See :attr:`Item.barcode_encoding` to
|
||||
know the barcode's encoding.
|
||||
"""
|
||||
|
||||
self.barcode_encoding = self.payload['barcodeEncoding']
|
||||
"""
|
||||
Encoding method of the barcode (EAN 13, UPC, etc.)
|
||||
"""
|
||||
|
||||
self.damage_confirmed = self.payload['damaged']['confirmed']
|
||||
"""
|
||||
Indicates whether the item has been damaged.
|
||||
|
||||
:type: bool
|
||||
"""
|
||||
|
||||
self.damage_pictures = self.payload['damaged']['pictures']
|
||||
"""
|
||||
Pictures of the damages.
|
||||
"""
|
||||
|
||||
self.status = self.payload['status']
|
||||
"""
|
||||
Status of the delivery item.
|
||||
|
||||
:type: str
|
||||
"""
|
||||
|
||||
self.quantity = self.payload['quantity']
|
||||
"""
|
||||
Quantity of the given item.
|
||||
|
||||
:type: int
|
||||
"""
|
||||
|
||||
self.labels = self.payload['labels']
|
||||
"""
|
||||
Custom labels given to the item.
|
||||
|
||||
:type: list
|
||||
"""
|
||||
|
||||
self.skills = self.payload['skills']
|
||||
"""
|
||||
Required skills to handle the delivery item.
|
||||
|
||||
:type: list
|
||||
"""
|
||||
|
||||
self.dimensions = DictObject(self.payload['dimensions'])
|
||||
"""
|
||||
Dimensions of the item, as custom settings set by the shipment company.
|
||||
|
||||
:type: urbantz.utils.DictObject
|
||||
"""
|
||||
|
||||
self.metadata = DictObject(self.payload['metadata'])
|
||||
"""
|
||||
Metadata about the delivery that seems to be set by UrbanTZ themselves.
|
||||
|
||||
:type: urbantz.utils.DictObject
|
||||
"""
|
||||
|
||||
self.logs = list(map(LogEntry.fromJSON, self.payload['log']))
|
||||
"""
|
||||
Status update logs.
|
||||
|
||||
:type: urbantz.items.LogEntry
|
||||
"""
|
||||
|
||||
def __repr__(self):
|
||||
return '<Item {}>'.format(self.id)
|
||||
|
||||
def toJSON(self):
|
||||
return self.payload
|
||||
|
||||
@classmethod
|
||||
def fromJSON(cls, payload):
|
||||
return cls(payload)
|
0
urbantz/tests/__init__.py
Normal file
0
urbantz/tests/__init__.py
Normal file
91
urbantz/tests/test_coordinates.py
Normal file
91
urbantz/tests/test_coordinates.py
Normal file
|
@ -0,0 +1,91 @@
|
|||
from unittest import TestCase
|
||||
from math import trunc, floor, ceil, radians
|
||||
from urbantz.base import Coordinates
|
||||
|
||||
|
||||
class TestCoordinates(TestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.coords = Coordinates(lat=12.57, lng=-13.49)
|
||||
|
||||
def test_init(self):
|
||||
self.assertEqual(self.coords.lat, 12.57)
|
||||
self.assertEqual(self.coords.lng, -13.49)
|
||||
|
||||
def test_repr(self):
|
||||
self.assertEqual(
|
||||
repr(self.coords),
|
||||
'Coordinates(lat=12.57, lng=-13.49)',
|
||||
)
|
||||
|
||||
def test_str(self):
|
||||
self.assertEqual(str(self.coords), '12.57, -13.49')
|
||||
|
||||
def test_hash(self):
|
||||
self.assertEqual(hash(self.coords), hash((12.57, -13.49)))
|
||||
|
||||
def test_tuple(self):
|
||||
self.assertTupleEqual(tuple(self.coords), (12.57, -13.49))
|
||||
|
||||
def test_len(self):
|
||||
self.assertEqual(len(self.coords), 2)
|
||||
|
||||
def test_length_hint(self):
|
||||
self.assertEqual(self.coords.__length_hint__(), 2)
|
||||
|
||||
def test_pos(self):
|
||||
self.assertEqual(+self.coords, self.coords)
|
||||
|
||||
def test_neg(self):
|
||||
self.assertTupleEqual(tuple(-self.coords), (-12.57, 13.49))
|
||||
|
||||
def test_abs(self):
|
||||
self.assertTupleEqual(tuple(abs(self.coords)), (12.57, 13.49))
|
||||
|
||||
def test_round(self):
|
||||
self.assertTupleEqual(tuple(round(self.coords)), (13, -13))
|
||||
self.assertTupleEqual(tuple(round(self.coords, 1)), (12.6, -13.5))
|
||||
|
||||
def test_trunc(self):
|
||||
self.assertTupleEqual(tuple(trunc(self.coords)), (12, -13))
|
||||
|
||||
def test_floor(self):
|
||||
self.assertTupleEqual(tuple(floor(self.coords)), (12, -14))
|
||||
|
||||
def test_ceil(self):
|
||||
self.assertTupleEqual(tuple(ceil(self.coords)), (13, -13))
|
||||
|
||||
def test_add(self):
|
||||
other = Coordinates(lat=7.43, lng=-6.51)
|
||||
self.assertEqual(self.coords + other, Coordinates(lat=20, lng=-20))
|
||||
with self.assertRaises(TypeError):
|
||||
self.coords + 4
|
||||
|
||||
def test_sub(self):
|
||||
other = Coordinates(lat=2.57, lng=-3.49)
|
||||
self.assertEqual(self.coords - other, Coordinates(lat=10, lng=-10))
|
||||
with self.assertRaises(TypeError):
|
||||
self.coords - "lol"
|
||||
|
||||
def test_toJSON(self):
|
||||
self.assertListEqual(self.coords.toJSON(), [-13.49, 12.57])
|
||||
|
||||
def test_fromJSON(self):
|
||||
self.assertEqual(Coordinates.fromJSON([-13.49, 12.57]), self.coords)
|
||||
|
||||
def test_to_radians(self):
|
||||
self.assertTupleEqual(
|
||||
self.coords.to_radians(),
|
||||
(radians(12.57), radians(-13.49)),
|
||||
)
|
||||
|
||||
def test_distance(self):
|
||||
other = Coordinates(lat=2.57, lng=-3.49)
|
||||
self.assertEqual(
|
||||
self.coords.distance(other),
|
||||
other.distance(self.coords),
|
||||
)
|
||||
self.assertEqual(self.coords.distance(other), 1564640.3229974532)
|
||||
with self.assertRaises(NotImplementedError):
|
||||
self.coords.distance("lol")
|
95
urbantz/tests/test_delivery.py
Normal file
95
urbantz/tests/test_delivery.py
Normal file
|
@ -0,0 +1,95 @@
|
|||
from unittest import TestCase
|
||||
from unittest.mock import patch, call
|
||||
from datetime import datetime
|
||||
from urbantz import Delivery
|
||||
from urbantz.delivery import Location
|
||||
from urbantz.exceptions import APIError
|
||||
|
||||
|
||||
class TestDelivery(TestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.delivery = Delivery('123456')
|
||||
|
||||
def test_repr(self):
|
||||
self.assertEqual(repr(self.delivery), "Delivery('123456')")
|
||||
|
||||
def test_api_url(self):
|
||||
self.assertEqual(
|
||||
self.delivery.api_url,
|
||||
'https://backend.urbantz.com/public/task/tracking/123456',
|
||||
)
|
||||
|
||||
@patch('urbantz.delivery.Delivery.use')
|
||||
@patch('urbantz.delivery.requests')
|
||||
def test_update(self, requests_mock, use_mock):
|
||||
requests_mock.get.return_value.json.return_value = {'some': 'payload'}
|
||||
|
||||
self.assertIsNone(self.delivery.last_updated)
|
||||
self.delivery.update()
|
||||
|
||||
self.assertEqual(requests_mock.get.call_count, 1)
|
||||
self.assertEqual(
|
||||
requests_mock.get.call_args,
|
||||
call(self.delivery.api_url),
|
||||
)
|
||||
self.assertEqual(requests_mock.get().raise_for_status.call_count, 1)
|
||||
self.assertEqual(requests_mock.get().json.call_count, 1)
|
||||
self.assertEqual(use_mock.call_count, 1)
|
||||
self.assertEqual(use_mock.call_args, call({'some': 'payload'}))
|
||||
self.assertIsInstance(self.delivery.last_updated, datetime)
|
||||
|
||||
@patch('urbantz.delivery.requests')
|
||||
def test_update_error(self, requests_mock):
|
||||
requests_mock.get.return_value.json.return_value = {
|
||||
'error': {
|
||||
'code': 42,
|
||||
'message': 'Oh snap!',
|
||||
}
|
||||
}
|
||||
with self.assertRaises(APIError) as excinfo:
|
||||
self.delivery.update()
|
||||
|
||||
self.assertEqual(requests_mock.get.call_count, 1)
|
||||
self.assertEqual(
|
||||
requests_mock.get.call_args,
|
||||
call(self.delivery.api_url),
|
||||
)
|
||||
self.assertEqual(requests_mock.get().json.call_count, 1)
|
||||
self.assertEqual(
|
||||
excinfo.exception,
|
||||
APIError({'code': 42, 'message': 'Oh snap!'}),
|
||||
)
|
||||
|
||||
@patch('urbantz.delivery.requests')
|
||||
def test_update_error_empty(self, requests_mock):
|
||||
"""
|
||||
Test that a Delivery will raise an APIError with a failed JSON parsing
|
||||
"""
|
||||
requests_mock.get.return_value.json.side_effect = ValueError
|
||||
with self.assertRaises(APIError) as excinfo:
|
||||
self.delivery.update()
|
||||
|
||||
self.assertEqual(requests_mock.get.call_count, 1)
|
||||
self.assertEqual(
|
||||
requests_mock.get.call_args,
|
||||
call(self.delivery.api_url),
|
||||
)
|
||||
self.assertEqual(requests_mock.get().raise_for_status.call_count, 1)
|
||||
self.assertEqual(requests_mock.get().json.call_count, 1)
|
||||
self.assertEqual(
|
||||
excinfo.exception,
|
||||
APIError({'message': 'API returned an empty payload'}),
|
||||
)
|
||||
|
||||
def test_empty_eta(self):
|
||||
self.delivery.eta_margin = None
|
||||
self.delivery.eta_rounding = None
|
||||
self.delivery.arrival_time = None
|
||||
self.assertIsNone(self.delivery.eta)
|
||||
|
||||
def test_empty_location(self):
|
||||
loc = Location()
|
||||
with self.assertRaises(AttributeError):
|
||||
loc.coordinates
|
47
urbantz/tests/test_exceptions.py
Normal file
47
urbantz/tests/test_exceptions.py
Normal file
|
@ -0,0 +1,47 @@
|
|||
from unittest import TestCase
|
||||
from urbantz.exceptions import APIError
|
||||
|
||||
|
||||
class TestExceptions(TestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(self):
|
||||
self.error = APIError({
|
||||
'code': 9000,
|
||||
'message': "I'm afraid I can't do that, Dave."
|
||||
})
|
||||
|
||||
def test_init(self):
|
||||
self.assertEqual(self.error.code, 9000)
|
||||
self.assertEqual(
|
||||
self.error.message,
|
||||
"I'm afraid I can't do that, Dave.",
|
||||
)
|
||||
|
||||
def test_empty(self):
|
||||
error = APIError({})
|
||||
self.assertIsNone(error.message)
|
||||
self.assertIsNone(error.code)
|
||||
self.assertEqual(str(error), 'Unknown error')
|
||||
self.assertEqual(repr(error), "<APIError 'Unknown error'>")
|
||||
|
||||
def test_repr(self):
|
||||
self.assertEqual(
|
||||
repr(self.error),
|
||||
'<APIError "I\'m afraid I can\'t do that, Dave.">',
|
||||
)
|
||||
|
||||
def test_str(self):
|
||||
self.assertEqual(str(self.error), "I'm afraid I can't do that, Dave.")
|
||||
|
||||
def test_fromJSON(self):
|
||||
self.assertEqual(APIError.fromJSON({
|
||||
'code': 9000,
|
||||
'message': "I'm afraid I can't do that, Dave.",
|
||||
}), self.error)
|
||||
|
||||
def test_toJSON(self):
|
||||
self.assertDictEqual(self.error.toJSON(), {
|
||||
'code': 9000,
|
||||
'message': "I'm afraid I can't do that, Dave.",
|
||||
})
|
206
urbantz/tests/test_full.py
Normal file
206
urbantz/tests/test_full.py
Normal file
|
@ -0,0 +1,206 @@
|
|||
from datetime import datetime, date
|
||||
from unittest import TestCase
|
||||
from urbantz.base import Coordinates
|
||||
from urbantz.delivery import Location, Delivery
|
||||
from urbantz.items import LogEntry, Item
|
||||
from urbantz.utils import DictObject
|
||||
import os.path
|
||||
import json
|
||||
|
||||
FOLDER = os.path.dirname(os.path.realpath(__file__))
|
||||
|
||||
|
||||
class TestFull(TestCase):
|
||||
"""
|
||||
Unit tests with a full delivery payload
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
with open(os.path.join(FOLDER, 'test_payload.json')) as f:
|
||||
cls.payload = json.load(f)
|
||||
cls.delivery = Delivery.fromJSON(cls.payload)
|
||||
|
||||
def test_types(self):
|
||||
self.assertIsNone(self.delivery.tracking_code)
|
||||
self.assertIsNone(self.delivery.last_updated)
|
||||
self.assertIsInstance(self.delivery.payload, dict)
|
||||
self.assertIsInstance(self.delivery.id, str)
|
||||
self.assertIsInstance(self.delivery.date, date)
|
||||
self.assertIsInstance(self.delivery.task_id, str)
|
||||
self.assertIsInstance(self.delivery.platform_id, str)
|
||||
self.assertIsInstance(self.delivery.driver_id, str)
|
||||
self.assertIsInstance(self.delivery.round_id, str)
|
||||
self.assertIsInstance(self.delivery.instructions, str)
|
||||
self.assertIsInstance(self.delivery.progress, str)
|
||||
self.assertIsInstance(self.delivery.status, str)
|
||||
self.assertIsInstance(self.delivery.arrival_time, datetime)
|
||||
self.assertIsInstance(self.delivery.eta_margin, int)
|
||||
self.assertIsInstance(self.delivery.eta_rounding, int)
|
||||
self.assertIsInstance(self.delivery.time_window, list)
|
||||
for dt in self.delivery.time_window:
|
||||
self.assertIsInstance(dt, datetime)
|
||||
self.assertIsInstance(self.delivery.position, Coordinates)
|
||||
self.assertIsInstance(self.delivery.destination, Location)
|
||||
self.assertIsInstance(self.delivery.recipient, DictObject)
|
||||
self.assertIsInstance(self.delivery.features, DictObject)
|
||||
self.assertIsInstance(self.delivery.items, list)
|
||||
for item in self.delivery.items:
|
||||
self.assertIsInstance(item, Item)
|
||||
self.assertIsInstance(self.delivery.logs, list)
|
||||
for log in self.delivery.logs:
|
||||
self.assertIsInstance(log, LogEntry)
|
||||
self.assertIsInstance(self.delivery.theme, DictObject)
|
||||
self.assertIsInstance(self.delivery.template, DictObject)
|
||||
|
||||
def test_attributes(self):
|
||||
self.assertEqual(self.delivery.id, "cafe1234")
|
||||
self.assertEqual(self.delivery.date, date(2019, 2, 3))
|
||||
self.assertEqual(self.delivery.task_id, "123456789")
|
||||
self.assertEqual(self.delivery.platform_id, "934")
|
||||
self.assertEqual(self.delivery.driver_id, "987654e")
|
||||
self.assertEqual(self.delivery.round_id, "552bc6f")
|
||||
self.assertEqual(self.delivery.instructions, "Third floor")
|
||||
self.assertEqual(self.delivery.progress, "COMPLETED")
|
||||
self.assertEqual(self.delivery.status, "DELIVERED")
|
||||
self.assertEqual(self.delivery.arrival_time,
|
||||
datetime(2019, 2, 3, 6, 45, 7))
|
||||
self.assertEqual(self.delivery.eta_margin, 20)
|
||||
self.assertEqual(self.delivery.eta_rounding, 5)
|
||||
self.assertEqual(self.delivery.time_window, [
|
||||
datetime(2019, 2, 3, 8, 0, 0),
|
||||
datetime(2019, 2, 3, 10, 0, 0),
|
||||
])
|
||||
|
||||
def test_dictobjects(self):
|
||||
self.assertEqual(self.delivery.recipient.account, "54321")
|
||||
self.assertIsNone(self.delivery.recipient.name)
|
||||
self.assertEqual(self.delivery.recipient.person, "M. Chuck Norris")
|
||||
self.assertEqual(self.delivery.recipient.phone, "33800772424")
|
||||
self.assertEqual(self.delivery.recipient.language, "fr")
|
||||
self.assertTrue(self.delivery.features.driver_round_resequence)
|
||||
self.assertTrue(self.delivery.features.driver_ratings)
|
||||
self.assertFalse(self.delivery.features.consumer_modify_instructions)
|
||||
self.assertTrue(self.delivery.features.allow_virtual_fleet)
|
||||
self.assertFalse(self.delivery.features.cancel_optimization)
|
||||
self.assertEqual(self.delivery.theme.primary, "#efefef")
|
||||
self.assertEqual(self.delivery.theme.secondary, "rgb(30, 40, 50)")
|
||||
self.assertEqual(self.delivery.template.map_availability, 60)
|
||||
self.assertTrue(self.delivery.template.enabled)
|
||||
self.assertEqual(self.delivery.template.email, "contact@example.com")
|
||||
self.assertEqual(self.delivery.template.phone, "0999999999")
|
||||
self.assertEqual(self.delivery.template.background_color,
|
||||
"rgba(50, 40, 20, 0.95)")
|
||||
self.assertEqual(self.delivery.template.primary_color, "#ffffff")
|
||||
self.assertEqual(self.delivery.template.secondary_color, "#731246")
|
||||
self.assertEqual(self.delivery.template.background_picture, "bg.png")
|
||||
self.assertEqual(self.delivery.template.logo, "logo.png")
|
||||
self.assertEqual(
|
||||
self.delivery.template.image_path,
|
||||
"https://backend.urbantz.com/pictures/platforms/934/",
|
||||
)
|
||||
self.assertEqual(self.delivery.template.name, "Something")
|
||||
self.assertEqual(self.delivery.template.icon, "icon.png")
|
||||
self.assertEqual(self.delivery.destination.address_lines, [
|
||||
"Baker Street", "742", "GU16 7HF", "Nowhere", "UK",
|
||||
])
|
||||
self.assertEqual(self.delivery.destination.geocode_score, 98)
|
||||
self.assertEqual(self.delivery.destination.clean_score, 0)
|
||||
self.assertEqual(self.delivery.destination.number, "742")
|
||||
self.assertEqual(self.delivery.destination.street, "Baker Street")
|
||||
self.assertEqual(self.delivery.destination.city, "Nowhere")
|
||||
self.assertEqual(self.delivery.destination.zip, "GU16 7HF")
|
||||
self.assertEqual(self.delivery.destination.country, "UK")
|
||||
self.assertEqual(self.delivery.destination.origin, "ARCGIS")
|
||||
self.assertEqual(self.delivery.destination.precision, "point")
|
||||
|
||||
def test_objects(self):
|
||||
self.assertEqual(self.delivery.position,
|
||||
Coordinates(lat=32.2135467, lng=0.1234567))
|
||||
self.assertEqual(self.delivery.destination.coordinates, Coordinates(
|
||||
lat=42.18906577220902, lng=2.21154054884875))
|
||||
self.assertListEqual(self.delivery.logs, [
|
||||
LogEntry(
|
||||
to="GEOCODED",
|
||||
by=None,
|
||||
when="2019-02-01T23:07:22.997Z",
|
||||
),
|
||||
LogEntry(
|
||||
to="ASSIGNED",
|
||||
by="65dc8",
|
||||
when="2019-02-02T05:16:22.557Z",
|
||||
),
|
||||
LogEntry(
|
||||
to="ONGOING",
|
||||
by="f3251c86513d",
|
||||
when="2019-02-03T07:08:32.107Z",
|
||||
),
|
||||
LogEntry(
|
||||
_id="5416a321",
|
||||
to="COMPLETED",
|
||||
by="6513d54e",
|
||||
when="2019-02-03T07:56:47.814Z",
|
||||
),
|
||||
])
|
||||
self.assertEqual(len(self.delivery.items), 1)
|
||||
item = self.delivery.items[0]
|
||||
self.assertEqual(repr(item), '<Item cafe1>')
|
||||
self.assertEqual(item.toJSON(), self.payload['items'][0])
|
||||
self.assertFalse(item.damage_confirmed)
|
||||
self.assertListEqual(item.damage_pictures, [])
|
||||
self.assertEqual(item.status, "DELIVERED")
|
||||
self.assertEqual(item.quantity, 1)
|
||||
self.assertListEqual(item.labels, [])
|
||||
self.assertListEqual(item.skills, [])
|
||||
self.assertEqual(item.id, "cafe1")
|
||||
self.assertEqual(item.type, "COFFEE")
|
||||
self.assertEqual(item.barcode, "133742133742")
|
||||
self.assertEqual(item.barcode_encoding, "upc")
|
||||
self.assertIsInstance(item.dimensions, DictObject)
|
||||
self.assertEqual(item.dimensions.weight, 19.01)
|
||||
self.assertEqual(item.dimensions.cardboard, 2)
|
||||
self.assertIsInstance(item.metadata, DictObject)
|
||||
self.assertIsNone(item.metadata.package_number)
|
||||
self.assertEqual(item.metadata.quantity, 0)
|
||||
self.assertEqual(item.metadata.order_id_ext, "5255887")
|
||||
self.assertEqual(item.metadata.new_client, "No")
|
||||
self.assertListEqual(item.logs, [
|
||||
LogEntry(
|
||||
by=None,
|
||||
when="2019-02-02T10:32:27.621Z",
|
||||
to="ARRIVED",
|
||||
),
|
||||
LogEntry(
|
||||
by="dead",
|
||||
when="2019-02-03T07:08:32.107Z",
|
||||
to="DEPARTED",
|
||||
),
|
||||
LogEntry(
|
||||
_id="106",
|
||||
by="c0fe",
|
||||
when="2019-02-03T07:56:47.814Z",
|
||||
to="DELIVERED",
|
||||
),
|
||||
])
|
||||
|
||||
def test_properties(self):
|
||||
self.assertListEqual(self.delivery.eta, [
|
||||
datetime(2019, 2, 3, 6, 35, 0),
|
||||
datetime(2019, 2, 3, 6, 55, 0),
|
||||
])
|
||||
self.assertListEqual([log.datetime for log in self.delivery.logs], [
|
||||
datetime(2019, 2, 1, 23, 7, 22, 997000),
|
||||
datetime(2019, 2, 2, 5, 16, 22, 557000),
|
||||
datetime(2019, 2, 3, 7, 8, 32, 107000),
|
||||
datetime(2019, 2, 3, 7, 56, 47, 814000),
|
||||
])
|
||||
self.assertListEqual(
|
||||
[log.datetime for log in self.delivery.items[0].logs],
|
||||
[
|
||||
datetime(2019, 2, 2, 10, 32, 27, 621000),
|
||||
datetime(2019, 2, 3, 7, 8, 32, 107000),
|
||||
datetime(2019, 2, 3, 7, 56, 47, 814000),
|
||||
]
|
||||
)
|
||||
self.assertEqual(self.delivery.toJSON(), self.payload)
|
152
urbantz/tests/test_payload.json
Normal file
152
urbantz/tests/test_payload.json
Normal file
|
@ -0,0 +1,152 @@
|
|||
{
|
||||
"_id": "cafe1234",
|
||||
"date": "2019-02-03T00:00:00.000Z",
|
||||
"taskId": "123456789",
|
||||
"type": "delivery",
|
||||
"by": "beef",
|
||||
"contact": {
|
||||
"account": "54321",
|
||||
"name": null,
|
||||
"person": "M. Chuck Norris",
|
||||
"phone": "33800772424",
|
||||
"language": "fr"
|
||||
},
|
||||
"instructions": "Third floor",
|
||||
"items": [
|
||||
{
|
||||
"damaged": {
|
||||
"confirmed": false,
|
||||
"pictures": []
|
||||
},
|
||||
"status": "DELIVERED",
|
||||
"quantity": 1,
|
||||
"labels": [],
|
||||
"skills": [],
|
||||
"_id": "cafe1",
|
||||
"type": "COFFEE",
|
||||
"barcode": "133742133742",
|
||||
"barcodeEncoding": "upc",
|
||||
"dimensions": {
|
||||
"weight": 19.01,
|
||||
"cardboard": 2
|
||||
},
|
||||
"log": [
|
||||
{
|
||||
"by": null,
|
||||
"when": "2019-02-02T10:32:27.621Z",
|
||||
"to": "ARRIVED"
|
||||
},
|
||||
{
|
||||
"by": "dead",
|
||||
"when": "2019-02-03T07:08:32.107Z",
|
||||
"to": "DEPARTED"
|
||||
},
|
||||
{
|
||||
"_id": "106",
|
||||
"by": "c0fe",
|
||||
"when": "2019-02-03T07:56:47.814Z",
|
||||
"to": "DELIVERED"
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"packageNumber": null,
|
||||
"quantity": 0,
|
||||
"orderIdExt": "5255887",
|
||||
"newClient": "No"
|
||||
}
|
||||
}
|
||||
],
|
||||
"location": {
|
||||
"location": {
|
||||
"type": "Point",
|
||||
"geometry": [
|
||||
2.21154054884875,
|
||||
42.18906577220902
|
||||
]
|
||||
},
|
||||
"addressLines": [
|
||||
"Baker Street",
|
||||
"742",
|
||||
"GU16 7HF",
|
||||
"Nowhere",
|
||||
"UK"
|
||||
],
|
||||
"geocodeScore": 98,
|
||||
"cleanScore": 0,
|
||||
"number": "742",
|
||||
"street": "Baker Street",
|
||||
"city": "Nowhere",
|
||||
"zip": "GU16 7HF",
|
||||
"country": "UK",
|
||||
"origin": "ARCGIS",
|
||||
"precision": "point"
|
||||
},
|
||||
"log": [
|
||||
{
|
||||
"to": "GEOCODED",
|
||||
"when": "2019-02-01T23:07:22.997Z",
|
||||
"by": null
|
||||
},
|
||||
{
|
||||
"by": "65dc8",
|
||||
"when": "2019-02-02T05:16:22.557Z",
|
||||
"to": "ASSIGNED"
|
||||
},
|
||||
{
|
||||
"by": "f3251c86513d",
|
||||
"when": "2019-02-03T07:08:32.107Z",
|
||||
"to": "ONGOING"
|
||||
},
|
||||
{
|
||||
"_id": "5416a321",
|
||||
"by": "6513d54e",
|
||||
"when": "2019-02-03T07:56:47.814Z",
|
||||
"to": "COMPLETED"
|
||||
}
|
||||
],
|
||||
"platform": "934",
|
||||
"progress": "COMPLETED",
|
||||
"serviceTime": 13,
|
||||
"status": "DELIVERED",
|
||||
"timeWindow": {
|
||||
"start": "2019-02-03T08:00:00.000Z",
|
||||
"stop": "2019-02-03T10:00:00.000Z"
|
||||
},
|
||||
"when": "2019-02-01T23:07:16.734Z",
|
||||
"arriveTime": "2019-02-03T06:45:07.000Z",
|
||||
"driver": "987654e",
|
||||
"round": "552bc6f",
|
||||
"position": [
|
||||
0.1234567,
|
||||
32.2135467
|
||||
],
|
||||
"template": {
|
||||
"mapAvailability": 60,
|
||||
"enabled": true,
|
||||
"email": "contact@example.com",
|
||||
"phone": "0999999999",
|
||||
"backgroundColor": "rgba(50, 40, 20, 0.95)",
|
||||
"primaryColor": "#ffffff",
|
||||
"secondaryColor": "#731246",
|
||||
"backgroundPicture": "bg.png",
|
||||
"logo": "logo.png",
|
||||
"imagePath": "https://backend.urbantz.com/pictures/platforms/934/",
|
||||
"name": "Something",
|
||||
"icon": "icon.png"
|
||||
},
|
||||
"features": {
|
||||
"driverRoundResequence": true,
|
||||
"driverRatings": true,
|
||||
"consumerModifyInstructions": false,
|
||||
"allowVirtualFleet": true,
|
||||
"cancelOptimization": false
|
||||
},
|
||||
"eta": {
|
||||
"margin": 20,
|
||||
"rounding": 5
|
||||
},
|
||||
"theme": {
|
||||
"primary": "#efefef",
|
||||
"secondary": "rgb(30, 40, 50)"
|
||||
}
|
||||
}
|
102
urbantz/tests/test_utils.py
Normal file
102
urbantz/tests/test_utils.py
Normal file
|
@ -0,0 +1,102 @@
|
|||
from unittest import TestCase
|
||||
from urbantz.utils import to_camel_case, DictObject
|
||||
|
||||
|
||||
class TestUtils(TestCase):
|
||||
|
||||
test_data = {
|
||||
'first_name': 'Chuck',
|
||||
'lastName': 'Norris',
|
||||
'nested_dict': {
|
||||
'key': 'value',
|
||||
},
|
||||
'nested_list': ['a', 'b', 'c'],
|
||||
}
|
||||
|
||||
def test_to_camel_case(self):
|
||||
self.assertEqual(to_camel_case('abcdef'), 'abcdef')
|
||||
self.assertEqual(to_camel_case('some_thing'), 'someThing')
|
||||
self.assertEqual(to_camel_case('a_b_c_d_e_f'), 'aBCDEF')
|
||||
self.assertEqual(to_camel_case('hEllO_wOrLd'), 'hEllOWOrLd')
|
||||
|
||||
def test_dictobject_is_dict(self):
|
||||
"""
|
||||
Test the DictObject at least acts like a dict
|
||||
"""
|
||||
d = DictObject(self.test_data)
|
||||
self.assertIsInstance(d, dict)
|
||||
self.assertTrue(d)
|
||||
|
||||
# Dict comparison
|
||||
self.assertEqual(d, self.test_data)
|
||||
self.assertEqual(dict(d), d)
|
||||
|
||||
# Get, set, delete items
|
||||
self.assertEqual(d['first_name'], 'Chuck')
|
||||
self.assertIn('first_name', d)
|
||||
del d['first_name']
|
||||
self.assertNotIn('first_name', d)
|
||||
d['first_name'] = 'Clutch'
|
||||
self.assertEqual(d['first_name'], 'Clutch')
|
||||
self.assertListEqual(
|
||||
list(d.keys()),
|
||||
['lastName', 'nested_dict', 'nested_list', 'first_name'],
|
||||
)
|
||||
self.assertListEqual(
|
||||
list(d.values()),
|
||||
['Norris', {'key': 'value'}, ['a', 'b', 'c'], 'Clutch'],
|
||||
)
|
||||
self.assertListEqual(list(d.items()), [
|
||||
('lastName', 'Norris'),
|
||||
('nested_dict', {'key': 'value'}),
|
||||
('nested_list', ['a', 'b', 'c']),
|
||||
('first_name', 'Clutch'),
|
||||
])
|
||||
|
||||
def test_dictobject_attributes(self):
|
||||
"""
|
||||
Test DictObject translates attributes to items
|
||||
"""
|
||||
|
||||
d = DictObject(self.test_data)
|
||||
self.assertEqual(d.first_name, 'Chuck')
|
||||
self.assertEqual(d.lastName, 'Norris')
|
||||
del d.first_name
|
||||
self.assertNotIn('first_name', d)
|
||||
with self.assertRaises(AttributeError):
|
||||
d.nope
|
||||
|
||||
def test_dictobject_camel_case(self):
|
||||
"""
|
||||
Test DictObject turns snake_cased attribute or item names into
|
||||
camelCased names and tries again when not found
|
||||
"""
|
||||
d = DictObject(self.test_data)
|
||||
self.assertEqual(d['lastName'], 'Norris')
|
||||
self.assertEqual(d['last_name'], 'Norris')
|
||||
self.assertEqual(d.lastName, 'Norris')
|
||||
self.assertEqual(d.last_name, 'Norris')
|
||||
self.assertIn('lastName', d)
|
||||
self.assertIn('last_name', d)
|
||||
del d.last_name
|
||||
self.assertNotIn('lastName', d)
|
||||
with self.assertRaises(AttributeError):
|
||||
del d.last_name
|
||||
with self.assertRaises(KeyError):
|
||||
del d['lastName']
|
||||
|
||||
def test_dictobject_edge_case(self):
|
||||
d = DictObject(self.test_data)
|
||||
|
||||
def errored_delitem(*args, **kwargs):
|
||||
raise KeyError
|
||||
|
||||
d.__delitem__ = errored_delitem
|
||||
del d.lastName
|
||||
self.assertEqual(d.lastName, 'Norris')
|
||||
|
||||
def test_dictobject_reverse(self):
|
||||
self.assertEqual(
|
||||
DictObject(self.test_data).toJSON(),
|
||||
self.test_data,
|
||||
)
|
|
@ -22,7 +22,7 @@ def main():
|
|||
default=10,
|
||||
help='Update frequency in seconds',
|
||||
)
|
||||
options = parser.parse_args()
|
||||
options = vars(parser.parse_args())
|
||||
delivery = options['delivery']
|
||||
|
||||
while True:
|
||||
|
@ -33,9 +33,10 @@ def main():
|
|||
raise SystemExit('Invalid delivery ID')
|
||||
print('Error while fetching data:', str(e))
|
||||
|
||||
distance = delivery.position.distance(delivery.destination.coordinates)
|
||||
print("{} {} meters".format(
|
||||
delivery.last_updated.isoformat(),
|
||||
round(delivery.position.distance(delivery.destination), 1),
|
||||
round(distance, 1),
|
||||
))
|
||||
sleep(options['frequency'])
|
||||
|
||||
|
|
178
urbantz/utils.py
178
urbantz/utils.py
|
@ -1,132 +1,66 @@
|
|||
from math import radians, cos, sin, asin, sqrt, trunc, floor, ceil
|
||||
|
||||
EARTH_RADIUS_KM = 6371
|
||||
from urbantz.base import JSONSerializable
|
||||
import re
|
||||
|
||||
|
||||
class Coordinates(object):
|
||||
def to_camel_case(value):
|
||||
return re.sub(r'_(.)', lambda m: m.group(1).upper(), value)
|
||||
|
||||
|
||||
class DictObject(JSONSerializable, dict):
|
||||
"""
|
||||
A helper class for GPS coordinates.
|
||||
A utility class that turns a dict's items into object attributes.
|
||||
This also performs snake to camel case conversion:
|
||||
if a key is missing in the original dict, the key gets converted
|
||||
to camel case.
|
||||
|
||||
>>> d = DictObject(first_name='Chuck', lastName='Norris')
|
||||
>>> d.first_name
|
||||
'Chuck'
|
||||
>>> d.last_name
|
||||
'Norris'
|
||||
"""
|
||||
|
||||
def __init__(self, lat=0, lng=0):
|
||||
"""
|
||||
Get coordinates from decimal degrees.
|
||||
|
||||
:param float lat: Latitude in decimal degrees.
|
||||
:param float lng: Longitude in decimal degrees.
|
||||
"""
|
||||
self.lat = lat
|
||||
self.lng = lng
|
||||
|
||||
def __repr__(self):
|
||||
return '{}(lat={}, lng={})'.format(
|
||||
self.__class__.__name__, self.lat, self.lng)
|
||||
|
||||
def __str__(self):
|
||||
return ', '.join(tuple(self))
|
||||
|
||||
def __hash__(self):
|
||||
return hash(tuple(self))
|
||||
|
||||
def __len__(self):
|
||||
return 2
|
||||
|
||||
def __length_hint__(self):
|
||||
return len(self)
|
||||
|
||||
def __iter__(self):
|
||||
return (self.lat, self.lng)
|
||||
|
||||
def __dict__(self):
|
||||
return {'lat': self.lat, 'lng': self.lng}
|
||||
|
||||
def __eq__(self, other):
|
||||
return hasattr(other, 'lat') and hasattr(other, 'lng') and \
|
||||
tuple(self) == tuple(other)
|
||||
|
||||
def __add__(self, other):
|
||||
if not hasattr(other, 'lat') or hasattr(other, 'lng'):
|
||||
return NotImplemented
|
||||
return self.__class__(
|
||||
lat=self.lat + other.lat,
|
||||
lng=self.lng + other.lng,
|
||||
)
|
||||
|
||||
def __sub__(self, other):
|
||||
if not hasattr(other, 'lat') or hasattr(other, 'lng'):
|
||||
return NotImplemented
|
||||
return self.__class__(
|
||||
lat=self.lat - other.lat,
|
||||
lng=self.lng - other.lng,
|
||||
)
|
||||
|
||||
def __pos__(self):
|
||||
return self
|
||||
|
||||
def __neg__(self):
|
||||
return self.__class__(lat=-self.lat, lng=-self.lng)
|
||||
|
||||
def __abs__(self):
|
||||
return self.__class__(lat=abs(self.lat), lng=abs(self.lng))
|
||||
|
||||
def __round__(self, ndigits=None):
|
||||
return self.__class__(
|
||||
lat=round(self.lat, ndigits),
|
||||
lng=round(self.lng, ndigits),
|
||||
)
|
||||
|
||||
def __trunc__(self):
|
||||
return self.__class__(lat=trunc(self.lat), lng=trunc(self.lng))
|
||||
|
||||
def __floor__(self):
|
||||
return self.__class__(lat=floor(self.lat), lng=floor(self.lng))
|
||||
|
||||
def __ceil__(self):
|
||||
return self.__class__(lat=ceil(self.lat), lng=ceil(self.lng))
|
||||
|
||||
def to_radians(self):
|
||||
"""
|
||||
Convert to a ``(lat, lng)`` tuple in radians.
|
||||
|
||||
:returns: Coordinates in radians.
|
||||
:rtype: tuple(float, float)
|
||||
"""
|
||||
return tuple(map(radians, self))
|
||||
|
||||
def distance(self, other):
|
||||
"""
|
||||
Compute Haversine distance between two coordinates in meters.
|
||||
|
||||
:param other: Another pair of coordinates to compute distance against.
|
||||
:type other: Coordinates
|
||||
:returns: Distance between the two coordinates, in meters.
|
||||
:rtype: float
|
||||
"""
|
||||
if not hasattr(other, 'to_radians'):
|
||||
raise NotImplementedError(
|
||||
'Distance requires a to_radians() method on both coordinates')
|
||||
lat1, lon1 = self.to_radians()
|
||||
lat2, lon2 = other.to_radians()
|
||||
dlon, dlat = lon2 - lon1, lat2 - lat1
|
||||
a = sin(dlat / 2) ** 2 + cos(lat1) * cos(lat2) * sin(dlon / 2) ** 2
|
||||
return EARTH_RADIUS_KM * 2000 * asin(sqrt(a))
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.__dict__.update(self)
|
||||
|
||||
def toJSON(self):
|
||||
"""
|
||||
Convert to UrbanTZ JSON geometry
|
||||
return dict(self)
|
||||
|
||||
:returns: UrbanTZ-compatible JSON geometry data
|
||||
:rtype: list(float)
|
||||
"""
|
||||
return [self.lng, self.lat]
|
||||
@classmethod
|
||||
def fromJSON(cls, payload):
|
||||
return cls(payload)
|
||||
|
||||
@staticmethod
|
||||
def fromJSON(geometry):
|
||||
"""
|
||||
Get a Coordinates instance from parsed UrbanTZ JSON geometry data.
|
||||
def __getattr__(self, name):
|
||||
try:
|
||||
return self.__getitem__(name)
|
||||
except KeyError:
|
||||
return super().__getattr__(name)
|
||||
|
||||
:param geometry: Parsed UrbanTZ geometry data: a list holding
|
||||
``[lng, lat]`` in decimal degrees.
|
||||
:type geometry: list(float)
|
||||
"""
|
||||
return Coordinates(lng=geometry[0], lat=geometry[1])
|
||||
def __missing__(self, name):
|
||||
camel = to_camel_case(name)
|
||||
if name == camel: # Prevent recursion
|
||||
raise KeyError(name)
|
||||
return self.__getitem__(camel)
|
||||
|
||||
def __delattr__(self, name):
|
||||
if name in self:
|
||||
try:
|
||||
self.__delitem__(name)
|
||||
return
|
||||
except KeyError:
|
||||
pass
|
||||
super().__delattr__(name)
|
||||
|
||||
def __delitem__(self, name):
|
||||
try:
|
||||
super().__delitem__(name)
|
||||
except KeyError:
|
||||
camel = to_camel_case(name)
|
||||
if name == camel:
|
||||
raise
|
||||
super().__delitem__(camel)
|
||||
|
||||
def __contains__(self, name):
|
||||
return super().__contains__(name) \
|
||||
or super().__contains__(to_camel_case(name))
|
||||
|
|
Reference in New Issue
Block a user