From 4b6d92958d756cd450c8418453977e1dd5d3186b Mon Sep 17 00:00:00 2001 From: Alexander Hess Date: Tue, 26 Jan 2021 17:07:50 +0100 Subject: [PATCH] Add functionality for drawing `folium.Map`s - this code is not unit-tested due to the complexity involving interactive `folium.Map`s => visual checks give high confidence --- setup.cfg | 24 ++- src/urban_meal_delivery/configuration.py | 4 + src/urban_meal_delivery/db/addresses.py | 46 +++++- src/urban_meal_delivery/db/cities.py | 154 ++++++++++++++++++ src/urban_meal_delivery/db/customers.py | 157 +++++++++++++++++++ src/urban_meal_delivery/db/grids.py | 31 ++++ src/urban_meal_delivery/db/pixels.py | 136 ++++++++++++++++ src/urban_meal_delivery/db/restaurants.py | 94 +++++++++++ src/urban_meal_delivery/db/utils/__init__.py | 2 + src/urban_meal_delivery/db/utils/colors.py | 69 ++++++++ tests/db/fake_data/factories.py | 2 +- tests/db/test_addresses.py | 8 +- 12 files changed, 714 insertions(+), 13 deletions(-) create mode 100644 src/urban_meal_delivery/db/utils/colors.py diff --git a/setup.cfg b/setup.cfg index b7efe8f..00d589d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -92,7 +92,7 @@ extend-ignore = # Google's Python Style Guide is not reStructuredText # until after being processed by Sphinx Napoleon. # Source: https://github.com/peterjc/flake8-rst-docstrings/issues/17 - RST201,RST203,RST301, + RST201,RST203,RST210,RST213,RST301, # String constant over-use is checked visually by the programmer. WPS226, # Allow underscores in numbers. @@ -101,6 +101,10 @@ extend-ignore = WPS305, # Classes should not have to specify a base class. WPS306, + # Let's be modern: The Walrus is ok. + WPS332, + # Let's not worry about the number of noqa's. + WPS402, # Putting logic into __init__.py files may be justified. WPS412, # Allow multiple assignment, e.g., x = y = 123 @@ -127,8 +131,6 @@ per-file-ignores = WPS114,WPS118, # Revisions may have too many expressions. WPS204,WPS213, - # Too many noqa's are ok. - WPS402, noxfile.py: # Type annotations are not strictly enforced. ANN0, ANN2, @@ -136,13 +138,17 @@ per-file-ignores = WPS202, # TODO (isort): Remove after simplifying the nox session "lint". WPS213, - # The noxfile is rather long => allow many noqa's. - WPS402, src/urban_meal_delivery/configuration.py: # Allow upper case class variables within classes. WPS115, + src/urban_meal_delivery/db/customers.py: + # The module is not too complex. + WPS232, + src/urban_meal_delivery/db/restaurants.py: + # The module is not too complex. + WPS232, src/urban_meal_delivery/forecasts/decomposition.py: - # The module does not have a high cognitive complexity. + # The module is not too complex. WPS232, src/urban_meal_delivery/forecasts/timify.py: # No SQL injection as the inputs come from a safe source. @@ -247,8 +253,14 @@ single_line_exclusions = typing [mypy] cache_dir = .cache/mypy +[mypy-folium.*] +ignore_missing_imports = true +[mypy-matplotlib.*] +ignore_missing_imports = true [mypy-nox.*] ignore_missing_imports = true +[mypy-numpy.*] +ignore_missing_imports = true [mypy-packaging] ignore_missing_imports = true [mypy-pandas] diff --git a/src/urban_meal_delivery/configuration.py b/src/urban_meal_delivery/configuration.py index 267d579..ad813b7 100644 --- a/src/urban_meal_delivery/configuration.py +++ b/src/urban_meal_delivery/configuration.py @@ -55,6 +55,10 @@ class Config: # The demand forecasting methods used in the simulations. FORECASTING_METHODS = ['hets', 'rtarima'] + # Colors for the visualizations ins `folium`. + RESTAURANT_COLOR = 'red' + CUSTOMER_COLOR = 'blue' + # Implementation-specific settings # -------------------------------- diff --git a/src/urban_meal_delivery/db/addresses.py b/src/urban_meal_delivery/db/addresses.py index 5b61d41..8ce7193 100644 --- a/src/urban_meal_delivery/db/addresses.py +++ b/src/urban_meal_delivery/db/addresses.py @@ -1,5 +1,10 @@ """Provide the ORM's `Address` model.""" +from __future__ import annotations + +from typing import Any + +import folium import sqlalchemy as sa from sqlalchemy import orm from sqlalchemy.dialects import postgresql @@ -16,7 +21,7 @@ class Address(meta.Base): # Columns id = sa.Column(sa.Integer, primary_key=True, autoincrement=False) # noqa:WPS125 - _primary_id = sa.Column('primary_id', sa.Integer, nullable=False, index=True) + primary_id = sa.Column(sa.Integer, nullable=False, index=True) created_at = sa.Column(sa.DateTime, nullable=False) place_id = sa.Column(sa.Unicode(length=120), nullable=False, index=True) latitude = sa.Column(postgresql.DOUBLE_PRECISION, nullable=False) @@ -83,7 +88,7 @@ class Address(meta.Base): `.is_primary` indicates the first in a group of `Address` objects. """ - return self.id == self._primary_id + return self.id == self.primary_id @property def location(self) -> utils.Location: @@ -121,3 +126,40 @@ class Address(meta.Base): Shortcut for `.location.y`. """ return self.location.y + + def clear_map(self) -> Address: # pragma: no cover + """Shortcut to the `.city.clear_map()` method. + + Returns: + self: enabling method chaining + """ # noqa:D402,DAR203 + self.city.clear_map() + return self + + @property # pragma: no cover + def map(self) -> folium.Map: # noqa:WPS125 + """Shortcut to the `.city.map` object.""" + return self.city.map + + def draw(self, **kwargs: Any) -> folium.Map: # pragma: no cover + """Draw the address on the `.city.map`. + + By default, addresses are shown as black dots. + Use `**kwargs` to overwrite that. + + Args: + **kwargs: passed on to `folium.Circle()`; overwrite default settings + + Returns: + `.city.map` for convenience in interactive usage + """ + defaults = { + 'color': 'black', + 'popup': f'{self.street}, {self.zip_code} {self.city_name}', + } + defaults.update(kwargs) + + marker = folium.Circle((self.latitude, self.longitude), **defaults) + marker.add_to(self.city.map) + + return self.map diff --git a/src/urban_meal_delivery/db/cities.py b/src/urban_meal_delivery/db/cities.py index b2f0cc4..bd5932f 100644 --- a/src/urban_meal_delivery/db/cities.py +++ b/src/urban_meal_delivery/db/cities.py @@ -1,9 +1,14 @@ """Provide the ORM's `City` model.""" +from __future__ import annotations + +import folium import sqlalchemy as sa from sqlalchemy import orm from sqlalchemy.dialects import postgresql +from urban_meal_delivery import config +from urban_meal_delivery import db from urban_meal_delivery.db import meta from urban_meal_delivery.db import utils @@ -94,3 +99,152 @@ class City(meta.Base): The city borders refer to the Google Maps viewport. """ return self.northeast.northing - self.southwest.northing + + def clear_map(self) -> City: # pragma: no cover + """Create a new `folium.Map` object aligned with the city's viewport. + + The map is available via the `.map` property. Note that it is a + mutable objects that is changed from various locations in the code base. + + Returns: + self: enabling method chaining + """ # noqa:DAR203 + self._map = folium.Map( + location=[self.center_latitude, self.center_longitude], + zoom_start=self.initial_zoom, + ) + return self + + @property # pragma: no cover + def map(self) -> folium.Map: # noqa:WPS125 + """A `folium.Map` object aligned with the city's viewport. + + See docstring for `.clear_map()` for further info. + """ + if not hasattr(self, '_map'): # noqa:WPS421 note:d334120e + self.clear_map() + + return self._map + + def draw_restaurants( # noqa:WPS231 + self, order_counts: bool = False, # pragma: no cover + ) -> folium.Map: + """Draw all restaurants on the`.map`. + + Args: + order_counts: show the number of orders + + Returns: + `.map` for convenience in interactive usage + """ + # Obtain all primary `Address`es in the city that host `Restaurant`s. + addresses = ( # noqa:ECE001 + db.session.query(db.Address) + .filter( + db.Address.id.in_( + db.session.query(db.Address.primary_id) # noqa:WPS221 + .join(db.Restaurant, db.Address.id == db.Restaurant.address_id) + .filter(db.Address.city == self) + .distinct() + .all(), + ), + ) + .all() + ) + + for address in addresses: + # Show the restaurant's name if there is only one. + # Otherwise, list all the restaurants' ID's. + restaurants = ( # noqa:ECE001 + db.session.query(db.Restaurant) + .join(db.Address, db.Restaurant.address_id == db.Address.id) + .filter(db.Address.primary_id == address.id) + .all() + ) + if len(restaurants) == 1: + tooltip = f'{restaurants[0].name} (#{restaurants[0].id})' # noqa:WPS221 + else: + tooltip = 'Restaurants ' + ', '.join( # noqa:WPS336 + f'#{restaurant.id}' for restaurant in restaurants + ) + + if order_counts: + # Calculate the number of orders for ALL restaurants ... + n_orders = ( # noqa:ECE001 + db.session.query(db.Order.id) + .join(db.Address, db.Order.pickup_address_id == db.Address.id) + .filter(db.Address.primary_id == address.id) + .count() + ) + # ... and adjust the size of the red dot on the `.map`. + if n_orders >= 1000: + radius = 20 # noqa:WPS220 + elif n_orders >= 500: + radius = 15 # noqa:WPS220 + elif n_orders >= 100: + radius = 10 # noqa:WPS220 + elif n_orders >= 10: + radius = 5 # noqa:WPS220 + else: + radius = 1 # noqa:WPS220 + + tooltip += f' | n_orders={n_orders}' # noqa:WPS336 + + address.draw( + radius=radius, + color=config.RESTAURANT_COLOR, + fill_color=config.RESTAURANT_COLOR, + fill_opacity=0.3, + tooltip=tooltip, + ) + + else: + address.draw( + radius=1, color=config.RESTAURANT_COLOR, tooltip=tooltip, + ) + + return self.map + + def draw_zip_codes(self) -> folium.Map: # pragma: no cover + """Draw all addresses on the `.map`, colorized by their `.zip_code`. + + This does not make a distinction between restaurant and customer addresses. + Also, due to the high memory usage, the number of orders is not calculated. + + Returns: + `.map` for convenience in interactive usage + """ + # First, create a color map with distinct colors for each zip code. + all_zip_codes = sorted( + row[0] + for row in db.session.execute( + f""" -- # noqa:S608 + SELECT DISTINCT + zip_code + FROM + {config.CLEAN_SCHEMA}.addresses + WHERE + city_id = {self.id}; + """, + ) + ) + cmap = utils.make_random_cmap(len(all_zip_codes), bright=False) + colors = { + code: utils.rgb_to_hex(*cmap(index)) + for index, code in enumerate(all_zip_codes) + } + + # Second, draw every address on the `.map. + for address in self.addresses: + # Non-primary addresses are covered by primary ones anyway. + if not address.is_primary: + continue + + marker = folium.Circle( # noqa:WPS317 + (address.latitude, address.longitude), + color=colors[address.zip_code], + radius=1, + ) + marker.add_to(self.map) + + return self.map diff --git a/src/urban_meal_delivery/db/customers.py b/src/urban_meal_delivery/db/customers.py index 2a96d9a..f6d59c2 100644 --- a/src/urban_meal_delivery/db/customers.py +++ b/src/urban_meal_delivery/db/customers.py @@ -1,8 +1,13 @@ """Provide the ORM's `Customer` model.""" +from __future__ import annotations + +import folium import sqlalchemy as sa from sqlalchemy import orm +from urban_meal_delivery import config +from urban_meal_delivery import db from urban_meal_delivery.db import meta @@ -22,3 +27,155 @@ class Customer(meta.Base): # Relationships orders = orm.relationship('Order', back_populates='customer') + + def clear_map(self) -> Customer: # pragma: no cover + """Shortcut to the `...city.clear_map()` method. + + Returns: + self: enabling method chaining + """ # noqa:D402,DAR203 + self.orders[0].pickup_address.city.clear_map() # noqa:WPS219 + return self + + @property # pragma: no cover + def map(self) -> folium.Map: # noqa:WPS125 + """Shortcut to the `...city.map` object.""" + return self.orders[0].pickup_address.city.map # noqa:WPS219 + + def draw( # noqa:C901,WPS210,WPS231 + self, restaurants: bool = True, order_counts: bool = False, # pragma: no cover + ) -> folium.Map: + """Draw all the customer's delivery addresses on the `...city.map`. + + By default, the pickup locations (= restaurants) are also shown. + + Args: + restaurants: show the pickup locations + order_counts: show both the number of pickups at the restaurants + and the number of deliveries at the customer's delivery addresses; + the former is only shown if `restaurants=True` + + Returns: + `...city.map` for convenience in interactive usage + """ + # Note: a `Customer` may have more than one delivery `Address`es. + # That is not true for `Restaurant`s after the data cleaning. + + # Obtain all primary `Address`es where + # at least one delivery was made to `self`. + delivery_addresses = ( # noqa:ECE001 + db.session.query(db.Address) + .filter( + db.Address.id.in_( + db.session.query(db.Address.primary_id) # noqa:WPS221 + .join(db.Order, db.Address.id == db.Order.delivery_address_id) + .filter(db.Order.customer_id == self.id) + .distinct() + .all(), + ), + ) + .all() + ) + + for address in delivery_addresses: + if order_counts: + n_orders = ( # noqa:ECE001 + db.session.query(db.Order) + .join(db.Address, db.Order.delivery_address_id == db.Address.id) + .filter(db.Order.customer_id == self.id) + .filter(db.Address.primary_id == address.id) + .count() + ) + if n_orders >= 25: + radius = 20 # noqa:WPS220 + elif n_orders >= 10: + radius = 15 # noqa:WPS220 + elif n_orders >= 5: + radius = 10 # noqa:WPS220 + elif n_orders > 1: + radius = 5 # noqa:WPS220 + else: + radius = 1 # noqa:WPS220 + + address.draw( + radius=radius, + color=config.CUSTOMER_COLOR, + fill_color=config.CUSTOMER_COLOR, + fill_opacity=0.3, + tooltip=f'n_orders={n_orders}', + ) + + else: + address.draw( + radius=1, color=config.CUSTOMER_COLOR, + ) + + if restaurants: + pickup_addresses = ( # noqa:ECE001 + db.session.query(db.Address) + .filter( + db.Address.id.in_( + db.session.query(db.Address.primary_id) # noqa:WPS221 + .join(db.Order, db.Address.id == db.Order.pickup_address_id) + .filter(db.Order.customer_id == self.id) + .distinct() + .all(), + ), + ) + .all() + ) + + for address in pickup_addresses: # noqa:WPS440 + # Show the restaurant's name if there is only one. + # Otherwise, list all the restaurants' ID's. + # We cannot show the `Order.restaurant.name` due to the aggregation. + restaurants = ( # noqa:ECE001 + db.session.query(db.Restaurant) + .join(db.Address, db.Restaurant.address_id == db.Address.id) + .filter(db.Address.primary_id == address.id) # noqa:WPS441 + .all() + ) + if len(restaurants) == 1: # type:ignore + tooltip = ( + f'{restaurants[0].name} (#{restaurants[0].id})' # type:ignore + ) + else: + tooltip = 'Restaurants ' + ', '.join( # noqa:WPS336 + f'#{restaurant.id}' for restaurant in restaurants # type:ignore + ) + + if order_counts: + n_orders = ( # noqa:ECE001 + db.session.query(db.Order) + .join(db.Address, db.Order.pickup_address_id == db.Address.id) + .filter(db.Order.customer_id == self.id) + .filter(db.Address.primary_id == address.id) # noqa:WPS441 + .count() + ) + if n_orders >= 25: + radius = 20 # noqa:WPS220 + elif n_orders >= 10: + radius = 15 # noqa:WPS220 + elif n_orders >= 5: + radius = 10 # noqa:WPS220 + elif n_orders > 1: + radius = 5 # noqa:WPS220 + else: + radius = 1 # noqa:WPS220 + + tooltip += f' | n_orders={n_orders}' # noqa:WPS336 + + address.draw( # noqa:WPS441 + radius=radius, + color=config.RESTAURANT_COLOR, + fill_color=config.RESTAURANT_COLOR, + fill_opacity=0.3, + tooltip=tooltip, + ) + + else: + address.draw( # noqa:WPS441 + radius=1, color=config.RESTAURANT_COLOR, tooltip=tooltip, + ) + + return self.map diff --git a/src/urban_meal_delivery/db/grids.py b/src/urban_meal_delivery/db/grids.py index d0b6629..dac6e48 100644 --- a/src/urban_meal_delivery/db/grids.py +++ b/src/urban_meal_delivery/db/grids.py @@ -2,6 +2,9 @@ from __future__ import annotations +from typing import Any + +import folium import sqlalchemy as sa from sqlalchemy import orm @@ -104,3 +107,31 @@ class Grid(meta.Base): pixel.addresses.append(assoc) return grid + + def clear_map(self) -> Grid: # pragma: no cover + """Shortcut to the `.city.clear_map()` method. + + Returns: + self: enabling method chaining + """ # noqa:D402,DAR203 + self.city.clear_map() + return self + + @property # pragma: no cover + def map(self) -> folium.Map: # noqa:WPS125 + """Shortcut to the `.city.map` object.""" + return self.city.map + + def draw(self, **kwargs: Any) -> folium.Map: # pragma: no cover + """Draw all pixels in the grid. + + Args: + **kwargs: passed on to `Pixel.draw()` + + Returns: + `.city.map` for convenience in interactive usage + """ + for pixel in self.pixels: + pixel.draw(**kwargs) + + return self.map diff --git a/src/urban_meal_delivery/db/pixels.py b/src/urban_meal_delivery/db/pixels.py index c182206..f5ca091 100644 --- a/src/urban_meal_delivery/db/pixels.py +++ b/src/urban_meal_delivery/db/pixels.py @@ -1,9 +1,14 @@ """Provide the ORM's `Pixel` model.""" +from __future__ import annotations + +import folium import sqlalchemy as sa import utm from sqlalchemy import orm +from urban_meal_delivery import config +from urban_meal_delivery import db from urban_meal_delivery.db import meta from urban_meal_delivery.db import utils @@ -105,3 +110,134 @@ class Pixel(meta.Base): self._southwest.relate_to(self.grid.city.southwest) return self._southwest + + def clear_map(self) -> Pixel: # pragma: no cover + """Shortcut to the `.city.clear_map()` method. + + Returns: + self: enabling method chaining + """ # noqa:D402,DAR203 + self.grid.city.clear_map() + return self + + @property # pragma: no cover + def map(self) -> folium.Map: # noqa:WPS125 + """Shortcut to the `.city.map` object.""" + return self.grid.city.map + + def draw( # noqa:C901,WPS210,WPS231 + self, restaurants: bool = True, order_counts: bool = False, # pragma: no cover + ) -> folium.Map: + """Draw the pixel on the `.grid.city.map`. + + Args: + restaurants: include the restaurants + order_counts: show the number of orders at a restaurant + + Returns: + `.grid.city.map` for convenience in interactive usage + """ + bounds = ( + (self.southwest.latitude, self.southwest.longitude), + (self.northeast.latitude, self.northeast.longitude), + ) + info_text = f'Pixel({self.n_x}, {self.n_y})' + + # Make the `Pixel`s look like a checkerboard. + if (self.n_x + self.n_y) % 2: + color = '#808000' + else: + color = '#ff8c00' + + marker = folium.Rectangle( + bounds=bounds, + color='gray', + opacity=0.2, + weight=5, + fill_color=color, + fill_opacity=0.2, + popup=info_text, + tooltip=info_text, + ) + marker.add_to(self.grid.city.map) + + if restaurants: + # Obtain all primary `Address`es in the city that host `Restaurant`s + # and are in the `self` `Pixel`. + addresses = ( # noqa:ECE001 + db.session.query(db.Address) + .filter( + db.Address.id.in_( + ( + db.session.query(db.Address.primary_id) + .join( + db.Restaurant, + db.Address.id == db.Restaurant.address_id, + ) + .join( + db.AddressPixelAssociation, + db.Address.id == db.AddressPixelAssociation.address_id, + ) + .filter(db.AddressPixelAssociation.pixel_id == self.id) + ) + .distinct() + .all(), + ), + ) + .all() + ) + + for address in addresses: + # Show the restaurant's name if there is only one. + # Otherwise, list all the restaurants' ID's. + restaurants = ( # noqa:ECE001 + db.session.query(db.Restaurant) + .join(db.Address, db.Restaurant.address_id == db.Address.id) + .filter(db.Address.primary_id == address.id) + .all() + ) + if len(restaurants) == 1: # type:ignore + tooltip = ( + f'{restaurants[0].name} (#{restaurants[0].id})' # type:ignore + ) + else: + tooltip = 'Restaurants ' + ', '.join( # noqa:WPS336 + f'#{restaurant.id}' for restaurant in restaurants # type:ignore + ) + + if order_counts: + # Calculate the number of orders for ALL restaurants ... + n_orders = ( # noqa:ECE001 + db.session.query(db.Order.id) + .join(db.Address, db.Order.pickup_address_id == db.Address.id) + .filter(db.Address.primary_id == address.id) + .count() + ) + # ... and adjust the size of the red dot on the `.map`. + if n_orders >= 1000: + radius = 20 # noqa:WPS220 + elif n_orders >= 500: + radius = 15 # noqa:WPS220 + elif n_orders >= 100: + radius = 10 # noqa:WPS220 + elif n_orders >= 10: + radius = 5 # noqa:WPS220 + else: + radius = 1 # noqa:WPS220 + + tooltip += f' | n_orders={n_orders}' # noqa:WPS336 + + address.draw( + radius=radius, + color=config.RESTAURANT_COLOR, + fill_color=config.RESTAURANT_COLOR, + fill_opacity=0.3, + tooltip=tooltip, + ) + + else: + address.draw( + radius=1, color=config.RESTAURANT_COLOR, tooltip=tooltip, + ) + + return self.map diff --git a/src/urban_meal_delivery/db/restaurants.py b/src/urban_meal_delivery/db/restaurants.py index 23fa896..cf02e53 100644 --- a/src/urban_meal_delivery/db/restaurants.py +++ b/src/urban_meal_delivery/db/restaurants.py @@ -1,8 +1,13 @@ """Provide the ORM's `Restaurant` model.""" +from __future__ import annotations + +import folium import sqlalchemy as sa from sqlalchemy import orm +from urban_meal_delivery import config +from urban_meal_delivery import db from urban_meal_delivery.db import meta @@ -45,3 +50,92 @@ class Restaurant(meta.Base): def __repr__(self) -> str: """Non-literal text representation.""" return '<{cls}({name})>'.format(cls=self.__class__.__name__, name=self.name) + + def clear_map(self) -> Restaurant: # pragma: no cover + """Shortcut to the `.address.city.clear_map()` method. + + Returns: + self: enabling method chaining + """ # noqa:D402,DAR203 + self.address.city.clear_map() + return self + + @property # pragma: no cover + def map(self) -> folium.Map: # noqa:WPS125 + """Shortcut to the `.address.city.map` object.""" + return self.address.city.map + + def draw( # noqa:WPS231 + self, customers: bool = True, order_counts: bool = False, # pragma: no cover + ) -> folium.Map: + """Draw the restaurant on the `.address.city.map`. + + By default, the restaurant's delivery locations are also shown. + + Args: + customers: show the restaurant's delivery locations + order_counts: show the number of orders at the delivery locations; + only useful if `customers=True` + + Returns: + `.address.city.map` for convenience in interactive usage + """ + if customers: + # Obtain all primary `Address`es in the city that + # received at least one delivery from `self`. + delivery_addresses = ( # noqa:ECE001 + db.session.query(db.Address) + .filter( + db.Address.id.in_( + db.session.query(db.Address.primary_id) # noqa:WPS221 + .join(db.Order, db.Address.id == db.Order.delivery_address_id) + .filter(db.Order.restaurant_id == self.id) + .distinct() + .all(), + ), + ) + .all() + ) + + for address in delivery_addresses: + if order_counts: + n_orders = ( # noqa:ECE001 + db.session.query(db.Order) + .join(db.Address, db.Order.delivery_address_id == db.Address.id) + .filter(db.Order.restaurant_id == self.id) + .filter(db.Address.primary_id == address.id) + .count() + ) + if n_orders >= 25: + radius = 20 # noqa:WPS220 + elif n_orders >= 10: + radius = 15 # noqa:WPS220 + elif n_orders >= 5: + radius = 10 # noqa:WPS220 + elif n_orders > 1: + radius = 5 # noqa:WPS220 + else: + radius = 1 # noqa:WPS220 + + address.draw( + radius=radius, + color=config.CUSTOMER_COLOR, + fill_color=config.CUSTOMER_COLOR, + fill_opacity=0.3, + tooltip=f'n_orders={n_orders}', + ) + + else: + address.draw( + radius=1, color=config.CUSTOMER_COLOR, + ) + + self.address.draw( + radius=20, + color=config.RESTAURANT_COLOR, + fill_color=config.RESTAURANT_COLOR, + fill_opacity=0.3, + tooltip=f'{self.name} (#{self.id}) | n_orders={len(self.orders)}', + ) + + return self.map diff --git a/src/urban_meal_delivery/db/utils/__init__.py b/src/urban_meal_delivery/db/utils/__init__.py index 59ade94..5d6f8b6 100644 --- a/src/urban_meal_delivery/db/utils/__init__.py +++ b/src/urban_meal_delivery/db/utils/__init__.py @@ -1,3 +1,5 @@ """Utilities used by the ORM models.""" +from urban_meal_delivery.db.utils.colors import make_random_cmap +from urban_meal_delivery.db.utils.colors import rgb_to_hex from urban_meal_delivery.db.utils.locations import Location diff --git a/src/urban_meal_delivery/db/utils/colors.py b/src/urban_meal_delivery/db/utils/colors.py new file mode 100644 index 0000000..ad45327 --- /dev/null +++ b/src/urban_meal_delivery/db/utils/colors.py @@ -0,0 +1,69 @@ +"""Utilities for drawing maps with `folium`.""" + +import colorsys + +import numpy as np +from matplotlib import colors + + +def make_random_cmap( + n_colors: int, bright: bool = True, # pragma: no cover +) -> colors.LinearSegmentedColormap: + """Create a random `Colormap` with `n_colors` different colors. + + Args: + n_colors: number of of different colors; size of `Colormap` + bright: `True` for strong colors, `False` for pastel colors + + Returns: + colormap + """ + np.random.seed(42) + + if bright: + hsv_colors = [ + ( + np.random.uniform(low=0.0, high=1), + np.random.uniform(low=0.2, high=1), + np.random.uniform(low=0.9, high=1), + ) + for _ in range(n_colors) + ] + + rgb_colors = [] + for color in hsv_colors: + rgb_colors.append(colorsys.hsv_to_rgb(*color)) + + else: + low = 0.0 + high = 0.66 + + rgb_colors = [ + ( + np.random.uniform(low=low, high=high), + np.random.uniform(low=low, high=high), + np.random.uniform(low=low, high=high), + ) + for _ in range(n_colors) + ] + + return colors.LinearSegmentedColormap.from_list( + 'random_color_map', rgb_colors, N=n_colors, + ) + + +def rgb_to_hex(*args: float) -> str: # pragma: no cover + """Convert RGB colors into hexadecimal notation. + + Args: + *args: percentages (0% - 100%) for the RGB channels + + Returns: + hexadecimal_representation + """ + red, green, blue = ( + int(255 * args[0]), + int(255 * args[1]), + int(255 * args[2]), + ) + return f'#{red:02x}{green:02x}{blue:02x}' # noqa:WPS221 diff --git a/tests/db/fake_data/factories.py b/tests/db/fake_data/factories.py index 61c27e9..46f2ff3 100644 --- a/tests/db/fake_data/factories.py +++ b/tests/db/fake_data/factories.py @@ -48,7 +48,7 @@ class AddressFactory(alchemy.SQLAlchemyModelFactory): # As non-primary addresses have no different behavior and # the property is only kept from the original dataset for # completeness sake, that is ok to do. - _primary_id = factory.LazyAttribute(lambda obj: obj.id) + primary_id = factory.LazyAttribute(lambda obj: obj.id) # Mimic a Google Maps Place ID with just random characters. place_id = factory.LazyFunction( diff --git a/tests/db/test_addresses.py b/tests/db/test_addresses.py index 0b14ccc..ab49855 100644 --- a/tests/db/test_addresses.py +++ b/tests/db/test_addresses.py @@ -39,8 +39,8 @@ class TestConstraints: def test_delete_a_referenced_address(self, db_session, address, make_address): """Remove a record that is referenced with a FK.""" db_session.add(address) - # Fake another_address that has the same `._primary_id` as `address`. - db_session.add(make_address(_primary_id=address.id)) + # Fake another_address that has the same `.primary_id` as `address`. + db_session.add(make_address(primary_id=address.id)) db_session.commit() db_session.delete(address) @@ -109,7 +109,7 @@ class TestProperties: def test_is_primary(self, address): """Test `Address.is_primary` property.""" - assert address.id == address._primary_id + assert address.id == address.primary_id result = address.is_primary @@ -117,7 +117,7 @@ class TestProperties: def test_is_not_primary(self, address): """Test `Address.is_primary` property.""" - address._primary_id = 999 + address.primary_id = 999 result = address.is_primary