Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

VRN Realtime Pull Converter: Park and Ride #137

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ We support following data sources:
| Stadt Reutlingen: Fahrrad-Abstellanlagen | bike | push (csv) | `reutlingen_bike` | no |
| Stadt Stuttgart | car | push (json) | `stuttgart` | yes |
| Stadt Ulm | car | pull | `ulm` | yes |
| Verkehrsverbund Rhein-Neckar GmbH: P+R Parkplätze | car | pull | `vrn_p_r` | yes |
| Verband Region Stuttgart: Bondorf | car | pull | `vrs_bondorf` | yes |
| Verband Region Stuttgart: Kirchheim | car | pull | `vrs_kirchheim` | yes |
| Verband Region Stuttgart: Neustadt | car | pull | `vrs_neustadt` | yes |
Expand Down
1 change: 1 addition & 0 deletions src/parkapi_sources/converters/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,6 @@
from .reutlingen_bike import ReutlingenBikePushConverter
from .stuttgart import StuttgartPushConverter
from .ulm import UlmPullConverter
from .vrn_p_r import VrnParkAndRidePullConverter
from .vrs import VrsBondorfPullConverter, VrsKirchheimPullConverter, VrsNeustadtPullConverter, VrsVaihingenPullConverter
from .vrs_p_r import VrsParkAndRidePushConverter
6 changes: 6 additions & 0 deletions src/parkapi_sources/converters/vrn_p_r/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""
Copyright 2024 binary butterfly GmbH
Use of this source code is governed by an MIT-style license that can be found in the LICENSE.txt.
"""

from .converter import VrnParkAndRidePullConverter
195 changes: 195 additions & 0 deletions src/parkapi_sources/converters/vrn_p_r/converter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
"""
Copyright 2024 binary butterfly GmbH
Use of this source code is governed by an MIT-style license that can be found in the LICENSE.txt.
"""

from abc import ABC

import requests
from validataclass.exceptions import ValidationError
from validataclass.validators import AnythingValidator, DataclassValidator, ListValidator

from parkapi_sources.converters.base_converter.pull import GeojsonInput, PullConverter
from parkapi_sources.exceptions import ImportParkingSiteException, ImportSourceException
from parkapi_sources.models import RealtimeParkingSiteInput, SourceInfo, StaticParkingSiteInput

from .models import VrnParkAndRideFeaturesInput, VrnParkAndRideMultiguideInput, VrnParkAndRideSonahInput


class VrnParkAndRidePullConverter(PullConverter, ABC):
list_validator = ListValidator(AnythingValidator(allowed_types=[dict]))
geojson_validator = DataclassValidator(GeojsonInput)
vrn_p_r_feature_validator = DataclassValidator(VrnParkAndRideFeaturesInput)
vrn_multiguide_validator = DataclassValidator(VrnParkAndRideMultiguideInput)
vrn_sonah_validator = DataclassValidator(VrnParkAndRideSonahInput)

source_info = SourceInfo(
uid='vrn_p_r',
name='Verkehrsverbund Rhein-Neckar GmbH - P+R Parkplätze',
public_url='https://www.vrn.de/opendata/datasets/pr-parkplaetze-mit-vrn-parksensorik',
timezone='Europe/Berlin',
has_realtime_data=True,
)

def _get_feature_inputs(self) -> tuple[list[VrnParkAndRideFeaturesInput], list[ImportParkingSiteException]]:
feature_inputs: list[VrnParkAndRideFeaturesInput] = []
import_parking_site_exceptions: list[ImportParkingSiteException] = []

response = requests.get(
url='https://spatial.vrn.de/data/rest/services/Hosted/p_r_parkapi_static/FeatureServer/5/query?where=objectid%3E0&outFields=*&returnGeometry=true&f=geojson',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line is too long

timeout=30,
)
response_data = response.json()

try:
geojson_input = self.geojson_validator.validate(response_data)
except ValidationError as e:
raise ImportSourceException(
source_uid=self.source_info.uid,
message=f'Invalid Input at source {self.source_info.uid}: {e.to_dict()}, data: {response_data}',
) from e

for feature_dict in geojson_input.features:
if self._should_ignore_dataset(feature_dict):
continue

try:
feature_input = self.vrn_p_r_feature_validator.validate(feature_dict)
except ValidationError as e:
import_parking_site_exceptions.append(
ImportParkingSiteException(
source_uid=self.source_info.uid,
parking_site_uid=feature_dict.get('properties', {}).get('id'),
message=f'Invalid data at uid {feature_dict.get("properties", {}).get("id")}: '
f'{e.to_dict()}, data: {feature_dict}',
),
)
continue

feature_inputs.append(feature_input)

return feature_inputs, import_parking_site_exceptions

def _should_ignore_dataset(self, feature_dict: dict) -> bool:
if self.config_helper.get('PARK_API_VRN_P_R_IGNORE_MISSING_CAPACITIES'):
return feature_dict.get('properties', {}).get('capacity') is None

return False

def get_static_parking_sites(self) -> tuple[list[StaticParkingSiteInput], list[ImportParkingSiteException]]:
feature_inputs, import_parking_site_exceptions = self._get_feature_inputs()
static_parking_site_inputs: list[StaticParkingSiteInput] = []

realtime_vrn_p_r_inputs, import_realtime_parking_site_exceptions = self._get_raw_realtime_parking_sites()
import_parking_site_exceptions += import_realtime_parking_site_exceptions

static_parking_site_inputs_by_uid: dict[str, StaticParkingSiteInput] = {}
for feature_input in feature_inputs:
static_parking_site_inputs_by_uid[str(feature_input.properties.vrn_sensor_id)] = feature_input.to_static_parking_site_input()

for realtime_vrn_p_r_input in realtime_vrn_p_r_inputs:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it by purpose that you ignore all datasets which don't have realtime data?

# If the realtime group_uid is not known in our static data: ignore the static data
parking_site_uid = str(realtime_vrn_p_r_input.uid)
if parking_site_uid in static_parking_site_inputs_by_uid:
# Update static data with only parking places having realtime data
static_parking_site_inputs.append(static_parking_site_inputs_by_uid[parking_site_uid])

return static_parking_site_inputs, import_parking_site_exceptions

def _get_raw_realtime_parking_sites(self) -> tuple[list[RealtimeParkingSiteInput], list[ImportParkingSiteException]]:
realtime_parking_site_inputs: list[RealtimeParkingSiteInput] = []
import_parking_site_exceptions: list[ImportParkingSiteException] = []

multiguide_response_data = self._request_vrn_multiguide()
sonah_response_data = self._request_vrn_sonah()

try:
multiguide_parking_site_dicts = self.list_validator.validate(multiguide_response_data)
except ValidationError as e:
raise ImportSourceException(
source_uid=self.source_info.uid,
message=f'Invalid Input at source {self.source_info.uid}: {e.to_dict()}, data: {multiguide_response_data}',
) from e

for multiguide_parking_site_dict in multiguide_parking_site_dicts:
try:
realtime_parking_site_inputs.append(
self.vrn_multiguide_validator.validate(multiguide_parking_site_dict).to_realtime_parking_site_input()
)
except ValidationError as e:
import_parking_site_exceptions.append(
ImportParkingSiteException(
source_uid=self.source_info.uid,
parking_site_uid=multiguide_parking_site_dict.get('Id'),
message=f'validation error for {multiguide_parking_site_dict}: {e.to_dict()}',
),
)

try:
sonah_parking_site_dicts = self.list_validator.validate(sonah_response_data)
except ValidationError as e:
raise ImportSourceException(
source_uid=self.source_info.uid,
message=f'Invalid Input at source {self.source_info.uid}: {e.to_dict()}, data: {sonah_response_data}',
) from e

for sonah_parking_site_dict in sonah_parking_site_dicts:
try:
realtime_parking_site_inputs.append(
self.vrn_sonah_validator.validate(sonah_parking_site_dict).to_realtime_parking_site_input()
)
except ValidationError as e:
import_parking_site_exceptions.append(
ImportParkingSiteException(
source_uid=self.source_info.uid,
parking_site_uid=sonah_parking_site_dict.get('LocationID'),
message=f'validation error for {sonah_parking_site_dict}: {e.to_dict()}',
),
)

return realtime_parking_site_inputs, import_parking_site_exceptions

def get_realtime_parking_sites(self) -> tuple[list[RealtimeParkingSiteInput], list[ImportParkingSiteException]]:
static_parking_site_inputs, import_parking_site_exceptions = self.get_static_parking_sites()
realtime_parking_site_inputs: list[RealtimeParkingSiteInput] = []

realtime_vrn_p_r_inputs, import_parking_site_exceptions = self._get_raw_realtime_parking_sites()
static_parking_site_inputs_by_uid: dict[str, StaticParkingSiteInput] = {}

for static_parking_site_input in static_parking_site_inputs:
static_parking_site_inputs_by_uid[static_parking_site_input.group_uid] = static_parking_site_input

for realtime_vrn_p_r_input in realtime_vrn_p_r_inputs:
# If the realtime group_uid is not known in our static data: ignore the realtime data
parking_site_uid = str(realtime_vrn_p_r_input.uid)
if parking_site_uid in static_parking_site_inputs_by_uid:
# Update realtime data with only parking places having static data
realtime_vrn_p_r_input.uid = static_parking_site_inputs_by_uid[parking_site_uid].uid
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you do it this way?

realtime_parking_site_inputs.append(realtime_vrn_p_r_input)

return realtime_parking_site_inputs, import_parking_site_exceptions

def _request_vrn_multiguide(self) -> list[dict]:
response = requests.get(
url='https://vrn.multiguide.info/api/area',
auth=(
self.config_helper.get('PARK_API_VRN_P_R_MULTIGUIDE_API_USERNAME'),
self.config_helper.get('PARK_API_VRN_P_R_MULTIGUIDE_API_PASSWORD'),
),
timeout=30,
)

return response.json()

def _request_vrn_sonah(self) -> list[dict]:
headers: dict[str, str] = {
'Accept': 'application/json',
'Authorization': self.config_helper.get('PARK_API_VRN_P_R_SONAH_API_BEARER_TOKEN'),
}
response = requests.get(
url='https://vrnm.dyndns.sonah.xyz/api/v3/rest/json/locations',
headers=headers,
timeout=30,
)

return response.json()
161 changes: 161 additions & 0 deletions src/parkapi_sources/converters/vrn_p_r/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
"""
Copyright 2024 binary butterfly GmbH
Use of this source code is governed by an MIT-style license that can be found in the LICENSE.txt.
"""

from datetime import date, datetime, timezone
from decimal import Decimal
from enum import Enum

from validataclass.dataclasses import DefaultUnset, ValidataclassMixin, validataclass
from validataclass.exceptions import ValidationError
from validataclass.helpers import OptionalUnset, UnsetValue
from validataclass.validators import (
DataclassValidator,
EnumValidator,
IntegerValidator,
NoneToUnsetValue,
NumericValidator,
StringValidator,
UrlValidator,
)

from parkapi_sources.converters.base_converter.pull import GeojsonFeatureGeometryInput
from parkapi_sources.models import RealtimeParkingSiteInput, StaticParkingSiteInput
from parkapi_sources.models.enums import ParkingSiteType, PurposeType
from parkapi_sources.validators import MappedBooleanValidator, ParsedDateValidator, TimestampDateTimeValidator


class VrnParkAndRideType(Enum):
CAR_PARK = 'Parkhaus'
OFF_STREET_PARKING_GROUND = 'Parkplatz'

def to_parking_site_type(self) -> ParkingSiteType:
return {
self.CAR_PARK: ParkingSiteType.CAR_PARK,
self.OFF_STREET_PARKING_GROUND: ParkingSiteType.OFF_STREET_PARKING_GROUND,
}.get(self)


@validataclass
class VrnParkAndRidePropertiesOpeningHoursInput:
string: str = StringValidator(min_length=1, max_length=256)
langIso639: str = StringValidator(min_length=1, max_length=256)


@validataclass
class VrnParkAndRidePropertiesInput(ValidataclassMixin):
original_uid: str = StringValidator(min_length=1, max_length=256)
name: str = StringValidator(min_length=0, max_length=256)
type: OptionalUnset[VrnParkAndRideType] = NoneToUnsetValue(EnumValidator(VrnParkAndRideType)), DefaultUnset
public_url: OptionalUnset[str] = NoneToUnsetValue(UrlValidator(max_length=4096)), DefaultUnset
photo_url: OptionalUnset[str] = NoneToUnsetValue(UrlValidator(max_length=4096)), DefaultUnset
lat: OptionalUnset[Decimal] = NumericValidator()
lon: OptionalUnset[Decimal] = NumericValidator()
address: OptionalUnset[str] = NoneToUnsetValue(StringValidator(min_length=0, max_length=256)), DefaultUnset
description: OptionalUnset[str] = NoneToUnsetValue(StringValidator(max_length=512)), DefaultUnset
operator_name: OptionalUnset[str] = NoneToUnsetValue(StringValidator(min_length=0, max_length=256)), DefaultUnset
capacity: int = IntegerValidator(min_value=0)
capacity_charging: OptionalUnset[int] = NoneToUnsetValue(IntegerValidator(min_value=0)), DefaultUnset
capacity_family: OptionalUnset[int] = NoneToUnsetValue(IntegerValidator(min_value=0)), DefaultUnset
capacity_woman: OptionalUnset[int] = NoneToUnsetValue(IntegerValidator(min_value=0)), DefaultUnset
capacity_bus: OptionalUnset[int] = NoneToUnsetValue(IntegerValidator(min_value=0)), DefaultUnset
capacity_truck: OptionalUnset[int] = NoneToUnsetValue(IntegerValidator(min_value=0)), DefaultUnset
capacity_carsharing: OptionalUnset[int] = NoneToUnsetValue(IntegerValidator(min_value=0)), DefaultUnset
capacity_disabled: OptionalUnset[int] = NoneToUnsetValue(IntegerValidator(min_value=0)), DefaultUnset
max_height: OptionalUnset[int] = NoneToUnsetValue(IntegerValidator(min_value=0)), DefaultUnset
has_realtime_data: OptionalUnset[bool] = NoneToUnsetValue(MappedBooleanValidator(mapping={'ja': True, 'nein': False})), DefaultUnset
vrn_sensor_id: OptionalUnset[int] = NoneToUnsetValue(IntegerValidator(min_value=0)), DefaultUnset
realtime_opening_status: OptionalUnset[str] = NoneToUnsetValue(StringValidator(min_length=0, max_length=256)), DefaultUnset
created_at: OptionalUnset[date] = NoneToUnsetValue(ParsedDateValidator(date_format='%Y-%m-%d')), DefaultUnset
static_data_updated_at: OptionalUnset[datetime] = (
NoneToUnsetValue(TimestampDateTimeValidator(allow_strings=True, divisor=1000)),
DefaultUnset,
)
has_lighting: OptionalUnset[bool] = NoneToUnsetValue(MappedBooleanValidator(mapping={'ja': True, 'nein': False})), DefaultUnset
has_fee: OptionalUnset[bool] = NoneToUnsetValue(MappedBooleanValidator(mapping={'ja': True, 'nein': False})), DefaultUnset
is_covered: OptionalUnset[bool] = NoneToUnsetValue(MappedBooleanValidator(mapping={'ja': True, 'nein': False})), DefaultUnset
related_location: OptionalUnset[str] = NoneToUnsetValue(StringValidator(min_length=0, max_length=256)), DefaultUnset
opening_hours: OptionalUnset[str] = NoneToUnsetValue(StringValidator(min_length=0, max_length=256)), DefaultUnset
park_and_ride_type: OptionalUnset[str] = NoneToUnsetValue(StringValidator(min_length=0, max_length=256)), DefaultUnset
max_stay: OptionalUnset[int] = NoneToUnsetValue(IntegerValidator(min_value=0)), DefaultUnset
fee_description: OptionalUnset[str] = NoneToUnsetValue(StringValidator(max_length=512)), DefaultUnset


@validataclass
class VrnParkAndRideFeaturesInput:
geometry: GeojsonFeatureGeometryInput = DataclassValidator(GeojsonFeatureGeometryInput)
properties: VrnParkAndRidePropertiesInput = DataclassValidator(VrnParkAndRidePropertiesInput)

def to_static_parking_site_input(self) -> StaticParkingSiteInput:
return StaticParkingSiteInput(
uid=str(self.properties.original_uid),
group_uid=str(self.properties.vrn_sensor_id),
name=self.properties.name if self.properties.name != '' else 'Fahrrad-Abstellanlagen',
type=self.properties.type.to_parking_site_type(),
description=self.properties.description,
capacity=self.properties.capacity,
has_realtime_data=self.properties.has_realtime_data,
has_lighting=self.properties.has_lighting,
is_covered=self.properties.is_covered,
related_location=self.properties.related_location,
operator_name=self.properties.operator_name,
max_height=self.properties.max_height,
has_fee=self.properties.has_fee,
fee_description=self.properties.fee_description,
capacity_charging=self.properties.capacity_charging,
lat=self.geometry.coordinates[1],
lon=self.geometry.coordinates[0],
static_data_updated_at=datetime.now(timezone.utc)
if self.properties.static_data_updated_at is UnsetValue
else self.properties.static_data_updated_at,
purpose=PurposeType.CAR,
opening_hours='24/7'
if 'Mo-So: 24 Stunden' in self.properties.opening_hours or 'Mo-So: Kostenlos' in self.properties.opening_hours
else UnsetValue,
)


@validataclass
class VrnParkAndRideMultiguideInput:
Name: str = StringValidator()
Id: int = IntegerValidator(allow_strings=True)
Constructed: int = IntegerValidator(allow_strings=True)
Available: int = IntegerValidator(allow_strings=True)
Free: int = IntegerValidator(allow_strings=True)
Occupied: int = IntegerValidator(allow_strings=True)
Reserved: int = IntegerValidator(allow_strings=True)
Defect: int = IntegerValidator(allow_strings=True)

def __post_init__(self):
if self.Constructed < self.Occupied:
raise ValidationError(reason='More occupied sites than capacity')

def to_realtime_parking_site_input(self) -> RealtimeParkingSiteInput:
return RealtimeParkingSiteInput(
uid=str(self.Id),
realtime_capacity=self.Constructed,
realtime_free_capacity=self.Free,
realtime_data_updated_at=datetime.now(timezone.utc),
)


@validataclass
class VrnParkAndRideSonahInput:
Name: str = StringValidator()
LocationID: int = IntegerValidator()
TotalParking: int = NumericValidator()
FreeParking: int = NumericValidator()
OccupiedParking: int = NumericValidator()

def __post_init__(self):
if self.TotalParking < self.OccupiedParking:
raise ValidationError(reason='More occupied sites than capacity')

def to_realtime_parking_site_input(self) -> RealtimeParkingSiteInput:
return RealtimeParkingSiteInput(
uid=str(self.LocationID),
realtime_capacity=int(self.TotalParking),
realtime_free_capacity=int(self.FreeParking),
realtime_data_updated_at=datetime.now(timezone.utc),
)
2 changes: 2 additions & 0 deletions src/parkapi_sources/parkapi_sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
ReutlingenPushConverter,
StuttgartPushConverter,
UlmPullConverter,
VrnParkAndRidePullConverter,
VrsBondorfPullConverter,
VrsKirchheimPullConverter,
VrsNeustadtPullConverter,
Expand Down Expand Up @@ -93,6 +94,7 @@ class ParkAPISources:
ReutlingenBikePushConverter,
StuttgartPushConverter,
UlmPullConverter,
VrnParkAndRidePullConverter,
VrsBondorfPullConverter,
VrsKirchheimPullConverter,
VrsNeustadtPullConverter,
Expand Down
Loading