Run as FastAPI application

This commit is contained in:
Csaba 2024-05-03 12:32:45 +02:00
parent 17a6888583
commit 2df0b38f03
21 changed files with 541 additions and 175 deletions

View file

@ -0,0 +1 @@
# from .enhancer import *

View file

@ -8,9 +8,9 @@ from amarillo.models.Carpool import Carpool
from amarillo.plugins.enhancer.services import stops
from amarillo.plugins.enhancer.services import trips
from amarillo.plugins.enhancer.services.carpools import CarpoolService
from amarillo.plugins.enhancer.services import gtfs_generator
from amarillo.services.config import config
from amarillo.configuration import configure_services
from .services.trips import TripTransformer
logger = logging.getLogger(__name__)
@ -19,22 +19,29 @@ enhancer_configured = False
def configure_enhancer_services():
#Make sure configuration only happens once
global enhancer_configured
global transformer
if enhancer_configured:
logger.info("Enhancer is already configured")
return
configure_services()
logger.info("Load stops...")
with open(config.stop_sources_file) as stop_sources_file:
with open('data/stop_sources.json') as stop_sources_file:
stop_sources = json.load(stop_sources_file)
stop_store = stops.StopsStore(stop_sources)
stop_store.load_stop_sources()
# TODO: do we need container?
container['stops_store'] = stop_store
container['trips_store'] = trips.TripStore(stop_store)
container['carpools'] = CarpoolService(container['trips_store'])
transformer = TripTransformer(stop_store)
logger.info("Restore carpools...")
for agency_id in container['agencies'].agencies:

View file

@ -0,0 +1,94 @@
from .models.Carpool import Carpool
from .services.trips import TripTransformer
import logging
import logging.config
from fastapi import FastAPI, status, Body
from .configuration import configure_enhancer_services
from amarillo.utils.container import container
logging.config.fileConfig('logging.conf', disable_existing_loggers=False)
logger = logging.getLogger("enhancer")
#TODO: clean up metadata
app = FastAPI(title="Amarillo Enhancer",
description="This service allows carpool agencies to publish "
"their trip offers, so routing services may suggest "
"them as trip options. For carpool offers, only the "
"minimum required information (origin/destination, "
"optionally intermediate stops, departure time and a "
"deep link for booking/contacting the driver) needs to "
"be published, booking/contact exchange is to be "
"handled by the publishing agency.",
version="0.0.1",
# TODO 404
terms_of_service="http://mfdz.de/carpool-hub-terms/",
contact={
# "name": "unused",
# "url": "http://unused",
"email": "info@mfdz.de",
},
license_info={
"name": "AGPL-3.0 License",
"url": "https://www.gnu.org/licenses/agpl-3.0.de.html",
},
openapi_tags=[
{
"name": "carpool",
# "description": "Find out more about Amarillo - the carpooling intermediary",
"externalDocs": {
"description": "Find out more about Amarillo - the carpooling intermediary",
"url": "https://github.com/mfdz/amarillo",
},
}],
servers=[
{
"description": "MobiData BW Amarillo service",
"url": "https://amarillo.mobidata-bw.de"
},
{
"description": "DABB bbnavi Amarillo service",
"url": "https://amarillo.bbnavi.de"
},
{
"description": "Demo server by MFDZ",
"url": "https://amarillo.mfdz.de"
},
{
"description": "Dev server for development",
"url": "https://amarillo-dev.mfdz.de"
},
{
"description": "Server for Mitanand project",
"url": "https://mitanand.mfdz.de"
},
{
"description": "Localhost for development",
"url": "http://localhost:8000"
}
],
redoc_url=None
)
configure_enhancer_services()
stops_store = container['stops_store']
transformer : TripTransformer = TripTransformer(stops_store)
logger.info(transformer)
@app.post("/",
operation_id="enhancecarpool",
summary="Add a new or update existing carpool",
description="Carpool object to be enhanced",
response_model=Carpool, # TODO
response_model_exclude_none=True,
responses={
status.HTTP_404_NOT_FOUND: {
"description": "Agency does not exist"},
})
#TODO: add examples
async def post_carpool(carpool: Carpool = Body(...)) -> Carpool:
logger.info(f"POST trip {carpool.agency}:{carpool.id}.")
enhanced = transformer.enhance_carpool(carpool)
return enhanced

