Source code for ferrosoft.apps.emiflow.services.visitor

"""Visitor implementations driven by :meth:`TransportChain.accept`.

Visitors here perform actions over an entire chain's structure
(elements, freight items, geo-points). :class:`DebuggerVisitor` dumps
every field to stdout for development; :class:`GeocodingRoutingVisitor`
populates missing coordinates and activity distances before emission
calculation.
"""
from django.utils.translation import gettext_lazy as _

from ferrosoft.apps.emiflow.models import (
    TransportChain,
    TransportChainElement,
    FreightItem,
    TransportChainVisitor,
    TCEGeoPoint,
    DistanceType,
    OperationEpitype,
)
from ferrosoft.apps.emiflow.services.geographic import DistanceStrategyCalculator
from ferrosoft.apps.ferrobase.models import CoordinateDecimalField
from ferrosoft.apps.ferromaps.services.addresses import (
    Geocoder,
    AddressRouterService,
    GreatCircleDistanceRouter,
    AddressServiceException,
)
from ferrosoft.decimal import zero


[docs] class DebuggerVisitor(TransportChainVisitor): """Visitor that prints every field of every visited model to stdout. Intended for ad-hoc debugging only — production code should not depend on it. """ # noinspection PyProtectedMember @staticmethod def _debug_fields(foo): """Print ``foo``'s class meta header followed by ``name: value`` per field.""" print("=== %s ===" % foo._meta) for field in foo._meta.fields: print("%s%s" % (field.name.ljust(50), getattr(foo, field.name)))
[docs] def before_chain(self, chain: "TransportChain"): self._debug_fields(chain)
[docs] def on_freight_item(self, item: "FreightItem"): self._debug_fields(item)
[docs] def on_element(self, element: "TransportChainElement"): self._debug_fields(element) if hasattr(element, "geo_origin"): self._debug_fields(element.geo_origin) if hasattr(element, "geo_destination"): self._debug_fields(element.geo_destination) self._debug_fields(element.operation)
[docs] class GeocodingRoutingError(RuntimeError): """Raised when geocoding or routing fails for a chain element.""" pass
[docs] class GeocodingRoutingVisitor(TransportChainVisitor): """Fill in missing coordinates and activity distances on each TCE. For every transport (non-hub) element with origin or destination geo-points whose coordinates are still ``(0, 0)``, the address is geocoded and the coordinates are persisted. Then, when the activity distance has not been entered (or ``force_distance_calculation`` is set), the configured :class:`DistanceStrategyCalculator` is used to compute and store it. Run before booking to make sure emissions can actually be calculated. """ def __init__( self, geocoder: Geocoder, distance_calc: DistanceStrategyCalculator, force_geocoding=False, force_distance_calculation=False, ): """Wire up the geocoder, distance calculator, and force flags.""" self._force_distance_calculation = force_distance_calculation self._force_geocoding = force_geocoding self._geocoder = geocoder self._distance_calc = distance_calc
[docs] @classmethod def get_instance(cls, **kwargs): """Build a visitor wired up with the default address-router-backed services.""" router = AddressRouterService.get_instance() return cls( router, DistanceStrategyCalculator(GreatCircleDistanceRouter(), router), **kwargs, )
[docs] def on_element(self, element: "TransportChainElement"): """Geocode geo-points and resolve activity distance for ``element``. Hub elements are skipped — distance is irrelevant there. For transport elements an origin and destination are required for any distance type other than *actual distance*. Geocoding and routing failures are re-raised as :class:`GeocodingRoutingError` with a user-facing message. """ if OperationEpitype(element.epitype) == OperationEpitype.HUB: # All the following code in this method pertains to activity distance, # which doesn't play a role for HUB operations. return activity = element.activity_distance_parameters if activity.origin is not None: self._geocode_point(activity.origin) elif activity.distance_type != DistanceType.ACTUAL_DISTANCE: raise GeocodingRoutingError( _("Origin is required for distance types other than actual distance") ) if activity.destination is not None: self._geocode_point(activity.destination) elif activity.distance_type != DistanceType.ACTUAL_DISTANCE: raise GeocodingRoutingError( _( "Destination is required for distance types other than actual distance" ) ) if activity.distance == zero or self._force_distance_calculation: try: distance = self._distance_calc.distance( activity, force_calculation=self._force_distance_calculation ) element.activity_distance_value = distance.value element.activity_distance_unit = distance.unit element.save( update_fields=[ "activity_distance_value", "activity_distance_unit", ] ) except AddressServiceException as e: raise GeocodingRoutingError(_("Distance routing failed")) from e
def _geocode_point(self, point: TCEGeoPoint): """Geocode ``point``'s address and persist its coordinates. No-op when the point already has non-zero coordinates and ``force_geocoding`` is ``False``. """ lat, lon = point.coordinates if (lat == zero and lon == zero) or self._force_geocoding: try: location = point.address.geocode(self._geocoder) point.longitude = CoordinateDecimalField.round(location.coords.lon) point.latitude = CoordinateDecimalField.round(location.coords.lat) point.save( update_fields=[ "longitude", "latitude", ] ) except AddressServiceException as e: raise GeocodingRoutingError(_("Geocoding failed")) from e