diff --git a/park_api/cities/Jena.geojson b/park_api/cities/Jena.geojson new file mode 100644 index 0000000..025d76d --- /dev/null +++ b/park_api/cities/Jena.geojson @@ -0,0 +1,27 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 50.9282, + 11.5880 + ] + }, + "properties": { + "name": "Jena", + "type": "city", + "url": "https://mobilitaet.jena.de/", + "source": "https://opendata.jena.de/data/parkplatzbelegung.xml", + "active_support": false, + "attribution": { + "contributor":"Kommunal Service Jena", + "url":"https://opendata.jena.de/dataset/parken", + "license":"dl-de/by-2-0" + } + } + } + ] +} diff --git a/park_api/cities/Jena.py b/park_api/cities/Jena.py new file mode 100644 index 0000000..74e8fe6 --- /dev/null +++ b/park_api/cities/Jena.py @@ -0,0 +1,207 @@ +import json +import requests +import pytz +from datetime import datetime, time +from bs4 import BeautifulSoup +from park_api import env +from park_api.geodata import GeoData +from park_api.util import convert_date + +# there is no need for any geodata for this file, as the api returns all of the information, +# but if this is removed, the code crashes +geodata = GeoData(__file__) + +def parse_html(lot_vacancy_xml): + + # there is a second source with all the general data for the parking lots + HEADERS = { + "User-Agent": "ParkAPI v%s - Info: %s" % + (env.SERVER_VERSION, env.SOURCE_REPOSITORY), + } + + lot_data_json = requests.get("https://opendata.jena.de/dataset/1a542cd2-c424-4fb6-b30b-d7be84b701c8/resource/76b93cff-4f6c-47fa-ab83-b07d64c8f38a/download/parking.json", headers={**HEADERS}) + + lot_vacancy = BeautifulSoup(lot_vacancy_xml, "xml") + lot_data = json.loads(lot_data_json.text) + + data = { + # the time contains the timezone and milliseconds which need to be stripped + "last_updated": lot_vacancy.find("publicationTime").text.split(".")[0], + "lots": [] + } + + for lot in lot_data["parkingPlaces"]: + # the lots from both sources need to be matched + lot_data_list = [ + _lot for _lot in lot_vacancy.find_all("parkingFacilityStatus") + if hasattr(_lot.parkingFacilityReference, "attr") + and _lot.parkingFacilityReference.attrs["id"] == lot["general"]["name"] + ] + + lot_id = lot["general"]["name"].lower().replace(" ", "-").replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss") + + lot_info = { + "id": lot_id, + "name": lot["general"]["name"], + "url": "https://mobilitaet.jena.de/de/" + lot_id, + "address": lot["details"]["parkingPlaceAddress"]["parkingPlaceAddress"], + "coords": lot["general"]["coordinates"], + "state": get_status(lot), + "lot_type": lot["general"]["objectType"], + "opening_hours": parse_opening_hours(lot), + "fee_hours": parse_charged_hours(lot), + "forecast": False, + } + + # some lots do not have live vacancy data + if len(lot_data_list) > 0: + lot_info["free"] = int(lot_data_list[0].totalNumberOfVacantParkingSpaces.text) + lot_info["total"] = int(lot_data_list[0].totalParkingCapacityShortTermOverride.text) + else: + continue + # lot_info["free"] = None + # lot_info["total"] = int(lot["details"]["parkingCapacity"]["totalParkingCapacityShortTermOverride"]) + # note: both api's have different values for the total parking capacity, + # but the vacant slot are based on the total parking capacity from the same api, + # so that is used if available + + # also in the vacancy api the total capacity for the "Goethe Gallerie" are 0 if it is closed + + + data["lots"].append(lot_info) + + return data + +# the rest of the code is there to deal with the api's opening/charging hours objects +# example: +# "openingTimes": [ +# { +# "alwaysCharged": True, +# "dateFrom": 2, +# "dateTo": 5, +# "times": [ +# { +# "from": "07:00", +# "to": "23:00" +# } +# ] +# }, +# { +# "alwaysCharged": False, +# "dateFrom": 7, +# "dateTo": 1, +# "times": [ +# { +# "from": "10:00", +# "to": "03:00" +# } +# ] +# } +# ] + +def parse_opening_hours(lot_data): + if lot_data["parkingTime"]["openTwentyFourSeven"]: return "24/7" + + return parse_times(lot_data["parkingTime"]["openingTimes"]) + +def parse_charged_hours(lot_data): + charged_hour_objs = [] + + ph_info = "An Feiertagen sowie außerhalb der oben genannten Zeiten ist das Parken gebührenfrei." + + if not lot_data["parkingTime"]["chargedOpeningTimes"] and lot_data["parkingTime"]["openTwentyFourSeven"]: + if lot_data["priceList"]: + if ph_info in str(lot_data["priceList"]["priceInfo"]): + return "24/7; PH off" + else: return "24/7" + else: return "off" + + # charging hours can also be indicated by the "alwaysCharged" variable in "openingTimes" + elif not lot_data["parkingTime"]["chargedOpeningTimes"] and not lot_data["parkingTime"]["openTwentyFourSeven"]: + for oh in lot_data["parkingTime"]["openingTimes"]: + if "alwaysCharged" in oh and oh["alwaysCharged"]: charged_hour_objs.append(oh) + if len(charged_hour_objs) == 0: return "off" + + elif lot_data["parkingTime"]["chargedOpeningTimes"]: + charged_hour_objs = lot_data["parkingTime"]["chargedOpeningTimes"] + + charged_hours = parse_times(charged_hour_objs) + + if ph_info in str(lot_data["priceList"]["priceInfo"]): + charged_hours += "; PH off" + + return charged_hours + +# creatin osm opening_hours strings from opening/charging hours objects +def parse_times(times_objs): + DAYS = ["", "Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"] + + ohs = "" + + for index, oh in enumerate(times_objs): + part = "" + + if oh["dateFrom"] == oh["dateTo"]: + part += DAYS[oh["dateFrom"]] + else: + part += DAYS[oh["dateFrom"]] + "-" + DAYS[oh["dateTo"]] + + part += " " + + for index2, time in enumerate(oh["times"]): + part += time["from"] + "-" + time["to"] + if index2 != len(oh["times"]) - 1: part += "," + + if index != len(times_objs) - 1: part += "; " + + ohs += part + + return ohs + +def get_status(lot_data): + if lot_data["parkingTime"]["openTwentyFourSeven"]: return "open" + + # check for public holiday? + + for oh in lot_data["parkingTime"]["openingTimes"]: + now = datetime.now(pytz.timezone("Europe/Berlin")) + + weekday = now.weekday() + 1 + + # oh rules can also go beyond week ends (e.g. from Sunday to Monday) + # this need to be treated differently + if oh["dateFrom"] <= oh["dateTo"]: + if not (weekday >= oh["dateFrom"]) or not (weekday <= oh["dateTo"] + 1): continue + else: + if weekday > oh["dateTo"] + 1 and weekday < oh["dateFrom"]: continue + + for times in oh["times"]: + time_from = get_timestamp_without_date(time.fromisoformat(times["from"]).replace(tzinfo=pytz.timezone("Europe/Berlin"))) + time_to = get_timestamp_without_date(time.fromisoformat(times["to"]).replace(tzinfo=pytz.timezone("Europe/Berlin"))) + + time_now = get_timestamp_without_date(now) + + # time ranges can go over to the next day (e.g 10:00-03:00) + if time_to >= time_from: + if time_now >= time_from and time_now <= time_to: + return "open" + else: continue + + else: + if oh["dateFrom"] <= oh["dateTo"]: + if (time_now >= time_from and weekday >= oh["dateFrom"] and weekday <= oh["dateTo"] + or time_now <= time_to and weekday >= oh["dateFrom"] + 1 and weekday <= oh["dateTo"] + 1): + return "open" + else: continue + + else: + if (time_now >= time_from and (weekday >= oh["dateFrom"] or weekday <= oh["dateTo"]) + or time_now <= time_to and (weekday >= oh["dateFrom"] + 1 or weekday <= oh["dateTo"] + 1)): + return "open" + else: continue + + # if no matching rule was found, the lot is closed + return "closed" + +def get_timestamp_without_date (date_obj): + return date_obj.hour * 3600 + date_obj.minute * 60 + date_obj.second diff --git a/requirements.txt b/requirements.txt index 97e7b90..ad2561a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,3 +8,4 @@ yoyo-migrations requests-mock utm ddt +lxml diff --git a/tests/fixtures/jena.xml b/tests/fixtures/jena.xml new file mode 100644 index 0000000..6890e47 --- /dev/null +++ b/tests/fixtures/jena.xml @@ -0,0 +1,129 @@ + + + + 2023-12-31T16:10:47.378+01:00 + + de + Kommunal Service Jena + + + + + noRestriction + real + + + 0 + 0 + 0.0 + stable + + 2023-12-30T23:10:39.826+01:00 + 0 + 0 + 0 + + + 0 + 0 + 12.0 + stable + + 2023-12-31T16:05:45.504+01:00 + 20 + 141 + 161 + + + 0 + 0 + 8.0 + stable + + 2023-12-31T14:11:34.771+01:00 + 15 + 178 + 193 + + + 0 + 0 + 0.0 + stable + + 2023-12-30T23:00:29.090+01:00 + 0 + 0 + 0 + + + 0 + 0 + 5.0 + stable + + 2023-12-31T16:07:44.769+01:00 + 10 + 180 + 190 + + + 0 + 0 + 19.0 + stable + + 2023-12-31T15:32:41.436+01:00 + 14 + 61 + 75 + + + 0 + 0 + 0.0 + stable + + 2023-11-16T07:26:33.218+01:00 + 0 + 0 + 0 + + + 0 + 0 + 100.0 + stable + + full + 2023-12-28T13:11:30.623+01:00 + 32 + 0 + 32 + + + 0 + 0 + 29.0 + stable + + 2023-12-31T16:08:44.571+01:00 + 44 + 106 + 150 + + + 0 + 0 + 43.0 + decreasing + + 2023-12-31T16:08:44.586+01:00 + 26 + 34 + 60 + + + + + \ No newline at end of file