View file

@ -0,0 +1,407 @@
from datetime import time, date, datetime
from pydantic import ConfigDict, BaseModel, Field, HttpUrl, EmailStr
from typing import List, Union, Set, Optional, Tuple
from datetime import time
from pydantic import BaseModel, Field
from geojson_pydantic.geometries import LineString
from enum import Enum, IntEnum
NumType = Union[float, int]
MAX_STOPS_PER_TRIP = 100
class Weekday(str, Enum):
monday = "monday"
tuesday = "tuesday"
wednesday = "wednesday"
thursday = "thursday"
friday = "friday"
saturday = "saturday"
sunday = "sunday"
class PickupDropoffType(str, Enum):
pickup_and_dropoff = "pickup_and_dropoff"
only_pickup = "only_pickup"
only_dropoff = "only_dropoff"
class YesNoEnum(IntEnum):
yes = 1
no = 2
class LuggageSize(IntEnum):
small = 1
medium = 2
large = 3
class StopTime(BaseModel):
id: Optional[str] = Field(
None,
description="Optional Stop ID. If given, it should conform to the "
"IFOPT specification. For official transit stops, "
"it should be their official IFOPT. In Germany, this is "
"the DHID which is available via the 'zentrales "
"Haltestellenverzeichnis (zHV)', published by DELFI e.V. "
"Note, that currently carpooling location.",
pattern=r"^([a-zA-Z]{2,6}):\d+:\d+(:\d*(:\w+)?)?$|^osm:[nwr]\d+$",
examples=["de:12073:900340137::2"])
name: str = Field(
description="Name of the location. Use a name that people will "
"understand in the local and tourist vernacular.",
min_length=1,
max_length=256,
examples=["Angermünde, Breitscheidstr."])
departureTime: Optional[str] = Field(
None,
description="Departure time from a specific stop for a specific "
"carpool trip. For times occurring after midnight on the "
"service day, the time is given as a value greater than "
"24:00:00 in HH:MM:SS local time for the day on which the "
"trip schedule begins. If there are not separate times for "
"arrival and departure at a stop, the same value for arrivalTime "
"and departureTime. Note, that arrivalTime/departureTime of "
"the stops are not mandatory, and might then be estimated by "
"this service.",
pattern=r"^[0-9][0-9]:[0-5][0-9](:[0-5][0-9])?$",
examples=["17:00"]
)
arrivalTime: Optional[str] = Field(
None,
description="Arrival time at a specific stop for a specific trip on a "
"carpool route. If there are not separate times for arrival "
"and departure at a stop, enter the same value for arrivalTime "
"and departureTime. For times occurring after midnight on the "
"service day, the time as a value greater than 24:00:00 in "
"HH:MM:SS local time for the day on which the trip schedule "
"begins. Note, that arrivalTime/departureTime of the stops "
"are not mandatory, and might then be estimated by this "
"service.",
pattern=r"^[0-9][0-9]:[0-5][0-9](:[0-5][0-9])?$",
examples=["18:00"])
lat: float = Field(
description="Latitude of the location. Should describe the location "
"where a passenger may mount/dismount the vehicle.",
ge=-90,
lt=90,
examples=["53.0137311391"])
lon: float = Field(
description="Longitude of the location. Should describe the location "
"where a passenger may mount/dismount the vehicle.",
ge=-180,
lt=180,
examples=["13.9934706687"])
pickup_dropoff: Optional[PickupDropoffType] = Field(
None, description="If passengers may be picked up, dropped off or both at this stop. "
"If not specified, this service may assign this according to some custom rules. "
"E.g. Amarillo may allow pickup only for the first third of the distance travelled, "
"and dropoff only for the last third." ,
examples=["only_pickup"]
)
model_config = ConfigDict(json_schema_extra={
"example": "{'id': 'de:12073:900340137::2', 'name': "
"'Angermünde, Breitscheidstr.', 'lat': 53.0137311391, "
"'lon': 13.9934706687}"
})
class Region(BaseModel):
id: str = Field(
description="ID of the region.",
min_length=1,
max_length=20,
pattern='^[a-zA-Z0-9]+$',
examples=["bb"])
bbox: Tuple[NumType, NumType, NumType, NumType] = Field(
description="Bounding box of the region. Format is [minLon, minLat, maxLon, maxLat]",
examples=[[10.5,49.2,11.3,51.3]])
class RidesharingInfo(BaseModel):
number_free_seats: int = Field(
description="Number of free seats",
ge=0,
examples=[3])
same_gender: Optional[YesNoEnum] = Field(
None,
description="Trip only for same gender:"
"1: Yes"
"2: No",
examples=[1])
luggage_size: Optional[LuggageSize] = Field(
None,
description="Size of the luggage:"
"1: small"
"2: medium"
"3: large",
examples=[3])
animal_car: Optional[YesNoEnum] = Field(
None,
description="Animals in Car allowed:"
"1: Yes"
"2: No",
examples=[2])
car_model: Optional[str] = Field(
None,
description="Car model",
min_length=1,
max_length=48,
examples=["Golf"])
car_brand: Optional[str] = Field(
None,
description="Car brand",
min_length=1,
max_length=48,
examples=["VW"])
creation_date: datetime = Field(
description="Date when trip was created",
examples=["2022-02-13T20:20:39+00:00"])
smoking: Optional[YesNoEnum] = Field(
None,
description="Smoking allowed:"
"1: Yes"
"2: No",
examples=[2])
payment_method: Optional[str] = Field(
None,
description="Method of payment",
min_length=1,
max_length=48)
class Driver(BaseModel):
driver_id: Optional[str] = Field(
None,
description="Identifies the driver.",
min_length=1,
max_length=256,
pattern='^[a-zA-Z0-9_-]+$',
examples=["789"])
profile_picture: Optional[HttpUrl] = Field(
None,
description="URL that contains the profile picture",
examples=["https://mfdz.de/driver/789/picture"])
rating: Optional[int] = Field(
None,
description="Rating of the driver from 1 to 5."
"0 no rating yet",
ge=0,
le=5,
examples=[5])
class Agency(BaseModel):
id: str = Field(
description="ID of the agency.",
min_length=1,
max_length=20,
pattern='^[a-zA-Z0-9]+$',
examples=["mfdz"])
name: str = Field(
description="Name",
min_length=1,
max_length=48,
pattern=r'^[\w -\.\|]+$',
examples=["MITFAHR|DE|ZENTRALE"])
url: HttpUrl = Field(
description="URL of the carpool agency.",
examples=["https://mfdz.de/"])
timezone: str = Field(
description="Timezone where the carpool agency is located.",
min_length=1,
max_length=48,
pattern=r'^[\w/]+$',
examples=["Europe/Berlin"])
lang: str = Field(
description="Primary language used by this carpool agency.",
min_length=1,
max_length=2,
pattern=r'^[a-zA-Z_]+$',
examples=["de"])
email: EmailStr = Field(
description="""Email address actively monitored by the agencys
customer service department. This email address should be a direct
contact point where carpool riders can reach a customer service
representative at the agency.""",
examples=["info@mfdz.de"])
terms_url: Optional[HttpUrl] = Field(
None, description="""A fully qualified URL pointing to the terms of service
(also often called "terms of use" or "terms and conditions")
for the service.""",
examples=["https://mfdz.de/nutzungsbedingungen"])
privacy_url: Optional[HttpUrl] = Field(
None, description="""A fully qualified URL pointing to the privacy policy for the service.""",
examples=["https://mfdz.de/datenschutz"])
model_config = ConfigDict(json_schema_extra={
"title": "Agency",
"description": "Carpool agency.",
"example":
#"""
{
"id": "mfdz",
"name": "MITFAHR|DE|ZENTRALE",
"url": "http://mfdz.de",
"timezone": "Europe/Berlin",
"lang": "de",
"email": "info@mfdz.de",
"terms_url": "https://mfdz.de/nutzungsbedingungen",
"privacy_url": "https://mfdz.de/datenschutz",
}
#"""
})
class Carpool(BaseModel):
id: str = Field(
description="ID of the carpool. Should be supplied and managed by the "
"carpooling platform which originally published this "
"offer.",
min_length=1,
max_length=256,
pattern='^[a-zA-Z0-9_-]+$',
examples=["103361"])
agency: str = Field(
description="Short one string name of the agency, used as a namespace "
"for ids.",
min_length=1,
max_length=20,
pattern='^[a-zA-Z0-9]+$',
examples=["mfdz"])
driver: Optional[Driver] = Field(
None,
description="Driver data",
examples=["""
{
"driver_id": "123",
"profile_picture": "https://mfdz.de/driver/789/picture",
"rating": 5
}
"""])
deeplink: HttpUrl = Field(
description="Link to an information page providing detail information "
"for this offer, and, especially, an option to book the "
"trip/contact the driver.",
examples=["https://mfdz.de/trip/103361"])
stops: List[StopTime] = Field(
...,
min_length=2,
max_length=MAX_STOPS_PER_TRIP,
description="Stops which this carpool passes by and offers to pick "
"up/drop off passengers. This list must at minimum "
"include two stops, the origin and destination of this "
"carpool trip. Note that for privacy reasons, the stops "
"usually should be official locations, like meeting "
"points, carpool parkings, ridesharing benches or "
"similar.",
examples=["""[
{
"id": "03",
"name": "drei",
"lat": 45,
"lon": 9
},
{
"id": "03b",
"name": "drei b",
"lat": 45,
"lon": 9
}
]"""])
# TODO can be removed, as first stop has departureTime as well
departureTime: time = Field(
description="Time when the carpool leaves at the first stop. Note, "
"that this API currently does not support flexible time "
"windows for departure, though drivers might be flexible."
"For recurring trips, the weekdays this trip will run. ",
examples=["17:00"])
# TODO think about using googlecal Format
departureDate: Union[date, Set[Weekday]] = Field(
description="Date when the trip will start, in case it is a one-time "
"trip. For recurring trips, specify weekdays. "
"Note, that when for different weekdays different "
"departureTimes apply, multiple carpool offers should be "
"published.",
examples=['A single date 2022-04-04 or a list of weekdays ["saturday", '
'"sunday"]'])
route_color: Optional[str] = Field(
None,
pattern='^([0-9A-Fa-f]{6})$',
description="Route color designation that matches public facing material. "
"The color difference between route_color and route_text_color "
"should provide sufficient contrast when viewed on a black and "
"white screen.",
examples=["0039A6"])
route_text_color: Optional[str] = Field(
None,
pattern='^([0-9A-Fa-f]{6})$',
description="Legible color to use for text drawn against a background of "
"route_color. The color difference between route_color and "
"route_text_color should provide sufficient contrast when "
"viewed on a black and white screen.",
examples=["D4D2D2"])
path: Optional[LineString] = Field(
None, description="Optional route geometry as json LineString.")
lastUpdated: Optional[datetime] = Field(
None,
description="LastUpdated should reflect the last time, the user "
"providing this offer, made an update or confirmed, "
"the offer is still valid. Note that this service might "
"purge outdated offers (e.g. older than 180 days). If not "
"passed, the service may assume 'now'",
examples=["2022-02-13T20:20:39+00:00"])
additional_ridesharing_info: Optional[RidesharingInfo] = Field(
None,
description="Extension of GRFS to the GTFS standard",
examples=["""
{
"number_free_seats": 2,
"creation_date": "2022-02-13T20:20:39+00:00",
"same_gender": 2,
"smoking": 1,
"luggage_size": 3
}
"""])
model_config = ConfigDict(json_schema_extra={
"title": "Carpool",
# description ...
"example":
"""
{
"id": "1234",
"agency": "mfdz",
"deeplink": "http://mfdz.de",
"stops": [
{
"id": "de:12073:900340137::2", "name": "ABC",
"lat": 45, "lon": 9
},
{
"id": "de:12073:900340137::3", "name": "XYZ",
"lat": 45, "lon": 9
}
],
"departureTime": "12:34",
"departureDate": "2022-03-30",
"lastUpdated": "2022-03-30T12:34:00+00:00"
}
"""
})

View file

@ -73,7 +73,7 @@ class Trip:
return self.bbox.intersects(box(*bbox))
class TripStore():
class TripStoreX():
"""
TripStore maintains the currently valid trips. A trip is a
carpool offer enhanced with all stops this
@ -91,6 +91,7 @@ class TripStore():
self.recent_trips = {}
#TODO: move file handling to main Amarillo
def put_carpool(self, carpool: Carpool):
"""
Adds carpool to the TripStore.

View file

@ -1 +0,0 @@
__path__ = __import__('pkgutil').extend_path(__path__, __name__)

View file

@ -1 +0,0 @@
__path__ = __import__('pkgutil').extend_path(__path__, __name__)

View file

@ -1 +0,0 @@
from .enhancer import *

View file

@ -1,78 +0,0 @@
import json
from threading import Thread
import logging
import logging.config
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
from amarillo.plugins.enhancer.configuration import configure_enhancer_services
from amarillo.utils.container import container
from amarillo.models.Carpool import Carpool
from amarillo.utils.utils import agency_carpool_ids_from_filename
logging.config.fileConfig('logging.conf', disable_existing_loggers=False)
logger = logging.getLogger("enhancer")
class EventHandler(FileSystemEventHandler):
# TODO FG HB should watch for both carpools and agencies
# in data/agency, data/agencyconf, see AgencyConfService
def on_closed(self, event):
logger.info("CLOSE_WRITE: Created %s", event.src_path)
try:
with open(event.src_path, 'r', encoding='utf-8') as f:
dict = json.load(f)
carpool = Carpool(**dict)
container['carpools'].put(carpool.agency, carpool.id, carpool)
except FileNotFoundError as e:
logger.error("Carpool could not be added, as already deleted (%s)", event.src_path)
except:
logger.exception("Eventhandler on_closed encountered exception")
def on_deleted(self, event):
try:
logger.info("DELETE: Removing %s", event.src_path)
(agency_id, carpool_id) = agency_carpool_ids_from_filename(event.src_path)
container['carpools'].delete(agency_id, carpool_id)
except:
logger.exception("Eventhandler on_deleted encountered exception")
def run_enhancer():
logger.info("Hello Enhancer")
configure_enhancer_services()
observer = Observer() # Watch Manager
observer.schedule(EventHandler(), 'data/carpool', recursive=True)
observer.start()
import time
try:
# TODO FG Is this really needed?
cnt = 0
ENHANCER_LOG_INTERVAL_IN_S = 600
while True:
if cnt == ENHANCER_LOG_INTERVAL_IN_S:
logger.debug("Currently stored carpool ids: %s", container['carpools'].get_all_ids())
cnt = 0
time.sleep(1)
cnt += 1
finally:
observer.stop()
observer.join()
logger.info("Goodbye Enhancer")
def setup(app):
thread = Thread(target=run_enhancer, daemon=True)
thread.start()
if __name__ == "__main__":
run_enhancer()

View file

@ -1,30 +0,0 @@
# TODO: move to enhancer
from collections import namedtuple
from datetime import timedelta
GtfsFeedInfo = namedtuple('GtfsFeedInfo', 'feed_id feed_publisher_name feed_publisher_url feed_lang feed_version')
GtfsAgency = namedtuple('GtfsAgency', 'agency_id agency_name agency_url agency_timezone agency_lang agency_email')
GtfsRoute = namedtuple('GtfsRoute', 'agency_id route_id route_long_name route_type route_url route_short_name')
GtfsStop = namedtuple('GtfsStop', 'stop_id stop_lat stop_lon stop_name')
GtfsStopTime = namedtuple('GtfsStopTime', 'trip_id departure_time arrival_time stop_id stop_sequence pickup_type drop_off_type timepoint')
GtfsTrip = namedtuple('GtfsTrip', 'route_id trip_id service_id shape_id trip_headsign bikes_allowed')
GtfsCalendar = namedtuple('GtfsCalendar', 'service_id start_date end_date monday tuesday wednesday thursday friday saturday sunday')
GtfsCalendarDate = namedtuple('GtfsCalendarDate', 'service_id date exception_type')
GtfsShape = namedtuple('GtfsShape','shape_id shape_pt_lon shape_pt_lat shape_pt_sequence')
# TODO Move to utils
class GtfsTimeDelta(timedelta):
def __str__(self):
seconds = self.total_seconds()
hours = seconds // 3600
minutes = (seconds % 3600) // 60
seconds = seconds % 60
str = '{:02d}:{:02d}:{:02d}'.format(int(hours), int(minutes), int(seconds))
return (str)
def __add__(self, other):
if isinstance(other, timedelta):
return self.__class__(self.days + other.days,
self.seconds + other.seconds,
self.microseconds + other.microseconds)
return NotImplemented

View file

@ -1,14 +0,0 @@
# Constants
NO_BIKES_ALLOWED = 2
RIDESHARING_ROUTE_TYPE = 1551
CALENDAR_DATES_EXCEPTION_TYPE_ADDED = 1
CALENDAR_DATES_EXCEPTION_TYPE_REMOVED = 2
STOP_TIMES_STOP_TYPE_REGULARLY = 0
STOP_TIMES_STOP_TYPE_NONE = 1
STOP_TIMES_STOP_TYPE_PHONE_AGENCY = 2
STOP_TIMES_STOP_TYPE_COORDINATE_DRIVER = 3
STOP_TIMES_TIMEPOINT_APPROXIMATE = 0
STOP_TIMES_TIMEPOINT_EXACT = 1
MFDZ_DEFAULT_UNCERTAINITY = 600

View file

@ -1,24 +0,0 @@
from amarillo.plugins.enhancer.services import stops
from amarillo.models.Carpool import StopTime
def test_load_stops_from_file():
store = stops.StopsStore([{"url": "amarillo/plugins/enhancer/tests/stops.csv", "vicinity": 50}])
store.load_stop_sources()
assert len(store.stopsDataFrames[0]['stops']) > 0
def test_load_csv_stops_from_web_():
store = stops.StopsStore([{"url": "https://data.mfdz.de/mfdz/stops/custom.csv", "vicinity": 50}])
store.load_stop_sources()
assert len(store.stopsDataFrames[0]['stops']) > 0
def test_load_geojson_stops_from_web_():
store = stops.StopsStore([{"url": "https://datahub.bbnavi.de/export/rideshare_points.geojson", "vicinity": 50}])
store.load_stop_sources()
assert len(store.stopsDataFrames[0]['stops']) > 0
def test_find_closest_stop():
store = stops.StopsStore([{"url": "amarillo/plugins/enhancer/tests/stops.csv", "vicinity": 50}])
store.load_stop_sources()
carpool_stop = StopTime(name="start", lat=53.1191, lon=14.01577)
stop = store.find_closest_stop(carpool_stop, 1000)
assert stop.name=='Mitfahrbank Biesenbrow'

View file

@ -1,23 +0,0 @@
from amarillo.tests.sampledata import cp1, carpool_repeating
from amarillo.plugins.enhancer.services.trips import TripStore
from amarillo.plugins.enhancer.services.stops import StopsStore
import logging
logger = logging.getLogger(__name__)
def test_trip_store_put_one_time_carpool():
trip_store = TripStore(StopsStore())
t = trip_store.put_carpool(cp1)
assert t != None
assert len(t.stop_times) >= 2
assert t.stop_times[0].stop_id == 'mfdz:12073:001'
assert t.stop_times[-1].stop_id == 'de:12073:900340137::3'
def test_trip_store_put_repeating_carpool():
trip_store = TripStore(StopsStore())
t = trip_store.put_carpool(carpool_repeating)
assert t != None
assert len(t.stop_times) >= 2

28
logging.conf Normal file
View file

@ -0,0 +1,28 @@
[loggers]
keys=root
[handlers]
keys=consoleHandler, fileHandler
[formatters]
keys=simpleFormatter
[logger_root]
level=INFO
handlers=consoleHandler, fileHandler
propagate=yes
[handler_consoleHandler]
class=StreamHandler
level=DEBUG
formatter=simpleFormatter
args=(sys.stdout,)
[handler_fileHandler]
class=handlers.RotatingFileHandler
level=ERROR
formatter=simpleFormatter
args=('error.log', 'a', 1000000, 3) # Filename, mode, maxBytes, backupCount
[formatter_simpleFormatter]
format=%(asctime)s - %(name)s - %(levelname)s - %(message)s