diff --git a/bicycle_network/api/views.py b/bicycle_network/api/views.py index 7fbd440e0..d7bb73423 100644 --- a/bicycle_network/api/views.py +++ b/bicycle_network/api/views.py @@ -82,11 +82,13 @@ def list(self, request): ) # Return elements that are inside bbox if "bbox" in filters: - ref = SpatialReference(4326) - val = self.request.query_params.get("bbox", None) - bbox_filter = munigeo_api.build_bbox_filter(ref, val, "geometry") - bbox_geometry_filter = munigeo_api.build_bbox_filter(ref, val, "geometry") - queryset = queryset.filter(Q(**bbox_filter) | Q(**bbox_geometry_filter)) + val = filters.get("bbox", None) + if val: + ref = SpatialReference(filters.get("bbox_srid", 4326)) + bbox_geometry_filter = munigeo_api.build_bbox_filter( + ref, val, "geometry" + ) + queryset = queryset.filter(Q(**bbox_geometry_filter)) page = self.paginate_queryset(queryset) if only_coords: diff --git a/config_dev.env.example b/config_dev.env.example index 4127bfee9..c7b6241cb 100644 --- a/config_dev.env.example +++ b/config_dev.env.example @@ -138,6 +138,13 @@ CELERY_BROKER_URL=redis://localhost:6379/0 # Cache location, e.g. redis on localhost using default port and database 0 CACHE_LOCATION=redis://localhost:6379/0 +# Email settings +EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend +EMAIL_HOST=smtp.example.com +EMAIL_HOST_USER=example@example.com +EMAIL_PORT=25 +EMAIL_USE_TLS=True + # Settings needed for enabling Turku area: #ADDITIONAL_INSTALLED_APPS=smbackend_turku,ptv #TURKU_API_KEY=secret @@ -161,10 +168,6 @@ TRAFFIC_COUNTER_OBSERVATIONS_BASE_URL=https://data.turku.fi/2yxpk2imqi2mzxpa6e6k LAM_COUNTER_STATIONS_URL=https://tie.digitraffic.fi/api/v3/metadata/tms-stations LAM_COUNTER_API_BASE_URL=https://tie-lam-test.digitraffic.fi -GAS_FILLING_STATIONS_IDS=service_node=200000,service=200000,units_offset=200000 -CHARGING_STATIONS_IDS=service_node=300000,service=300000,units_offset=300000 -BICYCLE_STANDS_IDS=service_node=400000,service=400000,units_offset=400000 -BIKE_SERVICE_STATIONS_IDS=service_node=500000,service=500000,units_offset=500000 MOBILITY_DATA_CHARGING_STATIONS_URL=https://services1.arcgis.com/rhs5fjYxdOG1Et61/ArcGIS/rest/services/GasFillingStations/FeatureServer/0/query?f=json&where=1%3D1&outFields=OPERATOR%2CLAT%2CLON%2CSTATION_NAME%2CADDRESS%2CCITY%2CZIP_CODE%2CLNG_CNG%2CObjectId MOBILITY_DATA_GAS_FILLING_STATIONS_URL=https://services1.arcgis.com/rhs5fjYxdOG1Et61/ArcGIS/rest/services/ChargingStations/FeatureServer/0/query?f=json&where=1%20%3D%201%20OR%201%20%3D%201&returnGeometry=true&spatialRel=esriSpatialRelIntersects&outFields=LOCATION_ID%2CNAME%2CADDRESS%2CURL%2COBJECTID%2CTYPE MOBILITY_DATA_GEOMETRY_URL=https://tie.digitraffic.fi/api/v3/data/traffic-messages/area-geometries?id=11&lastUpdated=false diff --git a/eco_counter/api/serializers.py b/eco_counter/api/serializers.py index 21d37b755..e0cbc80c8 100644 --- a/eco_counter/api/serializers.py +++ b/eco_counter/api/serializers.py @@ -31,6 +31,7 @@ class StationSerializer(serializers.ModelSerializer): y = serializers.SerializerMethodField() lon = serializers.SerializerMethodField() lat = serializers.SerializerMethodField() + sensor_types = serializers.SerializerMethodField() class Meta: model = Station @@ -47,6 +48,7 @@ class Meta: "y", "lon", "lat", + "sensor_types", ] def get_y(self, obj): @@ -63,6 +65,17 @@ def get_lon(self, obj): obj.geom.transform(4326) return obj.geom.x + def get_sensor_types(self, obj): + # Return the sensor types(car, bike etc) that has a total year value >0. + # i.e., there are sensors for counting the type of data. + types = ["at", "pt", "jt", "bt"] + result = [] + for type in types: + filter = {"station": obj, f"value_{type}__gt": 0} + if YearData.objects.filter(**filter).count() > 0: + result.append(type) + return result + class YearSerializer(serializers.ModelSerializer): station_name = serializers.PrimaryKeyRelatedField( diff --git a/eco_counter/tasks.py b/eco_counter/tasks.py index dd0e9a722..9b7fdb714 100644 --- a/eco_counter/tasks.py +++ b/eco_counter/tasks.py @@ -1,12 +1,13 @@ -from celery import shared_task from django.core import management +from smbackend.utils import shared_task_email -@shared_task + +@shared_task_email def import_counter_data(args, name="import_counter_data"): management.call_command("import_counter_data", "--counters", args) -@shared_task +@shared_task_email def initial_import_counter_data(args, name="initial_import_counter_data"): management.call_command("import_counter_data", "--init", args) diff --git a/eco_counter/tests/test_api.py b/eco_counter/tests/test_api.py index 3e870a5e8..4c10654fb 100644 --- a/eco_counter/tests/test_api.py +++ b/eco_counter/tests/test_api.py @@ -263,8 +263,9 @@ def test__months_multiple_years(api_client, years, test_timestamp): @pytest.mark.django_db -def test__station(api_client, station): +def test__station(api_client, station, year_datas): url = reverse("eco_counter:stations-list") response = api_client.get(url) assert response.status_code == 200 assert response.json()["results"][0]["name"] == station.name + assert response.json()["results"][0]["sensor_types"] == ["at"] diff --git a/iot/tasks.py b/iot/tasks.py index 7df693045..a4b7fd7d2 100644 --- a/iot/tasks.py +++ b/iot/tasks.py @@ -1,7 +1,8 @@ -from celery import shared_task from django.core import management +from smbackend.utils import shared_task_email -@shared_task + +@shared_task_email def import_iot_data(source_name, name="Import iot data"): management.call_command("import_iot_data", source_name) diff --git a/mobility_data/README.md b/mobility_data/README.md index 4a64680e3..95fc03ad8 100644 --- a/mobility_data/README.md +++ b/mobility_data/README.md @@ -160,12 +160,34 @@ To import data type: ./manage.py import_foli_stops ``` +### Barbecue places +``` +./manage.py import_wfs BarbecuePlace +``` + + +### Playgrounds +``` +./manage.py import_wfs PlayGround +``` + +### Föli park and ride stop +Imports park and ride stops for bikes and cars. +``` +./manage.py import_foli_parkandride_stops +``` + ### Outdoor gym devices Imports the outdoor gym devices from the services.unit model. i.e., sets references by id to the services.unit model. The data is then serialized from the services.unit model. ``` ./manage.py import_outdoor_gym_devices ``` +### Parking machines +``` +./manage.py import_parking_machines +``` + ## Deletion To delete mobile units for a content type. ``` diff --git a/mobility_data/api/serializers/mobile_unit.py b/mobility_data/api/serializers/mobile_unit.py index 8b0856440..6708eb514 100644 --- a/mobility_data/api/serializers/mobile_unit.py +++ b/mobility_data/api/serializers/mobile_unit.py @@ -42,7 +42,7 @@ class Meta: class MobileUnitSerializer(serializers.ModelSerializer): - content_type = ContentTypeSerializer(many=False, read_only=True) + content_types = ContentTypeSerializer(many=True, read_only=True) mobile_unit_group = MobileUnitGroupBasicInfoSerializer(many=False, read_only=True) geometry_coords = serializers.SerializerMethodField(read_only=True) @@ -64,13 +64,14 @@ class Meta: "description_fi", "description_sv", "description_en", - "content_type", + "content_types", "mobile_unit_group", "is_active", "created_time", "geometry", "geometry_coords", "extra", + "unit_id", ] # Contains the corresponding field names of the MobileUnit model if they differs @@ -84,10 +85,18 @@ class Meta: def to_representation(self, obj): representation = super().to_representation(obj) - # If mobile_unit has a unit_id we serialize the data from the services_unit table. - unit_id = obj.unit_id - if unit_id: - unit = Unit.objects.get(id=unit_id) + unit_id = getattr(obj, "unit_id", None) + # If serializing Unit instance or MobileUnit with unit_id. + if self.context.get("services_unit_instances", False) or unit_id: + if unit_id: + # When serializing the MobileUnit from the retrieve method + try: + unit = Unit.objects.get(id=unit_id) + except Unit.DoesNotExist: + return representation + else: + # The obj is a Unit instance. + unit = obj for field in self.fields: # lookup the field name in the service_unit table, as not all field names that contains # similar data has the same name. @@ -99,19 +108,34 @@ def to_representation(self, obj): if hasattr(unit, key): # unit.municipality is of type munigeo.models.Municipality and not serializable if key == "municipality": - representation[field] = unit.municipality.id + muni = getattr(unit, key, None) + representation[field] = muni.id if muni else None else: - representation[field] = getattr(unit, key) - + representation[field] = getattr(unit, key, None) + # Serialize the MobileUnit id, otherwise would serialize the serivce_unit id. + if field == "id": + try: + representation["id"] = MobileUnit.objects.get( + unit_id=unit.id + ).id + except MobileUnit.DoesNotExist: + representation["id"] = unit.id # The location field must be serialized with its wkt value. if unit.location: representation["geometry"] = unit.location.wkt return representation def get_geometry_coords(self, obj): - # If stored to Unit table, retrieve geometry from there. - if obj.unit_id: - geometry = Unit.objects.get(id=obj.unit_id).location + unit_id = getattr(obj, "unit_id", None) + if unit_id: + # If stored to Unit table, retrieve geometry from there. + try: + geometry = Unit.objects.get(id=unit_id).location + except Unit.DoesNotExist: + return None + # If serializing Unit object retrieved from the view. + elif self.context.get("services_unit_instances", False): + geometry = obj.location else: geometry = obj.geometry if isinstance(geometry, GEOSGeometry): @@ -130,7 +154,6 @@ def get_geometry_coords(self, obj): pos["lon"] = geometry.x pos["lat"] = geometry.y return pos - elif isinstance(geometry, LineString): if self.context["latlon"]: # Return LineString coordinates in (lat,lon) format @@ -142,7 +165,6 @@ def get_geometry_coords(self, obj): return coords else: return geometry.coords - elif isinstance(geometry, Polygon): if self.context["latlon"]: # Return Polygon coordinates in (lat,lon) format @@ -189,6 +211,5 @@ def get_geometry_coords(self, obj): return coords else: return geometry.coords - else: return "" diff --git a/mobility_data/api/views.py b/mobility_data/api/views.py index 5d671cd23..580710ed7 100644 --- a/mobility_data/api/views.py +++ b/mobility_data/api/views.py @@ -1,10 +1,16 @@ +import types from distutils.util import strtobool +from django.contrib.gis.gdal import SpatialReference from django.core.exceptions import ValidationError +from django.db.models import Q +from munigeo import api as munigeo_api from rest_framework import status, viewsets from rest_framework.exceptions import ParseError from rest_framework.response import Response +from services.models import Unit + from ..models import ContentType, GroupType, MobileUnit, MobileUnitGroup from .serializers import ( ContentTypeSerializer, @@ -14,6 +20,10 @@ MobileUnitSerializer, ) +FIELD_TYPES = types.SimpleNamespace() +FIELD_TYPES.FLOAT = float +FIELD_TYPES.INT = int +FIELD_TYPES.BOOL = bool # Mappings, so that deprecated type_names will work. # These will be removed when the front end is updated. group_name_mappings = {"CRE": "CultureRoute"} @@ -176,6 +186,7 @@ def list(self, request): and transforms to given srid. """ queryset = None + unit_ids = [] filters = self.request.query_params srid, latlon = get_srid_and_latlon(filters) if "type_name" in filters: @@ -187,10 +198,29 @@ def list(self, request): return Response( "type_name does not exist.", status=status.HTTP_400_BAD_REQUEST ) - queryset = MobileUnit.objects.filter(content_type__name=type_name) + queryset = MobileUnit.objects.filter(content_types__name=type_name) + # If the data locates in the services_unit table (i.e., MobileUnit has a unit_id) + # get the unit_ids to retrieve the Units for filtering(bbox and extra) + unit_ids = list( + queryset.filter(unit_id__isnull=False).values_list("unit_id", flat=True) + ) else: queryset = MobileUnit.objects.all() + services_unit_instances = True if len(unit_ids) > 0 else False + if services_unit_instances: + queryset = Unit.objects.filter(id__in=unit_ids) + + if "bbox" in filters: + val = filters.get("bbox", None) + geometry_field_name = "location" if services_unit_instances else "geometry" + if val: + ref = SpatialReference(filters.get("bbox_srid", 4326)) + bbox_geometry_filter = munigeo_api.build_bbox_filter( + ref, val, geometry_field_name + ) + queryset = queryset.filter(Q(**bbox_geometry_filter)) + for filter in filters: if filter.startswith("extra__"): if "type_name" not in filters: @@ -212,15 +242,27 @@ def list(self, request): f"extra field '{key}' does not exist", status=status.HTTP_400_BAD_REQUEST, ) - if field_type == float: - value = float(value) - elif field_type == int: - value = int(value) + + match field_type: + case FIELD_TYPES.FLOAT: + value = float(value) + case FIELD_TYPES.INT: + value = int(value) + case FIELD_TYPES.BOOL: + value = strtobool(value) + value = bool(value) + queryset = queryset.filter(**{filter: value}) page = self.paginate_queryset(queryset) serializer = MobileUnitSerializer( - page, many=True, context={"srid": srid, "latlon": latlon} + page, + many=True, + context={ + "srid": srid, + "latlon": latlon, + "services_unit_instances": services_unit_instances, + }, ) return self.get_paginated_response(serializer.data) diff --git a/mobility_data/constants.py b/mobility_data/constants.py index 3489e2c08..8d5a03033 100644 --- a/mobility_data/constants.py +++ b/mobility_data/constants.py @@ -12,6 +12,9 @@ from mobility_data.importers.loading_unloading_places import ( CONTENT_TYPE_NAME as LOADING_UNLOADING_PLACE, ) +from mobility_data.importers.parking_machines import ( + CONTENT_TYPE_NAME as PARKING_MACHINE, +) from mobility_data.importers.share_car_parking_places import ( CONTENT_TYPE_NAME as SHARE_CAR_PARKING_PLACE, ) @@ -48,4 +51,8 @@ "display_name": "berths", "to_services_list": False, }, + PARKING_MACHINE: { + "importer_name": "parking_machines", + "to_services_list": False, + }, } diff --git a/mobility_data/data/parking_machines.geojson b/mobility_data/data/parking_machines.geojson new file mode 100644 index 000000000..ef83f25ab --- /dev/null +++ b/mobility_data/data/parking_machines.geojson @@ -0,0 +1,103 @@ +{ + "type": "FeatureCollection", + "name": "Automaatit", + "crs": { "type": "name", "properties": { "name": "urn:ogc:def:crs:OGC:1.3:CRS84" } }, + "features": [ + { "type": "Feature", "properties": { "id": 1, "Osoite": "Puutarhakatu 15", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "3.7.2020", "Virta": "Verkko", "Maksutapa": "Kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 1.8, "Max.aika": null, "Maksuvyöhyke": "2", "Adress": "Trädgårdsgatan 15", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "3.7.2022", "Ström": "Nät", "Betalningssätt": "Kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 1.8, "Zon": "2", "Address": "Puutarhakatu 15", "Installed": "3.7.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "2", "Tariff/h": 1.8 }, "geometry": { "type": "Point", "coordinates": [ 22.255769473435137, 60.450812192929355 ] } }, + { "type": "Feature", "properties": { "id": 2, "Osoite": "Puutarhakatu 19", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "3.7.2020", "Virta": "Verkko", "Maksutapa": "Kolikko, kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 1.8, "Max.aika": null, "Maksuvyöhyke": "2", "Adress": "Trädgårdsgatan 19", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "3.7.2020", "Ström": "Nät", "Betalningssätt": "Mynt, kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 1.8, "Zon": "2", "Address": "Puutarhakatu 19", "Installed": "3.7.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Coin, card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "2", "Tariff/h": 1.8 }, "geometry": { "type": "Point", "coordinates": [ 22.25417454427166, 60.450266480867569 ] } }, + { "type": "Feature", "properties": { "id": 3, "Osoite": "Puutarhakatu 20", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "3.7.2020", "Virta": "Verkko", "Maksutapa": "Kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 1.8, "Max.aika": null, "Maksuvyöhyke": "2", "Adress": "Trädgårdsgatan 20", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "3.7.2020", "Ström": "Nät", "Betalningssätt": "Kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 1.8, "Zon": "2", "Address": "Puutarhakatu 20", "Installed": "3.7.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "2", "Tariff/h": 1.8 }, "geometry": { "type": "Point", "coordinates": [ 22.252808192099611, 60.449604877035185 ] } }, + { "type": "Feature", "properties": { "id": 4, "Osoite": "Käsityöläiskatu 7", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "12.8.2020", "Virta": "Verkko", "Maksutapa": "Kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 3.6, "Max.aika": null, "Maksuvyöhyke": "1", "Adress": "Hantverkaregatan 7", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "12.8.2020", "Ström": "Nät", "Betalningssätt": "Kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 3.6, "Zon": "1", "Address": "Käsityöläiskatu 7", "Installed": "12.8.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "1", "Tariff/h": 3.6 }, "geometry": { "type": "Point", "coordinates": [ 22.257221846094581, 60.450184315580088 ] } }, + { "type": "Feature", "properties": { "id": 5, "Osoite": "Humalistonkatu 5", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "12.8.2020", "Virta": "Verkko", "Maksutapa": "Kolikko, kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 3.6, "Max.aika": null, "Maksuvyöhyke": "1", "Adress": "Humlegårdsgatan 5", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "12.8.2020", "Ström": "Nät", "Betalningssätt": "Mynt, kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 3.6, "Zon": "1", "Address": "Humalistonkatu 5", "Installed": "12.8.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Coin, card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "1", "Tariff/h": 3.6 }, "geometry": { "type": "Point", "coordinates": [ 22.259980071100344, 60.450177339116053 ] } }, + { "type": "Feature", "properties": { "id": 6, "Osoite": "Eerikinkatu 21", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C", "Asennettu": "12.8.2020", "Virta": "Verkko", "Maksutapa": "Kortti, lähimaksu", "Näyttö": "7\"", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 3.6, "Max.aika": null, "Maksuvyöhyke": "1", "Adress": "Eriksgatan 21", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C", "Installerad": "12.8.2020", "Ström": "Nät", "Betalningssätt": "Kort, kontaktlös", "Skärm": "7\"", "Ägare": "Åbo stad", "Taxa/t": 3.6, "Zon": "1", "Address": "Eerikinkatu 21", "Installed": "12.8.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C", "Power": "Mains", "Payment Method": "Card, contactless", "Screen": "7\"", "Owner": "City of Turku", "Zone": "1", "Tariff/h": 3.6 }, "geometry": { "type": "Point", "coordinates": [ 22.26218847141747, 60.449239333253786 ] } }, + { "type": "Feature", "properties": { "id": 7, "Osoite": "Eerikinkatu 25", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "17.8.2020", "Virta": "Verkko", "Maksutapa": "Kolikko, kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 3.6, "Max.aika": null, "Maksuvyöhyke": "1", "Adress": "Eriksgatan 25", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "17.8.2020", "Ström": "Nät", "Betalningssätt": "Mynt, kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 3.6, "Zon": "1", "Address": "Eerikinkatu 25", "Installed": "17.8.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Coin, card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "1", "Tariff/h": 3.6 }, "geometry": { "type": "Point", "coordinates": [ 22.259787917167984, 60.448419897985524 ] } }, + { "type": "Feature", "properties": { "id": 8, "Osoite": "Käsityöläiskatu 4", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "13.8.2020", "Virta": "Verkko", "Maksutapa": "Kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 3.6, "Max.aika": null, "Maksuvyöhyke": "1", "Adress": "Hantverkaregatan 4", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "13.8.2020", "Ström": "Nät", "Betalningssätt": "Kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 3.6, "Zon": "1", "Address": "Käsityöläiskatu 4", "Installed": "13.8.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "1", "Tariff/h": 3.6 }, "geometry": { "type": "Point", "coordinates": [ 22.258768788015548, 60.448817764771036 ] } }, + { "type": "Feature", "properties": { "id": 9, "Osoite": "Ursiininkatu 4", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "13.8.2020", "Virta": "Verkko", "Maksutapa": "Kolikko, kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 1.8, "Max.aika": null, "Maksuvyöhyke": "2", "Adress": "Ursinsgatan 4", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "13.8.2020", "Ström": "Nät", "Betalningssätt": "Mynt, kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 1.8, "Zon": "2", "Address": "Ursiininkatu 4", "Installed": "13.8.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Coin, card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "2", "Tariff/h": 1.8 }, "geometry": { "type": "Point", "coordinates": [ 22.258686318669831, 60.446820115358349 ] } }, + { "type": "Feature", "properties": { "id": 10, "Osoite": "Rauhankatu 14", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "13.8.2020", "Virta": "Verkko", "Maksutapa": "Kolikko, kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 0.6, "Max.aika": null, "Maksuvyöhyke": "3", "Adress": "Fredsgatan 14", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "13.8.2020", "Ström": "Nät", "Betalningssätt": "Mynt, kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 0.6, "Zon": "3", "Address": "Rauhankatu 14", "Installed": "13.8.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Coin, card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "3", "Tariff/h": 0.6 }, "geometry": { "type": "Point", "coordinates": [ 22.252106097225958, 60.450698403933671 ] } }, + { "type": "Feature", "properties": { "id": 11, "Osoite": "Rauhankatu 5", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "13.8.2020", "Virta": "Verkko", "Maksutapa": "Kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 1.8, "Max.aika": null, "Maksuvyöhyke": "2", "Adress": "Fredsgatan 5", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "13.8.2020", "Ström": "Nät", "Betalningssätt": "Kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 1.8, "Zon": "2", "Address": "Rauhankatu 5", "Installed": "13.8.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "2", "Tariff/h": 1.8 }, "geometry": { "type": "Point", "coordinates": [ 22.255241933856727, 60.451958158547647 ] } }, + { "type": "Feature", "properties": { "id": 12, "Osoite": "Rauhankatu 4", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "17.8.2020", "Virta": "Verkko", "Maksutapa": "Kolikko, kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 3.6, "Max.aika": null, "Maksuvyöhyke": "1", "Adress": "Fredsgatan 4", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "17.8.2020", "Ström": "Nät", "Betalningssätt": "Mynt, kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 3.6, "Zon": "1", "Address": "Rauhankatu 4", "Installed": "17.8.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Coin, card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "1", "Tariff/h": 3.6 }, "geometry": { "type": "Point", "coordinates": [ 22.257154615291935, 60.452441782474324 ] } }, + { "type": "Feature", "properties": { "id": 13, "Osoite": "Humalistonkatu 14", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "17.8.2020", "Virta": "Verkko", "Maksutapa": "Kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 1.8, "Max.aika": null, "Maksuvyöhyke": "2", "Adress": "Humlegårdsgatan 14", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "17.8.2020", "Ström": "Nät", "Betalningssätt": "Kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 1.8, "Zon": "2", "Address": "Humalistonkatu 14", "Installed": "17.8.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "2", "Tariff/h": 1.8 }, "geometry": { "type": "Point", "coordinates": [ 22.257216154392239, 60.451862555940885 ] } }, + { "type": "Feature", "properties": { "id": 14, "Osoite": "Läntinen pitkäkatu 24", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "17.8.2020", "Virta": "Verkko", "Maksutapa": "Kolikko, kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 1.8, "Max.aika": null, "Maksuvyöhyke": "2", "Adress": "Västerlånggatan 24", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "17.8.2020", "Ström": "Nät", "Betalningssätt": "Mynt, kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 1.8, "Zon": "2", "Address": "Läntinen pitkäkatu 24", "Installed": "17.8.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Coin, card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "2", "Tariff/h": 1.8 }, "geometry": { "type": "Point", "coordinates": [ 22.255051468343908, 60.452987302517734 ] } }, + { "type": "Feature", "properties": { "id": 15, "Osoite": "Läntinen pitkäkatu 22", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "17.8.2020", "Virta": "Verkko", "Maksutapa": "Kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 1.8, "Max.aika": null, "Maksuvyöhyke": "2", "Adress": "Västerlånggatan 22", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "17.8.2020", "Ström": "Nät", "Betalningssätt": "Kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 1.8, "Zon": "2", "Address": "Läntinen pitkäkatu 22", "Installed": "17.8.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "2", "Tariff/h": 1.8 }, "geometry": { "type": "Point", "coordinates": [ 22.256323063276643, 60.453437911269269 ] } }, + { "type": "Feature", "properties": { "id": 16, "Osoite": "Läntinen pitkäkatu 31", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "18.8.2020", "Virta": "Verkko", "Maksutapa": "Kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 1.8, "Max.aika": null, "Maksuvyöhyke": "2", "Adress": "Västerlånggatan 31", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "18.8.2020", "Ström": "Nät", "Betalningssätt": "Kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 1.8, "Zon": "2", "Address": "Läntinen pitkäkatu 31", "Installed": "18.8.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "2", "Tariff/h": 1.8 }, "geometry": { "type": "Point", "coordinates": [ 22.257994466697046, 60.454187956221354 ] } }, + { "type": "Feature", "properties": { "id": 17, "Osoite": "Läntinen pitkäkatu 23", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "18.8.2020", "Virta": "Verkko", "Maksutapa": "Kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 1.8, "Max.aika": null, "Maksuvyöhyke": "2", "Adress": "Västerlånggatan 23", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "18.8.2020", "Ström": "Nät", "Betalningssätt": "Kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 1.8, "Zon": "2", "Address": "Läntinen pitkäkatu 23", "Installed": "18.8.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "2", "Tariff/h": 1.8 }, "geometry": { "type": "Point", "coordinates": [ 22.261165193612193, 60.455262424830082 ] } }, + { "type": "Feature", "properties": { "id": 18, "Osoite": "Läntinen pitkäkatu 6", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "25.8.2020", "Virta": "Verkko", "Maksutapa": "Kolikko, kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 1.8, "Max.aika": null, "Maksuvyöhyke": "2", "Adress": "Västerlånggatan 6", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "25.8.2020", "Ström": "Nät", "Betalningssätt": "Mynt, kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 1.8, "Zon": "2", "Address": "Läntinen pitkäkatu 6", "Installed": "25.8.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Coin, card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "2", "Tariff/h": 1.8 }, "geometry": { "type": "Point", "coordinates": [ 22.264365559509113, 60.456145444039713 ] } }, + { "type": "Feature", "properties": { "id": 19, "Osoite": "Läntinen pitkäkatu 9", "Sijainti": "Viheralue", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "25.8.2020", "Virta": "Verkko", "Maksutapa": "Kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 1.8, "Max.aika": null, "Maksuvyöhyke": "2", "Adress": "Västerlånggatan 9", "Tillverkare": "Cale", "Plats": "Grönomrode", "Modell": "CWT-C Touch", "Installerad": "25.8.2020", "Ström": "Nät", "Betalningssätt": "Kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 1.8, "Zon": "2", "Address": "Läntinen pitkäkatu 9", "Installed": "25.8.2020", "Location": "Greenery", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "2", "Tariff/h": 1.8 }, "geometry": { "type": "Point", "coordinates": [ 22.265258802895602, 60.456688407044439 ] } }, + { "type": "Feature", "properties": { "id": 20, "Osoite": "Linja-autoasema", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "25.8.2020", "Virta": "Verkko", "Maksutapa": "Kolikko, kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 1.8, "Max.aika": null, "Maksuvyöhyke": "2", "Adress": "Busstationen", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "25.8.2020", "Ström": "Nät", "Betalningssätt": "Mynt, kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 1.8, "Zon": "2", "Address": "Bus Station", "Installed": "25.8.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Coin, card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "2", "Tariff/h": 1.8 }, "geometry": { "type": "Point", "coordinates": [ 22.26823862990101, 60.456618323168733 ] } }, + { "type": "Feature", "properties": { "id": 21, "Osoite": "Brahenkatu 18-20", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "25.8.2020", "Virta": "Verkko", "Maksutapa": "Kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 1.8, "Max.aika": null, "Maksuvyöhyke": "2", "Adress": "Brahegatan 18-20", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "25.8.2020", "Ström": "Nät", "Betalningssätt": "Kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 1.8, "Zon": "2", "Address": "Brahenkatu 18-20", "Installed": "25.8.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "2", "Tariff/h": 1.8 }, "geometry": { "type": "Point", "coordinates": [ 22.265432860869307, 60.45609461101597 ] } }, + { "type": "Feature", "properties": { "id": 22, "Osoite": "Tuureporinkatu 13", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "25.8.2020", "Virta": "Verkko", "Maksutapa": "Kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 1.8, "Max.aika": null, "Maksuvyöhyke": "2", "Adress": "Tureborgsgatan 13", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "25.8.2020", "Ström": "Nät", "Betalningssätt": "Kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 1.8, "Zon": "2", "Address": "Tuureporinkatu 13", "Installed": "25.8.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "2", "Tariff/h": 1.8 }, "geometry": { "type": "Point", "coordinates": [ 22.267508058545992, 60.455899614161197 ] } }, + { "type": "Feature", "properties": { "id": 23, "Osoite": "Tuureporinkatu 16", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "25.8.2020", "Virta": "Verkko", "Maksutapa": "Kolikko, kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 1.8, "Max.aika": null, "Maksuvyöhyke": "2", "Adress": "Tureborgsgatan 16", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "25.8.2020", "Ström": "Nät", "Betalningssätt": "Mynt, kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 1.8, "Zon": "2", "Address": "Tuureporinkatu 16", "Installed": "25.8.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Coin, card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "2", "Tariff/h": 1.8 }, "geometry": { "type": "Point", "coordinates": [ 22.265579060229619, 60.45501128227162 ] } }, + { "type": "Feature", "properties": { "id": 24, "Osoite": "Kauppiaskatu 19", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "27.8.2020", "Virta": "Verkko", "Maksutapa": "Kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 1.8, "Max.aika": null, "Maksuvyöhyke": "2", "Adress": "Köpmansgatan 19", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "27.8.2020", "Ström": "Nät", "Betalningssätt": "Kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 1.8, "Zon": "2", "Address": "Kauppiaskatu 19", "Installed": "27.8.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "2", "Tariff/h": 1.8 }, "geometry": { "type": "Point", "coordinates": [ 22.263927120185748, 60.455138542084491 ] } }, + { "type": "Feature", "properties": { "id": 25, "Osoite": "Kauppiaskatu 17", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "27.8.2020", "Virta": "Verkko", "Maksutapa": "Kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 3.6, "Max.aika": null, "Maksuvyöhyke": "1", "Adress": "Köpmansgatan 17", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "27.8.2020", "Ström": "Nät", "Betalningssätt": "Kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 3.6, "Zon": "1", "Address": "Kauppiaskatu 17", "Installed": "27.8.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "1", "Tariff/h": 3.6 }, "geometry": { "type": "Point", "coordinates": [ 22.265027327999253, 60.454324227152817 ] } }, + { "type": "Feature", "properties": { "id": 26, "Osoite": "Puutori", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "26.8.2020", "Virta": "Verkko", "Maksutapa": "Kolikko, kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 3.6, "Max.aika": null, "Maksuvyöhyke": "1", "Adress": "Trätorget", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "26.8.2020", "Ström": "Nät", "Betalningssätt": "Mynt, kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 3.6, "Zon": "1", "Address": "Puutori", "Installed": "26.8.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Coin, card, contactless", "Screen": "9\", touch screen9\", touch screen", "Owner": "City of Turku", "Zone": "1", "Tariff/h": 3.6 }, "geometry": { "type": "Point", "coordinates": [ 22.268197772640576, 60.455031569350027 ] } }, + { "type": "Feature", "properties": { "id": 27, "Osoite": "Maariankatu 2", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "27.8.2020", "Virta": "Verkko", "Maksutapa": "Kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 3.6, "Max.aika": null, "Maksuvyöhyke": "1", "Adress": "Mariegatan 2", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "27.8.2020", "Ström": "Nät", "Betalningssätt": "Kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 3.6, "Zon": "1", "Address": "Maariankatu 2", "Installed": "27.8.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "1", "Tariff/h": 3.6 }, "geometry": { "type": "Point", "coordinates": [ 22.269849896888495, 60.454839172502602 ] } }, + { "type": "Feature", "properties": { "id": 28, "Osoite": "Brahenkatu 10", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "27.8.2020", "Virta": "Verkko", "Maksutapa": "Kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 3.6, "Max.aika": null, "Maksuvyöhyke": "1", "Adress": "Brahegatan 10", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "27.8.2020", "Ström": "Nät", "Betalningssätt": "Kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 3.6, "Zon": "1", "Address": "Brahenkatu 10", "Installed": "27.8.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "1", "Tariff/h": 3.6 }, "geometry": { "type": "Point", "coordinates": [ 22.268764353572763, 60.453721815846308 ] } }, + { "type": "Feature", "properties": { "id": 29, "Osoite": "Brahenkatu 7", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "27.8.2020", "Virta": "Verkko", "Maksutapa": "Kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 3.6, "Max.aika": null, "Maksuvyöhyke": "1", "Adress": "Brahegatan 7", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "27.8.2020", "Ström": "Nät", "Betalningssätt": "Kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 3.6, "Zon": "1", "Address": "Brahenkatu 7", "Installed": "27.8.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "1", "Tariff/h": 3.6 }, "geometry": { "type": "Point", "coordinates": [ 22.270232241076812, 60.452956273815545 ] } }, + { "type": "Feature", "properties": { "id": 30, "Osoite": "Yliopistonkatu 11", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "9.9.2020", "Virta": "Verkko", "Maksutapa": "Kolikko, kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 3.6, "Max.aika": null, "Maksuvyöhyke": "1", "Adress": "Universitetsgatan 11", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "9.9.2020", "Ström": "Nät", "Betalningssätt": "Mynt, kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 3.6, "Zon": "1", "Address": "Yliopistonkatu 11", "Installed": "9.9.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Coin, card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "1", "Tariff/h": 3.6 }, "geometry": { "type": "Point", "coordinates": [ 22.270612920328336, 60.453720163907732 ] } }, + { "type": "Feature", "properties": { "id": 31, "Osoite": "Nahkurinkatu 8", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "10.9.2020", "Virta": "Verkko", "Maksutapa": "Kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 1.8, "Max.aika": null, "Maksuvyöhyke": "2", "Adress": "Logarvaregatan 8", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "10.9.2020", "Ström": "Nät", "Betalningssätt": "Kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 1.8, "Zon": "2", "Address": "Nahkurinkatu 8", "Installed": "10.9.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "2", "Tariff/h": 1.8 }, "geometry": { "type": "Point", "coordinates": [ 22.27195361111416, 60.454879189541835 ] } }, + { "type": "Feature", "properties": { "id": 32, "Osoite": "Multavierunkatu 1", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "10.9.2020", "Virta": "Verkko", "Maksutapa": "Kolikko, kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 1.8, "Max.aika": null, "Maksuvyöhyke": "2", "Adress": "Multavierugatan 1", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "10.9.2020", "Ström": "Nät", "Betalningssätt": "Mynt, kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 1.8, "Zon": "2", "Address": "Multavierunkatu 1", "Installed": "10.9.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Coin, card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "2", "Tariff/h": 1.8 }, "geometry": { "type": "Point", "coordinates": [ 22.274649677455383, 60.454469280317213 ] } }, + { "type": "Feature", "properties": { "id": 33, "Osoite": "Eerikinkatu 3 vp", "Sijainti": "Viherosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "9.9.2020", "Virta": "Verkko", "Maksutapa": "Kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 1.8, "Max.aika": null, "Maksuvyöhyke": "2", "Adress": "Eriksgatan 3 me", "Tillverkare": "Cale", "Plats": "Grönomrode", "Modell": "CWT-C Touch", "Installerad": "9.9.2020", "Ström": "Nät", "Betalningssätt": "Kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 1.8, "Zon": "2", "Address": "Eerikinkatu 3 vp", "Installed": "9.9.2020", "Location": "Greenery", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "2", "Tariff/h": 1.8 }, "geometry": { "type": "Point", "coordinates": [ 22.274411237269273, 60.453231364393154 ] } }, + { "type": "Feature", "properties": { "id": 34, "Osoite": "Aurakatu 22", "Sijainti": "Viherosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "10.9.2020", "Virta": "Verkko", "Maksutapa": "Kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 3.6, "Max.aika": null, "Maksuvyöhyke": "1", "Adress": "Auragatan 22", "Tillverkare": "Cale", "Plats": "Grönomrode", "Modell": "CWT-C Touch", "Installerad": "10.9.2020", "Ström": "Nät", "Betalningssätt": "Kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 3.6, "Zon": "1", "Address": "Aurakatu 22", "Installed": "10.9.2020", "Location": "Greenery", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "1", "Tariff/h": 3.6 }, "geometry": { "type": "Point", "coordinates": [ 22.26318967618387, 60.452955972059186 ] } }, + { "type": "Feature", "properties": { "id": 35, "Osoite": "Maariankatu 8", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "17.12.2021", "Virta": "Verkko", "Maksutapa": "Kolikko, kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 3.6, "Max.aika": null, "Maksuvyöhyke": "1", "Adress": "Mariegatan 8", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "17.12.2021", "Ström": "Nät", "Betalningssätt": "Mynt, kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 3.6, "Zon": "1", "Address": "Maariankatu 8", "Installed": "17.12.2021", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Coin, card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "1", "Tariff/h": 3.6 }, "geometry": { "type": "Point", "coordinates": [ 22.266164317389642, 60.453604384909475 ] } }, + { "type": "Feature", "properties": { "id": 36, "Osoite": "Puolalankatu 5", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "9.9.2020", "Virta": "Verkko", "Maksutapa": "Kolikko, kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 3.6, "Max.aika": null, "Maksuvyöhyke": "1", "Adress": "Puolalagatan 5", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "9.9.2020", "Ström": "Nät", "Betalningssätt": "Mynt, kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 3.6, "Zon": "1", "Address": "Puolalankatu 5", "Installed": "9.9.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Coin, card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "1", "Tariff/h": 3.6 }, "geometry": { "type": "Point", "coordinates": [ 22.261904591298897, 60.452405246993045 ] } }, + { "type": "Feature", "properties": { "id": 37, "Osoite": "Puolalankatu 2", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C", "Asennettu": "10.9.2020", "Virta": "Verkko", "Maksutapa": "Kortti, lähimaksu", "Näyttö": "7\"", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 3.6, "Max.aika": null, "Maksuvyöhyke": "1", "Adress": "Puolalagatan 2", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C", "Installerad": "10.9.2020", "Ström": "Nät", "Betalningssätt": "Kort, kontaktlös", "Skärm": "7\"", "Ägare": "Åbo stad", "Taxa/t": 3.6, "Zon": "1", "Address": "Puolalankatu 2", "Installed": "10.9.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C", "Power": "Mains", "Payment Method": "Card, contactless", "Screen": "7\"", "Owner": "City of Turku", "Zone": "1", "Tariff/h": 3.6 }, "geometry": { "type": "Point", "coordinates": [ 22.262568837946596, 60.451667570101037 ] } }, + { "type": "Feature", "properties": { "id": 38, "Osoite": "Kauppahalli", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "11.9.2020", "Virta": "Verkko", "Maksutapa": "Kolikko, kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 3.6, "Max.aika": null, "Maksuvyöhyke": "1", "Adress": "Saluhallet", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "11.9.2020", "Ström": "Nät", "Betalningssätt": "Mynt, kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 3.6, "Zon": "1", "Address": "Market Hall", "Installed": "11.9.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Coin, card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "1", "Tariff/h": 3.6 }, "geometry": { "type": "Point", "coordinates": [ 22.265780082893588, 60.449524771568896 ] } }, + { "type": "Feature", "properties": { "id": 39, "Osoite": "Linnankatu 1", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C", "Asennettu": "11.9.2020", "Virta": "Verkko", "Maksutapa": "Kortti, lähimaksu", "Näyttö": "7\"", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 3.6, "Max.aika": null, "Maksuvyöhyke": "1", "Adress": "Slotsgatan 1", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C", "Installerad": "11.9.2020", "Ström": "Nät", "Betalningssätt": "Kort, kontaktlös", "Skärm": "7\"", "Ägare": "Åbo stad", "Taxa/t": 3.6, "Zon": "1", "Address": "Linnankatu 1", "Installed": "11.9.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C", "Power": "Mains", "Payment Method": "Card, contactless", "Screen": "7\"", "Owner": "City of Turku", "Zone": "1", "Tariff/h": 3.6 }, "geometry": { "type": "Point", "coordinates": [ 22.273690297937019, 60.451923978676078 ] } }, + { "type": "Feature", "properties": { "id": 40, "Osoite": "Aurakatu 2", "Sijainti": "Viherosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "10.9.2020", "Virta": "Verkko", "Maksutapa": "Kolikko, kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 3.6, "Max.aika": null, "Maksuvyöhyke": "1", "Adress": "Auragatan 2", "Tillverkare": "Cale", "Plats": "Grönomrode", "Modell": "CWT-C Touch", "Installerad": "10.9.2020", "Ström": "Nät", "Betalningssätt": "Mynt, kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 3.6, "Zon": "1", "Address": "Aurakatu 2", "Installed": "10.9.2020", "Location": "Greenery", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Coin, card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "1", "Tariff/h": 3.6 }, "geometry": { "type": "Point", "coordinates": [ 22.268066143927651, 60.44930830363591 ] } }, + { "type": "Feature", "properties": { "id": 41, "Osoite": "Kauppiaskatu 2", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "10.9.2020", "Virta": "Verkko", "Maksutapa": "Kolikko, kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 3.6, "Max.aika": null, "Maksuvyöhyke": "1", "Adress": "Köpmansgatan 2", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "10.9.2020", "Ström": "Nät", "Betalningssätt": "Mynt, kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 3.6, "Zon": "1", "Address": "Kauppiaskatu 2", "Installed": "10.9.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Coin, card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "1", "Tariff/h": 3.6 }, "geometry": { "type": "Point", "coordinates": [ 22.27064815615228, 60.450128994763617 ] } }, + { "type": "Feature", "properties": { "id": 42, "Osoite": "Linnankatu 5", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C", "Asennettu": "10.9.2020", "Virta": "Verkko", "Maksutapa": "Kortti, lähimaksu", "Näyttö": "7\"", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 3.6, "Max.aika": null, "Maksuvyöhyke": "1", "Adress": "Slottsgatan 5", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C", "Installerad": "10.9.2020", "Ström": "Nät", "Betalningssätt": "Kort, kontaktlös", "Skärm": "7\"", "Ägare": "Åbo stad", "Taxa/t": 3.6, "Zon": "1", "Address": "Linnankatu 5", "Installed": "10.9.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C", "Power": "Mains", "Payment Method": "Card, contactless", "Screen": "7\"", "Owner": "City of Turku", "Zone": "1", "Tariff/h": 3.6 }, "geometry": { "type": "Point", "coordinates": [ 22.271717105625264, 60.451106984503213 ] } }, + { "type": "Feature", "properties": { "id": 43, "Osoite": "Borenpuisto/aukio", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "15.9.2020", "Virta": "Verkko", "Maksutapa": "Kolikko, kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 1.8, "Max.aika": null, "Maksuvyöhyke": "2", "Adress": "Boreparken/plan", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "15.9.2020", "Ström": "Nät", "Betalningssätt": "Mynt, kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 1.8, "Zon": "2", "Address": "Borenpuisto/aukio", "Installed": "15.9.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Coin, card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "2", "Tariff/h": 1.8 }, "geometry": { "type": "Point", "coordinates": [ 22.256426088884556, 60.445362011920302 ] } }, + { "type": "Feature", "properties": { "id": 44, "Osoite": "Eerikinkatu 38", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "15.9.2020", "Virta": "Verkko", "Maksutapa": "Kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 1.8, "Max.aika": null, "Maksuvyöhyke": "2", "Adress": "Eriksgatan 38", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "15.9.2020", "Ström": "Nät", "Betalningssätt": "Kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 1.8, "Zon": "2", "Address": "Eerikinkatu 38", "Installed": "15.9.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "2", "Tariff/h": 1.8 }, "geometry": { "type": "Point", "coordinates": [ 22.254031506456919, 60.446270987052962 ] } }, + { "type": "Feature", "properties": { "id": 45, "Osoite": "Eerikinkatu 40", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "15.9.2020", "Virta": "Verkko", "Maksutapa": "Kolikko, kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 1.8, "Max.aika": null, "Maksuvyöhyke": "2", "Adress": "Eriksgatan 40", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "15.9.2020", "Ström": "Nät", "Betalningssätt": "Mynt, kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 1.8, "Zon": "2", "Address": "Eerikinkatu 40", "Installed": "15.9.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Coin, card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "2", "Tariff/h": 1.8 }, "geometry": { "type": "Point", "coordinates": [ 22.252807701852902, 60.44585626467363 ] } }, + { "type": "Feature", "properties": { "id": 46, "Osoite": "Sairashuoneenkatu 2", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "14.9.2020", "Virta": "Verkko", "Maksutapa": "Kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 1.8, "Max.aika": null, "Maksuvyöhyke": "2", "Adress": "Lasarattsgatan 2", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "14.9.2020", "Ström": "Nät", "Betalningssätt": "Kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 1.8, "Zon": "2", "Address": "Sairashuoneenkatu 2", "Installed": "14.9.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "2", "Tariff/h": 1.8 }, "geometry": { "type": "Point", "coordinates": [ 22.251775173461883, 60.444872332201818 ] } }, + { "type": "Feature", "properties": { "id": 47, "Osoite": "Puistokatu 12", "Sijainti": "Viherosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "14.9.2020", "Virta": "Verkko", "Maksutapa": "Kolikko, kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 0.6, "Max.aika": null, "Maksuvyöhyke": "3", "Adress": "Allegatan 12", "Tillverkare": "Cale", "Plats": "Grönomrode", "Modell": "CWT-C Touch", "Installerad": "14.9.2020", "Ström": "Nät", "Betalningssätt": "Mynt, kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 0.6, "Zon": "3", "Address": "Puistokatu 12", "Installed": "14.9.2020", "Location": "Greenery", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Coin, card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "3", "Tariff/h": 0.6 }, "geometry": { "type": "Point", "coordinates": [ 22.250455226870177, 60.447872484202826 ] } }, + { "type": "Feature", "properties": { "id": 48, "Osoite": "Itsenäisyydenaukio 2 vp", "Sijainti": "Viherosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "1.12.2020", "Virta": "Verkko", "Maksutapa": "Kolikko, kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 1.8, "Max.aika": null, "Maksuvyöhyke": "2", "Adress": "Självständighetsplan 2 me", "Tillverkare": "Cale", "Plats": "Grönomrode", "Modell": "CWT-C Touch", "Installerad": "1.12.2020", "Ström": "Nät", "Betalningssätt": "Mynt, kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 1.8, "Zon": "2", "Address": "Itsenäisyydenaukio 2 vp", "Installed": "1.12.2020", "Location": "Greenery", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Coin, card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "2", "Tariff/h": 1.8 }, "geometry": { "type": "Point", "coordinates": [ 22.262710182022698, 60.445569236961369 ] } }, + { "type": "Feature", "properties": { "id": 50, "Osoite": "Itäinen pitkäkatu/Olavinpuisto vp", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "15.9.2020", "Virta": "Verkko", "Maksutapa": "Kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 1.8, "Max.aika": null, "Maksuvyöhyke": "2", "Adress": "Östra Strandsgatan/Olafsparken", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "15.9.2020", "Ström": "Nät", "Betalningssätt": "Kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 1.8, "Zon": "2", "Address": "Itäinen pitkäkatu/Olavinpuisto vp", "Installed": "15.9.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "2", "Tariff/h": 1.8 }, "geometry": { "type": "Point", "coordinates": [ 22.269007832110184, 60.447885003377571 ] } }, + { "type": "Feature", "properties": { "id": 51, "Osoite": "Kaskenkatu 1", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "2.12.2020", "Virta": "Verkko", "Maksutapa": "Kolikko, kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 1.8, "Max.aika": null, "Maksuvyöhyke": "2", "Adress": "Kaskisgatan 1", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "2.12.2020", "Ström": "Nät", "Betalningssätt": "Mynt, kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 1.8, "Zon": "2", "Address": "Kaskenkatu 1", "Installed": "2.12.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Coin, card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "2", "Tariff/h": 1.8 }, "geometry": { "type": "Point", "coordinates": [ 22.271248289746925, 60.447359092619095 ] } }, + { "type": "Feature", "properties": { "id": 52, "Osoite": "Hämeenkatu 28", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "1.12.2020", "Virta": "Verkko", "Maksutapa": "Kolikko, kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 1.8, "Max.aika": null, "Maksuvyöhyke": "2", "Adress": "Tavastgatan 28", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "1.12.2020", "Ström": "Nät", "Betalningssätt": "Mynt, kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 1.8, "Zon": "2", "Address": "Hämeenkatu 28", "Installed": "1.12.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Coin, card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "2", "Tariff/h": 1.8 }, "geometry": { "type": "Point", "coordinates": [ 22.272740939586971, 60.448712477636697 ] } }, + { "type": "Feature", "properties": { "id": 53, "Osoite": "Hämeenkatu 21", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "14.9.2020", "Virta": "Verkko", "Maksutapa": "Kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 1.8, "Max.aika": null, "Maksuvyöhyke": "2", "Adress": "Tavastgatan 21", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "14.9.2020", "Ström": "Nät", "Betalningssätt": "Kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 1.8, "Zon": "2", "Address": "Hämeenkatu 21", "Installed": "14.9.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "2", "Tariff/h": 1.8 }, "geometry": { "type": "Point", "coordinates": [ 22.274129556670403, 60.449348582880177 ] } }, + { "type": "Feature", "properties": { "id": 54, "Osoite": "Hämeenkatu 19", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "14.9.2020", "Virta": "Verkko", "Maksutapa": "Kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 1.8, "Max.aika": null, "Maksuvyöhyke": "2", "Adress": "Tavastgatan 19", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "14.9.2020", "Ström": "Nät", "Betalningssätt": "Kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 1.8, "Zon": "2", "Address": "Hämeenkatu 19", "Installed": "14.9.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "2", "Tariff/h": 1.8 }, "geometry": { "type": "Point", "coordinates": [ 22.27594945421551, 60.449947345346075 ] } }, + { "type": "Feature", "properties": { "id": 55, "Osoite": "Nunnankatu 1", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "2.12.2020", "Virta": "Verkko", "Maksutapa": "Kolikko, kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 1.8, "Max.aika": null, "Maksuvyöhyke": "2", "Adress": "Nunnegatan 1", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "2.12.2020", "Ström": "Nät", "Betalningssätt": "Mynt, kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 1.8, "Zon": "2", "Address": "Nunnankatu 1", "Installed": "2.12.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Coin, card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "2", "Tariff/h": 1.8 }, "geometry": { "type": "Point", "coordinates": [ 22.273923382374562, 60.450153277326237 ] } }, + { "type": "Feature", "properties": { "id": 56, "Osoite": "Uudenmaankatu 7", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "1.12.2020", "Virta": "Verkko", "Maksutapa": "Kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 0.6, "Max.aika": null, "Maksuvyöhyke": "3", "Adress": "Nylandsgatan 7", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "1.12.2020", "Ström": "Nät", "Betalningssätt": "Kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 0.6, "Zon": "3", "Address": "Uudenmaankatu 7", "Installed": "1.12.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "3", "Tariff/h": 0.6 }, "geometry": { "type": "Point", "coordinates": [ 22.279477989161659, 60.449038532856854 ] } }, + { "type": "Feature", "properties": { "id": 57, "Osoite": "Vähä Hämeenkatu 16", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "3.12.2020", "Virta": "Verkko", "Maksutapa": "Kolikko, kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 0.6, "Max.aika": null, "Maksuvyöhyke": "3", "Adress": "Lilla Tavastgatan 16", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "3.12.2020", "Ström": "Nät", "Betalningssätt": "Mynt, kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 0.6, "Zon": "3", "Address": "Vähä Hämeenkatu 16", "Installed": "3.12.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Coin, card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "3", "Tariff/h": 0.6 }, "geometry": { "type": "Point", "coordinates": [ 22.279883914491133, 60.449633311242088 ] } }, + { "type": "Feature", "properties": { "id": 58, "Osoite": "Hovioikeudenkatu 2", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "14.12.2020", "Virta": "Verkko", "Maksutapa": "Kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 0.6, "Max.aika": null, "Maksuvyöhyke": "3", "Adress": "Hofrättsgatan 2", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "14.12.2020", "Ström": "Nät", "Betalningssätt": "Kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 0.6, "Zon": "3", "Address": "Hovioikeudenkatu 2", "Installed": "14.12.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "3", "Tariff/h": 0.6 }, "geometry": { "type": "Point", "coordinates": [ 22.280643577273363, 60.450943454869588 ] } }, + { "type": "Feature", "properties": { "id": 59, "Osoite": "Tuomiokirkontori 1-3", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "3.12.2020", "Virta": "Verkko", "Maksutapa": "Kolikko, kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 1.8, "Max.aika": null, "Maksuvyöhyke": "2", "Adress": "Domkyrkotorget 1-3", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "3.12.2020", "Ström": "Nät", "Betalningssätt": "Mynt, kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 1.8, "Zon": "2", "Address": "Tuomiokirkontori 1-3", "Installed": "3.12.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Coin, card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "2", "Tariff/h": 1.8 }, "geometry": { "type": "Point", "coordinates": [ 22.277983862006817, 60.451173921939066 ] } }, + { "type": "Feature", "properties": { "id": 60, "Osoite": "Tuomiokirkonkatu 3", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "10.12.2020", "Virta": "Verkko", "Maksutapa": "Kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 1.8, "Max.aika": null, "Maksuvyöhyke": "2", "Adress": "Domkyrkogatan 3", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "10.12.2020", "Ström": "Nät", "Betalningssätt": "Kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 1.8, "Zon": "2", "Address": "Tuomiokirkonkatu 3", "Installed": "10.12.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "2", "Tariff/h": 1.8 }, "geometry": { "type": "Point", "coordinates": [ 22.280249199106677, 60.451952316001226 ] } }, + { "type": "Feature", "properties": { "id": 61, "Osoite": "Kerttulinkatu 6", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "7.12.2020", "Virta": "Verkko", "Maksutapa": "Kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 1.8, "Max.aika": null, "Maksuvyöhyke": "2", "Adress": "Gertrudsgatan 6", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "7.12.2020", "Ström": "Nät", "Betalningssätt": "Kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 1.8, "Zon": "2", "Address": "Kerttulinkatu 6", "Installed": "7.12.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "2", "Tariff/h": 1.8 }, "geometry": { "type": "Point", "coordinates": [ 22.282016000564631, 60.452352972963375 ] } }, + { "type": "Feature", "properties": { "id": 62, "Osoite": "Hämeenkatu 6-8", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "9.12.2020", "Virta": "Verkko", "Maksutapa": "Kolikko, kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 1.8, "Max.aika": null, "Maksuvyöhyke": "2", "Adress": "Tavastgatan 6-8", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "9.12.2020", "Ström": "Nät", "Betalningssätt": "Mynt, kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 1.8, "Zon": "2", "Address": "Hämeenkatu 6-8", "Installed": "9.12.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Coin, card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "2", "Tariff/h": 1.8 }, "geometry": { "type": "Point", "coordinates": [ 22.283591614208678, 60.452205864044352 ] } }, + { "type": "Feature", "properties": { "id": 63, "Osoite": "Rehtorinpellonkatu 2", "Sijainti": "Viherosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "9.12.2020", "Virta": "Verkko", "Maksutapa": "Kolikko, kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 1.8, "Max.aika": null, "Maksuvyöhyke": "2", "Adress": "Rektorsåkersgatan 2", "Tillverkare": "Cale", "Plats": "Grönomrode", "Modell": "CWT-C Touch", "Installerad": "9.12.2020", "Ström": "Nät", "Betalningssätt": "Mynt, kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 1.8, "Zon": "2", "Address": "Rehtorinpellonkatu 2", "Installed": "9.12.2020", "Location": "Greenery", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Coin, card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "2", "Tariff/h": 1.8 }, "geometry": { "type": "Point", "coordinates": [ 22.287462642661801, 60.453937416413524 ] } }, + { "type": "Feature", "properties": { "id": 64, "Osoite": "Vähä Hämeenkatu 2", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "9.12.2020", "Virta": "Verkko", "Maksutapa": "Kolikko, kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 0.6, "Max.aika": null, "Maksuvyöhyke": "3", "Adress": "Lilla Tavastgatan 2", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "9.12.2020", "Ström": "Nät", "Betalningssätt": "Mynt, kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 0.6, "Zon": "3", "Address": "Vähä Hämeenkatu 2", "Installed": "9.12.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Coin, card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "3", "Tariff/h": 0.6 }, "geometry": { "type": "Point", "coordinates": [ 22.28764674597139, 60.452178003199926 ] } }, + { "type": "Feature", "properties": { "id": 65, "Osoite": "Lemminkäisenkatu 14", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "9.12.2020", "Virta": "Verkko", "Maksutapa": "Kolikko, kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 1.8, "Max.aika": null, "Maksuvyöhyke": "2", "Adress": "Lemminkäinengatan 14", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "9.12.2020", "Ström": "Nät", "Betalningssätt": "Mynt, kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 1.8, "Zon": "2", "Address": "Lemminkäisenkatu 14", "Installed": "9.12.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Coin, card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "2", "Tariff/h": 1.8 }, "geometry": { "type": "Point", "coordinates": [ 22.294781520449281, 60.448205452316486 ] } }, + { "type": "Feature", "properties": { "id": 66, "Osoite": "Lemminkäisenkatu 22", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "10.12.2020", "Virta": "Verkko", "Maksutapa": "Kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 1.8, "Max.aika": null, "Maksuvyöhyke": "2", "Adress": "Lemminkäinengatan 22", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "10.12.2020", "Ström": "Nät", "Betalningssätt": "Kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 1.8, "Zon": "2", "Address": "Lemminkäisenkatu 22", "Installed": "10.12.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "2", "Tariff/h": 1.8 }, "geometry": { "type": "Point", "coordinates": [ 22.296182597025911, 60.447646306211979 ] } }, + { "type": "Feature", "properties": { "id": 67, "Osoite": "Puutarhakatu 6-8", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C", "Asennettu": "6.2.2019", "Virta": "Verkko", "Maksutapa": "Kolikko, kortti, lähimaksu", "Näyttö": "7\"", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 3.6, "Max.aika": null, "Maksuvyöhyke": "1", "Adress": "Trädgårdsgatan 6-8", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C", "Installerad": "6.2.2019", "Ström": "Nät", "Betalningssätt": "Mynt, kort, kontaktlös", "Skärm": "7\"", "Ägare": "Åbo stad", "Taxa/t": 3.6, "Zon": "1", "Address": "Puutarhakatu 6-8", "Installed": "6.2.2019", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C", "Power": "Mains", "Payment Method": "Coin, card, contactless", "Screen": "7\"", "Owner": "City of Turku", "Zone": "1", "Tariff/h": 3.6 }, "geometry": { "type": "Point", "coordinates": [ 22.260430472827235, 60.45216772994651 ] } }, + { "type": "Feature", "properties": { "id": 68, "Osoite": "Humalistonkatu 8", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C", "Asennettu": "29.1.2018", "Virta": "Verkko", "Maksutapa": "Kortti, lähimaksu", "Näyttö": "7\"", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 3.6, "Max.aika": null, "Maksuvyöhyke": "1", "Adress": "Humlegårdsgatan 8", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C", "Installerad": "29.1.2018", "Ström": "Nät", "Betalningssätt": "Kort, kontaktlös", "Skärm": "7\"", "Ägare": "Åbo stad", "Taxa/t": 3.6, "Zon": "1", "Address": "Humalistonkatu 8", "Installed": "29.1.2018", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C", "Power": "Mains", "Payment Method": "Card, contactless", "Screen": "7\"", "Owner": "City of Turku", "Zone": "1", "Tariff/h": 3.6 }, "geometry": { "type": "Point", "coordinates": [ 22.258675701953273, 60.450865575940362 ] } }, + { "type": "Feature", "properties": { "id": 69, "Osoite": "Puutarhakatu 3", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C", "Asennettu": "6.2.2019", "Virta": "Verkko", "Maksutapa": "Kortti, lähimaksu", "Näyttö": "7\"", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 3.6, "Max.aika": null, "Maksuvyöhyke": "1", "Adress": "Trädgårdsgatan 3", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C", "Installerad": "6.2.2019", "Ström": "Nät", "Betalningssätt": "Kort, kontaktlös", "Skärm": "7\"", "Ägare": "Åbo stad", "Taxa/t": 3.6, "Zon": "1", "Address": "Puutarhakatu 3", "Installed": "6.2.2019", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C", "Power": "Mains", "Payment Method": "Card, contactless", "Screen": "7\"", "Owner": "City of Turku", "Zone": "1", "Tariff/h": 3.6 }, "geometry": { "type": "Point", "coordinates": [ 22.26175837633734, 60.45287615088543 ] } }, + { "type": "Feature", "properties": { "id": 70, "Osoite": "Käsityöläiskatu 11", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C", "Asennettu": "27.2.2019", "Virta": "Verkko", "Maksutapa": "Kolikko, kortti, lähimaksu", "Näyttö": "7\"", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 3.6, "Max.aika": null, "Maksuvyöhyke": "1", "Adress": "Hantverkaregatan 11", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C", "Installerad": "27.2.2019", "Ström": "Nät", "Betalningssätt": "Mynt, kort, kontaktlös", "Skärm": "7\"", "Ägare": "Åbo stad", "Taxa/t": 3.6, "Zon": "1", "Address": "Käsityöläiskatu 11", "Installed": "27.2.2019", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C", "Power": "Mains", "Payment Method": "Coin, card, contactless", "Screen": "7\"", "Owner": "City of Turku", "Zone": "1", "Tariff/h": 3.6 }, "geometry": { "type": "Point", "coordinates": [ 22.25604659396264, 60.450985440937544 ] } }, + { "type": "Feature", "properties": { "id": 71, "Osoite": "Yliopistonkatu 5", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "11.10.2022", "Virta": "Verkko", "Maksutapa": "Kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 1.8, "Max.aika": null, "Maksuvyöhyke": "2", "Adress": "Universitetsgatan 5", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "11.10.2022", "Ström": "Nät", "Betalningssätt": "Kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 1.8, "Zon": "2", "Address": "Yliopistonkatu 5", "Installed": "11.10.2022", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "2", "Tariff/h": 1.8 }, "geometry": { "type": "Point", "coordinates": [ 22.273213346106335, 60.454614196330851 ] } }, + { "type": "Feature", "properties": { "id": 72, "Osoite": "Itäinen Rantakatu 72", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "8.12.2020", "Virta": "Verkko", "Maksutapa": "Kolikko, kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 0.6, "Max.aika": null, "Maksuvyöhyke": "3", "Adress": "Östra Strandgatan 72", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "8.12.2020", "Ström": "Nät", "Betalningssätt": "Mynt, kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 0.6, "Zon": "3", "Address": "Itäinen Rantakatu 72", "Installed": "8.12.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Coin, card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "3", "Tariff/h": 0.6 }, "geometry": { "type": "Point", "coordinates": [ 22.243079545713517, 60.437818487078545 ] } }, + { "type": "Feature", "properties": { "id": 73, "Osoite": "Kasarminkatu 4", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "2.9.2020", "Virta": "Verkko", "Maksutapa": "Kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 0.6, "Max.aika": null, "Maksuvyöhyke": "3", "Adress": "Kaserngatan 4", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "2.9.2020", "Ström": "Nät", "Betalningssätt": "Kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 0.6, "Zon": "3", "Address": "Kasarminkatu 4", "Installed": "2.9.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "3", "Tariff/h": 0.6 }, "geometry": { "type": "Point", "coordinates": [ 22.280352370046622, 60.457669207319114 ] } }, + { "type": "Feature", "properties": { "id": 75, "Osoite": "Linnankatu 19", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "19.12.2022", "Virta": "Aurinkopaneeli", "Maksutapa": "Kolikko, kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 3.6, "Max.aika": null, "Maksuvyöhyke": "1", "Adress": "Slottsgatan 19", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "19.12.2022", "Ström": "Solcell", "Betalningssätt": "Mynt, kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 3.6, "Zon": "1", "Address": "Linnankatu 19", "Installed": "19.12.2022", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Solar panel", "Payment Method": "Coin, card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "1", "Tariff/h": 3.6 }, "geometry": { "type": "Point", "coordinates": [ 22.264872376936172, 60.448691040677524 ] } }, + { "type": "Feature", "properties": { "id": 77, "Osoite": "Piispankatu 10", "Sijainti": "Viherosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "2.9.2020", "Virta": "Verkko", "Maksutapa": "Kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 0.6, "Max.aika": null, "Maksuvyöhyke": "3", "Adress": "Biskopsgatan 10", "Tillverkare": "Cale", "Plats": "Grönomrode", "Modell": "CWT-C Touch", "Installerad": "2.9.2020", "Ström": "Nät", "Betalningssätt": "Kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 0.6, "Zon": "3", "Address": "Piispankatu 10", "Installed": "2.9.2020", "Location": "Greenery", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "3", "Tariff/h": 0.6 }, "geometry": { "type": "Point", "coordinates": [ 22.279733274298824, 60.455020619216043 ] } }, + { "type": "Feature", "properties": { "id": 78, "Osoite": "Piispankatu 19", "Sijainti": "Katuosa", "Valmistaja": "Cale§", "Malli": "CWT-C Touch", "Asennettu": "3.9.2020", "Virta": "Verkko", "Maksutapa": "Kolikko, kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 0.6, "Max.aika": null, "Maksuvyöhyke": "3", "Adress": "Biskopsgatan 19", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "3.9.2020", "Ström": "Nät", "Betalningssätt": "Mynt, kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 0.6, "Zon": "3", "Address": "Piispankatu 19", "Installed": "3.9.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Coin, card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "3", "Tariff/h": 0.6 }, "geometry": { "type": "Point", "coordinates": [ 22.277760964164081, 60.453216079342297 ] } }, + { "type": "Feature", "properties": { "id": 79, "Osoite": "Porthaninkatu 6", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "14.12.2020", "Virta": "Verkko", "Maksutapa": "Kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 0.6, "Max.aika": null, "Maksuvyöhyke": "3", "Adress": "Porthansgatan 6", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "14.12.2020", "Ström": "Nät", "Betalningssätt": "Kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 0.6, "Zon": "3", "Address": "Porthaninkatu 6", "Installed": "14.12.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "3", "Tariff/h": 0.6 }, "geometry": { "type": "Point", "coordinates": [ 22.28073811293207, 60.453918201523102 ] } }, + { "type": "Feature", "properties": { "id": 84, "Osoite": "Tehtaankatu 6", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "3.9.2020", "Virta": "Verkko", "Maksutapa": "Kolikko, kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 0.6, "Max.aika": null, "Maksuvyöhyke": "3", "Adress": "Fabriksgatan 6", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "3.9.2020", "Ström": "Nät", "Betalningssätt": "Mynt, kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 0.6, "Zon": "3", "Address": "Tehtaankatu 6", "Installed": "3.9.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Coin, card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "3", "Tariff/h": 0.6 }, "geometry": { "type": "Point", "coordinates": [ 22.280930555107421, 60.45626865766279 ] } }, + { "type": "Feature", "properties": { "id": 87, "Osoite": "Ursiininkatu 9", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "19.12.2022", "Virta": "Aurinkopaneeli", "Maksutapa": "Kolikko, kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 1.8, "Max.aika": null, "Maksuvyöhyke": "2", "Adress": "Ursinsgatan 9", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "19.12.2022", "Ström": "Solcell", "Betalningssätt": "Mynt, kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 1.8, "Zon": "2", "Address": "Ursiininkatu 9", "Installed": "19.12.2022", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Solar panel", "Payment Method": "Coin, card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "2", "Tariff/h": 1.8 }, "geometry": { "type": "Point", "coordinates": [ 22.256546650434711, 60.448655233433726 ] } }, + { "type": "Feature", "properties": { "id": 88, "Osoite": "Blomberginaukio", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "16.12.2020", "Virta": "Aurinkopaneeli", "Maksutapa": "Kolikko, kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 0.6, "Max.aika": null, "Maksuvyöhyke": "3", "Adress": "Blombergsplan", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "16.12.2020", "Ström": "Solcell", "Betalningssätt": "Mynt, kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 0.6, "Zon": "3", "Address": "Blomberginaukio", "Installed": "16.12.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Solar panel", "Payment Method": "Coin, card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "3", "Tariff/h": 0.6 }, "geometry": { "type": "Point", "coordinates": [ 22.29275674703927, 60.443803487057082 ] } }, + { "type": "Feature", "properties": { "id": 89, "Osoite": "Blomberginaukio", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "16.12.2020", "Virta": "Aurinkopaneeli", "Maksutapa": "Kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 0.6, "Max.aika": null, "Maksuvyöhyke": "3", "Adress": "Blombergsplan", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "16.12.2020", "Ström": "Solcell", "Betalningssätt": "Kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 0.6, "Zon": "3", "Address": "Blomberginaukio", "Installed": "16.12.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Solar panel", "Payment Method": "Card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "3", "Tariff/h": 0.6 }, "geometry": { "type": "Point", "coordinates": [ 22.294113833178297, 60.442723807754163 ] } }, + { "type": "Feature", "properties": { "id": 90, "Osoite": "Tahkonaukio", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "16.12.2020", "Virta": "Aurinkopaneeli", "Maksutapa": "Kolikko, kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 0.6, "Max.aika": null, "Maksuvyöhyke": "3", "Adress": "Tahkoplan", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "16.12.2020", "Ström": "Solcell", "Betalningssätt": "Mynt, kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 0.6, "Zon": "3", "Address": "Tahkonaukio", "Installed": "16.12.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Solar panel", "Payment Method": "Coin, card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "3", "Tariff/h": 0.6 }, "geometry": { "type": "Point", "coordinates": [ 22.294175006719268, 60.446804509763439 ] } }, + { "type": "Feature", "properties": { "id": 91, "Osoite": "Teollisuuskatu 16", "Sijainti": "Viherosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "11.10.2022", "Virta": "Aurinkopaneeli", "Maksutapa": "Kolikko, kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 0.6, "Max.aika": null, "Maksuvyöhyke": "3", "Adress": "Industrigatan 16", "Tillverkare": "Cale", "Plats": "Grönomrode", "Modell": "CWT-C Touch", "Installerad": "11.10.2022", "Ström": "Solcell", "Betalningssätt": "Mynt, kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 0.6, "Zon": "3", "Address": "Teollisuuskatu 16", "Installed": "11.10.2022", "Location": "Greenery", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Solar panel", "Payment Method": "Coin, card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "3", "Tariff/h": 0.6 }, "geometry": { "type": "Point", "coordinates": [ 22.301980689265612, 60.449590340262404 ] } }, + { "type": "Feature", "properties": { "id": 300, "Osoite": "Kunnallissairaalantie 20", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "8.12.2020", "Virta": "Verkko", "Maksutapa": "Kolikko, kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": "Taksa 0,5 €/t ensimmäiset 8t, 0,2 €/t aika yli 8t", "Omistaja": "Turun kaupunki", "Taksa/h": 0.5, "Max.aika": null, "Maksuvyöhyke": "Sairaala", "Adress": "Kommunalsjukhusvägen 20", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "8.12.2020", "Ström": "Nät", "Betalningssätt": "Mynt, kort, kontaktlös", "Skärm": "9\", pekskärm", "Tillägg": "Taxa 0,5 €/t första 8t, 0,2 €/t efter 8t", "Ägare": "Åbo stad", "Taxa/t": 0.5, "Zon": "Sjukhus", "Address": "Kunnallissairaalantie 20", "Installed": "8.12.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Coin, card, contactless", "Screen": "9\", touch screen", "Additional info": "Tariff 0,5 €/h first 8h, 0,2 €/h time over 8h", "Owner": "City of Turku", "Zone": "Hospital", "Tariff/h": 0.5 }, "geometry": { "type": "Point", "coordinates": [ 22.275377662478608, 60.440746141486549 ] } }, + { "type": "Feature", "properties": { "id": 301, "Osoite": "Luolavuorentie 2", "Sijainti": "Viherosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "8.12.2020", "Virta": "Verkko", "Maksutapa": "Kolikko, kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": "Taksa 0,5 €/t ensimmäiset 8t, 0,2 €/t aika yli 8t", "Omistaja": "Turun kaupunki", "Taksa/h": 0.5, "Max.aika": null, "Maksuvyöhyke": "Sairaala", "Adress": "Luolavuorivägen 2", "Tillverkare": "Cale", "Plats": "Grönomrode", "Modell": "CWT-C Touch", "Installerad": "8.12.2020", "Ström": "Nät", "Betalningssätt": "Mynt, kort, kontaktlös", "Skärm": "9\", pekskärm", "Tillägg": "Taxa 0,5 €/t första 8t, 0,2 €/t efter 8t", "Ägare": "Åbo stad", "Taxa/t": 0.5, "Zon": "Sjukhus", "Address": "Luolavuorentie 2", "Installed": "8.12.2020", "Location": "Greenery", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Coin, card, contactless", "Screen": "9\", touch screen", "Additional info": "Tariff 0,5 €/h first 8h, 0,2 €/h time over 8h", "Owner": "City of Turku", "Zone": "Hospital", "Tariff/h": 0.5 }, "geometry": { "type": "Point", "coordinates": [ 22.274444434528675, 60.438998327974652 ] } }, + { "type": "Feature", "properties": { "id": 302, "Osoite": "Luolavuorentie 2", "Sijainti": "Viherosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "8.12.2020", "Virta": "Verkko", "Maksutapa": "Kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": "Taksa 0,5 €/t ensimmäiset 8t, 0,2 €/t aika yli 8t", "Omistaja": "Turun kaupunki", "Taksa/h": 0.5, "Max.aika": null, "Maksuvyöhyke": "Sairaala", "Adress": "Luolavuorivägen 2", "Tillverkare": "Cale", "Plats": "Grönomrode", "Modell": "CWT-C Touch", "Installerad": "8.12.2020", "Ström": "Nät", "Betalningssätt": "Kort, kontaktlös", "Skärm": "9\", pekskärm", "Tillägg": "Taxa 0,5 €/t första 8t, 0,2 €/t efter 8t", "Ägare": "Åbo stad", "Taxa/t": 0.5, "Zon": "Sjukhus", "Address": "Luolavuorentie 2", "Installed": "8.12.2020", "Location": "Greenery", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Card, contactless", "Screen": "9\", touch screen", "Additional info": "Tariff 0,5 €/h first 8h, 0,2 €/h time over 8h", "Owner": "City of Turku", "Zone": "Hospital", "Tariff/h": 0.5 }, "geometry": { "type": "Point", "coordinates": [ 22.274072124365258, 60.439409437701471 ] } }, + { "type": "Feature", "properties": { "id": 401, "Osoite": "4. linja ", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "18.10.2022", "Virta": "Verkkovirta", "Maksutapa": "Kolikko, kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": "16 € / 26 h", "Omistaja": "Turun kaupunki", "Taksa/h": 1.3, "Max.aika": null, "Adress": "4. linjen", "Plats": "Gata", "Tillverkare": "Cale", "Modell": "CWT-C Touch", "Installerad": "18.10.2022", "Ström": "Nät", "Betalningssätt": "Mynt, kort, kontaktlös", "Skärm": "9\", pekskärm", "Tilläg": "16 € / 26 h", "Ägare": "Åbo stad", "Zon": "Hamn", "Taxa/t": 1.3, "Maksuvyöhyke": "Satama", "Address": "4. linja", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Installed": "18.10.2022", "Power": "Mains", "Payment method": "Coin, card, contactless", "Screen": "9\", touch screen", "Additional info": "16 € / 26 h", "Owner": "City of Turku", "Zone": "Harbour", "Tariff/h": 1.3 }, "geometry": { "type": "Point", "coordinates": [ 22.2227213543412, 60.435316415968622 ] } }, + { "type": "Feature", "properties": { "id": 403, "Osoite": "4. linja", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "18.10.2022", "Virta": "Verkkovirta", "Maksutapa": "Kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": "16 € / 26h", "Omistaja": "Turun kaupunki", "Taksa/h": 1.3, "Max.aika": null, "Adress": "4. linjen", "Plats": "Gata", "Tillverkare": "Cale", "Modell": "CWT-C Touch", "Installerad": "18.10.2022", "Ström": "Nät", "Betalningssätt": "Kort, kontaktlös", "Skärm": "9\", pekskärm", "Tilläg": "16 € / 26 h", "Ägare": "Åbo stad", "Zon": "Hamn", "Taxa/t": 1.3, "Maksuvyöhyke": "Satama", "Address": "4. linja", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Installed": "18.10.2022", "Power": "Mains", "Payment method": "Card, contactless", "Screen": "9\", touch screen", "Additional info": "16 € / 26 h", "Owner": "City of Turku", "Zone": "Harbour", "Tariff/h": 1.3 }, "geometry": { "type": "Point", "coordinates": [ 22.221447167201752, 60.435584779574739 ] } }, + { "type": "Feature", "properties": { "id": 404, "Osoite": "3. linja", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "11.10.2022", "Virta": "Verkkovirta", "Maksutapa": "Kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": "16 € / 26h", "Omistaja": "Turun kaupunki", "Taksa/h": 1.3, "Max.aika": null, "Adress": "3. linjen", "Plats": "Gata", "Tillverkare": "Cale", "Modell": "CWT-C Touch", "Installerad": "11.10.2022", "Ström": "Nät", "Betalningssätt": "Kort, kontaktlös", "Skärm": "9\", pekskärm", "Tilläg": "16 € / 26 h", "Ägare": "Åbo stad", "Zon": "Hamn", "Taxa/t": 1.3, "Maksuvyöhyke": "Satama", "Address": "3. linja", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Installed": "11.10.2022", "Power": "Mains", "Payment method": "Card, contactless", "Screen": "9\", touch screen", "Additional info": "16 € / 26 h", "Owner": "City of Turku", "Zone": "Harbour", "Tariff/h": 1.3 }, "geometry": { "type": "Point", "coordinates": [ 22.221389745850257, 60.435173290652273 ] } }, + { "type": "Feature", "properties": { "id": 405, "Osoite": "Linnankatu 91", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "7.10.2022", "Virta": "Verkkovirta", "Maksutapa": "Kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": "16 € / 26 h", "Omistaja": "Turun kaupunki", "Taksa/h": 1.3, "Max.aika": null, "Adress": "Slottsgatan 91", "Plats": "Gata", "Tillverkare": "Cale", "Modell": "CWT-C Touch", "Installerad": "7.10.2022", "Ström": "Nät", "Betalningssätt": "Kort, kontaktlös", "Skärm": "9\", pekskärm", "Tilläg": "16 € / 26 h", "Ägare": "Åbo stad", "Zon": "Hamn", "Taxa/t": 1.3, "Maksuvyöhyke": "Satama", "Address": "Linnankatu 91", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Installed": "7.10.2022", "Power": "Mains", "Payment method": "Card, contactless", "Screen": "9\", touch screen", "Additional info": "16 € / 26 h", "Owner": "City of Turku", "Zone": "Harbour", "Tariff/h": 1.3 }, "geometry": { "type": "Point", "coordinates": [ 22.219860237204227, 60.435680281344297 ] } }, + { "type": "Feature", "properties": { "id": 406, "Osoite": "2. linja", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "7.10.2022", "Virta": "Verkkovirta", "Maksutapa": "Kolikko, kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": "16 € / 26 h", "Omistaja": "Turun kaupunki", "Taksa/h": 1.3, "Max.aika": null, "Adress": "2. linjen", "Plats": "Gata", "Tillverkare": "Cale", "Modell": "CWT-C Touch", "Installerad": "7.10.2022", "Ström": "Nät", "Betalningssätt": "Mynt, kort, kontaktlös", "Skärm": "9\", pekskärm", "Tilläg": "16 € / 26 h", "Ägare": "Åbo stad", "Zon": "Hamn", "Taxa/t": 1.3, "Maksuvyöhyke": "Satama", "Address": "2. linja", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Installed": "7.10.2022", "Power": "Mains", "Payment method": "Coin, card, contactless", "Screen": "9\", touch screen", "Additional info": "16 € / 26 h", "Owner": "City of Turku", "Zone": "Harbour", "Tariff/h": 1.3, "Max.time": null }, "geometry": { "type": "Point", "coordinates": [ 22.224974204395551, 60.433923001505228 ] } }, + { "type": "Feature", "properties": { "id": 407, "Osoite": "2. linja", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "11.10.2022", "Virta": "Verkkovirta", "Maksutapa": "Kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": "16 € / 26 h", "Omistaja": "Turun kaupunki", "Taksa/h": 1.3, "Max.aika": null, "Adress": "2. linjen", "Plats": "Gata", "Tillverkare": "Cale", "Modell": "CWT-C Touch", "Installerad": "11.10.2022", "Ström": "Nät", "Betalningssätt": "Kort, kontaktlös", "Skärm": "9\", pekskärm", "Tilläg": "16 € / 26 h", "Ägare": "Åbo stad", "Zon": "Hamn", "Taxa/t": 1.3, "Maksuvyöhyke": "Satama", "Address": "2. linja", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Installed": "11.10.2022", "Power": "Mains", "Payment method": "Card, contactless", "Screen": "9\", touch screen", "Additional info": "16 € / 26 h", "Owner": "City of Turku", "Zone": "Harbour", "Tariff/h": 1.3 }, "geometry": { "type": "Point", "coordinates": [ 22.225946466932712, 60.434033761861251 ] } }, + { "type": "Feature", "properties": { "id": 408, "Osoite": "4. linja", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "17.10.2022", "Virta": "Verkkovirta", "Maksutapa": "Kolikko, kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": "16 € / 26 h", "Omistaja": "Turun kaupunki", "Taksa/h": 1.3, "Max.aika": null, "Adress": "4. linjen", "Plats": "Gata", "Tillverkare": "Cale", "Modell": "CWT-C Touch", "Installerad": "17.10.2022", "Ström": "Nät", "Betalningssätt": "Mynt, kort, kontaktlös", "Skärm": "9\", pekskärm", "Tilläg": "16 € / 26 h", "Ägare": "Åbo stad", "Zon": "Hamn", "Taxa/t": 1.3, "Maksuvyöhyke": "Satama", "Address": "4. linja", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Installed": "17.10.2022", "Power": "Mains", "Payment method": "Coin, card, contactless", "Screen": "9\", touch screen", "Additional info": "16 € / 26 h", "Owner": "City of Turku", "Zone": "Harbour", "Tariff/h": 1.3 }, "geometry": { "type": "Point", "coordinates": [ 22.224755030627708, 60.434824489085884 ] } }, + { "type": "Feature", "properties": { "id": 409, "Osoite": "Linnankatu 87", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "7.10.2022", "Virta": "Verkkovirta", "Maksutapa": "Kolikko, kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": "16 € / 26 h", "Omistaja": "Turun kaupunki", "Taksa/h": 1.3, "Max.aika": null, "Adress": "Slottsgatan 87", "Plats": "Gata", "Tillverkare": "Cale", "Modell": "CWT-C Touch", "Installerad": "7.10.2022", "Ström": "Nät", "Betalningssätt": "Mynt, kort, kontaktlös", "Skärm": "9\", pekskärm", "Tilläg": "16 € / 26 h", "Ägare": "Åbo stad", "Zon": "Hamn", "Taxa/t": 1.3, "Maksuvyöhyke": "Satama", "Address": "Linnankatu 87", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Installed": "7.10.2022", "Power": "Mains", "Payment method": "Coin, card, contactless", "Screen": "9\", touch screen", "Additional info": "16 € / 26 h", "Owner": "City of Turku", "Zone": "Harbour", "Tariff/h": 1.3 }, "geometry": { "type": "Point", "coordinates": [ 22.225127681727219, 60.435830090490398 ] } }, + { "type": "Feature", "properties": { "id": 413, "Osoite": "Satamakatu 18", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "14.10.2022", "Virta": "Verkkovirta", "Maksutapa": "Kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": "16 € / 26 h", "Omistaja": "Turun kaupunki", "Taksa/h": 1.3, "Max.aika": null, "Adress": "Hamngatan 18", "Plats": "Gata", "Tillverkare": "Cale", "Modell": "CWT-C Touch", "Installerad": "14.10.2022", "Ström": "Nät", "Betalningssätt": "Kort, kontaktlös", "Skärm": "9\", pekskärm", "Tilläg": "16 € / 26 h", "Ägare": "Åbo stad", "Zon": "Hamn", "Taxa/t": 1.3, "Maksuvyöhyke": "Satama", "Address": "Satamakatu 18", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Installed": "14.10.2022", "Power": "Mains", "Payment method": "Card, contactless", "Screen": "9\", touch screen", "Additional info": "16 € / 26 h", "Owner": "City of Turku", "Zone": "Harbour", "Tariff/h": 1.3 }, "geometry": { "type": "Point", "coordinates": [ 22.225972071802026, 60.436572492254285 ] } }, + { "type": "Feature", "properties": { "id": 415, "Osoite": "Satamakatu 18 vp", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "15.10.2022", "Virta": "Verkkovirta", "Maksutapa": "Kolikko, kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": "16 € / 26 h", "Omistaja": "Turun kaupunki", "Taksa/h": 1.3, "Max.aika": null, "Adress": "Hamngatan 18 me", "Plats": "Gata", "Tillverkare": "Cale", "Modell": "CWT-C Touch", "Installerad": "15.10.2022", "Ström": "Nät", "Betalningssätt": "Mynt, kort, kontaktlös", "Skärm": "9\", pekskärm", "Tilläg": "16 € / 26 h", "Ägare": "Åbo stad", "Zon": "Hamn", "Taxa/t": 1.3, "Maksuvyöhyke": "Satama", "Address": "Satamakatu 18 vp", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Installed": "15.10.2022", "Power": "Mains", "Payment method": "Coin, card, contactless", "Screen": "9\", touch screen", "Additional info": "16 € / 26 h", "Owner": "City of Turku", "Zone": "Harbour", "Tariff/h": 1.3 }, "geometry": { "type": "Point", "coordinates": [ 22.227764721514966, 60.436782262744913 ] } } + ] + } + \ No newline at end of file diff --git a/mobility_data/importers/bicycle_stands.py b/mobility_data/importers/bicycle_stands.py index 54afdca98..967721b49 100644 --- a/mobility_data/importers/bicycle_stands.py +++ b/mobility_data/importers/bicycle_stands.py @@ -9,7 +9,11 @@ from django.conf import settings from django.contrib.gis.gdal import DataSource from django.contrib.gis.geos import GEOSGeometry -from munigeo.models import AdministrativeDivision, AdministrativeDivisionGeometry +from munigeo.models import ( + AdministrativeDivision, + AdministrativeDivisionGeometry, + Municipality, +) from mobility_data.models import MobileUnit from services.models import Unit @@ -54,26 +58,27 @@ class BicyleStand: WFS_HULL_LOCKABLE_STR = "runkolukitusmahdollisuus" GEOJSON_HULL_LOCKABLE_STR = "runkolukittava" COVERED_IN_STR = "katettu" + EXTRA_FIELDS = [ + "maintained_by_turku", + "covered", + "hull_lockable", + "model", + "number_of_places", + "number_of_stands", + ] def __init__(self): self.geometry = None - self.model = None - self.number_of_stands = None - self.number_of_places = None # The total number of places for bicycles. - self.hull_lockable = None - self.covered = None - self.city = None - self.street_address = None - self.maintained_by_turku = None + self.municipality = None self.name = {} self.prefix_name = {} - self.street_address = {} + self.address = {} self.related_unit = None + self.extra = {f: None for f in self.EXTRA_FIELDS} def set_geojson_feature(self, feature): name = feature["kohde"].as_string().strip() unit_name = name.split(",")[0] - self.geometry = GEOSGeometry(feature.geom.wkt, srid=GEOJSON_SOURCE_DATA_SRID) self.geometry.transform(settings.DEFAULT_SRID) units_qs = Unit.objects.filter(name=unit_name) @@ -84,26 +89,33 @@ def set_geojson_feature(self, feature): self.related_unit = unit break - self.number_of_stands = feature["telineitä"].as_int() - self.number_of_places = feature["paikkoja"].as_int() + self.extra["number_of_stands"] = feature["telineitä"].as_int() + self.extra["number_of_places"] = feature["paikkoja"].as_int() model_elem = feature["pys.malli"].as_string() if model_elem: - self.model = model_elem + self.extra["model"] = model_elem + else: + self.extra["model"] = None quality_elem = feature["laatutaso"].as_string() if quality_elem: quality_text = quality_elem.lower() if self.GEOJSON_HULL_LOCKABLE_STR in quality_text: - self.hull_lockable = True + self.extra["hull_lockable"] = True else: - self.hull_lockable = False + self.extra["hull_lockable"] = False if self.COVERED_IN_STR in quality_text: - self.covered = True + self.extra["covered"] = True else: - self.covered = False + self.extra["covered"] = False + + municipality_name = get_municipality_name(self.geometry) + try: + self.municipality = Municipality.objects.get(name=municipality_name) + except Municipality.DoesNotExist: + self.municipality = None - self.city = get_municipality_name(self.geometry) self.name["fi"] = name # If related unit is known, use its translated names if self.related_unit: @@ -127,58 +139,64 @@ def set_geojson_feature(self, feature): else: # The last part is always the number address_number = address[-1] - translated_street_names = get_street_name_translations(street_name, self.city) - self.street_address["fi"] = f"{translated_street_names['fi']} {address_number}" - self.street_address["sv"] = f"{translated_street_names['sv']} {address_number}" - self.street_address["en"] = f"{translated_street_names['en']} {address_number}" + translated_street_names = get_street_name_translations( + street_name, municipality_name + ) + self.address["fi"] = f"{translated_street_names['fi']} {address_number}" + self.address["sv"] = f"{translated_street_names['sv']} {address_number}" + self.address["en"] = f"{translated_street_names['en']} {address_number}" def set_gml_feature(self, feature): object_id = feature["id"].as_string() # If ObjectId is set to "0", the bicycle stand is not maintained by Turku if object_id == "0": - self.maintained_by_turku = False + self.extra["maintained_by_turku"] = False else: - self.maintained_by_turku = True + self.extra["maintained_by_turku"] = True self.geometry = GEOSGeometry(feature.geom.wkt, srid=WFS_SOURCE_DATA_SRID) self.geometry.transform(settings.DEFAULT_SRID) model_elem = feature["Malli"] if model_elem is not None: - self.model = model_elem.as_string() + self.extra["model"] = model_elem.as_string() + else: + self.extra["model"] = None num_stands_elem = feature["Lukumaara"] if num_stands_elem is not None: num = num_stands_elem.as_int() # for bicycle stands that are Not maintained by Turku # the number of stands is set to 0 in the input data # but in reality there is no data so None is set. - if num == 0 and not self.maintained_by_turku: - self.number_of_stands = None + if num == 0 and not self.extra["maintained_by_turku"]: + self.extra["number_of_stands"] = None else: - self.number_of_stands = num + self.extra["number_of_stands"] = num num_places_elem = feature["Pyorapaikkojen_lukumaara"].as_string() - if num_places_elem: # Parse the numbers inside the string and finally sum them. # The input can contain string such as "8 runkolukittavaa ja 10 ei runkolukittavaa paikkaa" numbers = [int(s) for s in num_places_elem.split() if s.isdigit()] - self.number_of_places = sum(numbers) + self.extra["number_of_places"] = sum(numbers) quality_elem = feature["Pyorapaikkojen_laatutaso"].as_string() - if quality_elem: quality_text = quality_elem.lower() if self.WFS_HULL_LOCKABLE_STR in quality_text: - self.hull_lockable = True + self.extra["hull_lockable"] = True else: - self.hull_lockable = False - + self.extra["hull_lockable"] = False if self.COVERED_IN_STR in quality_text: - self.covered = True + self.extra["covered"] = True else: - self.covered = False - self.city = get_municipality_name(self.geometry) + self.extra["covered"] = False + try: + self.municipality = Municipality.objects.get( + name=get_municipality_name(self.geometry) + ) + except Municipality.DoesNotExist: + self.municipality = None full_names = get_closest_address_full_name(self.geometry) self.name[FI_KEY] = full_names[FI_KEY] self.name[SV_KEY] = full_names[SV_KEY] @@ -226,11 +244,11 @@ def get_bicycle_stand_objects(data_source=None): bicycle_stand.set_geojson_feature(feature) if ( bicycle_stand.name[FI_KEY] not in external_stands - and not bicycle_stand.maintained_by_turku + and not bicycle_stand.extra["maintained_by_turku"] ): external_stands[bicycle_stand.name[FI_KEY]] = True bicycle_stands.append(bicycle_stand) - elif bicycle_stand.maintained_by_turku: + elif bicycle_stand.extra["maintained_by_turku"]: bicycle_stands.append(bicycle_stand) logger.info(f"Retrieved {len(bicycle_stands)} bicycle stands.") @@ -257,18 +275,12 @@ def save_to_database(objects, delete_tables=True): content_type = create_bicycle_stand_content_type() for object in objects: mobile_unit = MobileUnit.objects.create( - content_type=content_type, + extra=object.extra, + municipality=object.municipality, ) - extra = {} - extra["model"] = object.model - extra["maintained_by_turku"] = object.maintained_by_turku - extra["number_of_stands"] = object.number_of_stands - extra["number_of_places"] = object.number_of_places - extra["hull_lockable"] = object.hull_lockable - extra["covered"] = object.covered - mobile_unit.extra = extra + mobile_unit.content_types.add(content_type) mobile_unit.geometry = object.geometry set_translated_field(mobile_unit, "name", object.name) - if object.street_address: - set_translated_field(mobile_unit, "address", object.street_address) + if object.address: + set_translated_field(mobile_unit, "address", object.address) mobile_unit.save() diff --git a/mobility_data/importers/bike_service_stations.py b/mobility_data/importers/bike_service_stations.py index b624def20..f7d00ad9a 100644 --- a/mobility_data/importers/bike_service_stations.py +++ b/mobility_data/importers/bike_service_stations.py @@ -29,8 +29,7 @@ def __init__(self, feature): self.address = {} self.description = {} self.extra = {} - self.address = {} - self.zip_code = None + self.address_zip = None self.municipality = None self.geometry = GEOSGeometry(feature.geom.wkt, srid=SOURCE_DATA_SRID) self.geometry.transform(settings.DEFAULT_SRID) @@ -41,7 +40,9 @@ def __init__(self, feature): # Addresses are in format: # Uudenmaankatu 18, 20700 Turku / Nylandsgatan 18, 20700 Turku addresses = feature["Osoite"].as_string().split("/") - self.zip_code, self.municipality = addresses[0].split(",")[1].strip().split(" ") + self.address_zip, self.municipality = ( + addresses[0].split(",")[1].strip().split(" ") + ) # remove zip code and municipality addresses = [address.split(",")[0].strip() for address in addresses] for i, language in enumerate(LANGUAGES): @@ -109,8 +110,11 @@ def save_to_database(objects, delete_tables=True): content_type = create_bike_service_station_content_type() for object in objects: mobile_unit = MobileUnit.objects.create( - content_type=content_type, extra=object.extra, geometry=object.geometry + extra=object.extra, + geometry=object.geometry, + address_zip=object.address_zip, ) + mobile_unit.content_types.add(content_type) set_translated_field(mobile_unit, "name", object.name) set_translated_field(mobile_unit, "description", object.description) set_translated_field(mobile_unit, "address", object.address) diff --git a/mobility_data/importers/charging_stations.py b/mobility_data/importers/charging_stations.py index 8d9e1708c..be6a5f36b 100644 --- a/mobility_data/importers/charging_stations.py +++ b/mobility_data/importers/charging_stations.py @@ -6,7 +6,6 @@ from django.contrib.gis.geos import Point from mobility_data.models import MobileUnit -from smbackend_turku.importers.constants import CHARGING_STATION_SERVICE_NAMES from .utils import ( delete_mobile_units, @@ -22,6 +21,11 @@ logger = logging.getLogger("mobility_data") +CHARGING_STATION_SERVICE_NAMES = { + "fi": "Autojen sähkölatauspiste", + "sv": "Elladdningsstation för bilar", + "en": "Car e-charging point", +} SOURCE_DATA_FILE_NAME = "LatauspisteetTurku.csv" SOURCE_DATA_SRID = 3877 CONTENT_TYPE_NAME = "ChargingStation" @@ -75,7 +79,7 @@ def __init__(self, values): self.extra["other"] = values["other"] self.extra["payment"] = values["payment"] self.municipality = get_municipality_name(self.geometry) - self.zip_code = get_postal_code(self.geometry) + self.address_zip = get_postal_code(self.geometry) tmp = values["address"].split(" ") address_number = None street_name = tmp[0] @@ -183,8 +187,9 @@ def save_to_database(objects, delete_tables=True): is_active=is_active, geometry=object.geometry, extra=object.extra, - content_type=content_type, + address_zip=object.address_zip, ) + mobile_unit.content_types.add(content_type) set_translated_field(mobile_unit, "name", object.name) set_translated_field(mobile_unit, "address", object.address) mobile_unit.save() diff --git a/mobility_data/importers/constants.py b/mobility_data/importers/constants.py index a501bfd59..12f05f266 100644 --- a/mobility_data/importers/constants.py +++ b/mobility_data/importers/constants.py @@ -1,3 +1,5 @@ +from django.contrib.gis.geos import Polygon + SOUTHWEST_FINLAND_BOUNDARY_SRID = 4236 SOUTHWEST_FINLAND_BOUNDARY = [ [20.377543, 60.876637], @@ -12764,3 +12766,7 @@ [20.437571, 60.901807], [20.377543, 60.876637], ] + +SOUTHWEST_FINLAND_GEOMETRY = Polygon( + SOUTHWEST_FINLAND_BOUNDARY, srid=SOUTHWEST_FINLAND_BOUNDARY_SRID +) diff --git a/mobility_data/importers/culture_routes.py b/mobility_data/importers/culture_routes.py index 6d37ae0dd..0829b9395 100644 --- a/mobility_data/importers/culture_routes.py +++ b/mobility_data/importers/culture_routes.py @@ -290,10 +290,10 @@ def save_to_database(routes, delete_tables=False): content_type = geometry_type mobile_unit, created = MobileUnit.objects.get_or_create( - content_type=content_type, mobile_unit_group=group, geometry=placemark.geometry, ) + mobile_unit.content_types.add(content_type) if created: mobile_unit.is_active = True set_translated_field(mobile_unit, "name", placemark.name) diff --git a/mobility_data/importers/data/lounaistieto_shapefiles_config.yml b/mobility_data/importers/data/lounaistieto_shapefiles_config.yml index 38c4f2cf9..022bbf198 100644 --- a/mobility_data/importers/data/lounaistieto_shapefiles_config.yml +++ b/mobility_data/importers/data/lounaistieto_shapefiles_config.yml @@ -1,4 +1,51 @@ data_sources: + - content_type_name: BusStopSouthwestFinland + content_type_description: "Bus stops in Southwest Finland." + data_url: "https://data.lounaistieto.fi/data/dataset/ee440090-7303-4453-8639-b5f711669acb/resource/14060749-5215-41af-9312-5393c966f987/download/tieverkkodata.zip/bussipysakit.shp" + fields: + name: + fi: pysnimi + sv: stopnamn + extra_fields: + piiri: piiri + tie: tie + tiety: tiety + puoli: puoli + bussity: bussity + pikavuo: pikavuo + katos: katos + korotettu: korotettu + pysid: pysid + pystunn196: pystunn196 + + - content_type_name: FerryDock + content_type_description: "Ferry docks in Southwest Finland." + data_url: "https://data.lounaistieto.fi/data/dataset/ea4dcae2-5832-403c-bf7e-b19783ee9a70/resource/faa02c52-2da8-4603-b8bf-4afca42d39a5/download/yhteysalusreitit_laiturit.zip/Laiturit.shp" + encoding: latin_1 + filter_by_southwest_finland: True + fields: + name: + fi: NIMI_ + municipality: KUNTA_1 + extra_fields: + nro: NRO + luokitus: LUOKITUS + alue: alue + reittialue: REITTIALUE + + - content_type_name: CommonFerryRoute + content_type_description: "Common ferry routes(yhteysalusreitti) in Southwest Finland." + data_url: "https://data.lounaistieto.fi/data/dataset/ea4dcae2-5832-403c-bf7e-b19783ee9a70/resource/faa02c52-2da8-4603-b8bf-4afca42d39a5/download/yhteysalusreitit_laiturit.zip/Yhteysalusreitit.shp" + encoding: latin_1 + filter_by_southwest_finland: True + fields: + name: + fi: REITTIALUE + sv: REITTIALUE + en: REITTIALUE + extra_fields: + yhteysalus: Yhteysalus + - content_type_name: FishingSpot content_type_description: "Fishing spots in Soutwest Finland." data_url: 'https://data.lounaistieto.fi/data/dataset/50451034-b760-4b39-8f88-701d9c968d88/resource/cd41b91f-f684-4cdc-bbf8-e2c4eaca3a70/download/kalapaikat.zip' diff --git a/mobility_data/importers/data/wfs_importer_config.yml b/mobility_data/importers/data/wfs_importer_config.yml index 345897fe6..f86db8084 100644 --- a/mobility_data/importers/data/wfs_importer_config.yml +++ b/mobility_data/importers/data/wfs_importer_config.yml @@ -1,4 +1,77 @@ features: + - content_type_name: PlayGround + content_type_description: Playgrounds in the city of Turku. + wfs_layer: GIS:Viheralueet + max_features: 50000 + include: + Kayttotyyppi: Leikkipaikka + fields: + name: + fi: Tunnus + extra_fields: + kayttotyyppi: + wfs_field: Kayttotyyppi + omistaja: + wfs_field: Omistaja + haltija: + wfs_field: Haltija + kunnossapitaja: + wfs_field: Kunnossapitaja + hoitaja: + wfs_field: Hoitaja + alueUrakkaAlue: + wfs_field: AlueUrakkaAlue + kunnossapitoluokka: + wfs_field: Kunnossapitoluokka + talvikunnossapito: + wfs_field: Talvikunnossapito + pintamateriaali: + wfs_field: Pintamateriaali + laskettuPintaAla: + wfs_field: LaskettuPintaAla + wfs_type: double + valmistusvuosi: + wfs_field: Valmistusvuosi + peruskorjausvuosi: + wfs_field: Peruskorjausvuosi + + - content_type_name: BarbecuePlace + content_type_description: Barbecue places in the city of Turku. + wfs_layer: GIS:Varusteet + max_features: 100000 + include: + Tyyppi: Grillipaikka + extra_fields: + valmistaja: + wfs_field: Valmistaja + valmistaja_koodi: + wfs_field: Valmistaja_koodi + wfs_type: int + malli: + wfs_field: Malli + malli_koodi: + wfs_field: Malli_koodi + wfs_type: int + hankintavuosi: + wfs_field: Hankintavuosi + wfs_type: int + kunto: + wfs_field: Kunto + kunto_koodi: + wfs_field: Kunto_koodi + wfs_type: int + lukumaara: + wfs_field: Lukumaara + wfs_type: int + pinta-ala: + wfs_field: Pinta-ala + wfs_type: double + pituus: + wfs_field: Pituus + wfs_type: double + asennus: + wfs_field: Asennus + - content_type_name: TicketMachineSign content_type_description: Ticket machine signs in the city of Turku. wfs_layer: GIS:Liikennemerkit diff --git a/mobility_data/importers/disabled_and_no_staff_parking.py b/mobility_data/importers/disabled_and_no_staff_parking.py index 4c68e89cf..ff9cfdb0e 100644 --- a/mobility_data/importers/disabled_and_no_staff_parking.py +++ b/mobility_data/importers/disabled_and_no_staff_parking.py @@ -98,8 +98,7 @@ def __init__(self, feature): feature["osoite"].as_string().split("/")[0].strip().split(" ")[-2:] ) try: - municipality = Municipality.objects.get(name=municipality) - self.municipality = municipality + self.municipality = Municipality.objects.get(name=municipality) except Municipality.DoesNotExist: self.municipality = None @@ -197,18 +196,14 @@ def save_to_database(objects, delete_tables=True): disabled_parking_content_type = get_and_create_disabled_parking_content_type() for object in objects: + mobile_unit = MobileUnit.objects.create( + extra=object.extra, + geometry=object.geometry, + ) if object.content_type == NO_STAFF_PARKING_CONTENT_TYPE_NAME: - mobile_unit = MobileUnit.objects.create( - content_type=no_staff_parking_content_type, - extra=object.extra, - geometry=object.geometry, - ) + mobile_unit.content_types.add(no_staff_parking_content_type) else: - mobile_unit = MobileUnit.objects.create( - content_type=disabled_parking_content_type, - extra=object.extra, - geometry=object.geometry, - ) + mobile_unit.content_types.add(disabled_parking_content_type) set_translated_field(mobile_unit, "name", object.name) set_translated_field(mobile_unit, "address", object.address) mobile_unit.address_zip = object.address_zip diff --git a/mobility_data/importers/foli_parkandride_stop.py b/mobility_data/importers/foli_parkandride_stop.py new file mode 100644 index 000000000..b1ff60df4 --- /dev/null +++ b/mobility_data/importers/foli_parkandride_stop.py @@ -0,0 +1,108 @@ +from django import db +from django.conf import settings +from django.contrib.gis.geos import Point +from munigeo.models import Municipality + +from mobility_data.models import MobileUnit + +from .utils import ( + delete_mobile_units, + fetch_json, + get_or_create_content_type, + set_translated_field, +) + +URL = "https://data.foli.fi/geojson/poi" +FOLI_PARKANDRIDE_CARS_STOP_CONTENT_TYPE_NAME = "FoliParkAndRideCarsStop" +FOLI_PARKANDRIDE_BIKES_STOP_CONTENT_TYPE_NAME = "FoliParkAndRideBikesStop" +PARKANDRIDE_CARS = "PARKANDRIDE_CARS" +PARKANDRIDE_BIKES = "PARKANDRIDE_BIKES" +SOURCE_DATA_SRID = 4326 + + +class ParkAndRideStop: + def __init__(self, feature): + properties = feature["properties"] + self.name = { + "fi": properties["name_fi"], + "sv": properties["name_sv"], + "en": properties["name_en"], + } + self.address = { + "fi": properties["address_fi"], + "sv": properties["address_sv"], + "en": properties["address_fi"], + } + self.address_zip = properties["text"].split(" ")[-1] + self.description = properties["text"] + try: + self.municipality = Municipality.objects.get(name=properties["city"]) + except Municipality.DoesNotExist: + self.municipality = None + geometry = feature["geometry"] + self.geometry = Point( + geometry["coordinates"][0], + geometry["coordinates"][1], + srid=SOURCE_DATA_SRID, + ) + self.geometry.transform(settings.DEFAULT_SRID) + + +def get_parkandride_stop_objects(): + json_data = fetch_json(URL) + car_stops = [] + bike_stops = [] + for feature in json_data["features"]: + if feature["properties"]["category"] == PARKANDRIDE_CARS: + car_stops.append(ParkAndRideStop(feature)) + elif feature["properties"]["category"] == PARKANDRIDE_BIKES: + bike_stops.append(ParkAndRideStop(feature)) + + return car_stops, bike_stops + + +@db.transaction.atomic +def get_and_create_foli_parkandride_bike_stop_content_type(): + description = "Föli park and ride bike stop." + content_type, _ = get_or_create_content_type( + FOLI_PARKANDRIDE_BIKES_STOP_CONTENT_TYPE_NAME, description + ) + return content_type + + +@db.transaction.atomic +def get_and_create_foli_parkandride_car_stop_content_type(): + description = "Föli park and ride car stop." + content_type, _ = get_or_create_content_type( + FOLI_PARKANDRIDE_CARS_STOP_CONTENT_TYPE_NAME, description + ) + return content_type + + +@db.transaction.atomic +def save_to_database(objects, content_type_name, delete_tables=True): + assert ( + content_type_name == FOLI_PARKANDRIDE_BIKES_STOP_CONTENT_TYPE_NAME + or content_type_name == FOLI_PARKANDRIDE_CARS_STOP_CONTENT_TYPE_NAME + ) + if delete_tables: + delete_mobile_units(content_type_name) + + if content_type_name == FOLI_PARKANDRIDE_BIKES_STOP_CONTENT_TYPE_NAME: + content_type = get_and_create_foli_parkandride_bike_stop_content_type() + elif content_type_name == FOLI_PARKANDRIDE_CARS_STOP_CONTENT_TYPE_NAME: + content_type = get_and_create_foli_parkandride_car_stop_content_type() + + for object in objects: + mobile_unit = MobileUnit.objects.create( + geometry=object.geometry, + address_zip=object.address_zip, + description=object.description, + municipality=object.municipality, + ) + mobile_unit.content_types.add(content_type) + set_translated_field(mobile_unit, "name", object.name) + set_translated_field(mobile_unit, "address", object.address) + mobile_unit.save() + + return len(objects) diff --git a/mobility_data/importers/foli_stops.py b/mobility_data/importers/foli_stops.py index d81639d13..a86a25cef 100644 --- a/mobility_data/importers/foli_stops.py +++ b/mobility_data/importers/foli_stops.py @@ -1,12 +1,12 @@ import logging -import requests from django import db +from django.conf import settings from django.contrib.gis.geos import Point from mobility_data.models import MobileUnit -from .utils import delete_mobile_units, get_or_create_content_type +from .utils import delete_mobile_units, fetch_json, get_or_create_content_type URL = "http://data.foli.fi/gtfs/stops" CONTENT_TYPE_NAME = "FoliStop" @@ -22,30 +22,18 @@ def __init__(self, stop_data): lon = stop_data["stop_lon"] lat = stop_data["stop_lat"] self.geometry = Point(lon, lat, srid=SOURCE_DATA_SRID) + self.geometry.transform(settings.DEFAULT_SRID) self.extra["stop_code"] = stop_data["stop_code"] self.extra["wheelchair_boarding"] = stop_data["wheelchair_boarding"] -def get_json_data(): - response = requests.get(URL) - assert ( - response.status_code == 200 - ), "Unable to fetch föli stops from url: {}, status code: {}".format( - URL, response.status_code - ) - return response.json() - - def get_foli_stops(): - json_data = get_json_data() - objects = [] - for stop_code in json_data: - objects.append(FoliStop(json_data[stop_code])) - return objects + json_data = fetch_json(URL) + return [FoliStop(json_data[stop_code]) for stop_code in json_data] @db.transaction.atomic -def create_foli_stop_content_type(): +def get_and_create_foli_stop_content_type(): description = "Föli stops." content_type, _ = get_or_create_content_type(CONTENT_TYPE_NAME, description) return content_type @@ -56,12 +44,12 @@ def save_to_database(objects, delete_tables=True): if delete_tables: delete_mobile_units(CONTENT_TYPE_NAME) - content_type = create_foli_stop_content_type() + content_type = get_and_create_foli_stop_content_type() for object in objects: - MobileUnit.objects.create( - content_type=content_type, + mobile_unit = MobileUnit.objects.create( name=object.name, geometry=object.geometry, extra=object.extra, ) + mobile_unit.content_types.add(content_type) logger.info(f"Saved {len(objects)} Föli stops to database.") diff --git a/mobility_data/importers/gas_filling_station.py b/mobility_data/importers/gas_filling_station.py index 18481916c..40430042b 100644 --- a/mobility_data/importers/gas_filling_station.py +++ b/mobility_data/importers/gas_filling_station.py @@ -3,6 +3,7 @@ from django import db from django.conf import settings from django.contrib.gis.geos import Point, Polygon +from munigeo.models import Municipality from mobility_data.models import MobileUnit @@ -30,35 +31,38 @@ class GasFillingStation: def __init__(self, elem, srid=settings.DEFAULT_SRID): # Contains the complete address with zip_code and city self.address = {} + self.extra = {} + self.name = {} # Contains Only steet_name and number self.street_address = {} self.is_active = True attributes = elem.get("attributes") x = attributes.get("LON", 0) y = attributes.get("LAT", 0) - self.point = Point(x, y, srid=srid) - self.name = attributes.get("STATION_NAME", "") + self.geometry = Point(x, y, srid=srid) + self.name["fi"] = attributes.get("STATION_NAME", "") address_field = attributes.get("ADDRESS", "") street_name, street_number = get_street_name_and_number(address_field) - self.zip_code = attributes.get("ZIP_CODE", "") - self.city = attributes.get("CITY", "") - translated_street_names = get_street_name_translations(street_name, self.city) + self.address_zip = attributes.get("ZIP_CODE", "") + municipality_name = attributes.get("CITY", "") + translated_street_names = get_street_name_translations( + street_name, municipality_name + ) + try: + self.municipality = Municipality.objects.get(name=municipality_name) + except Municipality.DoesNotExist: + self.municipality = None + for lang in LANGUAGES: if street_number: - self.address[ - lang - ] = f"{translated_street_names[lang]} {street_number}, " - self.address[lang] += f"{self.zip_code} {self.city}" - self.street_address[ - lang - ] = f"{translated_street_names[lang]} {street_number}" + self.address[lang] = f"{translated_street_names[lang]} {street_number}" else: - self.address[lang] = f"{translated_street_names[lang]}, " - self.address[lang] += f"{self.zip_code} {self.city}" - self.street_address[lang] = f"{translated_street_names[lang]}" + self.address[lang] = f"{translated_street_names[lang]}" - self.operator = attributes.get("OPERATOR", "") + self.operator = attributes.get("OPERATOR", "") self.lng_cng = attributes.get("LNG_CNG", "") + self.extra["operator"] = self.operator + self.extra["lng_cng"] = self.lng_cng def get_filtered_gas_filling_station_objects(json_data=None): @@ -78,7 +82,7 @@ def get_filtered_gas_filling_station_objects(json_data=None): # Filter objects by their location # Polygon used the detect if point intersects. i.e. is in the boundaries of SouthWest Finland. polygon = Polygon(SOUTHWEST_FINLAND_BOUNDARY, srid=SOUTHWEST_FINLAND_BOUNDARY_SRID) - filtered_objects = [o for o in objects if polygon.intersects(o.point)] + filtered_objects = [o for o in objects if polygon.intersects(o.geometry)] logger.info( "Filtered: {} gas filling stations by location to: {}.".format( len(json_data["features"]), len(filtered_objects) @@ -107,18 +111,15 @@ def save_to_database(objects, delete_tables=True): content_type = create_gas_filling_station_content_type() for object in objects: is_active = object.is_active - name = object.name - extra = {} - extra["operator"] = object.operator - extra["lng_cng"] = object.lng_cng - mobile_unit = MobileUnit.objects.create( is_active=is_active, - name=name, - geometry=object.point, - extra=extra, - content_type=content_type, + geometry=object.geometry, + extra=object.extra, + address_zip=object.address_zip, + municipality=object.municipality, ) + mobile_unit.content_types.add(content_type) + set_translated_field(mobile_unit, "name", object.name) set_translated_field(mobile_unit, "address", object.address) mobile_unit.save() logger.info(f"Saved {len(objects)} gas filling stations to database.") diff --git a/mobility_data/importers/loading_unloading_places.py b/mobility_data/importers/loading_unloading_places.py index 2af4e4b64..b45988ba0 100644 --- a/mobility_data/importers/loading_unloading_places.py +++ b/mobility_data/importers/loading_unloading_places.py @@ -151,10 +151,10 @@ def save_to_database(objects, delete_tables=True): content_type = get_and_create_loading_and_unloading_place_content_type() for object in objects: mobile_unit = MobileUnit.objects.create( - content_type=content_type, extra=object.extra, geometry=object.geometry, ) + mobile_unit.content_types.add(content_type) set_translated_field(mobile_unit, "name", object.name) set_translated_field(mobile_unit, "address", object.address) mobile_unit.address_zip = object.address_zip diff --git a/mobility_data/importers/lounaistieto_shapefiles.py b/mobility_data/importers/lounaistieto_shapefiles.py index ebcd5f6e1..010d7f349 100644 --- a/mobility_data/importers/lounaistieto_shapefiles.py +++ b/mobility_data/importers/lounaistieto_shapefiles.py @@ -13,8 +13,10 @@ ) from mobility_data.models import MobileUnit -logger = logging.getLogger("mobility_data") +from .constants import SOUTHWEST_FINLAND_GEOMETRY +logger = logging.getLogger("mobility_data") +SOUTHWEST_FINLAND_GEOMETRY.transform(settings.DEFAULT_SRID) DEFAULT_ENCODING = "utf-8" @@ -48,7 +50,7 @@ def add_feature(self, feature, config, srid): match feature.shape.shapeTypeName: case "POLYLINE": geometry = LineString(feature.shape.points, srid=srid) - case "POINT": + case "POINT" | "MULTIPOINTZ": points = feature.shape.points[0] assert len(points) == 2 geometry = Point(points[0], points[1], srid=srid) @@ -70,6 +72,11 @@ def add_feature(self, feature, config, srid): except Exception as e: logger.warning(f"Skipping feature {feature.geom}, invalid geom {e}") return False + + if config.get("filter_by_southwest_finland", False): + if not SOUTHWEST_FINLAND_GEOMETRY.covers(geometry): + return False + if "municipality" in config: municipality = feature.record[config["municipality"]] if municipality: @@ -78,11 +85,12 @@ def add_feature(self, feature, config, srid): id=municipality_id ).first() - for attr, field in config["fields"].items(): - for lang, field_name in field.items(): - # attr can have fallback definitons if None - if getattr(self, attr)[lang] is None: - getattr(self, attr)[lang] = feature.record[field_name] + if "fields" in config: + for attr, field in config["fields"].items(): + for lang, field_name in field.items(): + # attr can have fallback definitons if None + if getattr(self, attr)[lang] is None: + getattr(self, attr)[lang] = feature.record[field_name] if "extra_fields" in config: for attr, field in config["extra_fields"].items(): @@ -114,8 +122,9 @@ def save_to_database(objects, config): return for object in objects: mobile_unit = MobileUnit.objects.create( - content_type=content_type, extra=object.extra, geometry=object.geometry + extra=object.extra, geometry=object.geometry ) + mobile_unit.content_types.add(content_type) mobile_unit.municipality = object.municipality set_translated_field(mobile_unit, "name", object.name) set_translated_field(mobile_unit, "address", object.address) diff --git a/mobility_data/importers/marinas.py b/mobility_data/importers/marinas.py index 16d3a4f19..678e51567 100644 --- a/mobility_data/importers/marinas.py +++ b/mobility_data/importers/marinas.py @@ -99,12 +99,12 @@ def import_marinas(delete=True): marinas.append(Marina(feature)) content_type = create_marina_content_type() for marina in marinas: - MobileUnit.objects.create( - content_type=content_type, + mobile_unit = MobileUnit.objects.create( geometry=marina.geometry, name=marina.name, extra=marina.extra, ) + mobile_unit.content_types.add(content_type) return len(marinas) @@ -128,4 +128,5 @@ def import_guest_marina_and_boat_parking(delete=True): elif type_name == BOAT_PARKING: content_type = create_boat_parking_content_type() - MobileUnit.objects.create(content_type=content_type, geometry=geometry) + mobile_unit = MobileUnit.objects.create(geometry=geometry) + mobile_unit.content_types.add(content_type) diff --git a/mobility_data/importers/outdoor_gym_devices.py b/mobility_data/importers/outdoor_gym_devices.py index 6d17f64b3..13bc292aa 100644 --- a/mobility_data/importers/outdoor_gym_devices.py +++ b/mobility_data/importers/outdoor_gym_devices.py @@ -37,5 +37,7 @@ def save_outdoor_gym_devices(): content_type = create_content_type() units_qs = Unit.objects.filter(services=service) for unit in units_qs: - MobileUnit.objects.create(content_type=content_type, unit_id=unit.id) + mobile_unit = MobileUnit.objects.create(unit_id=unit.id) + mobile_unit.content_types.add(content_type) + mobile_unit.save() return units_qs.count() diff --git a/mobility_data/importers/parking_machines.py b/mobility_data/importers/parking_machines.py new file mode 100644 index 000000000..eaca1c259 --- /dev/null +++ b/mobility_data/importers/parking_machines.py @@ -0,0 +1,151 @@ +from django import db +from django.conf import settings +from django.contrib.gis.geos import Point + +from mobility_data.models import MobileUnit + +from .utils import ( + delete_mobile_units, + FieldTypes, + get_file_name_from_data_source, + get_or_create_content_type, + get_root_dir, + set_translated_field, +) + +SOURCE_DATA_SRID = 4326 +GEOJSON_FILENAME = "parking_machines.geojson" +CONTENT_TYPE_NAME = "ParkingMachine" +LANGUAGES = ["fi", "sv", "en"] + + +class ParkingMachine: + + extra_field_mappings = { + "Sijainti": { + "type": FieldTypes.MULTILANG_STRING, + "fi": "Sijainti", + "sv": "Plats", + "en": "Location", + }, + "Virta": { + "type": FieldTypes.MULTILANG_STRING, + "fi": "Virta", + "sv": "Ström", + "en": "Power", + }, + "Maksutapa": { + "type": FieldTypes.MULTILANG_STRING, + "fi": "Maksutapa", + "sv": "Betalningssätt", + # The source data contains Payment method with method starting with + # uppercase M and lowercase m. + "en": ["Payment method", "Payment Method"], + }, + "Näyttö": { + "type": FieldTypes.MULTILANG_STRING, + "fi": "Näyttö", + "sv": "Skärm", + "en": "Screen", + }, + "Omistaja": { + "type": FieldTypes.MULTILANG_STRING, + "fi": "Omistaja", + "sv": "Ägare", + "en": "Owner", + }, + "Maksuvyöhyke": { + "type": FieldTypes.MULTILANG_STRING, + "fi": "Maksuvyöhyke", + "sv": "Zon", + "en": "Zone", + }, + "Muuta": {"type": FieldTypes.STRING}, + "Taksa/h": {"type": FieldTypes.FLOAT}, + "Max.aika": {"type": FieldTypes.FLOAT}, + "Malli": {"type": FieldTypes.STRING}, + "Asennettu": {"type": FieldTypes.STRING}, + "Valmistaja": { + "type": FieldTypes.STRING, + }, + } + + def __init__(self, feature): + properties = feature["properties"] + geometry = feature["geometry"] + self.extra = {} + self.address = {"fi": properties["Osoite"]} + self.address["sv"] = properties["Adress"] + self.address["en"] = properties["Address"] + self.geometry = Point( + geometry["coordinates"][0], + geometry["coordinates"][1], + srid=SOURCE_DATA_SRID, + ) + self.geometry.transform(settings.DEFAULT_SRID) + + for field in properties.keys(): + if field in self.extra_field_mappings: + match self.extra_field_mappings[field]["type"]: + case FieldTypes.MULTILANG_STRING: + self.extra[field] = {} + for lang in LANGUAGES: + key = self.extra_field_mappings[field][lang] + # Support multiple keys for same field, e.g., + # 'Payment method' and 'Payment Method' + if type(key) == list: + for k in key: + val = properties.get(k, None) + if val: + self.extra[field][lang] = val + break + else: + self.extra[field][lang] = properties[key] + + case FieldTypes.STRING: + self.extra[field] = properties[field] + case FieldTypes.INTEGER: + val = properties[field] + self.extra[field] = int(val) if val else None + case FieldTypes.FLOAT: + val = properties[field] + self.extra[field] = float(val) if val else None + + +def get_json_data(): + file_name = get_file_name_from_data_source(CONTENT_TYPE_NAME) + if not file_name: + file_name = f"{get_root_dir()}/mobility_data/data/{GEOJSON_FILENAME}" + json_data = None + import json + + with open(file_name, "r") as json_file: + json_data = json.loads(json_file.read()) + return json_data + + +def get_parking_machine_objects(): + json_data = get_json_data()["features"] + return [ParkingMachine(feature) for feature in json_data] + + +@db.transaction.atomic +def get_and_create_parking_machine_content_type(): + description = "Parking machines in the City of Turku." + content_type, _ = get_or_create_content_type(CONTENT_TYPE_NAME, description) + return content_type + + +@db.transaction.atomic +def save_to_database(objects, delete_tables=True): + if delete_tables: + delete_mobile_units(CONTENT_TYPE_NAME) + content_type = get_and_create_parking_machine_content_type() + for object in objects: + mobile_unit = MobileUnit.objects.create( + geometry=object.geometry, + extra=object.extra, + ) + mobile_unit.content_types.add(content_type) + set_translated_field(mobile_unit, "address", object.address) + mobile_unit.save() diff --git a/mobility_data/importers/share_car_parking_places.py b/mobility_data/importers/share_car_parking_places.py index 6765a8cb3..87e25f9d0 100644 --- a/mobility_data/importers/share_car_parking_places.py +++ b/mobility_data/importers/share_car_parking_places.py @@ -95,9 +95,8 @@ def save_to_database(objects, delete_tables=True): delete_car_share_parking_places() content_type = create_car_share_parking_place_content_type() for object in objects: - mobile_unit = MobileUnit.objects.create( - content_type=content_type, extra=object.extra - ) + mobile_unit = MobileUnit.objects.create(extra=object.extra) + mobile_unit.content_types.add(content_type) set_translated_field(mobile_unit, "name", object.name) set_translated_field(mobile_unit, "address", object.address) mobile_unit.save() diff --git a/mobility_data/importers/utils.py b/mobility_data/importers/utils.py index 87411ffac..c49ba1dd4 100644 --- a/mobility_data/importers/utils.py +++ b/mobility_data/importers/utils.py @@ -68,7 +68,7 @@ def fetch_json(url): def delete_mobile_units(name): - ContentType.objects.filter(name=name).delete() + MobileUnit.objects.filter(content_types__name=name).delete() def create_mobile_unit_as_unit_reference(unit_id, content_type): @@ -78,10 +78,10 @@ def create_mobile_unit_as_unit_reference(unit_id, content_type): serialize the data from the services_unit table in the mobile_unit endpoint. """ - MobileUnit.objects.create( + mobile_unit = MobileUnit.objects.create( unit_id=unit_id, - content_type=content_type, ) + mobile_unit.content_types.add(content_type) def get_or_create_content_type(name, description): diff --git a/mobility_data/importers/wfs.py b/mobility_data/importers/wfs.py index bcfd2a6b6..bb15af06e 100644 --- a/mobility_data/importers/wfs.py +++ b/mobility_data/importers/wfs.py @@ -47,8 +47,9 @@ def save_to_database_using_yaml_config(objects, config): return for object in objects: mobile_unit = MobileUnit.objects.create( - content_type=content_type, extra=object.extra, geometry=object.geometry + extra=object.extra, geometry=object.geometry ) + mobile_unit.content_types.add(content_type) mobile_unit.municipality = object.municipality set_translated_field(mobile_unit, "name", object.name) set_translated_field(mobile_unit, "address", object.address) diff --git a/mobility_data/management/commands/import_foli_parkandride_stops.py b/mobility_data/management/commands/import_foli_parkandride_stops.py new file mode 100644 index 000000000..f237fd912 --- /dev/null +++ b/mobility_data/management/commands/import_foli_parkandride_stops.py @@ -0,0 +1,25 @@ +import logging + +from django.core.management import BaseCommand + +from mobility_data.importers.foli_parkandride_stop import ( + FOLI_PARKANDRIDE_BIKES_STOP_CONTENT_TYPE_NAME, + FOLI_PARKANDRIDE_CARS_STOP_CONTENT_TYPE_NAME, + get_parkandride_stop_objects, + save_to_database, +) + +logger = logging.getLogger("mobility_data") + + +class Command(BaseCommand): + def handle(self, *args, **options): + car_stops, bike_stops = get_parkandride_stop_objects() + logger.info( + f"Saved {save_to_database(car_stops, FOLI_PARKANDRIDE_CARS_STOP_CONTENT_TYPE_NAME)} " + "Föli park and ride car stops to database" + ) + logger.info( + f"Saved {save_to_database(bike_stops, FOLI_PARKANDRIDE_BIKES_STOP_CONTENT_TYPE_NAME)} " + "Föli park and ride bike stops to database" + ) diff --git a/mobility_data/management/commands/import_foli_stops.py b/mobility_data/management/commands/import_foli_stops.py index 21977c8d6..ebefee2d5 100644 --- a/mobility_data/management/commands/import_foli_stops.py +++ b/mobility_data/management/commands/import_foli_stops.py @@ -1,13 +1,13 @@ import logging -from mobility_data.importers.foli_stops import get_foli_stops, save_to_database +from django.core.management import BaseCommand -from ._base_import_command import BaseImportCommand +from mobility_data.importers.foli_stops import get_foli_stops, save_to_database logger = logging.getLogger("mobility_data") -class Command(BaseImportCommand): +class Command(BaseCommand): def handle(self, *args, **options): logger.info("Importing Föli stops") objects = get_foli_stops() diff --git a/mobility_data/management/commands/import_lounaistieto_shapefiles.py b/mobility_data/management/commands/import_lounaistieto_shapefiles.py index 3140af412..060b2b3f6 100644 --- a/mobility_data/management/commands/import_lounaistieto_shapefiles.py +++ b/mobility_data/management/commands/import_lounaistieto_shapefiles.py @@ -7,7 +7,6 @@ import_lounaistieto_data_source, ) from mobility_data.importers.utils import delete_mobile_units, get_root_dir -from mobility_data.models import ContentType from ._base_import_command import BaseImportCommand @@ -31,7 +30,8 @@ def handle(self, *args, **options): content_type = options["delete_data_source"] if len(content_type) == 0: logger.warning("Specify the content type to delete.") - delete_mobile_units(getattr(ContentType, content_type[0])) + delete_mobile_units(content_type[0]) + logger.info(f"Deleted MobileUnit and ContentType for {content_type[0]}") else: config_path = f"{get_root_dir()}/mobility_data/importers/data/" path = os.path.join(config_path, CONFIG_FILE) diff --git a/mobility_data/management/commands/import_mobility_data.py b/mobility_data/management/commands/import_mobility_data.py index 8515c554d..c514b3dc8 100644 --- a/mobility_data/management/commands/import_mobility_data.py +++ b/mobility_data/management/commands/import_mobility_data.py @@ -1,5 +1,5 @@ """ -Main importer for mobility data sources. +Imports all mobility data sources. """ import logging @@ -24,6 +24,7 @@ "lounaistieto_shapefiles", "foli_stops", "outdoor_gym_devices", + "foli_parkandride_stops", ] # Read the content type names to be imported wfs_content_type_names = get_configured_cotent_type_names() diff --git a/mobility_data/management/commands/import_parking_machines.py b/mobility_data/management/commands/import_parking_machines.py new file mode 100644 index 000000000..fdc52bda3 --- /dev/null +++ b/mobility_data/management/commands/import_parking_machines.py @@ -0,0 +1,17 @@ +import logging + +from django.core.management import BaseCommand + +from mobility_data.importers.parking_machines import ( + get_parking_machine_objects, + save_to_database, +) + +logger = logging.getLogger("mobility_data") + + +class Command(BaseCommand): + def handle(self, *args, **options): + objects = get_parking_machine_objects() + save_to_database(objects) + logger.info(f"Saved {len(objects)} parking machines to database.") diff --git a/mobility_data/management/commands/import_share_car_parking_places.py b/mobility_data/management/commands/import_share_car_parking_places.py index 68ade93f1..d0e003ffc 100644 --- a/mobility_data/management/commands/import_share_car_parking_places.py +++ b/mobility_data/management/commands/import_share_car_parking_places.py @@ -19,4 +19,4 @@ def handle(self, *args, **options): objects = get_car_share_parking_place_objects(geojson_file=geojson_file) save_to_database(objects) - logger.info(f"Imported {len(objects)} char share parking places.") + logger.info(f"Imported {len(objects)} car share parking places.") diff --git a/mobility_data/migrations/0035_add_many_to_many_field_content_types_to_mobile_unit.py b/mobility_data/migrations/0035_add_many_to_many_field_content_types_to_mobile_unit.py new file mode 100644 index 000000000..029d6b23f --- /dev/null +++ b/mobility_data/migrations/0035_add_many_to_many_field_content_types_to_mobile_unit.py @@ -0,0 +1,20 @@ +# Generated by Django 4.1.2 on 2023-02-08 07:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("mobility_data", "0034_remove_grouptype_type_name"), + ] + + operations = [ + migrations.AddField( + model_name="mobileunit", + name="content_types", + field=models.ManyToManyField( + related_name="mobile_units", to="mobility_data.contenttype" + ), + ), + ] diff --git a/mobility_data/migrations/0036_populate_mobile_type_content_types.py b/mobility_data/migrations/0036_populate_mobile_type_content_types.py new file mode 100644 index 000000000..cafd7c797 --- /dev/null +++ b/mobility_data/migrations/0036_populate_mobile_type_content_types.py @@ -0,0 +1,21 @@ +# Generated by Django 4.1.2 on 2023-02-08 07:12 + +from django.db import migrations + + +def make_many_to_many_content_types(apps, schema_editor): + MobileUnit = apps.get_model("mobility_data", "MobileUnit") + + for mobile_unit in MobileUnit.objects.all(): + mobile_unit.content_types.add(mobile_unit.content_type) + + +class Migration(migrations.Migration): + + dependencies = [ + ("mobility_data", "0035_add_many_to_many_field_content_types_to_mobile_unit"), + ] + + operations = [ + migrations.RunPython(make_many_to_many_content_types), + ] diff --git a/mobility_data/migrations/0037_remove_mobileunit_content_type.py b/mobility_data/migrations/0037_remove_mobileunit_content_type.py new file mode 100644 index 000000000..8d0626c0a --- /dev/null +++ b/mobility_data/migrations/0037_remove_mobileunit_content_type.py @@ -0,0 +1,17 @@ +# Generated by Django 4.1.2 on 2023-02-08 08:57 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("mobility_data", "0036_populate_mobile_type_content_types"), + ] + + operations = [ + migrations.RemoveField( + model_name="mobileunit", + name="content_type", + ), + ] diff --git a/mobility_data/migrations/0038_alter_contenttype_and_grouptype_ordering_to_field_name.py b/mobility_data/migrations/0038_alter_contenttype_and_grouptype_ordering_to_field_name.py new file mode 100644 index 000000000..2d450fe7f --- /dev/null +++ b/mobility_data/migrations/0038_alter_contenttype_and_grouptype_ordering_to_field_name.py @@ -0,0 +1,21 @@ +# Generated by Django 4.1.2 on 2023-02-08 14:04 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("mobility_data", "0037_remove_mobileunit_content_type"), + ] + + operations = [ + migrations.AlterModelOptions( + name="contenttype", + options={"ordering": ["name"]}, + ), + migrations.AlterModelOptions( + name="grouptype", + options={"ordering": ["name"]}, + ), + ] diff --git a/mobility_data/models/content_type.py b/mobility_data/models/content_type.py index 375166065..9dbec462e 100644 --- a/mobility_data/models/content_type.py +++ b/mobility_data/models/content_type.py @@ -12,7 +12,7 @@ class BaseType(models.Model): class Meta: abstract = True - ordering = ["id"] + ordering = ["name"] def __str__(self): return self.name diff --git a/mobility_data/models/mobile_unit.py b/mobility_data/models/mobile_unit.py index d5edbe44f..0054346f6 100644 --- a/mobility_data/models/mobile_unit.py +++ b/mobility_data/models/mobile_unit.py @@ -53,9 +53,7 @@ class MobileUnit(BaseUnit): ) address_zip = models.CharField(max_length=10, null=True) - content_type = models.ForeignKey( - ContentType, on_delete=models.CASCADE, related_name="units" - ) + content_types = models.ManyToManyField(ContentType, related_name="mobile_units") unit_id = models.IntegerField( null=True, verbose_name="optional id to a unit in the servicemap, if id exist data is serialized from services_unit table", diff --git a/mobility_data/specification.swagger2.0.yaml b/mobility_data/specification.swagger2.0.yaml index 832ad84da..3c46b7c78 100644 --- a/mobility_data/specification.swagger2.0.yaml +++ b/mobility_data/specification.swagger2.0.yaml @@ -60,7 +60,7 @@ definitions: type: string description_en: type: string - content_type: + content_types: $ref: "#/definitions/content_type" mobile_unit_group: $ref: "#/definitions/mobile_unit_group" @@ -134,6 +134,9 @@ paths: - $ref: "#/components/parameters/latlon_param" - $ref: "#/components/parameters/type_name_param" - $ref: "#/components/parameters/extra_param" + - $ref: "#/components/parameters/bbox_param" + - $ref: "#/components/parameters/bbox_srid_param" + responses: 200: description: "List of MobileUnits." @@ -286,4 +289,25 @@ components: required: false schema: type: string - example: extra__fieldname=value \ No newline at end of file + example: extra__fieldname=value + + bbox_param: + name: bbox + in: query + description: Search for mobile units that are within this bounding box. Decimal coordinates + are given in order west, south, east, north. Period is used as decimal + separator. Default srid is 4326. + schema: + type: array + items: + type: number + example: 24.9405559,60.1695096,24.9805559,60.1895096 + + bbox_srid_param: + name: bbox_srid + in: query + description: An SRID coordinate reference system identifier which specifies the + coordinate system used in the bbox parameter. + schema: + type: integer + example: 3046 \ No newline at end of file diff --git a/mobility_data/tasks.py b/mobility_data/tasks.py index 3e2ab1994..9ad42bb02 100644 --- a/mobility_data/tasks.py +++ b/mobility_data/tasks.py @@ -1,8 +1,9 @@ -from celery import shared_task from django.core import management +from smbackend.utils import shared_task_email -@shared_task + +@shared_task_email def import_culture_routes(args=None, name="import_culture_routes"): if args: management.call_command("import_culture_routes", args) @@ -10,17 +11,17 @@ def import_culture_routes(args=None, name="import_culture_routes"): management.call_command("import_culture_routes") -@shared_task +@shared_task_email def import_payments_zones(name="import_payment_zones"): management.call_command("import_wfs", "PaymentZone") -@shared_task +@shared_task_email def import_speed_limit_zones(name="import_speed_limit_zones"): management.call_command("import_wfs", "SpeedLimitZone") -@shared_task +@shared_task_email def import_scooter_restrictions(name="import_scooter_restrictions"): management.call_command( "import_wfs", @@ -28,12 +29,12 @@ def import_scooter_restrictions(name="import_scooter_restrictions"): ) -@shared_task +@shared_task_email def import_mobility_data(name="import_mobility_data"): management.call_command("import_mobility_data") -@shared_task +@shared_task_email def import_accessories(name="import_accessories"): management.call_command( "import_wfs", @@ -41,66 +42,81 @@ def import_accessories(name="import_accessories"): ) -@shared_task +@shared_task_email +def import_barbecue_places(name="import_barbecue_places"): + management.call_command("import_wfs", ["BarbecuePlace"]) + + +@shared_task_email +def import_playgrounds(name="import_playgrounds"): + management.call_command("import_wfs", ["PlayGround"]) + + +@shared_task_email def import_share_car_parking_places(name="impor_share_car_parking_places"): management.call_command("import_share_car_parking_places") -@shared_task +@shared_task_email def import_bicycle_networks(name="import_bicycle_networks"): management.call_command( "import_wfs", ["BrushSaltedBicycleNetwork", "BrushSandedBicycleNetwork"] ) -@shared_task +@shared_task_email def import_marinas(name="import_marinas"): management.call_command("import_marinas") -@shared_task +@shared_task_email def import_foli_stops(name="import_foli_stops"): management.call_command("import_foli_stops") -@shared_task +@shared_task_email +def import_foli_parkandride_stops(name="import_foli_parkandride_stops"): + management.call_command("import_foli_parkandride_stops") + + +@shared_task_email def import_outdoor_gym_devices(name="import_outdoor_gym_devices"): management.call_command("import_outdoor_gym_devices") -@shared_task +@shared_task_email def import_disabled_and_no_staff_parkings(name="import_disabled_and_no_staff_parkings"): management.call_command("import_disabled_and_no_staff_parkings") -@shared_task +@shared_task_email def import_loading_and_unloading_places(name="import_loading_and_unloading_places"): management.call_command("import_loading_and_unloading_places") -@shared_task +@shared_task_email def import_lounaistieto_shapefiles(name="import_lounaistieto_shapefiles"): management.call_command("import_lounaistieto_shapefiles") -@shared_task +@shared_task_email def import_paavonpolkus(name="import_paavonpolkus"): management.call_command("import_wfs", "PaavonPolku") -@shared_task +@shared_task_email def delete_mobility_data(args=None, name="delete_mobility_data"): management.call_command("delete_mobility_data", args) -@shared_task +@shared_task_email def import_outdoor_trails(name="import_outdoor_trails"): management.call_command( "import_wfs", ["PaddlingTrail", "HikingTrail", "NatureTrail", "FitnessTrail"] ) -@shared_task +@shared_task_email def import_traffic_signs(name="import_traffic_signs"): management.call_command( "import_wfs", @@ -124,11 +140,16 @@ def import_traffic_signs(name="import_traffic_signs"): ) -@shared_task +@shared_task_email def import_wfs(args=None, name="import_wfs"): management.call_command("import_wfs", args) -@shared_task +@shared_task_email +def import_parking_machines(name="import_parking_machines"): + management.call_command("import_parking_machines") + + +@shared_task_email def delete_deprecated_units(name="delete_deprecated_units"): management.call_command("delete_deprecated_units") diff --git a/mobility_data/tests/conftest.py b/mobility_data/tests/conftest.py index 7f91edc86..a344ab0de 100644 --- a/mobility_data/tests/conftest.py +++ b/mobility_data/tests/conftest.py @@ -1,6 +1,7 @@ import pytest from django.conf import settings from django.contrib.gis.geos import GEOSGeometry, Point +from django.utils import timezone from munigeo.models import ( Address, AdministrativeDivision, @@ -11,6 +12,8 @@ ) from rest_framework.test import APIClient +from services.models import Service, Unit, UnitServiceDetails + from ..models import ContentType, GroupType, MobileUnit, MobileUnitGroup # borders of Turku in well known text format. @@ -46,11 +49,29 @@ def api_client(): @pytest.mark.django_db @pytest.fixture -def content_type(): - content_type = ContentType.objects.create( - name="Test", description="test content type" +def content_types(): + content_types = [ + ContentType.objects.create( + id="aa6c2903-d36f-4c61-b828-19084fc7a64b", + name="Test", + description="test content type", + ) + ] + content_types.append( + ContentType.objects.create( + id="ba6c2903-d36f-4c61-b828-19084fc7a64b", + name="Test2", + description="test content type2", + ) + ) + content_types.append( + ContentType.objects.create( + id="ca6c2903-d36f-4c61-b828-19084fc7a64b", + name="Test unit", + description="test content type3", + ) ) - return content_type + return content_types @pytest.mark.django_db @@ -64,16 +85,68 @@ def group_type(): @pytest.mark.django_db @pytest.fixture -def mobile_unit(content_type): - extra = {"test_int": 4242, "test_float": 42.42, "test_string": "4242"} +def mobile_units(content_types): + mobile_units = [] + extra = { + "test_int": 4242, + "test_float": 42.42, + "test_string": "4242", + "test_bool": False, + } + geometry = Point(22.21, 60.3, srid=4326) + geometry.transform(settings.DEFAULT_SRID) mobile_unit = MobileUnit.objects.create( + id="aa6c2903-d36f-4c61-b828-19084fc7a64b", name="Test mobileunit", description="Test description", - content_type=content_type, - geometry=Point(42.42, 21.21, srid=settings.DEFAULT_SRID), + geometry=geometry, + extra=extra, + ) + mobile_unit.content_types.add(content_types[0]) + mobile_units.append(mobile_units) + extra = { + "test_int": 14, + "test_float": 2.4, + "test_string": "hello", + "test_bool": True, + } + mobile_unit = MobileUnit.objects.create( + id="ba6c2903-d36f-4c61-b828-19084fc7a64b", + name="Test2 mobileunit", + description="Test2 description", + geometry=Point(23.43, 62.22, srid=settings.DEFAULT_SRID), extra=extra, ) - return mobile_unit + mobile_unit.content_types.add(content_types[0]) + mobile_unit.content_types.add(content_types[1]) + mobile_units.append(mobile_units) + mobile_unit = MobileUnit.objects.create( + id="ca6c2903-d36f-4c61-b828-19084fc7a64b", unit_id=1 + ) + mobile_unit.content_types.add(content_types[2]) + mobile_units.append(mobile_units) + return mobile_units + + +@pytest.mark.django_db +@pytest.fixture +def service(): + return Service.objects.create(id=1, name="test", last_modified_time=timezone.now()) + + +@pytest.mark.django_db +@pytest.fixture +def unit(service): + unit = Unit.objects.create( + id=1, + name="Test unit", + description="desc", + last_modified_time=timezone.now(), + provider_type=1, + location=Point(24.24, 62.22, srid=settings.DEFAULT_SRID), + ) + UnitServiceDetails(unit=unit, service=service).save() + return unit @pytest.mark.django_db @@ -89,9 +162,12 @@ def mobile_unit_group(group_type): @pytest.mark.django_db @pytest.fixture -def municipality(): - muni = Municipality.objects.create(id="turku", name="Turku") - return muni +def municipalities(): + munis = [] + munis.append(Municipality.objects.create(id="turku", name="Turku")) + munis.append(Municipality.objects.create(id="lieto", name="Lieto")) + munis.append(Municipality.objects.create(id="raisio", name="Raisio")) + return munis @pytest.mark.django_db @@ -180,11 +256,12 @@ def streets(): @pytest.mark.django_db @pytest.fixture -def address(streets, municipality): +def address(streets, municipalities): + turku_muni = municipalities[0] addresses = [] location = Point(22.244, 60.4, srid=4326) address = Address.objects.create( - municipality_id=municipality.id, + municipality_id=turku_muni.id, id=100, location=location, street=streets[0], @@ -195,7 +272,7 @@ def address(streets, municipality): addresses.append(address) location = Point(22.227168, 60.4350612, srid=4326) address = Address.objects.create( - municipality_id=municipality.id, + municipality_id=turku_muni.id, id=101, location=location, street=streets[1], @@ -205,7 +282,7 @@ def address(streets, municipality): addresses.append(address) location = Point(22.264457, 60.448905, srid=4326) address = Address.objects.create( - municipality_id=municipality.id, + municipality_id=turku_muni.id, id=102, location=location, street=streets[2], @@ -216,7 +293,7 @@ def address(streets, municipality): addresses.append(address) location = Point(22.2383, 60.411726, srid=4326) address = Address.objects.create( - municipality_id=municipality.id, + municipality_id=turku_muni.id, id=103, location=location, street=streets[3], @@ -227,7 +304,7 @@ def address(streets, municipality): addresses.append(address) location = Point(22.2871092678621, 60.44677715747775, srid=4326) address = Address.objects.create( - municipality_id=municipality.id, + municipality_id=turku_muni.id, id=104, location=location, street=streets[4], @@ -238,7 +315,7 @@ def address(streets, municipality): addresses.append(address) location = Point(22.26097246971352, 60.45055294118857, srid=4326) address = Address.objects.create( - municipality_id=municipality.id, + municipality_id=turku_muni.id, id=105, location=location, street=streets[5], @@ -249,7 +326,7 @@ def address(streets, municipality): addresses.append(address) location = Point(22.247047171564706, 60.45159033848499, srid=4326) address = Address.objects.create( - municipality_id=municipality.id, + municipality_id=turku_muni.id, id=106, location=location, street=streets[6], diff --git a/mobility_data/tests/data/foli_parkandride_stops.json b/mobility_data/tests/data/foli_parkandride_stops.json new file mode 100644 index 000000000..0cf593528 --- /dev/null +++ b/mobility_data/tests/data/foli_parkandride_stops.json @@ -0,0 +1,123 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "id": "poi_1054", + "geometry": { + "type": "Point", + "coordinates": [ + 22.4579, + 60.50413 + ] + }, + "properties": { + "category": "PARKANDRIDE_CARS", + "name": "Lieto Keskusta, K-Supermarket Lietorin piha", + "name_fi": "Lieto Keskusta, K-Supermarket Lietorin piha", + "name_sv": "Lundo centrum, K-Supermarket Lietori", + "name_en": "Lieto centre, K-Supermarket Lietori", + "popup": "
\n Lieto Keskusta, K-Supermarket Lietorin piha<\/span>\n
Hyv\u00e4ttyl\u00e4ntie 2<\/div>\n<\/div>\n", + "text": "Lieto Keskusta, K-Supermarket Lietorin piha\nHyv\u00e4ttyl\u00e4ntie 2, 21420", + "city": "Lieto", + "city_fi": "Lieto", + "city_sv": "Lundo", + "address": "Hyv\u00e4ttyl\u00e4ntie 2", + "address_fi": "Hyv\u00e4ttyl\u00e4ntie 2", + "address_sv": "Hyv\u00e4ttyl\u00e4ntie 2", + "icon": { + "id": "icon_parkandride_cars", + "svg": "\n \n \n \n \n \n <\/g>\n<\/svg>\n" + } + } + }, + { + "type": "Feature", + "id": "poi_1057", + "geometry": { + "type": "Point", + "coordinates": [ + 22.3808, + 60.50614 + ] + }, + "properties": { + "category": "PARKANDRIDE_CARS", + "name": "Lieto Ilmarinen K-Market Ilmarisen piha", + "name_fi": "Lieto Ilmarinen K-Market Ilmarisen piha", + "name_sv": "Lundo Ilmarinen, K-Market Ilmarinen", + "name_en": "Lieto Ilmarinen, K-Market Ilmarinen", + "popup": "
\n Lieto Ilmarinen K-Market Ilmarisen piha<\/span>\n
Vanha Tampereentie 868<\/div>\n<\/div>\n", + "text": "Lieto Ilmarinen K-Market Ilmarisen piha\nVanha Tampereentie 868, 21350", + "city": "Lieto", + "city_fi": "Lieto", + "city_sv": "Lundo", + "address": "Vanha Tampereentie 868", + "address_fi": "Vanha Tampereentie 868", + "address_sv": "Vanha Tampereentie 868", + "icon": { + "id": "icon_parkandride_cars" + } + } + }, + { + "type": "Feature", + "id": "poi_1059", + "geometry": { + "type": "Point", + "coordinates": [ + 22.17121, + 60.48494 + ] + }, + "properties": { + "category": "PARKANDRIDE_BIKES", + "name": "St1 Raisio", + "name_fi": "St1 Raisio", + "name_sv": "St1 Raisio", + "name_en": "St1 Raisio", + "popup": "
\n St1 Raisio<\/span>\n
Kirkkov\u00e4\u00e4rtinkuja 2<\/div>\n<\/div>\n", + "text": "St1 Raisio\nKirkkov\u00e4\u00e4rtinkuja 2, 21200", + "city": "Raisio", + "city_fi": "Raisio", + "city_sv": "Reso", + "address": "Kirkkov\u00e4\u00e4rtinkuja 2", + "address_fi": "Kirkkov\u00e4\u00e4rtinkuja 2", + "address_sv": "Kirkkov\u00e4\u00e4rtinkuja 2", + "icon": { + "id": "icon_parkandride_bikes", + "svg": "\n \n \n \n \n \n \n \n <\/g>\n<\/svg>\n" + } + } + }, + { + "type": "Feature", + "id": "poi_1083", + "geometry": { + "type": "Point", + "coordinates": [ + 22.293, + 60.43613 + ] + }, + "properties": { + "category": "PARKANDRIDE_BIKES", + "name": "Kurjenm\u00e4ki", + "name_fi": "Kurjenm\u00e4ki", + "name_sv": "Kurjenm\u00e4ki", + "name_en": "Kurjenm\u00e4ki", + "popup": "
\n Kurjenm\u00e4ki<\/span>\n
Uudenmaankatu<\/div>\n<\/div>\n", + "text": "Kurjenm\u00e4ki\nUudenmaankatu, 20720", + "city": "Turku", + "city_fi": "Turku", + "city_sv": "\u00c5bo", + "address": "Uudenmaankatu", + "address_fi": "Uudenmaankatu", + "address_sv": "Uudenmaankatu", + "icon": { + "id": "icon_parkandride_bikes" + } + } + } + ] +} \ No newline at end of file diff --git a/mobility_data/tests/data/parking_machines.geojson b/mobility_data/tests/data/parking_machines.geojson new file mode 100644 index 000000000..afa999b1c --- /dev/null +++ b/mobility_data/tests/data/parking_machines.geojson @@ -0,0 +1,11 @@ +{ + "type": "FeatureCollection", + "name": "Automaatit", + "crs": { "type": "name", "properties": { "name": "urn:ogc:def:crs:OGC:1.3:CRS84" } }, + "features": [ + { "type": "Feature", "properties": { "id": 1, "Osoite": "Puutarhakatu 15", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "3.7.2020", "Virta": "Verkko", "Maksutapa": "Kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 1.8, "Max.aika": null, "Maksuvyöhyke": "2", "Adress": "Trädgårdsgatan 15", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "3.7.2022", "Ström": "Nät", "Betalningssätt": "Kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 1.8, "Zon": "2", "Address": "Puutarhakatu 15", "Installed": "3.7.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "2", "Tariff/h": 1.8 }, "geometry": { "type": "Point", "coordinates": [ 22.255769473435137, 60.450812192929355 ] } }, + { "type": "Feature", "properties": { "id": 9, "Osoite": "Ursiininkatu 4", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "13.8.2020", "Virta": "Verkko", "Maksutapa": "Kolikko, kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": null, "Omistaja": "Turun kaupunki", "Taksa/h": 1.8, "Max.aika": null, "Maksuvyöhyke": "2", "Adress": "Ursinsgatan 4", "Tillverkare": "Cale", "Plats": "Gata", "Modell": "CWT-C Touch", "Installerad": "13.8.2020", "Ström": "Nät", "Betalningssätt": "Mynt, kort, kontaktlös", "Skärm": "9\", pekskärm", "Ägare": "Åbo stad", "Taxa/t": 1.8, "Zon": "2", "Address": "Ursiininkatu 4", "Installed": "13.8.2020", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Power": "Mains", "Payment Method": "Coin, card, contactless", "Screen": "9\", touch screen", "Owner": "City of Turku", "Zone": "2", "Tariff/h": 1.8 }, "geometry": { "type": "Point", "coordinates": [ 22.258686318669831, 60.446820115358349 ] } }, + { "type": "Feature", "properties": { "id": 415, "Osoite": "Satamakatu 18 vp", "Sijainti": "Katuosa", "Valmistaja": "Cale", "Malli": "CWT-C Touch", "Asennettu": "15.10.2022", "Virta": "Verkkovirta", "Maksutapa": "Kolikko, kortti, lähimaksu", "Näyttö": "9\", kosketus", "Muuta": "16 € / 26 h", "Omistaja": "Turun kaupunki", "Taksa/h": 1.3, "Max.aika": null, "Adress": "Hamngatan 18 me", "Plats": "Gata", "Tillverkare": "Cale", "Modell": "CWT-C Touch", "Installerad": "15.10.2022", "Ström": "Nät", "Betalningssätt": "Mynt, kort, kontaktlös", "Skärm": "9\", pekskärm", "Tilläg": "16 € / 26 h", "Ägare": "Åbo stad", "Zon": "Hamn", "Taxa/t": 1.3, "Maksuvyöhyke": "Satama", "Address": "Satamakatu 18 vp", "Location": "On-street", "Manufacturer": "Cale", "Model": "CWT-C Touch", "Installed": "15.10.2022", "Power": "Mains", "Payment method": "Coin, card, contactless", "Screen": "9\", touch screen", "Additional info": "16 € / 26 h", "Owner": "City of Turku", "Zone": "Harbour", "Tariff/h": 1.3 }, "geometry": { "type": "Point", "coordinates": [ 22.227764721514966, 60.436782262744913 ] } } + ] + } + \ No newline at end of file diff --git a/mobility_data/tests/test_api.py b/mobility_data/tests/test_api.py index a8cb72f46..a8f20b136 100644 --- a/mobility_data/tests/test_api.py +++ b/mobility_data/tests/test_api.py @@ -5,13 +5,14 @@ @pytest.mark.django_db -def test_content_type(api_client, content_type): +def test_content_type(api_client, content_types): url = reverse("mobility_data:content_types-list") response = api_client.get(url) assert response.status_code == 200 - results = response.json()["results"][0] - assert results["name"] == "Test" - assert results["description"] == "test content type" + assert len(response.json()["results"]) == len(content_types) + result = response.json()["results"][0] + assert result["name"] == "Test" + assert result["description"] == "test content type" @pytest.mark.django_db @@ -19,50 +20,105 @@ def test_group_type(api_client, group_type): url = reverse("mobility_data:group_types-list") response = api_client.get(url) assert response.status_code == 200 - results = response.json()["results"][0] - assert results["name"] == "TestGroup" - assert results["description"] == "test group type" + result = response.json()["results"][0] + assert result["name"] == "TestGroup" + assert result["description"] == "test group type" @pytest.mark.django_db -def test_mobile_unit(api_client, mobile_unit, content_type): +def test_mobile_unit(api_client, mobile_units, content_types, unit): url = reverse("mobility_data:mobile_units-list") response = api_client.get(url) assert response.status_code == 200 - results = response.json()["results"][0] - assert results["name"] == "Test mobileunit" - assert results["description"] == "Test description" - assert results["content_type"]["id"] == str(content_type.id) - assert results["extra"]["test_string"] == "4242" - assert results["extra"]["test_int"] == 4242 - assert results["extra"]["test_float"] == 42.42 - assert results["geometry"] == Point(42.42, 21.21, srid=settings.DEFAULT_SRID) + assert len(response.json()["results"]) == len(mobile_units) + url = reverse( + "mobility_data:mobile_units-detail", + args=["aa6c2903-d36f-4c61-b828-19084fc7a64b"], + ) + response = api_client.get(url) + assert response.status_code == 200 + result = response.json() + assert result["name"] == "Test mobileunit" + assert result["description"] == "Test description" + assert result["content_types"][0]["id"] == str(content_types[0].id) + assert result["extra"]["test_string"] == "4242" + assert result["extra"]["test_int"] == 4242 + assert result["extra"]["test_float"] == 42.42 + assert result["geometry"] == Point( + 235404.6706163187, 6694437.919005549, srid=settings.DEFAULT_SRID + ) + url = reverse( + "mobility_data:mobile_units-detail", + args=["ba6c2903-d36f-4c61-b828-19084fc7a64b"], + ) + response = api_client.get(url) + assert response.status_code == 200 + # Test multiple content types + result = response.json() + assert len(result["content_types"]) == 2 + assert result["content_types"][0]["name"] == "Test" + assert result["content_types"][1]["name"] == "Test2" + # Test string in extra field url = ( reverse("mobility_data:mobile_units-list") - + "?type_name=Test?extra__test_string=4242" + + "?type_name=Test&extra__test_string=4242" ) response = api_client.get(url) assert response.status_code == 200 - results = response.json()["results"][0] - assert results["name"] == "Test mobileunit" + assert len(response.json()["results"]) == 1 + assert response.json()["results"][0]["name"] == "Test mobileunit" # Test int value in extra field url = ( reverse("mobility_data:mobile_units-list") - + "?type_name=Test?extra__test_int=4242" + + "?type_name=Test&extra__test_int=4242" ) response = api_client.get(url) assert response.status_code == 200 - results = response.json()["results"][0] - assert results["name"] == "Test mobileunit" + assert len(response.json()["results"]) == 1 + assert response.json()["results"][0]["name"] == "Test mobileunit" # Test float value in extra field url = ( reverse("mobility_data:mobile_units-list") - + "?type_name=Test?extra__test_float=42.42" + + "?type_name=Test&extra__test_float=42.42" + ) + response = api_client.get(url) + assert response.status_code == 200 + assert len(response.json()["results"]) == 1 + assert response.json()["results"][0]["name"] == "Test mobileunit" + # Test vool value in extra field + url = ( + reverse("mobility_data:mobile_units-list") + + "?type_name=Test&extra__test_bool=True" + ) + response = api_client.get(url) + assert response.status_code == 200 + assert len(response.json()["results"]) == 1 + assert response.json()["results"][0]["name"] == "Test2 mobileunit" + # Test that we get a mobile unit inside bbox. + url = ( + reverse("mobility_data:mobile_units-list") + + "?bbox=21.1,59.2,22.3,61.4&bbox_srid=4326" + ) + response = api_client.get(url) + assert len(response.json()["results"]) == 1 + # Test bbox where no mobile units are inside. + url = reverse("mobility_data:mobile_units-list") + "?bbox=22.1,60.2,2.3,60.4" + response = api_client.get(url) + assert len(response.json()["results"]) == 0 + # Test data serialization from services_unit model + url = reverse( + "mobility_data:mobile_units-detail", + args=["ca6c2903-d36f-4c61-b828-19084fc7a64b"], ) response = api_client.get(url) assert response.status_code == 200 - results = response.json()["results"][0] - assert results["name"] == "Test mobileunit" + result = response.json() + assert result["name"] == "Test unit" + assert result["description"] == "desc" + assert result["content_types"][0]["name"] == "Test unit" + assert result["geometry"] == "POINT (24.24 62.22)" + assert result["geometry_coords"]["lon"] == 24.24 + assert result["geometry_coords"]["lat"] == 62.22 @pytest.mark.django_db @@ -70,7 +126,7 @@ def test_mobile_unit_group(api_client, mobile_unit_group, group_type): url = reverse("mobility_data:mobile_unit_groups-list") response = api_client.get(url) assert response.status_code == 200 - results = response.json()["results"][0] - assert results["name"] == "Test mobileunitgroup" - assert results["description"] == "Test description" - assert results["group_type"]["id"] == str(group_type.id) + result = response.json()["results"][0] + assert result["name"] == "Test mobileunitgroup" + assert result["description"] == "Test description" + assert result["group_type"]["id"] == str(group_type.id) diff --git a/mobility_data/tests/test_import_accessories.py b/mobility_data/tests/test_import_accessories.py index 36ca78400..c81b005da 100644 --- a/mobility_data/tests/test_import_accessories.py +++ b/mobility_data/tests/test_import_accessories.py @@ -30,13 +30,13 @@ def test_import_accessories( ) public_toilet_content_type = ContentType.objects.get(name="PublicToilet") - assert public_toilet_content_type public_toilet_units_qs = MobileUnit.objects.filter( - content_type=public_toilet_content_type + content_types=public_toilet_content_type ) assert public_toilet_units_qs.count() == 2 public_toilet_unit = public_toilet_units_qs[0] - assert public_toilet_unit.content_type == public_toilet_content_type + assert public_toilet_unit.content_types.all().count() == 1 + assert public_toilet_unit.content_types.first() == public_toilet_content_type extra = public_toilet_unit.extra assert extra["Kunto"] == "Ei tietoa" assert extra["Malli"] == "Testi Vessa" @@ -52,26 +52,29 @@ def test_import_accessories( assert extra["Varustelaji_koodi"] == 4022 bench_content_type = ContentType.objects.get(name="PublicBench") - assert bench_content_type - - bench_units_qs = MobileUnit.objects.filter(content_type=bench_content_type) + bench_units_qs = MobileUnit.objects.filter(content_types=bench_content_type) # Bench id 107620803 locates in Kaarina and therefore is not included. assert bench_units_qs.count() == 1 bench_unit = bench_units_qs.first() - assert bench_unit.content_type == bench_content_type + assert bench_unit.content_types.all().count() == 1 + assert bench_unit.content_types.first() == bench_content_type point = Point(23464051.217, 6706051.818, srid=DEFAULT_SOURCE_DATA_SRID) point.transform(settings.DEFAULT_SRID) bench_unit.geometry.equals_exact(point, tolerance=0.0001) + table_content_type = ContentType.objects.get(name="PublicTable") - assert table_content_type - table_units_qs = MobileUnit.objects.filter(content_type=table_content_type) + table_units_qs = MobileUnit.objects.filter(content_types=table_content_type) assert table_units_qs.count() == 2 - assert table_units_qs[0].content_type == table_content_type + assert table_units_qs[0].content_types.all().count() == 1 + assert table_units_qs[0].content_types.first() == table_content_type furniture_group_content_type = ContentType.objects.get(name="PublicFurnitureGroup") - assert furniture_group_content_type furniture_group_units_qs = MobileUnit.objects.filter( - content_type=furniture_group_content_type + content_types=furniture_group_content_type ) assert furniture_group_units_qs.count() == 2 - assert furniture_group_units_qs[0].content_type == furniture_group_content_type + assert furniture_group_units_qs[0].content_types.all().count() == 1 + assert ( + furniture_group_units_qs[0].content_types.first() + == furniture_group_content_type + ) diff --git a/mobility_data/tests/test_import_bicycle_stands.py b/mobility_data/tests/test_import_bicycle_stands.py index d14fa0477..c58cc7396 100644 --- a/mobility_data/tests/test_import_bicycle_stands.py +++ b/mobility_data/tests/test_import_bicycle_stands.py @@ -7,7 +7,7 @@ @pytest.mark.django_db def test_geojson_import( - municipality, + municipalities, administrative_division_type, administrative_division, administrative_division_geometry, @@ -34,11 +34,12 @@ def test_geojson_import( assert kupittaan_palloiluhalli.extra["covered"] is True assert turun_amk.extra["hull_lockable"] is True assert turun_amk.extra["covered"] is False + assert turun_amk.municipality.name == "Turku" @pytest.mark.django_db def test_wfs_importer( - municipality, + municipalities, administrative_division_type, administrative_division, administrative_division_geometry, @@ -48,13 +49,14 @@ def test_wfs_importer( import_command("import_bicycle_stands", test_mode="bicycle_stands.gml") assert MobileUnit.objects.all().count() == 3 # 0 in fixture xml. - stand_normal = MobileUnit.objects.all()[0] + stand_normal = MobileUnit.objects.first() # 182213917 in fixture xml. stand_covered_hull_lockable = MobileUnit.objects.all()[1] # 319490982 in fixture xml stand_external = MobileUnit.objects.all()[2] assert stand_normal.name_fi == "Linnanpuisto" assert stand_normal.name_sv == "Slottsparken" + assert stand_normal.municipality.name == "Turku" extra = stand_normal.extra assert extra["model"] == "Normaali" assert extra["maintained_by_turku"] is True @@ -62,7 +64,6 @@ def test_wfs_importer( assert extra["hull_lockable"] is False assert extra["number_of_places"] == 24 assert extra["number_of_stands"] == 2 - assert extra["number_of_stands"] == 2 assert stand_covered_hull_lockable.name == "Pitkäpellonkatu 7" assert stand_covered_hull_lockable.name_sv == "Långåkersgatan 7" extra = stand_covered_hull_lockable.extra diff --git a/mobility_data/tests/test_import_bike_service_stations.py b/mobility_data/tests/test_import_bike_service_stations.py index 46bcaeb98..307e61af2 100644 --- a/mobility_data/tests/test_import_bike_service_stations.py +++ b/mobility_data/tests/test_import_bike_service_stations.py @@ -12,7 +12,7 @@ def test_import_bike_service_stations(): "import_bike_service_stations", test_mode="bike_service_stations.geojson" ) assert ContentType.objects.filter(name=CONTENT_TYPE_NAME).count() == 1 - assert MobileUnit.objects.filter(content_type__name=CONTENT_TYPE_NAME).count() == 3 + assert MobileUnit.objects.filter(content_types__name=CONTENT_TYPE_NAME).count() == 3 kupittaankentta = MobileUnit.objects.get(name="Kupittaankenttä") assert kupittaankentta.name_sv == "Kuppisplan" assert kupittaankentta.name_en == "Kupittaa court" diff --git a/mobility_data/tests/test_import_charging_stations.py b/mobility_data/tests/test_import_charging_stations.py index 233649e49..a6098fefc 100644 --- a/mobility_data/tests/test_import_charging_stations.py +++ b/mobility_data/tests/test_import_charging_stations.py @@ -1,16 +1,18 @@ import pytest from munigeo.models import Address -from mobility_data.importers.charging_stations import CONTENT_TYPE_NAME +from mobility_data.importers.charging_stations import ( + CHARGING_STATION_SERVICE_NAMES, + CONTENT_TYPE_NAME, +) from mobility_data.models import ContentType, MobileUnit -from smbackend_turku.importers.constants import CHARGING_STATION_SERVICE_NAMES from .utils import import_command @pytest.mark.django_db def test_import_charging_stations( - municipality, + municipalities, administrative_division_type, administrative_division, administrative_division_geometry, @@ -19,9 +21,8 @@ def test_import_charging_stations( ): import_command("import_charging_stations", test_mode="charging_stations.csv") assert ContentType.objects.filter(name=CONTENT_TYPE_NAME).count() == 1 - assert MobileUnit.objects.filter(content_type__name=CONTENT_TYPE_NAME).count() == 3 + assert MobileUnit.objects.filter(content_types__name=CONTENT_TYPE_NAME).count() == 3 aimopark = MobileUnit.objects.get(name="Aimopark, Yliopistonkatu 29") - assert aimopark assert aimopark.address == "Yliopistonkatu 29" assert aimopark.address_sv == "Universitetsgatan 29" assert aimopark.address_en == "Yliopistonkatu 29" diff --git a/mobility_data/tests/test_import_disabled_and_no_staff_parkings.py b/mobility_data/tests/test_import_disabled_and_no_staff_parkings.py index 0bc14d531..e5120173a 100644 --- a/mobility_data/tests/test_import_disabled_and_no_staff_parkings.py +++ b/mobility_data/tests/test_import_disabled_and_no_staff_parkings.py @@ -11,7 +11,7 @@ @pytest.mark.django_db -def test_geojson_import(municipality): +def test_geojson_import(municipalities): import_command( "import_disabled_and_no_staff_parkings", test_mode="autopysäköinti_eihlö.geojson", @@ -21,8 +21,13 @@ def test_geojson_import(municipality): turku_muni = Municipality.objects.get(name="Turku") except Municipality.DoesNotExist: assert turku_muni + kupittaan_maauimala = MobileUnit.objects.get(name="Kupittaan maauimala") - assert kupittaan_maauimala.content_type.name == DISABLED_PARKING_CONTENT_TYPE_NAME + assert kupittaan_maauimala.content_types.all().count() == 1 + assert ( + kupittaan_maauimala.content_types.first().name + == DISABLED_PARKING_CONTENT_TYPE_NAME + ) assert kupittaan_maauimala assert kupittaan_maauimala.name_sv == "Kuppis utebad" assert kupittaan_maauimala.name_en == "Kupittaa outdoor pool" @@ -35,10 +40,9 @@ def test_geojson_import(municipality): assert kupittaan_maauimala.extra["rajoitustyyppi"]["fi"] == "Erityisalue" assert kupittaan_maauimala.extra["rajoitustyyppi"]["sv"] == "Specialområde" assert kupittaan_maauimala.extra["rajoitustyyppi"]["en"] == "Special area" - kupittaan_seikkailupuisto = MobileUnit.objects.get(name="Kupittaan seikkailupuisto") assert ( - kupittaan_seikkailupuisto.content_type.name + kupittaan_seikkailupuisto.content_types.first().name == NO_STAFF_PARKING_CONTENT_TYPE_NAME ) assert kupittaan_seikkailupuisto @@ -51,7 +55,8 @@ def test_geojson_import(municipality): kupittaan_urheiluhalli = MobileUnit.objects.get(name="Kupittaan urheiluhalli") assert kupittaan_urheiluhalli assert ( - kupittaan_urheiluhalli.content_type.name == NO_STAFF_PARKING_CONTENT_TYPE_NAME + kupittaan_urheiluhalli.content_types.first().name + == NO_STAFF_PARKING_CONTENT_TYPE_NAME ) assert kupittaan_urheiluhalli.name_en == "Kupittaa sports hall" assert kupittaan_urheiluhalli.extra["sahkolatauspaikkoja"] == 42 diff --git a/mobility_data/tests/test_import_foli_parkandride_stops.py b/mobility_data/tests/test_import_foli_parkandride_stops.py new file mode 100644 index 000000000..fde5640ff --- /dev/null +++ b/mobility_data/tests/test_import_foli_parkandride_stops.py @@ -0,0 +1,63 @@ +from unittest.mock import patch + +import pytest + +from mobility_data.models import ContentType, MobileUnit + +from .utils import get_test_fixture_json_data + + +@pytest.mark.django_db +@patch("mobility_data.importers.utils.fetch_json") +def test_import_foli_stops(fetch_json_mock, municipalities): + from mobility_data.importers.foli_parkandride_stop import ( + FOLI_PARKANDRIDE_BIKES_STOP_CONTENT_TYPE_NAME, + FOLI_PARKANDRIDE_CARS_STOP_CONTENT_TYPE_NAME, + get_parkandride_stop_objects, + save_to_database, + ) + + fetch_json_mock.return_value = get_test_fixture_json_data( + "foli_parkandride_stops.json" + ) + car_stops, bike_stops = get_parkandride_stop_objects() + save_to_database(car_stops, FOLI_PARKANDRIDE_CARS_STOP_CONTENT_TYPE_NAME) + save_to_database(bike_stops, FOLI_PARKANDRIDE_BIKES_STOP_CONTENT_TYPE_NAME) + + cars_stops_content_type = ContentType.objects.get( + name=FOLI_PARKANDRIDE_CARS_STOP_CONTENT_TYPE_NAME + ) + bikes_stops_content_type = ContentType.objects.get( + name=FOLI_PARKANDRIDE_BIKES_STOP_CONTENT_TYPE_NAME + ) + # Fixture data contains two park and ride stops for cars and bikes. + assert MobileUnit.objects.filter(content_types=cars_stops_content_type).count() == 2 + assert ( + MobileUnit.objects.filter(content_types=bikes_stops_content_type).count() == 2 + ) + # Test Föli park and ride cars stop + lieto_centre = MobileUnit.objects.get(name_en="Lieto centre, K-Supermarket Lietori") + assert lieto_centre.content_types.all().count() == 1 + assert lieto_centre.content_types.first() == cars_stops_content_type + assert lieto_centre.name_fi == "Lieto Keskusta, K-Supermarket Lietorin piha" + assert lieto_centre.name_sv == "Lundo centrum, K-Supermarket Lietori" + assert lieto_centre.address_zip == "21420" + assert ( + lieto_centre.description + == "Lieto Keskusta, K-Supermarket Lietorin piha\nHyvättyläntie 2, 21420" + ) + assert lieto_centre.address_fi == "Hyvättyläntie 2" + assert lieto_centre.address_sv == "Hyvättyläntie 2" + assert lieto_centre.address_en == "Hyvättyläntie 2" + assert lieto_centre.municipality.name == "Lieto" + # Test Föli park and ride bikes stop + raisio_st1 = MobileUnit.objects.get(name_en="St1 Raisio") + assert raisio_st1.content_types.first() == bikes_stops_content_type + assert raisio_st1.name_fi == "St1 Raisio" + assert raisio_st1.name_sv == "St1 Raisio" + assert raisio_st1.address_zip == "21200" + assert raisio_st1.description == "St1 Raisio\nKirkkoväärtinkuja 2, 21200" + assert raisio_st1.municipality.name == "Raisio" + assert raisio_st1.address_fi == "Kirkkoväärtinkuja 2" + assert raisio_st1.address_sv == "Kirkkoväärtinkuja 2" + assert raisio_st1.address_en == "Kirkkoväärtinkuja 2" diff --git a/mobility_data/tests/test_import_foli_stops.py b/mobility_data/tests/test_import_foli_stops.py index 78e91473a..4ebb8f450 100644 --- a/mobility_data/tests/test_import_foli_stops.py +++ b/mobility_data/tests/test_import_foli_stops.py @@ -5,25 +5,25 @@ from mobility_data.models import ContentType, MobileUnit -from .utils import get_json_data +from .utils import get_test_fixture_json_data @pytest.mark.django_db -@patch("mobility_data.importers.foli_stops.get_json_data") -def test_import_foli_stops(get_json_data_mock): +@patch("mobility_data.importers.utils.fetch_json") +def test_import_foli_stops(fetch_json_mock): from mobility_data.importers import foli_stops - get_json_data_mock.return_value = get_json_data("foli_stops.json") + fetch_json_mock.return_value = get_test_fixture_json_data("foli_stops.json") objects = foli_stops.get_foli_stops() foli_stops.save_to_database(objects) assert ContentType.objects.count() == 1 assert ContentType.objects.first().name == foli_stops.CONTENT_TYPE_NAME assert MobileUnit.objects.count() == 3 turun_satama = MobileUnit.objects.get(name="Turun satama (Silja)") - assert turun_satama.content_type == ContentType.objects.first() + assert turun_satama.content_types.all().count() == 1 + assert turun_satama.content_types.first() == ContentType.objects.first() assert turun_satama.extra["stop_code"] == "1" assert turun_satama.extra["wheelchair_boarding"] == 0 - point = Point(22.21966, 60.43497, srid=4326) point_turun_satama = turun_satama.geometry point_turun_satama.transform(4326) - assert point_turun_satama == point + assert point_turun_satama == Point(22.21966, 60.43497, srid=4326) diff --git a/mobility_data/tests/test_import_gas_filling_stations.py b/mobility_data/tests/test_import_gas_filling_stations.py index 0a6ee2fa1..29f226206 100644 --- a/mobility_data/tests/test_import_gas_filling_stations.py +++ b/mobility_data/tests/test_import_gas_filling_stations.py @@ -7,15 +7,16 @@ @pytest.mark.django_db -def test_importer(): +def test_importer(municipalities): import_command("import_gas_filling_stations", test_mode="gas_filling_stations.json") assert ContentType.objects.filter(name=CONTENT_TYPE_NAME).count() == 1 - assert MobileUnit.objects.filter(content_type__name=CONTENT_TYPE_NAME).count() == 2 + assert MobileUnit.objects.filter(content_types__name=CONTENT_TYPE_NAME).count() == 2 assert MobileUnit.objects.get(name="Raisio Kuninkoja") unit = MobileUnit.objects.get(name="Turku Satama") - assert unit - assert unit.address == "Tuontiväylä 42 abc 1-2, 20200 Turku" + assert unit.address == "Tuontiväylä 42 abc 1-2" + assert unit.address_zip == "20200" + assert unit.municipality.name == "Turku" # Transform to source data srid unit.geometry.transform(3857) assert pytest.approx(unit.geometry.x, 0.0000000001) == 2472735.3962113541 diff --git a/mobility_data/tests/test_import_loading_and_unloading_places.py b/mobility_data/tests/test_import_loading_and_unloading_places.py index 42a344f22..5aeb10674 100644 --- a/mobility_data/tests/test_import_loading_and_unloading_places.py +++ b/mobility_data/tests/test_import_loading_and_unloading_places.py @@ -9,19 +9,21 @@ @pytest.mark.django_db @pytest.mark.django_db -def test_import(municipality): +def test_import(municipalities): import_command( "import_loading_and_unloading_places", test_mode="loading_and_unloading_places.geojson", ) assert ContentType.objects.all().count() == 1 assert MobileUnit.objects.all().count() == 3 + turku_muni = None try: turku_muni = Municipality.objects.get(name="Turku") except Municipality.DoesNotExist: assert turku_muni lantinen_rantakatu = MobileUnit.objects.get(name="Läntinen Rantakatu") - assert lantinen_rantakatu.content_type.name == CONTENT_TYPE_NAME + assert lantinen_rantakatu.content_types.all().count() == 1 + assert lantinen_rantakatu.content_types.first().name == CONTENT_TYPE_NAME assert lantinen_rantakatu.name_sv == "Östra Strandgatan" assert lantinen_rantakatu.name_en == "Läntinen Rantakatu" assert lantinen_rantakatu.address_fi == "Läntinen Rantakatu 13" diff --git a/mobility_data/tests/test_import_parking_machines.py b/mobility_data/tests/test_import_parking_machines.py new file mode 100644 index 000000000..3c69e1c1a --- /dev/null +++ b/mobility_data/tests/test_import_parking_machines.py @@ -0,0 +1,51 @@ +from unittest.mock import patch + +import pytest + +from mobility_data.models import ContentType, MobileUnit + +from .utils import get_test_fixture_json_data + + +@pytest.mark.django_db +@patch("mobility_data.importers.parking_machines.get_json_data") +def test_import_parking_machines(get_json_data_mock): + from mobility_data.importers import parking_machines + + get_json_data_mock.return_value = get_test_fixture_json_data( + "parking_machines.geojson" + ) + objects = parking_machines.get_parking_machine_objects() + parking_machines.save_to_database(objects) + assert ContentType.objects.first().name == parking_machines.CONTENT_TYPE_NAME + assert MobileUnit.objects.count() == 3 + satamakatu = MobileUnit.objects.first() + assert satamakatu.content_types.all().count() == 1 + assert satamakatu.content_types.first() == ContentType.objects.first() + assert satamakatu.address == "Satamakatu 18 vp" + assert satamakatu.address_sv == "Hamngatan 18 me" + assert satamakatu.address_en == "Satamakatu 18 vp" + assert satamakatu.extra["Malli"] == "CWT-C Touch" + assert satamakatu.extra["Muuta"] == "16 € / 26 h" + assert satamakatu.extra["Taksa/h"] == 1.3 + assert satamakatu.extra["Max.aika"] is None + assert satamakatu.extra["Asennettu"] == "15.10.2022" + assert satamakatu.extra["Valmistaja"] == "Cale" + assert satamakatu.extra["Virta"]["fi"] == "Verkkovirta" + assert satamakatu.extra["Virta"]["sv"] == "Nät" + assert satamakatu.extra["Virta"]["en"] == "Mains" + assert satamakatu.extra["Näyttö"]["fi"] == '9", kosketus' + assert satamakatu.extra["Näyttö"]["sv"] == '9", pekskärm' + assert satamakatu.extra["Näyttö"]["en"] == '9", touch screen' + assert satamakatu.extra["Omistaja"]["fi"] == "Turun kaupunki" + assert satamakatu.extra["Omistaja"]["sv"] == "Åbo stad" + assert satamakatu.extra["Omistaja"]["en"] == "City of Turku" + assert satamakatu.extra["Sijainti"]["fi"] == "Katuosa" + assert satamakatu.extra["Sijainti"]["sv"] == "Gata" + assert satamakatu.extra["Sijainti"]["en"] == "On-street" + assert satamakatu.extra["Maksuvyöhyke"]["fi"] == "Satama" + assert satamakatu.extra["Maksuvyöhyke"]["sv"] == "Hamn" + assert satamakatu.extra["Maksuvyöhyke"]["en"] == "Harbour" + assert satamakatu.extra["Maksutapa"]["fi"] == "Kolikko, kortti, lähimaksu" + assert satamakatu.extra["Maksutapa"]["sv"] == "Mynt, kort, kontaktlös" + assert satamakatu.extra["Maksutapa"]["en"] == "Coin, card, contactless" diff --git a/mobility_data/tests/test_import_payment_zones.py b/mobility_data/tests/test_import_payment_zones.py index e34a749ed..4f2535443 100644 --- a/mobility_data/tests/test_import_payment_zones.py +++ b/mobility_data/tests/test_import_payment_zones.py @@ -27,11 +27,11 @@ def test_import_payment_zones(): content_type = ContentType.objects.first() assert content_type.name == "PaymentZone" assert MobileUnit.objects.all().count() == 2 - payment_zone0 = MobileUnit.objects.all()[0] + payment_zone0 = MobileUnit.objects.first() payment_zone1 = MobileUnit.objects.all()[1] - payment_zone0.content_type == content_type - payment_zone1.content_type == content_type + payment_zone0.content_types.first() == content_type + payment_zone1.content_types.first() == content_type market_square = Point( 239760.23602773887, 6711049.638094525, srid=settings.DEFAULT_SRID ) diff --git a/mobility_data/tests/test_import_scooter_restrictions.py b/mobility_data/tests/test_import_scooter_restrictions.py index 83b53ae6f..d0393c29a 100644 --- a/mobility_data/tests/test_import_scooter_restrictions.py +++ b/mobility_data/tests/test_import_scooter_restrictions.py @@ -40,10 +40,11 @@ def test_import_scooter_restrictions(): # Test scooter parking parking_content_type = ContentType.objects.get(name="ScooterParkingArea") assert parking_content_type - parking_units_qs = MobileUnit.objects.filter(content_type=parking_content_type) + parking_units_qs = MobileUnit.objects.filter(content_types=parking_content_type) assert parking_units_qs.count() == 3 parking_unit = parking_units_qs[2] - parking_unit.content_type == parking_content_type + assert parking_unit.content_types.all().count() == 1 + parking_unit.content_types.first() == parking_content_type point = Point(239576.42, 6711050.26, srid=DEFAULT_SOURCE_DATA_SRID) parking_unit.geometry.equals_exact(point, tolerance=0.0001) import_command( @@ -54,10 +55,11 @@ def test_import_scooter_restrictions(): # Test scooter speed limits speed_limit_content_type = ContentType.objects.get(name="ScooterSpeedLimitArea") assert speed_limit_content_type - speed_limits_qs = MobileUnit.objects.filter(content_type=speed_limit_content_type) + speed_limits_qs = MobileUnit.objects.filter(content_types=speed_limit_content_type) assert speed_limits_qs.count() == 3 speed_limit_unit = MobileUnit.objects.get(id=speed_limits_qs[0].id) - assert speed_limit_unit.content_type == speed_limit_content_type + assert speed_limit_unit.content_types.all().count() == 1 + assert speed_limit_unit.content_types.first() == speed_limit_content_type market_square = Point(239755.11, 6711065.07, srid=settings.DEFAULT_SRID) turku_cathedral = Point(240377.95, 6711025.00, srid=settings.DEFAULT_SRID) # Scooter speed limit unit locates in the market square(kauppator) @@ -71,10 +73,11 @@ def test_import_scooter_restrictions(): # Test scooter no parking zones no_parking_content_type = ContentType.objects.get(name="ScooterNoParkingArea") assert no_parking_content_type - no_parking_qs = MobileUnit.objects.filter(content_type=no_parking_content_type) + no_parking_qs = MobileUnit.objects.filter(content_types=no_parking_content_type) assert no_parking_qs.count() == 3 no_parking_unit = MobileUnit.objects.get(id=no_parking_qs[0].id) - assert no_parking_unit.content_type == no_parking_content_type + assert no_parking_unit.content_types.all().count() == 1 + assert no_parking_unit.content_types.first() == no_parking_content_type aninkaisten_bridge = Point(239808.23, 6711973.03, srid=settings.DEFAULT_SRID) # no_parking_unit locates in aninkaisten bridge assert no_parking_unit.geometry.contains(aninkaisten_bridge) is True diff --git a/mobility_data/tests/test_import_share_car_parking_places.py b/mobility_data/tests/test_import_share_car_parking_places.py index 1d8cb7f7d..e3bac978d 100644 --- a/mobility_data/tests/test_import_share_car_parking_places.py +++ b/mobility_data/tests/test_import_share_car_parking_places.py @@ -12,7 +12,7 @@ def test_import_car_share_parking_places(): "import_share_car_parking_places", test_mode="share_car_parking_places.geojson" ) assert ContentType.objects.filter(name=CONTENT_TYPE_NAME).count() == 1 - assert MobileUnit.objects.filter(content_type__name=CONTENT_TYPE_NAME).count() == 3 + assert MobileUnit.objects.filter(content_types__name=CONTENT_TYPE_NAME).count() == 3 linnankatu = MobileUnit.objects.get( name="Yhteiskäyttöautojen pysäköintipaikka, Linnankatu 29" ) diff --git a/mobility_data/tests/test_import_speed_limits.py b/mobility_data/tests/test_import_speed_limits.py index 382d30b15..724da5708 100644 --- a/mobility_data/tests/test_import_speed_limits.py +++ b/mobility_data/tests/test_import_speed_limits.py @@ -29,12 +29,12 @@ def test_import_speed_limits(): assert content_type.name == "SpeedLimitZone" assert MobileUnit.objects.all().count() == 3 - zone_80 = MobileUnit.objects.all()[0] + zone_80 = MobileUnit.objects.first() zone_40 = MobileUnit.objects.all()[1] zone_20 = MobileUnit.objects.all()[2] - assert zone_80.content_type == content_type - assert zone_40.content_type == content_type - assert zone_20.content_type == content_type + assert zone_80.content_types.first() == content_type + assert zone_40.content_types.first() == content_type + assert zone_20.content_types.first() == content_type assert zone_80.extra["speed_limit"] == 80 assert zone_40.extra["speed_limit"] == 40 diff --git a/mobility_data/tests/utils.py b/mobility_data/tests/utils.py index a159d2a83..8d59f5192 100644 --- a/mobility_data/tests/utils.py +++ b/mobility_data/tests/utils.py @@ -2,6 +2,7 @@ import os from io import StringIO +from django.contrib.gis.gdal import DataSource from django.core.management import call_command @@ -20,9 +21,17 @@ def import_command(command, *args, **kwargs): ) -def get_json_data(file_name): +def get_test_fixture_json_data(file_name): data_path = os.path.join(os.path.dirname(__file__), "data") file = os.path.join(data_path, file_name) with open(file) as f: data = json.load(f) return data + + +def get_test_fixture_data_layer(file_name): + data_path = os.path.join(os.path.dirname(__file__), "data") + file = os.path.join(data_path, file_name) + ds = DataSource(file) + assert len(ds) == 1 + return ds[0] diff --git a/ptv/tasks.py b/ptv/tasks.py index 1e07c1789..872902026 100644 --- a/ptv/tasks.py +++ b/ptv/tasks.py @@ -1,8 +1,9 @@ -from celery import shared_task from django.core import management +from smbackend.utils import shared_task_email -@shared_task + +@shared_task_email def import_ptv_data(name="import_ptv_data"): # Note, Aura=19 has been removed, thus it is not found in palvelutietovaranto. management.call_command( diff --git a/smbackend/settings.py b/smbackend/settings.py index 09f9b2580..0e786c728 100644 --- a/smbackend/settings.py +++ b/smbackend/settings.py @@ -57,6 +57,11 @@ YIT_CONTRACTS_URL=(str, None), YIT_TOKEN_URL=(str, None), KUNTEC_KEY=(str, None), + EMAIL_BACKEND=(str, None), + EMAIL_HOST=(str, None), + EMAIL_HOST_USER=(str, None), + EMAIL_PORT=(int, None), + EMAIL_USE_TLS=(bool, None), ) # Build paths inside the project like this: os.path.join(BASE_DIR, ...) @@ -217,6 +222,13 @@ def gettext(s): # Shortcut generation URL template SHORTCUTTER_UNIT_URL = env("SHORTCUTTER_UNIT_URL") + +EMAIL_BACKEND = env("EMAIL_BACKEND") +EMAIL_HOST = env("EMAIL_HOST") +EMAIL_HOST_USER = env("EMAIL_HOST_USER") +EMAIL_PORT = env("EMAIL_PORT") +EMAIL_USE_TLS = env("EMAIL_USE_TLS") + # Static & Media files STATIC_ROOT = env("STATIC_ROOT") STATIC_URL = env("STATIC_URL") @@ -332,6 +344,9 @@ def preprocessing_filter_spec(endpoints): CELERY_CACHE_BACKEND = "default" CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler" CELERY_CACHE_BACKEND = "django-cache" +# User in this group will be notified with failed tasks. +CELERY_ADMIN_GROUP = "CeleryAdmin" + CACHES = { "default": { diff --git a/smbackend/utils.py b/smbackend/utils.py new file mode 100644 index 000000000..413c56877 --- /dev/null +++ b/smbackend/utils.py @@ -0,0 +1,45 @@ +import traceback +from functools import wraps + +from celery import shared_task +from django.conf import settings +from django.contrib.auth.models import User +from django.core.mail import send_mail + + +def get_emails_to_notify(): + """ + Return email addresses for users in group 'settings.CELERY_ADMIN_GROUP'. + """ + return [ + user.email + for user in User.objects.filter(groups__name=settings.CELERY_ADMIN_GROUP) + ] + + +def shared_task_email(func): + """ + Replacement for @shared_task decorator that emails users in group settins.CELERY_ADMIN_GROUP + if an exception is raised. + """ + + @wraps(func) + def new_func(*args, **kwargs): + try: + return func(*args, **kwargs) + except Exception: + notify_emails = get_emails_to_notify() + if len(notify_emails) > 0: + subject = "Celery task failure" + message = f"Function: {str(func)} \n" + message += traceback.format_exc() + send_mail( + subject, + message, + settings.EMAIL_HOST_USER, + notify_emails, + fail_silently=False, + ) + raise + + return shared_task(new_func) diff --git a/smbackend_turku/README.md b/smbackend_turku/README.md index c54e28e8c..d1318e858 100644 --- a/smbackend_turku/README.md +++ b/smbackend_turku/README.md @@ -50,59 +50,30 @@ Note, if geo-search addresses are imported this might take ~45minutes. ## Importing external data sources -Importing from external data sources should always be done after importing the services and units. -To import the mobility data, currently imports: gas filling stations, bicycle stands, charging stations and bike service stations. +Importing from external data sources should always be done after importing the services and units. External data sources are configured in: +smbackend_turku/importers/data/external_sources_config.yml + ``` -./manage.py turku_services_import mobility_data +./manage.py turku_services_import external_sources ``` To delete all data imported from external sources: ``` ./manage.py turku_services_import services units --delete-external-sources ``` -To delete a specific imported external data source: -e.g. remove bicycle_stands -``` -./manage.py turku_services_import bicycle_stands --delete-external-source -``` -Currently following importers import to the mobility view by setting -a id, which is used to retrieve the data from the service_unit table: -gas_filling_stations and bicycle_stands. e.g. These -importers import data to both the services list and mobility view. - -When importing services and units the ids are received from the source. Therefore the ids for the external sources must be manually set to avoid id collisions. -The ids are set in a dict in the .env file with following keys: -* service_node is the id of the service_node, recommended value < 3000000 -* service is the id of the service, recommended value > 1000 -* units_offset is the offset that will be given to the imported units ids, recommended value > 10000. -Example: -GAS_FILLING_STATIONS_IDS=service_node=200000,service=200000,units_offset=200000 - -### Gas filling stations -Add following line to the .env file: -GAS_FILLING_STATIONS_IDS=service_node=200000,service=200000,units_offset=200000 - -To import type: +To import a specific external data source give the name of the external data source +defined in the config file as argument. +e.g.: ``` ./manage.py turku_services_import gas_filling_stations ``` +Imports external data source named gas_filling_stations. -### Bicycle stands -Add following line to the .env file: -BICYCLE_STANDS_IDS=service_node=400000,service=400000,units_offset=400000 - -To import type: -``` -./manage.py turku_services_import bicycle_stands -``` - -### Bike service stations -Add following line to the .env file: -BIKE_SERVICE_STATIONS_IDS=service_node=500000,service=500000,units_offset=500000 -To import type: +To delete a specific imported external data source: +e.g., remove external source named bicycle_stands. ``` -./manage.py turku_services_import bike_service_stations +./manage.py turku_services_import --delete-external-source bicycle_stands ``` -### Mobility data -For detailed information about importing, see: /mobility_data/README.md - +### Note +When importing services and units the ids are received from the source. Therefore the ids for the external sources must be manually set to avoid id collisions. +The ids are configured in the smbackend_turku/importers/data/external_sources_config.yml diff --git a/smbackend_turku/importers/bicycle_stands.py b/smbackend_turku/importers/bicycle_stands.py index b8066e360..0136d6704 100644 --- a/smbackend_turku/importers/bicycle_stands.py +++ b/smbackend_turku/importers/bicycle_stands.py @@ -1,140 +1,28 @@ -from datetime import datetime - -from django.conf import settings - from mobility_data.importers.bicycle_stands import ( create_bicycle_stand_content_type, - delete_bicycle_stands as mobility_data_delete_bicycle_stands, get_bicycle_stand_objects, ) -from mobility_data.importers.utils import create_mobile_unit_as_unit_reference -from services.management.commands.services_import.services import ( - update_service_counts, - update_service_node_counts, -) -from services.models import Service, ServiceNode, Unit, UnitServiceDetails -from smbackend_turku.importers.constants import ( - BICYCLE_STAND_SERVICE_NAME, - BICYCLE_STAND_SERVICE_NAMES, - BICYCLE_STAND_SERVICE_NODE_NAME, - BICYCLE_STAND_SERVICE_NODE_NAMES, -) -from smbackend_turku.importers.utils import ( - create_service, - create_service_node, - delete_external_source, - get_municipality, - set_field, - set_service_names_field, - set_syncher_object_field, - set_tku_translated_field, - UTC_TIMEZONE, -) +from smbackend_turku.importers.utils import BaseExternalSource -class BicycleStandImporter: - - SERVICE_ID = settings.BICYCLE_STANDS_IDS["service"] - SERVICE_NODE_ID = settings.BICYCLE_STANDS_IDS["service_node"] - UNITS_ID_OFFSET = settings.BICYCLE_STANDS_IDS["units_offset"] - SERVICE_NODE_NAME = BICYCLE_STAND_SERVICE_NODE_NAME - SERVICE_NAME = BICYCLE_STAND_SERVICE_NAME - SERVICE_NODE_NAMES = BICYCLE_STAND_SERVICE_NODE_NAMES - SERVICE_NAMES = BICYCLE_STAND_SERVICE_NAMES - - def __init__(self, logger=None, root_service_node_name=None, test_data=None): +class BicycleStandImporter(BaseExternalSource): + def __init__(self, logger=None, config=None, test_data=None): + super().__init__(config) self.logger = logger - self.root_service_node_name = root_service_node_name self.test_data = test_data def import_bicycle_stands(self): - service_id = self.SERVICE_ID self.logger.info("Importing Bicycle Stands...") content_type = create_bicycle_stand_content_type() - saved_bicycle_stands = 0 filtered_objects = get_bicycle_stand_objects() - for i, data_obj in enumerate(filtered_objects): - unit_id = i + self.UNITS_ID_OFFSET - obj = Unit(id=unit_id) - set_field(obj, "location", data_obj.geometry) - set_tku_translated_field(obj, "name", data_obj.prefix_name) - if data_obj.street_address: - set_tku_translated_field(obj, "street_address", data_obj.street_address) - else: - set_tku_translated_field(obj, "street_address", data_obj.name) - extra = {} - extra["model"] = data_obj.model - extra["maintained_by_turku"] = data_obj.maintained_by_turku - extra["number_of_stands"] = data_obj.number_of_stands - extra["number_of_places"] = data_obj.number_of_places - extra["hull_lockable"] = data_obj.hull_lockable - extra["covered"] = data_obj.covered - # Add non prefixed names to extra, so that the front end does not need - # to remove the prefix. - extra["name_fi"] = data_obj.name["fi"] - extra["name_sv"] = data_obj.name["sv"] - extra["name_en"] = data_obj.name["en"] - set_field(obj, "extra", extra) - if data_obj.maintained_by_turku: - # 1 = self produced - set_syncher_object_field(obj, "provider_type", 1) - else: - # 7 = Unknown production method - set_syncher_object_field(obj, "provider_type", 7) - - try: - service = Service.objects.get(id=service_id) - except Service.DoesNotExist: - self.logger.warning('Service "{}" does not exist!'.format(service_id)) - continue - UnitServiceDetails.objects.get_or_create(unit=obj, service=service) - service_nodes = ServiceNode.objects.filter(related_services=service) - obj.service_nodes.add(*service_nodes) - set_field(obj, "root_service_nodes", obj.get_root_service_nodes()[0]) - municipality = get_municipality(data_obj.city) - set_field(obj, "municipality", municipality) - obj.last_modified_time = datetime.now(UTC_TIMEZONE) - set_service_names_field(obj) - if data_obj.related_unit: - data_obj.related_unit.related_units.add(obj) - data_obj.related_unit.save() - obj.save() - create_mobile_unit_as_unit_reference(unit_id, content_type) - saved_bicycle_stands += 1 - update_service_node_counts() - update_service_counts() + super().save_objects_as_units(filtered_objects, content_type) def delete_bicycle_stands(**kwargs): importer = BicycleStandImporter(**kwargs) - delete_external_source( - importer.SERVICE_ID, - importer.SERVICE_NODE_ID, - mobility_data_delete_bicycle_stands, - ) - update_service_node_counts() - update_service_counts() + importer.delete_external_source() def import_bicycle_stands(**kwargs): importer = BicycleStandImporter(**kwargs) - # Delete all Bicycle stand units before storing, to ensure stored data is up to date. - delete_external_source( - importer.SERVICE_ID, - importer.SERVICE_NODE_ID, - mobility_data_delete_bicycle_stands, - ) - - create_service_node( - importer.SERVICE_NODE_ID, - importer.SERVICE_NODE_NAME, - importer.root_service_node_name, - importer.SERVICE_NODE_NAMES, - ) - create_service( - importer.SERVICE_ID, - importer.SERVICE_NODE_ID, - importer.SERVICE_NAME, - importer.SERVICE_NAMES, - ) importer.import_bicycle_stands() diff --git a/smbackend_turku/importers/bike_service_stations.py b/smbackend_turku/importers/bike_service_stations.py index 47389894f..ad468fc41 100644 --- a/smbackend_turku/importers/bike_service_stations.py +++ b/smbackend_turku/importers/bike_service_stations.py @@ -1,120 +1,28 @@ -from datetime import datetime - -from django.conf import settings - from mobility_data.importers.bike_service_stations import ( create_bike_service_station_content_type, - delete_bike_service_stations as mobility_data_delete_bike_service_stations, get_bike_service_station_objects, ) -from mobility_data.importers.utils import create_mobile_unit_as_unit_reference -from services.management.commands.services_import.services import ( - update_service_counts, - update_service_node_counts, -) -from services.models import Service, ServiceNode, Unit, UnitServiceDetails -from smbackend_turku.importers.constants import ( - BIKE_SERVICE_STATION_SERVICE_NAME, - BIKE_SERVICE_STATION_SERVICE_NAMES, - BIKE_SERVICE_STATION_SERVICE_NODE_NAME, - BIKE_SERVICE_STATION_SERVICE_NODE_NAMES, -) -from smbackend_turku.importers.utils import ( - create_service, - create_service_node, - delete_external_source, - get_municipality, - set_field, - set_service_names_field, - set_syncher_object_field, - set_tku_translated_field, - UTC_TIMEZONE, -) - +from smbackend_turku.importers.utils import BaseExternalSource -class BikeServiceStationImporter: - SERVICE_ID = settings.BIKE_SERVICE_STATIONS_IDS["service"] - SERVICE_NODE_ID = settings.BIKE_SERVICE_STATIONS_IDS["service_node"] - UNITS_ID_OFFSET = settings.BIKE_SERVICE_STATIONS_IDS["units_offset"] - SERVICE_NAME = BIKE_SERVICE_STATION_SERVICE_NAME - SERVICE_NAMES = BIKE_SERVICE_STATION_SERVICE_NAMES - SERVICE_NODE_NAME = BIKE_SERVICE_STATION_SERVICE_NODE_NAME - SERVICE_NODE_NAMES = BIKE_SERVICE_STATION_SERVICE_NODE_NAMES - - def __init__(self, logger=None, root_service_node_name=None, test_data=None): +class BikeServiceStationImporter(BaseExternalSource): + def __init__(self, config=None, logger=None, test_data=None): + super().__init__(config) self.logger = logger - self.root_service_node_name = root_service_node_name self.test_data = test_data def import_bike_service_stations(self): - service_id = self.SERVICE_ID self.logger.info("Importing Bike service stations...") content_type = create_bike_service_station_content_type() - saved_bike_service_stations = 0 filtered_objects = get_bike_service_station_objects(geojson_file=self.test_data) - for i, data_obj in enumerate(filtered_objects): - unit_id = i + self.UNITS_ID_OFFSET - obj = Unit(id=unit_id) - set_field(obj, "location", data_obj.geometry) - set_tku_translated_field(obj, "name", data_obj.name) - set_tku_translated_field(obj, "street_address", data_obj.address) - set_tku_translated_field(obj, "description", data_obj.description) - set_field(obj, "extra", data_obj.extra) - # 1 = self produced - set_syncher_object_field(obj, "provider_type", 1) - try: - service = Service.objects.get(id=service_id) - except Service.DoesNotExist: - self.logger.warning('Service "{}" does not exist!'.format(service_id)) - continue - UnitServiceDetails.objects.get_or_create(unit=obj, service=service) - service_nodes = ServiceNode.objects.filter(related_services=service) - obj.service_nodes.add(*service_nodes) - set_field(obj, "root_service_nodes", obj.get_root_service_nodes()[0]) - municipality = get_municipality(data_obj.municipality) - set_field(obj, "municipality", municipality) - set_field(obj, "address_zip", data_obj.zip_code) - - obj.last_modified_time = datetime.now(UTC_TIMEZONE) - set_service_names_field(obj) - obj.save() - create_mobile_unit_as_unit_reference(unit_id, content_type) - saved_bike_service_stations += 1 - update_service_node_counts() - update_service_counts() - self.logger.info(f"Imported {len(filtered_objects)} bike service stations...") + super().save_objects_as_units(filtered_objects, content_type) def delete_bike_service_stations(**kwargs): importer = BikeServiceStationImporter(**kwargs) - delete_external_source( - importer.SERVICE_ID, - importer.SERVICE_NODE_ID, - mobility_data_delete_bike_service_stations, - ) - update_service_node_counts() - update_service_counts() + importer.delete_external_source() def import_bike_service_stations(**kwargs): importer = BikeServiceStationImporter(**kwargs) - # Delete all Bike service station units before storing, to ensure stored data is up to date. - delete_external_source( - importer.SERVICE_ID, - importer.SERVICE_NODE_ID, - mobility_data_delete_bike_service_stations, - ) - create_service_node( - importer.SERVICE_NODE_ID, - importer.SERVICE_NODE_NAME, - importer.root_service_node_name, - importer.SERVICE_NODE_NAMES, - ) - create_service( - importer.SERVICE_ID, - importer.SERVICE_NODE_ID, - importer.SERVICE_NAME, - importer.SERVICE_NAMES, - ) importer.import_bike_service_stations() diff --git a/smbackend_turku/importers/constants.py b/smbackend_turku/importers/constants.py index fb71ba9ad..e69de29bb 100644 --- a/smbackend_turku/importers/constants.py +++ b/smbackend_turku/importers/constants.py @@ -1,52 +0,0 @@ -GAS_FILLING_STATION_SERVICE_NODE_NAME = "Kaasutankkausasemat" -GAS_FILLING_STATION_SERVICE_NAME = "Kaasutankkausasema" -GAS_FILLING_STATION_SERVICE_NODE_NAMES = { - "fi": GAS_FILLING_STATION_SERVICE_NODE_NAME, - "sv": "Tankstationer med gas", - "en": "Gas filling stations", -} - -GAS_FILLING_STATION_SERVICE_NAMES = { - "fi": GAS_FILLING_STATION_SERVICE_NAME, - "sv": "Tankstation med gas", - "en": "Gas filling station", -} - -CHARGING_STATION_SERVICE_NODE_NAME = "Autojen sähkölatauspisteet" -CHARGING_STATION_SERVICE_NAME = "Autojen sähkölatauspiste" -CHARGING_STATION_SERVICE_NODE_NAMES = { - "fi": CHARGING_STATION_SERVICE_NODE_NAME, - "sv": "Elladdningsstationer för bilar", - "en": "Car e-charging points", -} -CHARGING_STATION_SERVICE_NAMES = { - "fi": CHARGING_STATION_SERVICE_NAME, - "sv": "Elladdningsstation för bilar", - "en": "Car e-charging point", -} - -BICYCLE_STAND_SERVICE_NODE_NAME = "Pyöräpysäköinnit" -BICYCLE_STAND_SERVICE_NAME = "Pyöräpysäköinti" -BICYCLE_STAND_SERVICE_NODE_NAMES = { - "fi": BICYCLE_STAND_SERVICE_NODE_NAME, - "sv": "Cykelparkeringar", - "en": "Bicycle parkings", -} -BICYCLE_STAND_SERVICE_NAMES = { - "fi": BICYCLE_STAND_SERVICE_NAME, - "sv": "Cykelparkering", - "en": "Bicycle parking", -} - -BIKE_SERVICE_STATION_SERVICE_NODE_NAME = "Pyöränkorjauspisteet" -BIKE_SERVICE_STATION_SERVICE_NAME = "Pyöränkorjauspiste" -BIKE_SERVICE_STATION_SERVICE_NODE_NAMES = { - "fi": BIKE_SERVICE_STATION_SERVICE_NODE_NAME, - "sv": "Cykelservicestationer", - "en": "Bike service stations", -} -BIKE_SERVICE_STATION_SERVICE_NAMES = { - "fi": BIKE_SERVICE_STATION_SERVICE_NAME, - "sv": "Cykelservicestation", - "en": "Bike service station", -} diff --git a/smbackend_turku/importers/data/divisions_config.yml b/smbackend_turku/importers/data/divisions_config.yml index 1e0f04961..e79b87866 100644 --- a/smbackend_turku/importers/data/divisions_config.yml +++ b/smbackend_turku/importers/data/divisions_config.yml @@ -11,9 +11,10 @@ divisions: wfs_layer: 'GIS:Palveluverkko_suuralueet' fields: name: - fi: Tunnus - origin_id: Tunnus - ocd_id: Tunnus + fi: Tunnus_FIN + sv: Tunnus_FIN + origin_id: Tunnus_FIN + ocd_id: Tunnus_FIN - type: district name: Kaupunginosa @@ -22,9 +23,10 @@ divisions: wfs_layer: 'GIS:Kaupunginosat' fields: name: - fi: Kaupunginosan_Nimi - origin_id: Kaupunginosan_Nimi - ocd_id: Kaupunginosan_Nimi + fi: nimi_FIN + sv: nimi_SVE + origin_id: nimi_FIN + ocd_id: nimi_FIN - type: sub_district name: Pienalue @@ -34,6 +36,8 @@ divisions: fields: name: fi: Numero + sv: Numero + en: Numero origin_id: Numero ocd_id: Numero @@ -55,7 +59,9 @@ divisions: check_turku_boundary: False fields: name: - fi: Tunnus + fi: Tunnus + sv: Tunnus + en: Tunnus origin_id: Tunnus ocd_id: Tunnus @@ -73,6 +79,7 @@ divisions: name: "Oppilaaksiottoalue, ruotsinkielinen alakoulu" ocd_id: oppilaaksiottoalue_alakoulu_sv wfs_layer: 'GIS:Oppilasalueet_ruotsi_1-6' + check_turku_boundary: False fields: name: sv: Oppilasalueen_kuvaus @@ -83,19 +90,10 @@ divisions: name: "Oppilaaksiottoalue, ruotsinkielinen yläkoulu" ocd_id: oppilaaksiottoalue_ylakoulu_sv wfs_layer: 'GIS:Oppilasalueet_ruotsi_7-9' + check_turku_boundary: False fields: name: sv: Oppilasalueen_kuvaus origin_id: Oppilasalueen_kuvaus ocd_id: Oppilasalueen_kuvaus - - - type: parish - name: "Seurakunta" - ocd_id: seurakunta - wfs_layer: 'GIS:Seurakunnat' - fields: - name: - fi: Numero - origin_id: Numero - ocd_id: Numero - + diff --git a/smbackend_turku/importers/data/external_sources_config.yml b/smbackend_turku/importers/data/external_sources_config.yml new file mode 100644 index 000000000..b5643ffb2 --- /dev/null +++ b/smbackend_turku/importers/data/external_sources_config.yml @@ -0,0 +1,84 @@ +external_data_sources: + # The name must be equal(excluding the 'import_' prefix) to the name of function + # that creates the importer instance add calls its importer function + - name: bike_service_stations + root_service_node_name: Vapaa-aika + # The offset of the ids of the units to be created. As the ids of the units comes + # from the source data, an offset must be given to avoid id collision. + units_offset: 500000 + # 1 = self produced, see units model for more details of provider types. + provider_type: 1 + # ID and name of the service that will be created for the units + service: + id: 500000 + name: + fi: Pyöränkorjauspiste + sv: Cykelservicestation + en: Bike service station + # ID and name of the service node that will be created. + service_node: + id: 500000 + name: + fi: Pyöränkorjauspisteet + sv: Cykelservicestationer + en: Bike service stations + # When importing, create mobile_units that has a reference id to the unit. + # The data of the mobile_unit is then serialized from the services_unit table. + create_mobile_units_with_unit_reference: True + # If mobile units area created, the name of the content type that is + # created during import must be given. + mobility_data_content_type_name: BikeServiceStation + + - name: gas_filling_stations + root_service_node_name: Vapaa-aika + units_offset: 200000 + service: + id: 200000 + name: + fi: Kaasutankkausasema + sv: Tankstation med gas + en: Gas filling station + service_node: + id: 200000 + name: + fi: Kaasutankkausasemat + sv: Tankstationer med gas + en: Gas filling stations + create_mobile_units_with_unit_reference: True + mobility_data_content_type_name: GasFillingStation + + - name: charging_stations + root_service_node_name: Vapaa-aika + units_offset: 300000 + service: + id: 300000 + name: + fi: Autojen sähkölatauspiste + sv: Elladdningsstation för bilar + en: Car e-charging point + service_node: + id: 300000 + name: + fi: Autojen sähkölatauspisteet + sv: Elladdningsstationer för bilar + en: Car e-charging points + create_mobile_units_with_unit_reference: True + mobility_data_content_type_name: ChargingStation + + - name: bicycle_stands + root_service_node_name: Vapaa-aika + units_offset: 400000 + service: + id: 400000 + name: + fi: Pyöräpysäköinti + sv: Cykelparkering + en: Bicycle parking + service_node: + id: 400000 + name: + fi: Pyöräpysäköinnit + sv: Cykelparkeringar + en: Bicycle parkings + create_mobile_units_with_unit_reference: True + mobility_data_content_type_name: BicycleStand \ No newline at end of file diff --git a/smbackend_turku/importers/divisions.py b/smbackend_turku/importers/divisions.py index 0cd27a6e2..88ed163b4 100644 --- a/smbackend_turku/importers/divisions.py +++ b/smbackend_turku/importers/divisions.py @@ -46,9 +46,7 @@ def __init__(self, logger=None, importer=None): self.muni_data_path = "data" def _import_division(self, muni, div, type_obj, syncher, parent_dict, feat): - check_turku_boundary = True - if "check_turku_boundary" in div: - check_turku_boundary = div["check_turku_boundary"] + check_turku_boundary = div.get("check_turku_boundary", True) geom = feat.geom if not geom.srid: geom.srid = SOURCE_DATA_SRID diff --git a/smbackend_turku/importers/services.py b/smbackend_turku/importers/services.py index ff369bdb9..f85480386 100644 --- a/smbackend_turku/importers/services.py +++ b/smbackend_turku/importers/services.py @@ -8,14 +8,9 @@ update_service_root_service_nodes, ) from services.models import Service, ServiceNode -from smbackend_turku.importers.bicycle_stands import BicycleStandImporter -from smbackend_turku.importers.bike_service_stations import BikeServiceStationImporter -from smbackend_turku.importers.stations import ( - ChargingStationImporter, - GasFillingStationImporter, -) from smbackend_turku.importers.utils import ( convert_code_to_int, + get_external_sources_yaml_config, get_turku_resource, set_syncher_object_field, set_syncher_tku_translated_field, @@ -24,17 +19,6 @@ UTC_TIMEZONE = pytz.timezone("UTC") SERVICE_AS_SERVICE_NODE_PREFIX = "service_" -""" -List of All external importers that imports data to the services list. -If importer is not added to the list, its imported data will be deleted during importing of Units and Services. -""" -EXTERNAL_IMPORTERS = [ - GasFillingStationImporter, - ChargingStationImporter, - BikeServiceStationImporter, - BicycleStandImporter, -] - BLACKLISTED_SERVICE_NODES = [ "2_1", "2_2", @@ -65,18 +49,20 @@ def _import_service_nodes(self, keyword_handler): self._handle_service_node(parent_node, keyword_handler) if not self.delete_external_sources: - for importer in EXTERNAL_IMPORTERS: - self._handle_external_service_node(importer) + for config in get_external_sources_yaml_config(): + self._handle_external_service_node(config) self.nodesyncher.finish() - def _handle_external_service_node(self, importer): + def _handle_external_service_node(self, config): """ Mark service_node that has been imported from external source. If not marked the nodesyncher.finish() will delete the service node. """ try: - service_node = ServiceNode.objects.get(name=importer.SERVICE_NODE_NAME) + service_node = ServiceNode.objects.get( + name=config["service_node"]["name"]["fi"] + ) service_node = self.nodesyncher.get(service_node.id) self.nodesyncher.mark(service_node) except ServiceNode.DoesNotExist: @@ -89,18 +75,18 @@ def _import_services(self, keyword_handler): self._handle_service(service, keyword_handler) if not self.delete_external_sources: - for importer in EXTERNAL_IMPORTERS: - self._handle_external_service(importer) + for config in get_external_sources_yaml_config(): + self._handle_external_service(config) self.servicesyncher.finish() - def _handle_external_service(self, importer): + def _handle_external_service(self, config): """ Mark service that has been imported from external source. If not marked the servicesyncher.finish() will delete the service. """ try: - service = Service.objects.get(name=importer.SERVICE_NAME) + service = Service.objects.get(name=config["service"]["name"]["fi"]) service = self.servicesyncher.get(service.id) self.servicesyncher.mark(service) except Service.DoesNotExist: diff --git a/smbackend_turku/importers/stations.py b/smbackend_turku/importers/stations.py index b8025cf44..22f16b141 100644 --- a/smbackend_turku/importers/stations.py +++ b/smbackend_turku/importers/stations.py @@ -1,259 +1,57 @@ -from datetime import datetime - -from django.conf import settings - from mobility_data.importers.charging_stations import ( create_charging_station_content_type, - delete_charging_stations as mobility_data_delete_charging_stations, get_charging_station_objects, ) from mobility_data.importers.gas_filling_station import ( create_gas_filling_station_content_type, - delete_gas_filling_stations as mobility_data_delete_gas_filling_stations, get_filtered_gas_filling_station_objects, ) -from mobility_data.importers.utils import create_mobile_unit_as_unit_reference -from services.management.commands.services_import.services import ( - update_service_counts, - update_service_node_counts, -) -from services.models import Service, ServiceNode, Unit, UnitServiceDetails -from smbackend_turku.importers.utils import ( - create_service, - create_service_node, - delete_external_source, - get_municipality, - set_field, - set_service_names_field, - set_tku_translated_field, - UTC_TIMEZONE, -) - -from .constants import ( - CHARGING_STATION_SERVICE_NAME, - CHARGING_STATION_SERVICE_NAMES, - CHARGING_STATION_SERVICE_NODE_NAME, - CHARGING_STATION_SERVICE_NODE_NAMES, - GAS_FILLING_STATION_SERVICE_NAME, - GAS_FILLING_STATION_SERVICE_NAMES, - GAS_FILLING_STATION_SERVICE_NODE_NAME, - GAS_FILLING_STATION_SERVICE_NODE_NAMES, -) - -LANGUAGES = [language[0] for language in settings.LANGUAGES] -SOURCE_DATA_SRID = 4326 - - -def create_language_dict(value): - """ - Helper function that generates a dict with elements for every language with - the value given as parameter. - :param value: the value to be set for all the languages - :return: the dict - """ - lang_dict = {} - languages = [language[0] for language in settings.LANGUAGES] - for lang in languages: - lang_dict[lang] = value - return lang_dict +from smbackend_turku.importers.utils import BaseExternalSource -# def get_first_available_id(model, offset): -# """ -# Find the highest unit id and add 1. This ensures that we get unique ids. -# :param model: the model class -# :return: the highest available id. -# """ -# queryset = model.objects.all() -# if queryset.count() > 0: -# return model.objects.all().order_by("-id")[0].id+1 -# else: -# # This branch is evaluated only when running tests -# return 100000 - - -class GasFillingStationImporter: - - SERVICE_ID = settings.GAS_FILLING_STATIONS_IDS["service"] - SERVICE_NODE_ID = settings.GAS_FILLING_STATIONS_IDS["service_node"] - UNITS_ID_OFFSET = settings.GAS_FILLING_STATIONS_IDS["units_offset"] - - SERVICE_NODE_NAME = GAS_FILLING_STATION_SERVICE_NODE_NAME - SERVICE_NAME = GAS_FILLING_STATION_SERVICE_NAME - SERVICE_NODE_NAMES = GAS_FILLING_STATION_SERVICE_NODE_NAMES - SERVICE_NAMES = GAS_FILLING_STATION_SERVICE_NAMES - - def __init__(self, logger=None, root_service_node_name=None, test_data=None): +class GasFillingStationImporter(BaseExternalSource): + def __init__(self, config=None, logger=None, test_data=None): + super().__init__(config) self.logger = logger - self.root_service_node_name = root_service_node_name self.test_data = test_data def import_gas_filling_stations(self): - service_id = self.SERVICE_ID self.logger.info("Importing gas filling stations...") content_type = create_gas_filling_station_content_type() filtered_objects = get_filtered_gas_filling_station_objects( json_data=self.test_data ) - for i, data_obj in enumerate(filtered_objects): - unit_id = i + self.UNITS_ID_OFFSET - obj = Unit(id=unit_id) - point = data_obj.point - point.transform(SOURCE_DATA_SRID) - set_field(obj, "location", point) - set_tku_translated_field(obj, "name", create_language_dict(data_obj.name)) - set_tku_translated_field(obj, "street_address", data_obj.street_address) - set_tku_translated_field(obj, "address_postal_full", data_obj.address) - set_field(obj, "address_zip", data_obj.zip_code) - description = "{} {}".format(data_obj.operator, data_obj.lng_cng) - set_tku_translated_field( - obj, "description", create_language_dict(description) - ) - extra = {} - extra["operator"] = data_obj.operator - extra["lng_cng"] = data_obj.lng_cng - set_field(obj, "extra", extra) - try: - service = Service.objects.get(id=service_id) - except Service.DoesNotExist: - self.logger.warning('Service "{}" does not exist!'.format(service_id)) - continue - UnitServiceDetails.objects.get_or_create(unit=obj, service=service) - service_nodes = ServiceNode.objects.filter(related_services=service) - obj.service_nodes.add(*service_nodes) - set_field(obj, "root_service_nodes", obj.get_root_service_nodes()[0]) - municipality = get_municipality(data_obj.city) - set_field(obj, "municipality", municipality) - obj.last_modified_time = datetime.now(UTC_TIMEZONE) - set_service_names_field(obj) - obj.save() - # Save to mobile_units tables as reference to services_unit table. - create_mobile_unit_as_unit_reference(unit_id, content_type) - update_service_node_counts() - update_service_counts() - self.logger.info(f"Imported {len(filtered_objects)} gas filling stations...") - + super().save_objects_as_units(filtered_objects, content_type) -class ChargingStationImporter: - SERVICE_ID = settings.CHARGING_STATIONS_IDS["service"] - SERVICE_NODE_ID = settings.CHARGING_STATIONS_IDS["service_node"] - UNITS_ID_OFFSET = settings.CHARGING_STATIONS_IDS["units_offset"] - SERVICE_NODE_NAME = CHARGING_STATION_SERVICE_NODE_NAME - SERVICE_NAME = CHARGING_STATION_SERVICE_NAME - SERVICE_NODE_NAMES = CHARGING_STATION_SERVICE_NODE_NAMES - SERVICE_NAMES = CHARGING_STATION_SERVICE_NAMES - - def __init__( - self, logger=None, importer=None, root_service_node_name=None, test_data=None - ): +class ChargingStationImporter(BaseExternalSource): + def __init__(self, logger=None, config=None, importer=None, test_data=None): + super().__init__(config) self.logger = logger - self.importer = importer - self.root_service_node_name = root_service_node_name self.test_data = test_data def import_charging_stations(self): self.logger.info("Importing charging stations...") - service_id = self.SERVICE_ID filtered_objects = get_charging_station_objects(csv_file=self.test_data) - # create mobility_data content type content_type = create_charging_station_content_type() - for i, data_obj in enumerate(filtered_objects): - unit_id = i + self.UNITS_ID_OFFSET - obj = Unit(id=unit_id) - geometry = data_obj.geometry - geometry.transform(SOURCE_DATA_SRID) - set_field(obj, "location", data_obj.geometry) - set_field(obj, "address_zip", data_obj.zip_code) - set_tku_translated_field(obj, "name", data_obj.name) - set_tku_translated_field(obj, "street_address", data_obj.address) - set_tku_translated_field(obj, "description", CHARGING_STATION_SERVICE_NAMES) - set_field(obj, "extra", data_obj.extra) - try: - service = Service.objects.get(id=service_id) - except Service.DoesNotExist: - self.logger.warning('Service "{}" does not exist!'.format(service_id)) - continue - UnitServiceDetails.objects.get_or_create(unit=obj, service=service) - service_nodes = ServiceNode.objects.filter(related_services=service) - obj.service_nodes.add(*service_nodes) - set_field(obj, "root_service_nodes", obj.get_root_service_nodes()[0]) - municipality = get_municipality(data_obj.municipality) - set_field(obj, "municipality", municipality) - obj.last_modified_time = datetime.now(UTC_TIMEZONE) - set_service_names_field(obj) - obj.save() - # Save to mobile_units tables as reference to services_unit table. - create_mobile_unit_as_unit_reference(unit_id, content_type) - update_service_node_counts() - update_service_counts() - self.logger.info(f"Imported {len(filtered_objects)} charging stations...") + super().save_objects_as_units(filtered_objects, content_type) def delete_gas_filling_stations(**kwargs): importer = GasFillingStationImporter(**kwargs) - # Delete all gas filling station units before storing, to ensure stored data is up-to-date. - delete_external_source( - importer.SERVICE_ID, - importer.SERVICE_NODE_ID, - mobility_data_delete_gas_filling_stations, - ) - update_service_node_counts() - update_service_counts() + importer.delete_external_source() def import_gas_filling_stations(**kwargs): importer = GasFillingStationImporter(**kwargs) - delete_external_source( - importer.SERVICE_ID, - importer.SERVICE_NODE_ID, - mobility_data_delete_gas_filling_stations, - ) - - create_service_node( - importer.SERVICE_NODE_ID, - importer.SERVICE_NODE_NAME, - importer.root_service_node_name, - importer.SERVICE_NODE_NAMES, - ) - create_service( - importer.SERVICE_ID, - importer.SERVICE_NODE_ID, - importer.SERVICE_NAME, - importer.SERVICE_NAMES, - ) importer.import_gas_filling_stations() def delete_charging_stations(**kwargs): importer = ChargingStationImporter(**kwargs) - # Delete all charging station units before storing, to ensure stored data is up-to-date. - delete_external_source( - importer.SERVICE_ID, - importer.SERVICE_NODE_ID, - mobility_data_delete_charging_stations, - ) - update_service_node_counts() - update_service_counts() + importer.delete_external_source() def import_charging_stations(**kwargs): importer = ChargingStationImporter(**kwargs) - delete_external_source( - importer.SERVICE_ID, - importer.SERVICE_NODE_ID, - mobility_data_delete_charging_stations, - ) - create_service_node( - importer.SERVICE_NODE_ID, - importer.SERVICE_NODE_NAME, - importer.root_service_node_name, - importer.SERVICE_NODE_NAMES, - ) - create_service( - importer.SERVICE_ID, - importer.SERVICE_NODE_ID, - importer.SERVICE_NAME, - importer.SERVICE_NAMES, - ) importer.import_charging_stations() diff --git a/smbackend_turku/importers/units.py b/smbackend_turku/importers/units.py index 7ea00056d..d854fedd1 100644 --- a/smbackend_turku/importers/units.py +++ b/smbackend_turku/importers/units.py @@ -24,8 +24,8 @@ UnitServiceDetails, ) from services.utils import AccessibilityShortcomingCalculator -from smbackend_turku.importers.services import EXTERNAL_IMPORTERS from smbackend_turku.importers.utils import ( + get_external_sources_yaml_config, get_localized_value, get_municipality, get_municipality_name_by_point, @@ -114,11 +114,10 @@ def import_units(self): for unit in units: self._handle_unit(unit) if not self.delete_external_source: - for importer in EXTERNAL_IMPORTERS: - self._handle_external_units(importer) + for config in get_external_sources_yaml_config(): + self._handle_external_units(config) self.unitsyncher.finish() - update_service_node_counts() update_service_counts() remove_empty_service_nodes(self.logger) @@ -153,15 +152,14 @@ def _handle_unit(self, unit_data): self._save_object(obj) self.unitsyncher.mark(obj) - def _handle_external_units(self, importer): + def _handle_external_units(self, config): """ Mark units that has been imported from external source. If not marked the unitsyncher.finish() will delete the units. """ - service = None try: - service = Service.objects.get(name=importer.SERVICE_NAME) + service = Service.objects.get(name=config["service"]["name"]["fi"]) except Service.DoesNotExist: pass if service: diff --git a/smbackend_turku/importers/utils.py b/smbackend_turku/importers/utils.py index 8b9e31ba0..8b7714eb4 100644 --- a/smbackend_turku/importers/utils.py +++ b/smbackend_turku/importers/utils.py @@ -6,6 +6,8 @@ import pytz import requests +import yaml +from django import db from django.conf import settings from django.core.exceptions import ObjectDoesNotExist from munigeo.models import ( @@ -15,13 +17,43 @@ Municipality, ) -from services.models import Service, ServiceNode, Unit +from mobility_data.importers.utils import ( + create_mobile_unit_as_unit_reference, + delete_mobile_units, +) +from services.management.commands.services_import.services import ( + update_service_counts, + update_service_node_counts, +) +from services.models import Service, ServiceNode, Unit, UnitServiceDetails # TODO: Change to production endpoint when available TURKU_BASE_URL = "https://digiaurajoki.turku.fi:9443/kuntapalvelut/api/v1/" ACCESSIBILITY_BASE_URL = "https://asiointi.hel.fi/kapaesteettomyys/api/v1/" UTC_TIMEZONE = pytz.timezone("UTC") +data_path = os.path.join(os.path.dirname(__file__), "data") +EXTERNAL_SOURCES_CONFIG_FILE = f"{data_path}/external_sources_config.yml" + + +def get_external_sources_yaml_config(): + config = yaml.safe_load(open(EXTERNAL_SOURCES_CONFIG_FILE, "r", encoding="utf-8")) + return config["external_data_sources"] + + +def get_external_source_config(external_source_name): + config = get_external_sources_yaml_config() + for c in config: + if c["name"] == external_source_name: + return c + return None + + +def get_configured_external_sources_names(config=None): + if not config: + config = get_external_sources_yaml_config() + return [f["name"] for f in config] + def clean_text(text, default=None): if not isinstance(text, str): @@ -258,19 +290,20 @@ def get_turku_boundary(): return None -def create_service_node(service_node_id, name, parent_name, service_node_names): +def create_service_node(service_node_id, parent_name, service_node_names): """ Creates service_node with given name and id if it does not exist. Sets the parent service_node and name fields. :param service_node_id: the id of the service_node to be created. - :param name: name of the service_node. :param parent_name: name of the parent service_node, if None the service_node will be topmost in the tree hierarchy. :param service_node_names: dict with names in all languages """ service_node = None try: - service_node = ServiceNode.objects.get(id=service_node_id, name=name) + service_node = ServiceNode.objects.get( + id=service_node_id, name=service_node_names["fi"] + ) except ServiceNode.DoesNotExist: service_node = ServiceNode(id=service_node_id) @@ -291,18 +324,17 @@ def create_service_node(service_node_id, name, parent_name, service_node_names): service_node.save() -def create_service(service_id, service_node_id, service_name, service_names): +def create_service(service_id, service_node_id, service_names): """ Creates service with given service_id and name if it does not exist. Adds the service to the given service_node and sets the name fields. :param service_id: the id of the service. :param service_node_id: the id of the service_node to which the service will have a relation - :param service_name: name of the service :param service_names: dict with names in all languages """ service = None try: - service = Service.objects.get(id=service_id, name=service_name) + service = Service.objects.get(id=service_id, name=service_names["fi"]) except Service.DoesNotExist: service = Service( id=service_id, clarification_enabled=False, period_enabled=False @@ -314,8 +346,11 @@ def create_service(service_id, service_node_id, service_name, service_names): service.save() +@db.transaction.atomic def delete_external_source( - service_id, service_node_id, mobility_data_delete_func=False + service_id, + service_node_id, + mobile_units_content_type_name, ): """ Deletes the data source from services list and optionally from mobility_data. @@ -323,5 +358,78 @@ def delete_external_source( Unit.objects.filter(services__id=service_id).delete() Service.objects.filter(id=service_id).delete() ServiceNode.objects.filter(id=service_node_id).delete() - if mobility_data_delete_func: - mobility_data_delete_func() + delete_mobile_units(mobile_units_content_type_name) + update_service_node_counts() + update_service_counts() + + +class BaseExternalSource: + def __init__(self, config): + self.config = config + self.SERVICE_ID = config["service"]["id"] + self.SERVICE_NODE_ID = config["service_node"]["id"] + self.UNITS_ID_OFFSET = config["units_offset"] + self.SERVICE_NAME = config["service"]["name"]["fi"] + self.SERVICE_NAMES = config["service"]["name"] + self.SERVICE_NODE_NAME = config["service_node"]["name"]["fi"] + self.SERVICE_NODE_NAMES = config["service_node"]["name"] + self.delete_external_source() + create_service_node( + self.config["service_node"]["id"], + self.config["root_service_node_name"], + self.config["service_node"]["name"], + ) + create_service( + self.config["service"]["id"], + self.config["service_node"]["id"], + self.config["service"]["name"], + ) + + def delete_external_source(self): + delete_external_source( + self.config["service"]["id"], + self.config["service_node"]["id"], + self.config["mobility_data_content_type_name"], + ) + + @db.transaction.atomic + def save_objects_as_units(self, objects, content_type): + for i, object in enumerate(objects): + unit_id = i + self.UNITS_ID_OFFSET + unit = Unit(id=unit_id) + set_field(unit, "location", object.geometry) + set_tku_translated_field(unit, "name", object.name) + set_tku_translated_field(unit, "street_address", object.address) + if hasattr(object, "description"): + set_tku_translated_field(unit, "description", object.description) + if hasattr(object, "extra"): + set_field(unit, "extra", object.extra) + if "provider_type" in self.config: + set_syncher_object_field( + unit, "provider_type", self.config["provider_type"] + ) + try: + service = Service.objects.get(id=self.SERVICE_ID) + except Service.DoesNotExist: + self.logger.warning( + 'Service "{}" does not exist!'.format(self.SERVICE_ID) + ) + continue + UnitServiceDetails.objects.get_or_create(unit=unit, service=service) + service_nodes = ServiceNode.objects.filter(related_services=service) + unit.service_nodes.add(*service_nodes) + set_field(unit, "root_service_nodes", unit.get_root_service_nodes()[0]) + if hasattr(object, "municipality"): + municipality = get_municipality(object.municipality) + set_field(unit, "municipality", municipality) + + if hasattr(object, "address_zip"): + set_field(unit, "address_zip", object.address_zip) + unit.last_modified_time = datetime.datetime.now(UTC_TIMEZONE) + set_service_names_field(unit) + unit.save() + if self.config.get("create_mobile_units_with_unit_reference", False): + create_mobile_unit_as_unit_reference(unit_id, content_type) + update_service_node_counts() + update_service_counts() + self.logger.info(f"Imported {len(objects)} {self.config['name']}...") diff --git a/smbackend_turku/management/commands/turku_services_import.py b/smbackend_turku/management/commands/turku_services_import.py index a66ac3d68..d3bff40eb 100644 --- a/smbackend_turku/management/commands/turku_services_import.py +++ b/smbackend_turku/management/commands/turku_services_import.py @@ -9,11 +9,11 @@ from smbackend_turku.importers.accessibility import import_accessibility from smbackend_turku.importers.addresses import import_addresses -from smbackend_turku.importers.bicycle_stands import ( +from smbackend_turku.importers.bicycle_stands import ( # noqa: F401 delete_bicycle_stands, import_bicycle_stands, ) -from smbackend_turku.importers.bike_service_stations import ( +from smbackend_turku.importers.bike_service_stations import ( # noqa: F401 delete_bike_service_stations, import_bike_service_stations, ) @@ -23,28 +23,35 @@ import_geo_search_addresses, ) from smbackend_turku.importers.services import import_services -from smbackend_turku.importers.stations import ( +from smbackend_turku.importers.stations import ( # noqa: F401 delete_charging_stations, delete_gas_filling_stations, import_charging_stations, import_gas_filling_stations, ) from smbackend_turku.importers.units import import_units +from smbackend_turku.importers.utils import get_external_source_config # noqa: F401 +from smbackend_turku.importers.utils import get_configured_external_sources_names + +IMPORTER_FUNCTIONS_CODE = """ +@db.transaction.atomic +def import_{name}(self): + config = get_external_source_config("{name}") + import_{name}(logger=self.logger, config=config) +@db.transaction.atomic +def delete_{name}(self): + config = get_external_source_config("{name}") + delete_{name}(logger=self.logger, config=config) +""" class Command(BaseCommand): help = "Import services from City of Turku APIs and from external sources." # Umbrella source that imports all external_sources - MOBILITY_DATA = "mobility_data" - - external_sources = [ - "gas_filling_stations", - "charging_stations", - "bicycle_stands", - "bike_service_stations", - ] + EXTERNAL_SOURCES = "external_sources" + external_sources = get_configured_external_sources_names() importer_types = [ "services", "accessibility", @@ -53,11 +60,15 @@ class Command(BaseCommand): "geo_search_addresses", "enriched_addresses", "divisions", - MOBILITY_DATA, + EXTERNAL_SOURCES, ] + external_sources supported_languages = [lang[0] for lang in settings.LANGUAGES] + for name in external_sources: + code = IMPORTER_FUNCTIONS_CODE.format(name=name) + exec(code) + def __init__(self): super(Command, self).__init__() for imp in self.importer_types: @@ -136,55 +147,16 @@ def import_divisions(self): return import_divisions(logger=self.logger) @db.transaction.atomic - def import_gas_filling_stations(self): - import_gas_filling_stations( - logger=self.logger, root_service_node_name="Vapaa-aika" - ) - - @db.transaction.atomic - def delete_gas_filling_stations(self): - delete_gas_filling_stations(logger=self.logger) - - @db.transaction.atomic - def import_charging_stations(self): - import_charging_stations( - logger=self.logger, root_service_node_name="Vapaa-aika" - ) - - @db.transaction.atomic - def delete_charging_stations(self): - delete_charging_stations(logger=self.logger) - - @db.transaction.atomic - def import_bicycle_stands(self): - import_bicycle_stands( - logger=self.logger, - root_service_node_name="Vapaa-aika", - ) - - @db.transaction.atomic - def delete_bicycle_stands(self): - delete_bicycle_stands(logger=self.logger) - - @db.transaction.atomic - def delete_bike_service_stations(self): - delete_bike_service_stations(logger=self.logger) - - @db.transaction.atomic - def import_bike_service_stations(self): - import_bike_service_stations( - logger=self.logger, root_service_node_name="Vapaa-aika" - ) - - @db.transaction.atomic - def import_mobility_data(self): - self.import_bicycle_stands() - self.import_gas_filling_stations() + def import_external_sources(self): + for name in self.external_sources: + method = getattr(self, "import_%s" % name) + method() # Activate the default language for the duration of the import # to make sure translated fields are populated correctly. @translation.override(settings.LANGUAGES[0][0]) def handle(self, **options): + self.options = options self.verbosity = int(options.get("verbosity", 1)) self.logger = logging.getLogger("turku_services_import") @@ -207,12 +179,12 @@ def handle(self, **options): sys.stderr.write("Nothing to delete.\n") else: importers = self.options["import_types"] - if self.MOBILITY_DATA in self.options["import_types"]: - # Add external sources and by creating a set ensure there are no duplicates - # as the user can add args as mobility_data charging_stations + if self.EXTERNAL_SOURCES in self.options["import_types"]: + # Add EXTERNAL_SOURCES by creating a set to ensure there are no duplicates + # as the user can add args as EXTERNAL_SOURCES charging_stations importers = set(importers + self.external_sources) - # remove the mobility_data source as it has no function attached to it. - importers.remove(self.MOBILITY_DATA) + # remove the EXTERNAL_SOURCES source as it has no function attached to it. + importers.remove(self.EXTERNAL_SOURCES) import_count = 0 for imp in self.importer_types: diff --git a/smbackend_turku/tasks.py b/smbackend_turku/tasks.py index 517983c7f..a36ab80eb 100644 --- a/smbackend_turku/tasks.py +++ b/smbackend_turku/tasks.py @@ -1,30 +1,31 @@ -from celery import shared_task from django.core import management +from smbackend.utils import shared_task_email -@shared_task + +@shared_task_email def turku_services_import(args, name="turku_services_import"): management.call_command("turku_services_import", args) -@shared_task +@shared_task_email def delete_external_source(source, name="delete_external_source"): management.call_command("turku_services_import", source, "--delete-external-source") -@shared_task +@shared_task_email def import_mds_data(name="import_mds_data"): management.call_command( "turku_services_import", "services", "accessibility", "units" ) -@shared_task +@shared_task_email def import_division(name="import_division"): management.call_command("turku_services_import", "divisions") -@shared_task +@shared_task_email def import_all_addresses(name="import_all_addresses"): # Task that imports all the addresses and indexes search columns management.call_command("turku_services_import", "addresses") @@ -33,54 +34,54 @@ def import_all_addresses(name="import_all_addresses"): management.call_command("index_search_columns") -@shared_task +@shared_task_email def import_addresses(name="import_addresses"): # Imports addresses for Turku and Karina from the WFS server hosted by Turku management.call_command("turku_services_import", "addresses") -@shared_task +@shared_task_email def geo_import_municipalities(name="geo_import_municipalities"): management.call_command("geo_import", "finland", "--municipalities") -@shared_task +@shared_task_email def index_search_columns(name="index_search_columns"): management.call_command("index_search_columns") -@shared_task +@shared_task_email def import_geo_search_addresses(name="import_geo_search_addresses"): # Imports the addresses of Southwest Finland(not Turku and Kaarina) from geo-search(paikkatietohaku) management.call_command("turku_services_import", "geo_search_addresses") -@shared_task +@shared_task_email def import_enriched_addresses(name="import_enriched_addresses"): # Enriches addresses for Turku and Karina from geo-search(paikkatietohaku) management.call_command("turku_services_import", "enriched_addresses") -@shared_task +@shared_task_email def import_bicycle_stands(name="import_bicycle_stands"): management.call_command("turku_services_import", "bicycle_stands") -@shared_task +@shared_task_email def import_bike_service_stations(name="bike_service_stations"): management.call_command("turku_services_import", "bike_service_stations") -@shared_task +@shared_task_email def import_gas_filling_stations(name="import_gas_filling_stations"): management.call_command("turku_services_import", "gas_filling_stations") -@shared_task +@shared_task_email def import_charging_stations(name="import_charging_stations"): management.call_command("turku_services_import", "charging_stations") -@shared_task -def import_mobility_data(name="import_mobility_data"): - management.call_command("turku_services_import", "mobility_data") +@shared_task_email +def import_external_sources(name="import_external_sources"): + management.call_command("turku_services_import", "external_sources") diff --git a/smbackend_turku/tests/test_bike_service_stations.py b/smbackend_turku/tests/test_bike_service_stations.py index 266c1905f..b8dfe7fc1 100644 --- a/smbackend_turku/tests/test_bike_service_stations.py +++ b/smbackend_turku/tests/test_bike_service_stations.py @@ -7,10 +7,7 @@ from services.models import Service, ServiceNode, Unit from smbackend_turku.importers.bike_service_stations import import_bike_service_stations -from smbackend_turku.importers.constants import ( - BIKE_SERVICE_STATION_SERVICE_NAMES, - BIKE_SERVICE_STATION_SERVICE_NODE_NAMES, -) +from smbackend_turku.importers.utils import get_external_source_config @pytest.mark.django_db @@ -22,26 +19,25 @@ def test_bike_service_stations_import( ): logger = logging.getLogger(__name__) utc_timezone = pytz.timezone("UTC") + config = get_external_source_config("bike_service_stations") # create root servicenode to which the imported service_node will connect ServiceNode.objects.create( - id=42, name="TestRoot", last_modified_time=datetime.now(utc_timezone) + id=42, name="Vapaa-aika", last_modified_time=datetime.now(utc_timezone) ) import_bike_service_stations( logger=logger, - root_service_node_name="TestRoot", + config=config, test_data="bike_service_stations.geojson", ) assert Unit.objects.all().count() == 3 Service.objects.all().count() == 1 - service = Service.objects.all()[0] - assert service.name == BIKE_SERVICE_STATION_SERVICE_NAMES["fi"] - assert service.name_sv == BIKE_SERVICE_STATION_SERVICE_NAMES["sv"] - assert service.name_en == BIKE_SERVICE_STATION_SERVICE_NAMES["en"] - service_node = ServiceNode.objects.get( - name=BIKE_SERVICE_STATION_SERVICE_NODE_NAMES["fi"] - ) - assert service_node.name_sv == BIKE_SERVICE_STATION_SERVICE_NODE_NAMES["sv"] - assert service_node.name_en == BIKE_SERVICE_STATION_SERVICE_NODE_NAMES["en"] + service = Service.objects.first() + assert service.name == config["service"]["name"]["fi"] + assert service.name_sv == config["service"]["name"]["sv"] + assert service.name_en == config["service"]["name"]["en"] + service_node = ServiceNode.objects.get(name=config["service_node"]["name"]["fi"]) + assert service_node.name_sv == config["service_node"]["name"]["sv"] + assert service_node.name_en == config["service_node"]["name"]["en"] nauvo = Unit.objects.get(name="Nauvo") assert nauvo.name_sv == "Nagu" assert nauvo.name_en == "Nauvo" diff --git a/smbackend_turku/tests/test_charging_stations.py b/smbackend_turku/tests/test_charging_stations.py index 6508f2670..095ec1bfe 100644 --- a/smbackend_turku/tests/test_charging_stations.py +++ b/smbackend_turku/tests/test_charging_stations.py @@ -5,11 +5,8 @@ import pytz from services.models import Service, ServiceNode, Unit -from smbackend_turku.importers.constants import ( - CHARGING_STATION_SERVICE_NAMES, - CHARGING_STATION_SERVICE_NODE_NAMES, -) from smbackend_turku.importers.stations import import_charging_stations +from smbackend_turku.importers.utils import get_external_source_config @pytest.mark.django_db @@ -24,34 +21,32 @@ def test_charging_stations_import( ): logger = logging.getLogger(__name__) utc_timezone = pytz.timezone("UTC") + config = get_external_source_config("gas_filling_stations") + # create root servicenode to which the imported service_node will connect ServiceNode.objects.create( - id=42, name="TestRoot", last_modified_time=datetime.now(utc_timezone) + id=42, name="Vapaa-aika", last_modified_time=datetime.now(utc_timezone) ) - import_charging_stations( logger=logger, - root_service_node_name="TestRoot", + config=config, test_data="charging_stations.csv", ) assert Unit.objects.all().count() == 3 Service.objects.all().count() == 1 - service = Service.objects.all()[0] - assert service.name == CHARGING_STATION_SERVICE_NAMES["fi"] - assert service.name_sv == CHARGING_STATION_SERVICE_NAMES["sv"] - assert service.name_en == CHARGING_STATION_SERVICE_NAMES["en"] - service_node = ServiceNode.objects.get( - name=CHARGING_STATION_SERVICE_NODE_NAMES["fi"] - ) - assert service_node.name_sv == CHARGING_STATION_SERVICE_NODE_NAMES["sv"] - assert service_node.name_en == CHARGING_STATION_SERVICE_NODE_NAMES["en"] + service = Service.objects.first() + assert service.name == config["service"]["name"]["fi"] + assert service.name_sv == config["service"]["name"]["sv"] + assert service.name_en == config["service"]["name"]["en"] + service_node = ServiceNode.objects.get(name=config["service_node"]["name"]["fi"]) + assert service_node.name_sv == config["service_node"]["name"]["sv"] + assert service_node.name_en == config["service_node"]["name"]["en"] aimopark = Unit.objects.get(name="Aimopark, Yliopistonkatu 29") assert aimopark.name_sv == "Aimopark, Universitetsgatan 29" assert aimopark.street_address == "Yliopistonkatu 29" assert aimopark.street_address_sv == "Universitetsgatan 29" assert aimopark.municipality.name == "Turku" assert aimopark.address_zip == "20100" - assert aimopark.description_sv == CHARGING_STATION_SERVICE_NAMES["sv"] assert aimopark.root_service_nodes == "42" assert aimopark.services.count() == 1 - assert aimopark.services.all()[0] == service + assert aimopark.services.first() == service diff --git a/smbackend_turku/tests/test_gas_filling_stations.py b/smbackend_turku/tests/test_gas_filling_stations.py index 77d676431..afdf4b749 100644 --- a/smbackend_turku/tests/test_gas_filling_stations.py +++ b/smbackend_turku/tests/test_gas_filling_stations.py @@ -3,23 +3,22 @@ import pytest import pytz -from django.conf import settings from services.models import Service, ServiceNode, Unit -from smbackend_turku.importers.stations import ( - GasFillingStationImporter as Importer, - import_gas_filling_stations, -) +from smbackend_turku.importers.stations import import_gas_filling_stations +from smbackend_turku.importers.utils import get_external_source_config from smbackend_turku.tests.utils import create_municipalities, get_test_resource @pytest.mark.django_db def test_gas_filling_stations_import(): logger = logging.getLogger(__name__) + config = get_external_source_config("gas_filling_stations") + utc_timezone = pytz.timezone("UTC") # create root servicenode to which the imported service_node will connect root_service_node = ServiceNode.objects.create( - id=42, name="TestRoot", last_modified_time=datetime.now(utc_timezone) + id=42, name="Vapaa-aika", last_modified_time=datetime.now(utc_timezone) ) # Municipality must be created in order to update_service_node_count() # to execute without errors @@ -27,24 +26,21 @@ def test_gas_filling_stations_import(): # Import using fixture data import_gas_filling_stations( logger=logger, - root_service_node_name="TestRoot", + config=config, test_data=get_test_resource(resource_name="gas_filling_stations"), ) - service = Service.objects.get(name=Importer.SERVICE_NAME) - assert service - assert service.id == settings.GAS_FILLING_STATIONS_IDS["service"] - service_node = ServiceNode.objects.get(name=Importer.SERVICE_NODE_NAME) - assert service_node - assert service_node.id == settings.GAS_FILLING_STATIONS_IDS["service_node"] + service = Service.objects.get(name=config["service"]["name"]["fi"]) + assert service.id == config["service"]["id"] + service_node = ServiceNode.objects.get(name=config["service_node"]["name"]["fi"]) + assert service_node.id == config["service_node"]["id"] assert service_node.parent.id == root_service_node.id assert Unit.objects.all().count() == 2 - assert Unit.objects.all()[1].id == settings.GAS_FILLING_STATIONS_IDS["units_offset"] + assert Unit.objects.all()[1].id == config["units_offset"] assert Unit.objects.get(name="Raisio Kuninkoja") unit = Unit.objects.get(name="Turku Satama") - assert unit assert pytest.approx(unit.location.x, 0.0000000001) == 236760.1062021295 assert unit.extra["operator"] == "Gasum" assert unit.service_nodes.all().count() == 1 assert unit.services.all().count() == 1 - assert unit.services.all()[0].name == Importer.SERVICE_NAME - assert unit.service_nodes.all()[0].name == Importer.SERVICE_NODE_NAME + assert unit.services.first().name == config["service"]["name"]["fi"] + assert unit.service_nodes.first().name == config["service_node"]["name"]["fi"] diff --git a/street_maintenance/README.md b/street_maintenance/README.md index c819906a9..adc27f18e 100644 --- a/street_maintenance/README.md +++ b/street_maintenance/README.md @@ -1,53 +1,43 @@ -# Street Maintance +# Street Maintenance history Django app for importing and serving street maintenance data. -## Importers +## Importer +Name: +import_street_maintenance_history -### Infraroad -``` -./manage.py import_infraroad_street_maintenance_history -``` -### YIT -``` -./manage.py import_yit_street_maintenance_history -``` -### Kuntec +Providers: +* YIT +* KUNTEC +* INFRAROAD +* DESTIA + +Parameters: +* --providers, list of providers to import, e.g., --provider yit kuntec +* --history-size, the number of days to import (default is 4 and max is 31) +* --fetch-size (only available for infraroad and destia), the number of works to import per unit(default is 10000). + +### Examples: +To import DESTIA street maintenance history with history size 2: ``` -./manage.py import_kuntec_street_maintenance_history +./manage.py import_street_maintenance_history --providers destia --history-size 2 ``` - -### Destia +To import KUNTEC and INFRAROAD street maintenance history: ``` -./manage.py import_destia_street_maintenance_history +./manage.py import_street_maintenance_history --providers kuntec infraroad ``` +Note, only the MaintenanceWorks and MaintenanceUnits for the given provider from the latest import are stored and the rest are deleted. The GeometryHistory is generated only if more than one MaintenanceWork is created. ### Periodically imorting To periodically import data use Celery, for more information [see](https://github.com/City-of-Turku/smbackend/wiki/Celery-Tasks#street-maintenance-history-street_maintenancetasksimport_street_maintenance_history). -### Deleting street maintenance history for a provider +## Deleting street maintenance history for a provider It is possible to delete street maintenance history for a provider. e.g., to delete all street maintenance history for provider 'destia': ``` ./manage.py delete_street_maintenance_history destia ``` -## History sizes -To set the history size use the '--history-size' parameter and give the value as argument. -e.g., would import the Autori data for the last 30 days. -``` -./manage.py import_yit_street_maintenance_history --history-size 30 -``` -### Infraroad -The default history size for a infraroad maintenance unit is 10000. That is works per unit. A work contains the timestamp, point data and events. -### Destia -The default history size for a Destia maintenance unit is 10000. -### Yit -The history size is in days. The default is 5. -Note, the max size for Autori history is 31 days. -### Kuntec -The history size is in days. The default is 5. -Note, the max size for Kuntec history is 31 days. ## API See: specificatin.swagger.yaml \ No newline at end of file diff --git a/street_maintenance/api/serializers.py b/street_maintenance/api/serializers.py index 965a119cc..6ae513acd 100644 --- a/street_maintenance/api/serializers.py +++ b/street_maintenance/api/serializers.py @@ -51,6 +51,7 @@ class Meta: "geometry", "timestamp", "events", + "original_event_names", ] def to_representation(self, obj): diff --git a/street_maintenance/api/views.py b/street_maintenance/api/views.py index fea6ecba8..b8440ef1b 100644 --- a/street_maintenance/api/views.py +++ b/street_maintenance/api/views.py @@ -20,15 +20,14 @@ from street_maintenance.models import GeometryHistory, MaintenanceUnit, MaintenanceWork UTC_TIMEZONE = pytz.timezone("UTC") - -# Default is 3minutes 3*60s -DEFAULT_MAX_WORK_LENGTH = 180 +EXAMPLE_TIME_FORMAT = "YYYY-MM-DD HH:MM:SS" +EXAMPLE_TIME = "2022-09-18 10:00:00" EVENT_PARAM = OpenApiParameter( name="event", location=OpenApiParameter.QUERY, description=( "Return objects of given event. " - 'Event choices are: "auraus", "liukkaudentorjunta", "hiekanpoisto", "puhtaanapito", ' + f'Event choices are: {", ".join(PROVIDERS).lower()}, ' 'E.g. "auraus".' ), required=False, @@ -46,7 +45,7 @@ location=OpenApiParameter.QUERY, description=( "Get objects with timestamp newer than the start_date_time. " - 'The format for the timestamp is: YYYY-MM-DD HH:MM:SS, e.g. "2022-09-18 10:00:00".' + f'The format for the timestamp is: {EXAMPLE_TIME_FORMAT}, e.g. "{EXAMPLE_TIME}".' ), required=False, type=str, @@ -59,7 +58,7 @@ class LargeResultsSetPagination(PageNumberPagination): """ page_size_query_param = "page_size" - max_page_size = 50_000 + max_page_size = 200_000 class ActiveEventsViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): @@ -77,13 +76,16 @@ class ActiveEventsViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): @extend_schema_view( list=extend_schema( parameters=_maintenance_works_list_parameters, - description="A maintenance work is a single work performed by a provider. The geometry can be a point or a " - "linestring. Note, if the geometry is a point, the latitude and longitude will be separately serialized." - " If the geometry is a linestring a separate list of coordinates will be serialized.", + description="A MaintenanceWork object is a single work performed by a provider. The geometry can be a point " + "or a linestring. The geometry and timestamp are assigned directly from the source data. The " + "original_event_names contains the names of the events in the source data and the events field is the mapped " + "names. Note, if the geometry is a point, the latitude and longitude will be separately serialized. If the " + "geometry is a linestring a separate list of coordinates will be serialized.", ) ) class MaintenanceWorkViewSet(viewsets.ReadOnlyModelViewSet): serializer_class = MaintenanceWorkSerializer + pagination_class = LargeResultsSetPagination def get_queryset(self): queryset = MaintenanceWork.objects.all() @@ -106,7 +108,7 @@ def get_queryset(self): ) except ValueError: raise ParseError( - "'start_date_time' must be in format YYYY-MM-DD HH:MM:SS elem.g.,'2022-09-18 10:00:00'" + f"'start_date_time' must be in format {EXAMPLE_TIME_FORMAT} elem.g.,'{EXAMPLE_TIME}'" ) start_date_time = start_date_time.replace(tzinfo=UTC_TIMEZONE) queryset = queryset.filter(timestamp__gte=start_date_time) @@ -126,8 +128,10 @@ def list(self, request): @extend_schema_view( list=extend_schema( - description="Maintanance units from where the works are derived.", - ) + description="MaintananceUnit objets are the entities that creates the MaintenanceWorks. Every MaintenanceWork " + "has a relation to a MaintenanceUnit. The type of the MaintenanceUnit can vary depending on the provider. It " + "can be a machine or a event", + ), ) class MaintenanceUnitViewSet(viewsets.ReadOnlyModelViewSet): queryset = MaintenanceUnit.objects.all() @@ -144,8 +148,11 @@ class MaintenanceUnitViewSet(viewsets.ReadOnlyModelViewSet): @extend_schema_view( list=extend_schema( parameters=_geometry_history_list_parameters, - description="Returns objects where geometries are precalculated/processed from point data or linestrings." - "The coordinates are in SRID 4326.", + description="GeometryHistory objects are processed from MaintenanceWork objects. MaintenanceWorks with " + "linestrings are validated and if valid a GeometryHistory object is created with the linestring geometry. " + "From MaintenanceWork objects that cointains point data linestrings are generated. The linestrings are " + "generated by comparing the timestamp, event, distance and provider. For every valid generated linestring a " + "GeometryHistory object is created. The coordinates are in SRID 4326.", ) ) class GeometryHitoryViewSet(viewsets.ReadOnlyModelViewSet): @@ -173,7 +180,7 @@ def get_queryset(self): ) except ValueError: raise ParseError( - "'start_date_time' must be in format YYYY-MM-DD HH:MM:SS elem.g.,'2022-09-18 10:00:00'" + f"'start_date_time' must be in format {EXAMPLE_TIME_FORMAT} elem.g.,'{EXAMPLE_TIME}'" ) start_date_time = start_date_time.replace(tzinfo=UTC_TIMEZONE) queryset = queryset.filter(timestamp__gte=start_date_time) diff --git a/street_maintenance/management/commands/base_import_command.py b/street_maintenance/management/commands/base_import_command.py deleted file mode 100644 index eb102f8ed..000000000 --- a/street_maintenance/management/commands/base_import_command.py +++ /dev/null @@ -1,16 +0,0 @@ -import logging -from datetime import datetime - -from django.core.management import BaseCommand - -logger = logging.getLogger("street_maintenance") - - -class BaseImportCommand(BaseCommand): - def __init__(self): - self.start_time = datetime.now() - - def display_duration(self, provider): - end_time = datetime.now() - duration = end_time - self.start_time - logger.info(f"Imported {provider} street maintenance history in: {duration}") diff --git a/street_maintenance/management/commands/constants.py b/street_maintenance/management/commands/constants.py index 698818ec6..96254bce6 100644 --- a/street_maintenance/management/commands/constants.py +++ b/street_maintenance/management/commands/constants.py @@ -1,3 +1,5 @@ +import types + from django.conf import settings KUNTEC_KEY = settings.KUNTEC_KEY @@ -12,6 +14,11 @@ (DESTIA, "Destia"), ) PROVIDERS = [INFRAROAD, YIT, KUNTEC, DESTIA] +PROVIDER_TYPES = types.SimpleNamespace() +PROVIDER_TYPES.YIT = YIT +PROVIDER_TYPES.KUNTEC = KUNTEC +PROVIDER_TYPES.DESTIA = DESTIA +PROVIDER_TYPES.INFRAROAD = INFRAROAD UNITS = "UNITS" WORKS = "WORKS" @@ -130,6 +137,8 @@ "pysäkkikatosten hoito": [MUUT], "liikennemerkkien puhdistus": [MUUT], "siirtoajo": [MUUT], + "Kelintarkastus": [MUUT], + "Sulamisvesien hallinta / höyrytys": [MUUT], } TIMESTAMP_FORMATS = { INFRAROAD: "%Y-%m-%d %H:%M:%S", @@ -137,9 +146,15 @@ KUNTEC: "%Y-%m-%dT%H:%M:%SZ", YIT: "%Y-%m-%d %H:%M:%S%z", } +DATE_FORMATS = { + INFRAROAD: "%Y-%m-%d", + DESTIA: "%Y-%m-%d", + KUNTEC: "%Y-%m-%d", + YIT: "%Y-%m-%d", +} # GeometryHistory API list start_date_time parameter format. START_DATE_TIME_FORMAT = "%Y-%m-%d %H:%M:%S" -# The number of works(point data with timestamp and event) to be fetched for every unit. +# The number of works(point data with a timestamp and events) to be fetched for every unit. INFRAROAD_DEFAULT_WORKS_FETCH_SIZE = 10000 DESTIA_DEFAULT_WORKS_FETCH_SIZE = 10000 # In days, Note if value is increased the fetch size should also be increased. @@ -152,3 +167,17 @@ KUNTEC_DEFAULT_WORKS_HISTORY_SIZE = 4 KUNTEC_MAX_WORKS_HISTORY_SIZE = 31 +HISTORY_SIZE = "history_size" +FETCH_SIZE = "fetch_size" +HISTORY_SIZES = { + INFRAROAD: { + HISTORY_SIZE: INFRAROAD_DEFAULT_WORKS_HISTORY_SIZE, + FETCH_SIZE: INFRAROAD_DEFAULT_WORKS_FETCH_SIZE, + }, + DESTIA: { + HISTORY_SIZE: DESTIA_DEFAULT_WORKS_HISTORY_SIZE, + FETCH_SIZE: DESTIA_DEFAULT_WORKS_FETCH_SIZE, + }, + KUNTEC: {HISTORY_SIZE: KUNTEC_DEFAULT_WORKS_HISTORY_SIZE}, + YIT: {HISTORY_SIZE: YIT_DEFAULT_WORKS_HISTORY_SIZE}, +} diff --git a/street_maintenance/management/commands/delete_street_maintenance_history.py b/street_maintenance/management/commands/delete_street_maintenance_history.py index 1ed0f09cf..ec91b0193 100644 --- a/street_maintenance/management/commands/delete_street_maintenance_history.py +++ b/street_maintenance/management/commands/delete_street_maintenance_history.py @@ -1,8 +1,9 @@ import logging +from django.core.management import BaseCommand + from street_maintenance.models import GeometryHistory, MaintenanceUnit -from .base_import_command import BaseImportCommand from .constants import PROVIDERS logger = logging.getLogger("mobility_data") @@ -11,37 +12,50 @@ PROVIDERS.append("AUTORI") -class Command(BaseImportCommand): +class Command(BaseCommand): def add_arguments(self, parser): - parser.add_argument("provider", nargs="+", help=", ".join(PROVIDERS)) + parser.add_argument( + "providers", + type=str, + nargs="*", + help=", ".join(PROVIDERS), + ) def handle(self, *args, **options): - provider = options["provider"][0].upper() - if provider not in PROVIDERS: - logger.error( - f"Invalid provider argument, choices are: {', '.join(PROVIDERS)}" - ) - return - - logger.info(f"Deleting street maintenance history for {provider}.") - provider = provider.upper() - deleted_units = MaintenanceUnit.objects.filter(provider=provider).delete() - deleted_histories = GeometryHistory.objects.filter(provider=provider).delete() - if "street_maintenance.MaintenanceUnit" in deleted_units[1]: - num_deleted_units = deleted_units[1]["street_maintenance.MaintenanceUnit"] - else: - num_deleted_units = 0 - if "street_maintenance.MaintenanceWork" in deleted_units[1]: - num_deleted_works = deleted_units[1]["street_maintenance.MaintenanceWork"] - else: - num_deleted_works = 0 - if "street_maintenance.GeometryHistory" in deleted_histories[1]: - num_deleted_histories = deleted_histories[1][ - "street_maintenance.GeometryHistory" - ] - else: - num_deleted_histories = 0 - - logger.info(f"GeometryHistorys deleted {num_deleted_histories}.") - logger.info(f"MaintenanceUnits deleted {num_deleted_units}.") - logger.info(f"MaintenanceWorks deleted {num_deleted_works}.") + providers = [p.upper() for p in options.get("providers", None)] + + for provider in providers: + if provider not in PROVIDERS: + logger.error( + f"Invalid providers argument {provider}, choices are: {', '.join(PROVIDERS)}" + ) + continue + + logger.info(f"Deleting street maintenance history for {provider}.") + provider = provider.upper() + deleted_units = MaintenanceUnit.objects.filter(provider=provider).delete() + deleted_histories = GeometryHistory.objects.filter( + provider=provider + ).delete() + if "street_maintenance.MaintenanceUnit" in deleted_units[1]: + num_deleted_units = deleted_units[1][ + "street_maintenance.MaintenanceUnit" + ] + else: + num_deleted_units = 0 + if "street_maintenance.MaintenanceWork" in deleted_units[1]: + num_deleted_works = deleted_units[1][ + "street_maintenance.MaintenanceWork" + ] + else: + num_deleted_works = 0 + if "street_maintenance.GeometryHistory" in deleted_histories[1]: + num_deleted_histories = deleted_histories[1][ + "street_maintenance.GeometryHistory" + ] + else: + num_deleted_histories = 0 + + logger.info(f"GeometryHistorys deleted {num_deleted_histories}.") + logger.info(f"MaintenanceUnits deleted {num_deleted_units}.") + logger.info(f"MaintenanceWorks deleted {num_deleted_works}.") diff --git a/street_maintenance/management/commands/import_destia_street_maintenance_history.py b/street_maintenance/management/commands/import_destia_street_maintenance_history.py deleted file mode 100644 index b000ef7fa..000000000 --- a/street_maintenance/management/commands/import_destia_street_maintenance_history.py +++ /dev/null @@ -1,62 +0,0 @@ -import logging - -from street_maintenance.models import MaintenanceUnit - -from .base_import_command import BaseImportCommand -from .constants import ( - DESTIA, - DESTIA_DEFAULT_WORKS_FETCH_SIZE, - DESTIA_DEFAULT_WORKS_HISTORY_SIZE, -) -from .utils import ( - create_maintenance_units, - create_maintenance_works, - precalculate_geometry_history, -) - -logger = logging.getLogger("street_maintenance") - - -class Command(BaseImportCommand): - def add_arguments(self, parser): - parser.add_argument( - "--fetch-size", - type=int, - nargs="+", - default=False, - help=( - "Max number of location history items to fetch per unit." - + "Default {DESTIA_DEFAULT_WORKS_FETCH_SIZE}." - ), - ) - parser.add_argument( - "--history-size", - type=int, - nargs="+", - default=False, - help=( - "History size in days." + "Default {DESTIA_DEFAULT_WORKS_HISTORY_SIZE}." - ), - ) - - def handle(self, *args, **options): - super().__init__() - MaintenanceUnit.objects.filter(provider=DESTIA).delete() - if options["history_size"]: - history_size = options["history_size"][0] - else: - history_size = DESTIA_DEFAULT_WORKS_HISTORY_SIZE - - if options["fetch_size"]: - fetch_size = options["fetch_size"][0] - else: - fetch_size = DESTIA_DEFAULT_WORKS_FETCH_SIZE - create_maintenance_units(DESTIA) - num_works_created = create_maintenance_works(DESTIA, history_size, fetch_size) - if num_works_created > 0: - precalculate_geometry_history(DESTIA) - else: - logger.warning( - f"No works created for {DESTIA}, skipping geometry history population." - ) - super().display_duration(DESTIA) diff --git a/street_maintenance/management/commands/import_infraroad_street_maintenance_history.py b/street_maintenance/management/commands/import_infraroad_street_maintenance_history.py deleted file mode 100644 index ffd7854f0..000000000 --- a/street_maintenance/management/commands/import_infraroad_street_maintenance_history.py +++ /dev/null @@ -1,66 +0,0 @@ -import logging - -from street_maintenance.models import MaintenanceUnit - -from .base_import_command import BaseImportCommand -from .constants import ( - INFRAROAD, - INFRAROAD_DEFAULT_WORKS_FETCH_SIZE, - INFRAROAD_DEFAULT_WORKS_HISTORY_SIZE, -) -from .utils import ( - create_maintenance_units, - create_maintenance_works, - precalculate_geometry_history, -) - -logger = logging.getLogger("street_maintenance") - - -class Command(BaseImportCommand): - def add_arguments(self, parser): - parser.add_argument( - "--fetch-size", - type=int, - nargs="+", - default=False, - help=( - "Max number of location history items to fetch per unit." - + "Default {INFRAROAD_DEFAULT_WORKS_FETCH_SIZE}." - ), - ) - parser.add_argument( - "--history-size", - type=int, - nargs="+", - default=False, - help=( - "History size in days." - + "Default {INFRAROAD_DEFAULT_WORKS_HISTORY_SIZE}." - ), - ) - - def handle(self, *args, **options): - super().__init__() - MaintenanceUnit.objects.filter(provider=INFRAROAD).delete() - if options["history_size"]: - history_size = options["history_size"][0] - else: - history_size = INFRAROAD_DEFAULT_WORKS_HISTORY_SIZE - - if options["fetch_size"]: - fetch_size = options["fetch_size"][0] - else: - fetch_size = INFRAROAD_DEFAULT_WORKS_FETCH_SIZE - create_maintenance_units(INFRAROAD) - num_works_created = create_maintenance_works( - INFRAROAD, history_size, fetch_size - ) - if num_works_created > 0: - precalculate_geometry_history(INFRAROAD) - else: - logger.warning( - f"No works created for {INFRAROAD}, skipping geometry history population." - ) - - super().display_duration(INFRAROAD) diff --git a/street_maintenance/management/commands/import_kuntec_street_maintenance_history.py b/street_maintenance/management/commands/import_kuntec_street_maintenance_history.py deleted file mode 100644 index d87a2ac38..000000000 --- a/street_maintenance/management/commands/import_kuntec_street_maintenance_history.py +++ /dev/null @@ -1,123 +0,0 @@ -import logging -from datetime import datetime, timedelta - -import polyline -import requests -from django.conf import settings -from django.contrib.gis.geos import LineString - -from street_maintenance.models import DEFAULT_SRID, MaintenanceUnit, MaintenanceWork - -# from django.core.management.base import BaseCommand -from .base_import_command import BaseImportCommand -from .constants import ( - EVENT_MAPPINGS, - KUNTEC, - KUNTEC_DEFAULT_WORKS_HISTORY_SIZE, - KUNTEC_KEY, - KUNTEC_MAX_WORKS_HISTORY_SIZE, - TIMESTAMP_FORMATS, - URLS, - WORKS, -) -from .utils import ( - create_kuntec_maintenance_units, - get_linestring_in_boundary, - get_turku_boundary, - precalculate_geometry_history, -) - -TURKU_BOUNDARY = get_turku_boundary() -logger = logging.getLogger("street_maintenance") - - -class Command(BaseImportCommand): - def add_arguments(self, parser): - parser.add_argument( - "--history-size", - type=int, - nargs="+", - default=False, - help="History size in days.", - ) - - def create_kuntec_maintenance_works(self, history_size=None): - works = [] - now = datetime.now() - start = (now - timedelta(days=history_size)).strftime(TIMESTAMP_FORMATS[KUNTEC]) - end = now.strftime(TIMESTAMP_FORMATS[KUNTEC]) - for unit in MaintenanceUnit.objects.filter(provider=KUNTEC): - url = URLS[KUNTEC][WORKS].format( - key=KUNTEC_KEY, start=start, end=end, unit_id=unit.unit_id - ) - response = requests.get(url) - if response.status_code != 200: - continue - if "data" in response.json(): - for units in response.json()["data"]["units"]: - for route in units["routes"]: - events = [] - # Routes of type 'stop' are discarded. - if route["type"] == "route": - # Check for mapped events to include as works. - for name in unit.names: - event_name = name.lower() - if event_name in EVENT_MAPPINGS: - for e in EVENT_MAPPINGS[event_name]: - # If mapping value is None, the event is not used. - if e: - events.append(e) - else: - logger.warning( - f"Found unmapped event: {event_name}" - ) - # If route has mapped event(s) and contains a polyline add work. - if len(events) > 0 and "polyline" in route: - coords = polyline.decode(route["polyline"], geojson=True) - if len(coords) > 1: - geometry = LineString(coords, srid=DEFAULT_SRID) - else: - continue - # Create linestring that is inside the boundary of Turku - # and discard parts of the geometry if they are outside the boundary. - geometry = get_linestring_in_boundary( - geometry, TURKU_BOUNDARY - ) - if not geometry: - continue - timestamp = route["start"]["time"] - works.append( - MaintenanceWork( - timestamp=timestamp, - maintenance_unit=unit, - events=events, - geometry=geometry, - ) - ) - MaintenanceWork.objects.bulk_create(works) - logger.info(f"Imported {len(works)} Kuntec maintenance works.") - return len(works) - - def handle(self, *args, **options): - super().__init__() - assert settings.KUNTEC_KEY, "KUNTEC_KEY not found in environment." - MaintenanceUnit.objects.filter(provider=KUNTEC).delete() - history_size = KUNTEC_DEFAULT_WORKS_HISTORY_SIZE - if options["history_size"]: - history_size = int(options["history_size"][0]) - if history_size > KUNTEC_MAX_WORKS_HISTORY_SIZE: - error_msg = f"Max value for the history size is: {KUNTEC_MAX_WORKS_HISTORY_SIZE}" - raise ValueError(error_msg) - create_kuntec_maintenance_units() - works_created = self.create_kuntec_maintenance_works(history_size=history_size) - - # In some unknown(erroneous mapon server?) cases, there are no works with route and/or Unit with io_din - # Status 'On'(1) even if in reality there are. In that case we want to store the previeus state of the - # precalculated geometry history for Kuntec data. - if works_created > 0: - precalculate_geometry_history(KUNTEC) - else: - logger.warning( - f"No works created for {KUNTEC}, skipping geometry history population." - ) - super().display_duration(KUNTEC) diff --git a/street_maintenance/management/commands/import_street_maintenance_history.py b/street_maintenance/management/commands/import_street_maintenance_history.py new file mode 100644 index 000000000..6dcf795d1 --- /dev/null +++ b/street_maintenance/management/commands/import_street_maintenance_history.py @@ -0,0 +1,132 @@ +import logging +from datetime import datetime + +from django.core.management import BaseCommand + +from street_maintenance.models import MaintenanceUnit, MaintenanceWork + +from .constants import ( + FETCH_SIZE, + HISTORY_SIZE, + HISTORY_SIZES, + PROVIDER_TYPES, + PROVIDERS, +) +from .utils import ( + create_kuntec_maintenance_units, + create_kuntec_maintenance_works, + create_maintenance_units, + create_maintenance_works, + create_yit_maintenance_units, + create_yit_maintenance_works, + get_yit_access_token, + precalculate_geometry_history, +) + +logger = logging.getLogger("street_maintenance") + + +class Command(BaseCommand): + def add_arguments(self, parser): + parser.add_argument( + "--fetch-size", + type=int, + nargs="+", + default=False, + help=("Max number of location history items to fetch per unit."), + ) + parser.add_argument( + "--history-size", + type=int, + nargs="+", + default=False, + help=("History size in days."), + ) + parser.add_argument( + "--providers", + type=str, + nargs="+", + default=False, + help=", ".join(PROVIDERS), + ) + + def handle(self, *args, **options): + history_size = None + fetch_size = None + + if options["history_size"]: + history_size = options["history_size"] + history_size = ( + history_size[0] if type(history_size) == list else history_size + ) + if options["fetch_size"]: + fetch_size = options["fetch_size"] + fetch_size = fetch_size[0] if type(fetch_size) == list else fetch_size + + providers = [p.upper() for p in options.get("providers", None)] + for provider in providers: + if provider not in PROVIDERS: + logger.warning( + f"Provider {provider} not defined, choices are {', '.join(PROVIDERS)}" + ) + continue + start_time = datetime.now() + history_size = ( + history_size if history_size else HISTORY_SIZES[provider][HISTORY_SIZE] + ) + fetch_size = ( + fetch_size + if fetch_size + else HISTORY_SIZES[provider].get(FETCH_SIZE, None) + ) + match provider.upper(): + case PROVIDER_TYPES.DESTIA | PROVIDER_TYPES.INFRAROAD: + num_created_units, num_del_units = create_maintenance_units( + provider + ) + num_created_works, num_del_works = create_maintenance_works( + provider, history_size, fetch_size + ) + case PROVIDER_TYPES.KUNTEC: + num_created_units, num_del_units = create_kuntec_maintenance_units() + num_created_works, num_del_works = create_kuntec_maintenance_works( + history_size + ) + + case PROVIDER_TYPES.YIT: + access_token = get_yit_access_token() + num_created_units, num_del_units = create_yit_maintenance_units( + access_token + ) + num_created_works, num_del_works = create_yit_maintenance_works( + access_token, history_size + ) + + tot_num_units = MaintenanceUnit.objects.filter(provider=provider).count() + tot_num_works = MaintenanceWork.objects.filter( + maintenance_unit__provider=provider + ).count() + logger.info( + f"Deleted {num_del_units} obsolete Units for provider {provider}" + ) + logger.info( + f"Created {num_created_units} units of total {tot_num_units} units for provider {provider}." + ) + logger.info( + f"Deleted {num_del_works} obsolete Works for provider {provider}" + ) + logger.info( + f"Created {num_created_works} Works of total {tot_num_works} Works for provider {provider}." + ) + + if num_created_works > 0: + precalculate_geometry_history(provider) + else: + logger.warning( + f"No works created for {provider}, skipping geometry history population." + ) + end_time = datetime.now() + duration = end_time - start_time + logger.info( + f"Imported {provider} street maintenance history in: {duration}" + ) diff --git a/street_maintenance/management/commands/import_yit_street_maintenance_history.py b/street_maintenance/management/commands/import_yit_street_maintenance_history.py deleted file mode 100644 index eccdd3c52..000000000 --- a/street_maintenance/management/commands/import_yit_street_maintenance_history.py +++ /dev/null @@ -1,126 +0,0 @@ -import logging - -from django.contrib.gis.geos import LineString - -from street_maintenance.models import DEFAULT_SRID, MaintenanceUnit, MaintenanceWork - -from .base_import_command import BaseImportCommand -from .constants import ( - EVENT_MAPPINGS, - YIT, - YIT_DEFAULT_WORKS_HISTORY_SIZE, - YIT_MAX_WORKS_HISTORY_SIZE, -) -from .utils import ( - create_dict_from_yit_events, - create_yit_maintenance_units, - get_linestring_in_boundary, - get_turku_boundary, - get_yit_access_token, - get_yit_contract, - get_yit_event_types, - get_yit_routes, - is_nested_coordinates, - precalculate_geometry_history, -) - -TURKU_BOUNDARY = get_turku_boundary() -logger = logging.getLogger("street_maintenance") - - -class Command(BaseImportCommand): - def add_arguments(self, parser): - parser.add_argument( - "--history-size", - type=int, - nargs="+", - default=False, - help="History size in days.", - ) - - def create_yit_maintenance_works(self, history_size=None): - access_token = get_yit_access_token() - create_yit_maintenance_units(access_token) - contract = get_yit_contract(access_token) - list_of_events = get_yit_event_types(access_token) - event_name_mappings = create_dict_from_yit_events(list_of_events) - routes = get_yit_routes(access_token, contract, history_size) - works = [] - for route in routes: - if len(route["geography"]["features"]) > 1: - logger.warning( - f"Route contains multiple features. {route['geography']['features']}" - ) - coordinates = route["geography"]["features"][0]["geometry"]["coordinates"] - if is_nested_coordinates(coordinates) and len(coordinates) > 1: - geometry = LineString(coordinates, srid=DEFAULT_SRID) - else: - # Remove other data, contains faulty linestrings. - continue - # Create linestring that is inside the boundary of Turku - # and discard parts of the geometry if they are outside the boundary. - geometry = get_linestring_in_boundary(geometry, TURKU_BOUNDARY) - if not geometry: - continue - events = [] - operations = route["operations"] - for operation in operations: - event_name = event_name_mappings[operation].lower() - if event_name in EVENT_MAPPINGS: - for e in EVENT_MAPPINGS[event_name]: - # If mapping value is None, the event is not used. - if e: - events.append(e) - else: - logger.warning( - f"Found unmapped event: {event_name_mappings[operation]}" - ) - - # If no events found discard the work - if len(events) == 0: - continue - if len(route["geography"]["features"]) > 1: - logger.warning( - f"Route contains multiple features. {route['geography']['features']}" - ) - unit_id = route["vehicleType"] - try: - unit = MaintenanceUnit.objects.get(unit_id=unit_id) - except MaintenanceUnit.DoesNotExist: - logger.warning(f"Maintenance unit: {unit_id}, not found.") - continue - works.append( - MaintenanceWork( - timestamp=route["startTime"], - maintenance_unit=unit, - events=events, - geometry=geometry, - ) - ) - - MaintenanceWork.objects.bulk_create(works) - logger.info(f"Imported {len(works)} YIT mainetance works.") - return len(works) - - def handle(self, *args, **options): - super().__init__() - MaintenanceUnit.objects.filter(provider=YIT).delete() - history_size = YIT_DEFAULT_WORKS_HISTORY_SIZE - if options["history_size"]: - history_size = int(options["history_size"][0]) - if history_size > YIT_MAX_WORKS_HISTORY_SIZE: - error_msg = ( - f"Max value for the history size is: {YIT_MAX_WORKS_HISTORY_SIZE}" - ) - raise ValueError(error_msg) - - works_created = self.create_yit_maintenance_works(history_size=history_size) - # In some unknown(erroneous server?) cases no data for works is availale. In that case we want to store - # the previeus state of the precalculated geometry history data. - if works_created > 0: - precalculate_geometry_history(YIT) - else: - logger.warning( - f"No works created for {YIT}, skipping geometry history population." - ) - super().display_duration(YIT) diff --git a/street_maintenance/management/commands/utils.py b/street_maintenance/management/commands/utils.py index 0a6fabe49..a3597e11a 100644 --- a/street_maintenance/management/commands/utils.py +++ b/street_maintenance/management/commands/utils.py @@ -4,6 +4,7 @@ from datetime import datetime, timedelta import numpy as np +import polyline import requests from django import db from django.conf import settings @@ -22,7 +23,7 @@ EVENT_MAPPINGS, EVENTS, KUNTEC, - PROVIDERS, + KUNTEC_KEY, ROUTES, TIMESTAMP_FORMATS, TOKEN, @@ -39,6 +40,26 @@ VALID_LINESTRING_MAX_POINT_DISTANCE = 0.01 +def get_turku_boundary(): + division_turku = AdministrativeDivision.objects.get(name="Turku") + turku_boundary = AdministrativeDivisionGeometry.objects.get( + division=division_turku + ).boundary + turku_boundary.transform(DEFAULT_SRID) + return turku_boundary + + +TURKU_BOUNDARY = get_turku_boundary() + + +def get_json_data(url): + response = requests.get(url) + assert ( + response.status_code == 200 + ), "Fetching Maintenance Unit {} status code: {}".format(url, response.status_code) + return response.json() + + def check_linestring_validity( linestring, threshold=VALID_LINESTRING_MAX_POINT_DISTANCE ): @@ -149,7 +170,6 @@ def get_linestrings_from_points(objects, queryset, provider): prev_geometry = elem.geometry points.append(elem.geometry) prev_timestamp = elem.timestamp - if len(points) > 1: discarded_linestrings += add_geometry_history_objects( objects, points, elem, provider @@ -200,15 +220,6 @@ def precalculate_geometry_history(provider): logger.info(f"Created {len(objects)} HistoryGeometry rows for provider: {provider}") -def get_turku_boundary(): - division_turku = AdministrativeDivision.objects.get(name="Turku") - turku_boundary = AdministrativeDivisionGeometry.objects.get( - division=division_turku - ).boundary - turku_boundary.transform(DEFAULT_SRID) - return turku_boundary - - def get_linestring_in_boundary(linestring, boundary): """ Returns a linestring from the input linestring where all the coordinates @@ -226,24 +237,170 @@ def get_linestring_in_boundary(linestring, boundary): return False +@db.transaction.atomic +def create_yit_maintenance_works(access_token, history_size): + contract = get_yit_contract(access_token) + list_of_events = get_yit_event_types(access_token) + event_name_mappings = create_dict_from_yit_events(list_of_events) + routes = get_yit_routes(access_token, contract, history_size) + objs_to_delete = list( + MaintenanceWork.objects.filter(maintenance_unit__provider=YIT).values_list( + "id", flat=True + ) + ) + num_created = 0 + for route in routes: + if len(route["geography"]["features"]) > 1: + logger.warning( + f"Route contains multiple features. {route['geography']['features']}" + ) + coordinates = route["geography"]["features"][0]["geometry"]["coordinates"] + + if is_nested_coordinates(coordinates) and len(coordinates) > 1: + geometry = LineString(coordinates, srid=DEFAULT_SRID) + else: + # Remove other data, contains faulty linestrings. + continue + # Create linestring that is inside the boundary of Turku + # and discard parts of the geometry if they are outside the boundary. + geometry = get_linestring_in_boundary(geometry, TURKU_BOUNDARY) + if not geometry: + continue + events = [] + original_event_names = [] + operations = route["operations"] + for operation in operations: + event_name = event_name_mappings[operation].lower() + if event_name in EVENT_MAPPINGS: + for e in EVENT_MAPPINGS[event_name]: + # If mapping value is None, the event is not used. + if e: + if e not in events: + events.append(e) + original_event_names.append(event_name_mappings[operation]) + else: + logger.warning( + f"Found unmapped event: {event_name_mappings[operation]}" + ) + + # If no events found discard the work + if len(events) == 0: + continue + if len(route["geography"]["features"]) > 1: + logger.warning( + f"Route contains multiple features. {route['geography']['features']}" + ) + unit_id = route["vehicleType"] + try: + unit = MaintenanceUnit.objects.get(unit_id=unit_id) + except MaintenanceUnit.DoesNotExist: + logger.warning(f"Maintenance unit: {unit_id}, not found.") + continue + + obj, created = MaintenanceWork.objects.get_or_create( + timestamp=route["startTime"], + maintenance_unit=unit, + events=events, + original_event_names=original_event_names, + geometry=geometry, + ) + if obj.id in objs_to_delete: + objs_to_delete.remove(obj.id) + if created: + num_created += 1 + MaintenanceWork.objects.filter(id__in=objs_to_delete).delete() + return num_created, len(objs_to_delete) + + +@db.transaction.atomic +def create_kuntec_maintenance_works(history_size): + num_created = 0 + now = datetime.now() + start = (now - timedelta(days=history_size)).strftime(TIMESTAMP_FORMATS[KUNTEC]) + end = now.strftime(TIMESTAMP_FORMATS[KUNTEC]) + objs_to_delete = list( + MaintenanceWork.objects.filter(maintenance_unit__provider=KUNTEC).values_list( + "id", flat=True + ) + ) + for unit in MaintenanceUnit.objects.filter(provider=KUNTEC): + url = URLS[KUNTEC][WORKS].format( + key=KUNTEC_KEY, start=start, end=end, unit_id=unit.unit_id + ) + json_data = get_json_data(url) + if "data" in json_data: + for unit_data in json_data["data"]["units"]: + for route in unit_data["routes"]: + events = [] + original_event_names = [] + # Routes of type 'stop' are discarded. + if route["type"] == "route": + # Check for mapped events to include as works. + for name in unit.names: + event_name = name.lower() + if event_name in EVENT_MAPPINGS: + for e in EVENT_MAPPINGS[event_name]: + # If mapping value is None, the event is not used. + if e: + if e not in events: + events.append(e) + original_event_names.append(name) + else: + logger.warning(f"Found unmapped event: {event_name}") + # If route has mapped event(s) and contains a polyline add work. + if len(events) > 0 and "polyline" in route: + coords = polyline.decode(route["polyline"], geojson=True) + if len(coords) > 1: + geometry = LineString(coords, srid=DEFAULT_SRID) + else: + continue + # Create linestring that is inside the boundary of Turku + # and discard parts of the geometry if they are outside the boundary. + geometry = get_linestring_in_boundary(geometry, TURKU_BOUNDARY) + if not geometry: + continue + timestamp = route["start"]["time"] + + obj, created = MaintenanceWork.objects.get_or_create( + timestamp=timestamp, + maintenance_unit=unit, + events=events, + original_event_names=original_event_names, + geometry=geometry, + ) + if obj.id in objs_to_delete: + objs_to_delete.remove(obj.id) + if created: + num_created += 1 + + MaintenanceWork.objects.filter(id__in=objs_to_delete).delete() + return num_created, len(objs_to_delete) + + +@db.transaction.atomic def create_maintenance_works(provider, history_size, fetch_size): turku_boundary = get_turku_boundary() - works = [] + num_created = 0 + import_from_date_time = datetime.now() - timedelta(days=history_size) import_from_date_time = import_from_date_time.replace( tzinfo=zoneinfo.ZoneInfo("Europe/Helsinki") ) + objs_to_delete = list( + MaintenanceWork.objects.filter(maintenance_unit__provider=provider).values_list( + "id", flat=True + ) + ) for unit in MaintenanceUnit.objects.filter(provider=provider): - response = requests.get( + json_data = get_json_data( URLS[provider][WORKS].format(id=unit.unit_id, history_size=fetch_size) ) - if "location_history" in response.json(): - json_data = response.json()["location_history"] + if "location_history" in json_data: + json_data = json_data["location_history"] else: logger.warning(f"Location history not found for unit: {unit.unit_id}") continue for work in json_data: - timestamp = datetime.strptime( work["timestamp"], TIMESTAMP_FORMATS[provider] ).replace(tzinfo=zoneinfo.ZoneInfo("Europe/Helsinki")) @@ -259,48 +416,56 @@ def create_maintenance_works(provider, history_size, fetch_size): continue events = [] + original_event_names = [] for event in work["events"]: event_name = event.lower() if event_name in EVENT_MAPPINGS: for e in EVENT_MAPPINGS[event_name]: # If mapping value is None, the event is not used. if e: - events.append(e) + if e not in events: + events.append(e) + original_event_names.append(event) else: logger.warning(f"Found unmapped event: {event}") # If no events found discard the work if len(events) == 0: continue - works.append( - MaintenanceWork( - timestamp=timestamp, - maintenance_unit=unit, - geometry=point, - events=events, - ) + obj, created = MaintenanceWork.objects.get_or_create( + timestamp=timestamp, + maintenance_unit=unit, + geometry=point, + events=events, + original_event_names=original_event_names, ) - MaintenanceWork.objects.bulk_create(works) - logger.info(f"Imported {len(works)} {provider} mainetance works.") - return len(works) + if obj.id in objs_to_delete: + objs_to_delete.remove(obj.id) + if created: + num_created += 1 + + MaintenanceWork.objects.filter(id__in=objs_to_delete).delete() + return num_created, len(objs_to_delete) +@db.transaction.atomic def create_maintenance_units(provider): - assert provider in PROVIDERS - response = requests.get(URLS[provider][UNITS]) - assert ( - response.status_code == 200 - ), "Fetching Maintenance Unit {} status code: {}".format( - URLS[provider][UNITS], response.status_code + num_created = 0 + objs_to_delete = list( + MaintenanceUnit.objects.filter(provider=provider).values_list("id", flat=True) ) - for unit in response.json(): + for unit in get_json_data(URLS[provider][UNITS]): # The names of the unit is derived from the events. names = [n for n in unit["last_location"]["events"]] - MaintenanceUnit.objects.create( + obj, created = MaintenanceUnit.objects.get_or_create( unit_id=unit["id"], names=names, provider=provider ) - num_units_imported = MaintenanceUnit.objects.filter(provider=provider).count() - logger.info(f"Imported {num_units_imported} {provider} mainetance Units.") - return num_units_imported + if obj.id in objs_to_delete: + objs_to_delete.remove(obj.id) + if created: + num_created += 1 + + MaintenanceUnit.objects.filter(id__in=objs_to_delete).delete() + return num_created, len(objs_to_delete) def get_yit_contract(access_token): @@ -334,16 +499,15 @@ def create_dict_from_yit_events(list_of_events): return events +@db.transaction.atomic def create_kuntec_maintenance_units(): - units_url = URLS[KUNTEC][UNITS] - response = requests.get(units_url) - assert ( - response.status_code == 200 - ), "Fetching Maintenance Unit {} status code: {}".format( - units_url, response.status_code - ) + json_data = get_json_data(URLS[KUNTEC][UNITS]) no_io_din = 0 - for unit in response.json()["data"]["units"]: + num_created = 0 + objs_to_delete = list( + MaintenanceUnit.objects.filter(provider=KUNTEC).values_list("id", flat=True) + ) + for unit in json_data["data"]["units"]: names = [] if "io_din" in unit: on_states = 0 @@ -355,21 +519,24 @@ def create_kuntec_maintenance_units(): # If names, we have a unit with at least one io_din with State On. if len(names) > 0: unit_id = unit["unit_id"] - MaintenanceUnit.objects.create( + obj, created = MaintenanceUnit.objects.get_or_create( unit_id=unit_id, names=names, provider=KUNTEC ) + if obj.id in objs_to_delete: + objs_to_delete.remove(obj.id) + if created: + num_created += 1 + else: no_io_din += 1 + MaintenanceUnit.objects.filter(id__in=objs_to_delete).delete() logger.info( f"Discarding {no_io_din} Kuntec units that do not have a io_din with Status 'On'(1)." ) - logger.info( - f"Imported {MaintenanceUnit.objects.filter(provider=KUNTEC).count()}" - + " Kuntec mainetance Units." - ) + return num_created, len(objs_to_delete) -def create_yit_maintenance_units(access_token): +def get_yit_vehicles(access_token): response = requests.get( URLS[YIT][VEHICLES], headers={"Authorization": f"Bearer {access_token}"} ) @@ -378,13 +545,27 @@ def create_yit_maintenance_units(access_token): ), " Fetching YIT vehicles {} failed, status code: {}".format( URLS[YIT][VEHICLES], response.status_code ) - for unit in response.json(): - names = [unit["vehicleTypeName"]] - MaintenanceUnit.objects.create(unit_id=unit["id"], names=names, provider=YIT) - logger.info( - f"Imported {MaintenanceUnit.objects.filter(provider=YIT).count()}" - + " YIT mainetance Units." + return response.json() + + +@db.transaction.atomic +def create_yit_maintenance_units(access_token): + vehicles = get_yit_vehicles(access_token) + num_created = 0 + objs_to_delete = list( + MaintenanceUnit.objects.filter(provider=YIT).values_list("id", flat=True) ) + for unit in vehicles: + names = [unit["vehicleTypeName"]] + obj, created = MaintenanceUnit.objects.get_or_create( + unit_id=unit["id"], names=names, provider=YIT + ) + if obj.id in objs_to_delete: + objs_to_delete.remove(obj.id) + if created: + num_created += 1 + MaintenanceUnit.objects.filter(id__in=objs_to_delete).delete() + return num_created, len(objs_to_delete) def get_yit_routes(access_token, contract, history_size): @@ -416,6 +597,10 @@ def get_yit_routes(access_token, contract, history_size): def get_yit_access_token(): + """ + Note the IP address of the host calling Autori API (hosts YIT data) must be + given for whitelistning. + """ assert settings.YIT_SCOPE, "YIT_SCOPE not defined in environment." assert settings.YIT_CLIENT_ID, "YIT_CLIENT_ID not defined in environment." assert settings.YIT_CLIENT_SECRET, "YIT_CLIENT_SECRET not defined in environment." diff --git a/street_maintenance/migrations/0012_maintenancework_add_field_original_event_names.py b/street_maintenance/migrations/0012_maintenancework_add_field_original_event_names.py new file mode 100644 index 000000000..f15f279e8 --- /dev/null +++ b/street_maintenance/migrations/0012_maintenancework_add_field_original_event_names.py @@ -0,0 +1,21 @@ +# Generated by Django 4.1.2 on 2023-02-16 11:35 + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("street_maintenance", "0011_rename_provider_autori_to_yit"), + ] + + operations = [ + migrations.AddField( + model_name="maintenancework", + name="original_event_names", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField(max_length=64), default=list, size=None + ), + ), + ] diff --git a/street_maintenance/models.py b/street_maintenance/models.py index bc466a4e6..9ce318535 100644 --- a/street_maintenance/models.py +++ b/street_maintenance/models.py @@ -19,6 +19,7 @@ def __str__(self): class MaintenanceWork(models.Model): geometry = models.GeometryField(srid=DEFAULT_SRID, null=True) events = ArrayField(models.CharField(max_length=64), default=list) + original_event_names = ArrayField(models.CharField(max_length=64), default=list) timestamp = models.DateTimeField() maintenance_unit = models.ForeignKey( "MaintenanceUnit", diff --git a/street_maintenance/tasks.py b/street_maintenance/tasks.py index db905964b..245186e93 100644 --- a/street_maintenance/tasks.py +++ b/street_maintenance/tasks.py @@ -1,57 +1,28 @@ -from celery import shared_task from django.core import management - -@shared_task -def delete_street_maintenance_history( - args=None, name="delete_street_maintenance_history" -): - management.call_command("delete_street_maintenance_history", args) - - -@shared_task -def import_infraroad_street_maintenance_history( - args=None, name="import_infraroad_street_maintenance_history" -): - if args: - management.call_command( - "import_infraroad_street_maintenance_history", "--history-size", args - ) - else: - management.call_command("import_infraroad_street_maintenance_history") +from smbackend.utils import shared_task_email -@shared_task -def import_yit_street_maintenance_history( - args=None, name="import_yit_street_maintenance_history" -): - if args: - management.call_command( - "import_yit_street_maintenance_history", "--history-size", args - ) - else: - management.call_command("import_yit_street_maintenance_history") - - -@shared_task -def import_kuntec_street_maintenance_history( - args=None, name="import_kuntec_street_maintenance_history" -): - if args: - management.call_command( - "import_kuntec_street_maintenance_history", "--history-size", args - ) - else: - management.call_command("import_kuntec_street_maintenance_history") +@shared_task_email +def delete_street_maintenance_history(*args, name="delete_street_maintenance_history"): + management.call_command("delete_street_maintenance_history", *args) -@shared_task -def import_destia_street_maintenance_history( - args=None, name="import_destia_street_maintenance_history" +@shared_task_email +def import_street_maintenance_history( + name="import_street_maintenance_history", *args, **kwargs ): - if args: - management.call_command( - "import_destia_maintenance_history", "--history-size", args + if "providers" not in kwargs: + raise Exception( + "No 'providers' item in kwargs. e.g., {'providers':['destia', 'infraroad']}" ) - else: - management.call_command("import_destia_street_maintenance_history") + if "fetch-size" not in kwargs: + kwargs["fetch-size"] = None + if "history-size" not in kwargs: + kwargs["history-size"] = None + management.call_command( + "import_street_maintenance_history", + providers=kwargs["providers"], + fetch_size=kwargs["fetch-size"], + history_size=kwargs["history-size"], + ) diff --git a/street_maintenance/tests/conftest.py b/street_maintenance/tests/conftest.py index 14e0667eb..cd4b5fdc2 100644 --- a/street_maintenance/tests/conftest.py +++ b/street_maintenance/tests/conftest.py @@ -2,9 +2,15 @@ import pytest import pytz -from django.contrib.gis.geos import LineString +from django.contrib.gis.geos import GEOSGeometry, LineString +from munigeo.models import ( + AdministrativeDivision, + AdministrativeDivisionGeometry, + AdministrativeDivisionType, +) from rest_framework.test import APIClient +from mobility_data.tests.conftest import TURKU_WKT from street_maintenance.management.commands.constants import ( AURAUS, INFRAROAD, @@ -43,7 +49,6 @@ def geometry_historys(): events=[AURAUS], ) geometry_historys.append(obj) - obj = GeometryHistory.objects.create( timestamp=now - timedelta(days=2), geometry=geometry, @@ -52,7 +57,6 @@ def geometry_historys(): events=[LIUKKAUDENTORJUNTA], ) geometry_historys.append(obj) - obj = GeometryHistory.objects.create( timestamp=now - timedelta(days=1), geometry=geometry, @@ -60,7 +64,6 @@ def geometry_historys(): provider=KUNTEC, events=[AURAUS], ) - geometry_historys.append(obj) obj = GeometryHistory.objects.create( timestamp=now - timedelta(days=2), @@ -70,4 +73,31 @@ def geometry_historys(): events=[AURAUS, LIUKKAUDENTORJUNTA], ) geometry_historys.append(obj) - return geometry_historys + + +@pytest.mark.django_db +@pytest.fixture +def administrative_division_type(): + adm_div_type = AdministrativeDivisionType.objects.create( + id=1, type="muni", name="Municipality" + ) + return adm_div_type + + +@pytest.mark.django_db +@pytest.fixture +def administrative_division(administrative_division_type): + adm_div = AdministrativeDivision.objects.get_or_create( + id=1, name="Turku", origin_id=853, type_id=1 + ) + return adm_div + + +@pytest.mark.django_db +@pytest.fixture +def administrative_division_geometry(administrative_division): + turku_multipoly = GEOSGeometry(TURKU_WKT, srid=3067) + adm_div_geom = AdministrativeDivisionGeometry.objects.create( + id=1, division_id=1, boundary=turku_multipoly + ) + return adm_div_geom diff --git a/street_maintenance/tests/test_api.py b/street_maintenance/tests/test_api.py index 61bc35483..e46fcd43c 100644 --- a/street_maintenance/tests/test_api.py +++ b/street_maintenance/tests/test_api.py @@ -48,7 +48,7 @@ def test_geometry_history(api_client, geometry_historys): geometry_history = response.json()["results"][0] assert geometry_history["geometry_type"] == "LineString" assert geometry_history["provider"] == INFRAROAD - start_date_time = datetime.now(UTC_TIMEZONE) - timedelta(days=1) + start_date_time = datetime.now(UTC_TIMEZONE) - timedelta(days=1, hours=2) url = ( reverse("street_maintenance:geometry_history-list") + f"?start_date_time={start_date_time.strftime(START_DATE_TIME_FORMAT)}" diff --git a/street_maintenance/tests/test_importers.py b/street_maintenance/tests/test_importers.py new file mode 100644 index 000000000..ecfdbc9a3 --- /dev/null +++ b/street_maintenance/tests/test_importers.py @@ -0,0 +1,227 @@ +from unittest.mock import patch + +import pytest + +from street_maintenance.management.commands.constants import DESTIA, INFRAROAD +from street_maintenance.models import MaintenanceUnit, MaintenanceWork + +from .utils import ( + get_fluentprogress_units_mock_data, + get_fluentprogress_works_mock_data, + get_kuntec_units_mock_data, + get_kuntec_works_mock_data, + get_yit_contract_mock_data, + get_yit_event_types_mock_data, + get_yit_routes_mock_data, + get_yit_vehicles_mock_data, +) + + +@pytest.mark.django_db +@patch("street_maintenance.management.commands.utils.get_yit_vehicles") +def test_yit_units( + get_yit_vehicles_mock, + administrative_division, + administrative_division_geometry, +): + from street_maintenance.management.commands.utils import ( + create_yit_maintenance_units, + ) + + get_yit_vehicles_mock.return_value = get_yit_vehicles_mock_data(2) + num_created_units, num_del_units = create_yit_maintenance_units("test_access_token") + assert MaintenanceUnit.objects.count() == 2 + assert num_created_units == 2 + assert num_del_units == 0 + unit = MaintenanceUnit.objects.first() + unit_id = unit.id + assert unit.names == ["Huoltoauto"] + assert unit.unit_id == "82260ff7-589e-4cee-a8e0-124b615381f1" + get_yit_vehicles_mock.return_value = get_yit_vehicles_mock_data(1) + num_created_units, num_del_units = create_yit_maintenance_units("test_access_token") + assert unit_id == MaintenanceUnit.objects.first().id + assert num_created_units == 0 + assert num_del_units == 1 + assert MaintenanceUnit.objects.count() == 1 + + +@pytest.mark.django_db +@patch( + "street_maintenance.management.commands.utils.get_yit_vehicles", + return_value=get_yit_vehicles_mock_data(2), +) +@patch( + "street_maintenance.management.commands.utils.get_yit_contract", + return_value=get_yit_contract_mock_data(), +) +@patch( + "street_maintenance.management.commands.utils.get_yit_event_types", + return_value=get_yit_event_types_mock_data(), +) +@patch("street_maintenance.management.commands.utils.get_yit_routes") +def test_yit_works( + get_yit_routes_mock, + get_yit_vechiles_mock, + administrative_division, + administrative_division_geometry, +): + from street_maintenance.management.commands.utils import ( + create_yit_maintenance_units, + create_yit_maintenance_works, + ) + + create_yit_maintenance_units("test_access_token") + get_yit_routes_mock.return_value = get_yit_routes_mock_data(2) + num_created_works, num_del_works = create_yit_maintenance_works( + "test_access_token", 3 + ) + assert num_created_works == 2 + assert num_del_works == 0 + assert MaintenanceWork.objects.count() == 2 + work = MaintenanceWork.objects.first() + work_id = work.id + assert work.events == ["liukkaudentorjunta"] + assert work.original_event_names == ["Suolaus"] + get_yit_routes_mock.return_value = get_yit_routes_mock_data(1) + num_created_works, num_del_works = create_yit_maintenance_works( + "test_access_token", 3 + ) + assert num_created_works == 0 + assert num_del_works == 1 + assert work_id == MaintenanceWork.objects.first().id + assert MaintenanceWork.objects.count() == 1 + + +@pytest.mark.django_db +@patch("street_maintenance.management.commands.utils.get_json_data") +def test_kuntec( + get_json_data_mock, administrative_division, administrative_division_geometry +): + from street_maintenance.management.commands.utils import ( + create_kuntec_maintenance_units, + create_kuntec_maintenance_works, + ) + + # Note, the fixture JSON contains one unit item with IO_DIN state 0(off) + # i.e., will not be included + get_json_data_mock.return_value = get_kuntec_units_mock_data(2) + num_created_units, num_del_units = create_kuntec_maintenance_units() + assert num_created_units == 2 + assert num_del_units == 0 + assert MaintenanceUnit.objects.count() == 2 + unit = MaintenanceUnit.objects.first() + unit_id = unit.id + assert unit.unit_id == "150635" + assert unit.names == ["Auraus", "Hiekoitus"] + get_json_data_mock.return_value = get_kuntec_units_mock_data(1) + num_created_units, num_del_units = create_kuntec_maintenance_units() + assert unit_id == MaintenanceUnit.objects.first().id + assert num_created_units == 0 + assert num_del_units == 1 + assert MaintenanceUnit.objects.count() == 1 + get_json_data_mock.return_value = get_kuntec_works_mock_data(2) + num_created_works, num_del_works = create_kuntec_maintenance_works(3) + assert num_created_works == 2 + assert num_del_works == 0 + assert MaintenanceWork.objects.count() == 2 + work = MaintenanceWork.objects.first() + work_id = work.id + work.events = ["auraus", "liukkaudentorjunta"] + work.original_event_names = ["Auraus", "Hiekoitus"] + get_json_data_mock.return_value = get_kuntec_works_mock_data(1) + num_created_works, num_del_works = create_kuntec_maintenance_works(3) + assert num_created_works == 0 + assert num_del_works == 1 + assert work_id == MaintenanceWork.objects.first().id + assert MaintenanceWork.objects.count() == 1 + + +@pytest.mark.django_db +@patch("street_maintenance.management.commands.utils.get_json_data") +def test_infraroad( + get_json_data_mock, administrative_division, administrative_division_geometry +): + from street_maintenance.management.commands.utils import ( + create_maintenance_units, + create_maintenance_works, + ) + + # Test unit creation + get_json_data_mock.return_value = get_fluentprogress_units_mock_data(2) + num_created_units, num_del_units = create_maintenance_units(INFRAROAD) + assert MaintenanceUnit.objects.count() == 2 + assert num_created_units == 2 + assert num_del_units == 0 + unit = MaintenanceUnit.objects.first() + unit_id = unit.id + unit.unit_id = "2817625" + unit.names = ["au"] + get_json_data_mock.return_value = get_fluentprogress_units_mock_data(1) + num_created_units, num_del_units = create_maintenance_units(INFRAROAD) + assert unit_id == MaintenanceUnit.objects.first().id + assert num_created_units == 0 + assert num_del_units == 1 + assert MaintenanceUnit.objects.count() == 1 + get_json_data_mock.return_value = get_fluentprogress_works_mock_data(3) + num_created_works, num_del_works = create_maintenance_works(INFRAROAD, 1, 10) + assert num_created_works == 3 + assert num_del_works == 0 + assert MaintenanceWork.objects.count() == 3 + work = MaintenanceWork.objects.first() + work_id = work.id + work.events = ["auraus"] + work.original_event_names = ["au"] + get_json_data_mock.return_value = get_fluentprogress_works_mock_data(1) + num_created_works, num_del_works = create_maintenance_works(INFRAROAD, 1, 10) + assert num_created_works == 0 + assert num_del_works == 2 + assert work_id == MaintenanceWork.objects.first().id + assert MaintenanceWork.objects.count() == 1 + + +@pytest.mark.django_db +@patch("street_maintenance.management.commands.utils.get_json_data") +def test_destia( + get_json_data_mock, administrative_division, administrative_division_geometry +): + from street_maintenance.management.commands.utils import ( + create_maintenance_units, + create_maintenance_works, + ) + + # Test unit creation + get_json_data_mock.return_value = get_fluentprogress_units_mock_data(2) + num_created_units, num_del_units = create_maintenance_units(DESTIA) + assert MaintenanceUnit.objects.count() == 2 + assert num_created_units == 2 + assert num_del_units == 0 + unit = MaintenanceUnit.objects.first() + unit_id = unit.id + unit.unit_id = "2817625" + unit.names = ["au"] + get_json_data_mock.return_value = get_fluentprogress_units_mock_data(1) + num_created_units, num_del_units = create_maintenance_units(DESTIA) + assert unit_id == MaintenanceUnit.objects.first().id + assert num_created_units == 0 + assert num_del_units == 1 + assert MaintenanceUnit.objects.count() == 1 + get_json_data_mock.return_value = get_fluentprogress_works_mock_data(3) + num_created_works, num_del_works = create_maintenance_works(DESTIA, 1, 10) + assert num_created_works == 3 + assert num_del_works == 0 + assert MaintenanceWork.objects.count() == 3 + work = MaintenanceWork.objects.first() + work_id = work.id + work.events = ["auraus"] + work.original_event_names = ["au"] + work = MaintenanceWork.objects.get( + original_event_names=["au", "sivuaura", "sirotin"] + ) + # Test that duplicate events are not included, as "sivuaura" and "au" are mapped to "auraus" + assert work.events == ["auraus", "liukkaudentorjunta"] + get_json_data_mock.return_value = get_fluentprogress_works_mock_data(1) + num_created_works, num_del_works = create_maintenance_works(DESTIA, 1, 10) + assert num_created_works == 0 + assert num_del_works == 2 + assert work_id == MaintenanceWork.objects.first().id + assert MaintenanceWork.objects.count() == 1 diff --git a/street_maintenance/tests/utils.py b/street_maintenance/tests/utils.py new file mode 100644 index 000000000..078807e51 --- /dev/null +++ b/street_maintenance/tests/utils.py @@ -0,0 +1,360 @@ +from datetime import datetime + +from street_maintenance.management.commands.constants import ( + DATE_FORMATS, + INFRAROAD, + YIT, +) + + +def get_yit_vehicles_mock_data(num_elements): + data = [ + {"id": "82260ff7-589e-4cee-a8e0-124b615381f1", "vehicleTypeName": "Huoltoauto"}, + { + "id": "77396829-6275-4dbe-976f-f9d4f5254653", + "vehicleTypeName": "Kuorma-auto", + }, + ] + assert num_elements <= len(data) + return data[:num_elements] + + +def get_yit_event_types_mock_data(): + data = [ + { + "id": "a4f6188f-8c25-4f42-89be-f5a1bd7d833a", + "operationName": "Auraus ja sohjonpoisto", + }, + {"id": "a51e8e4c-8b16-4132-a882-70f6624c1f2b", "operationName": "Suolaus"}, + ] + return data + + +def get_yit_contract_mock_data(): + return "d73447e6-df70-4f4a-817d-3387b58aca6c" + + +def get_yit_routes_mock_data(num_elements): + current_date = datetime.now().date().strftime(DATE_FORMATS[YIT]) + data = [ + { + "vehicleType": "82260ff7-589e-4cee-a8e0-124b615381f1", + "length": 0.0, + "geography": { + "crs": None, + "features": [ + { + "geometry": { + "coordinates": [ + [22.315554108363656, 60.47901418729062], + [22.31555399713308, 60.47901429688299], + ], + "type": "LineString", + }, + "properties": { + "streetAddress": "Polttolaitoksenkatu 13, Turku", + "featureType": "StreetAddress", + }, + "type": "Feature", + } + ], + "type": "FeatureCollection", + }, + "created": f"{current_date}T11:52:00.5136066Z", + "updated": f"{current_date}T11:52:00.5136066Z", + "deleted": False, + "id": "9c566b34-2bb5-46b0-9c0a-99f53eada2d2", + "user": "442a5ab2-d58c-4c22-bae2-bcf55327cde7", + "contract": "d73447e6-df70-4f4a-817d-3387b58aca6c", + "startTime": f"{current_date}T11:50:21.708Z", + "endTime": f"{current_date}T11:50:21.709Z", + "operations": ["a51e8e4c-8b16-4132-a882-70f6624c1f2b"], + }, + { + # Note, the MaintenanceUnit is retrieved by the vehicleType + "vehicleType": "82260ff7-589e-4cee-a8e0-124b615381f1", + "length": 21.0, + "geography": { + "crs": None, + "features": [ + { + "geometry": { + "coordinates": [ + [22.308685, 60.471465], + [22.308758333333337, 60.47151166666666], + [22.308844999999998, 60.47154999999999], + [22.30887476486449, 60.471557320473416], + ], + "type": "LineString", + }, + "properties": { + "streetAddress": "Koroistenkaari, Turku", + "featureType": "StreetAddress", + }, + "type": "Feature", + } + ], + "type": "FeatureCollection", + }, + "created": f"{current_date}T11:50:58.6173037Z", + "updated": f"{current_date}T11:50:58.6173037Z", + "deleted": False, + "id": "aaee2c3b-4296-44b3-aba0-82b859d4eea8", + "user": "442a5ab2-d58c-4c22-bae2-bcf55327cde7", + "contract": "d73447e6-df70-4f4a-817d-3387b58aca6c", + "startTime": f"{current_date}T11:48:44.694Z", + "endTime": f"{current_date}T11:48:46.984Z", + "operations": ["a51e8e4c-8b16-4132-a882-70f6624c1f2b"], + }, + ] + assert num_elements <= len(data) + return data[:num_elements] + + +# Both Destia and Infraroad uses the fluentprogress API +def get_fluentprogress_works_mock_data(num_elements): + current_date = datetime.now().date().strftime(DATE_FORMATS[INFRAROAD]) + location_history = [ + { + "timestamp": f"{current_date} 08:29:49", + "coords": "(22.24957474 60.49515401)", + "events": ["au"], + }, + { + "timestamp": f"{current_date} 08:29:28", + "coords": "(22.24946401 60.49515848)", + "events": ["au", "sivuaura", "sirotin"], + }, + { + "timestamp": f"{current_date} 08:28:32", + "coords": "(22.24944127 60.49519463)", + "events": ["hiekoitus"], + }, + ] + assert num_elements <= len(location_history) + data = {"location_history": location_history[:num_elements]} + return data + + +def get_fluentprogress_units_mock_data(num_elements): + current_date = datetime.now().date().strftime(DATE_FORMATS[INFRAROAD]) + data = [ + { + "id": 2817625, + "last_location": { + "timestamp": f"{current_date} 06:31:34", + "coords": "(22.249642023816705 60.49569119699299)", + "events": ["au"], + }, + }, + { + "id": 12891825, + "last_location": { + "timestamp": f"{current_date} 08:29:49", + "coords": "(22.24957474 60.49515401)", + "events": ["Kenttien hoito"], + }, + }, + ] + assert num_elements <= len(data) + return data[:num_elements] + + +def get_kuntec_works_mock_data(num_elements): + current_date = datetime.now().date().strftime(DATE_FORMATS[INFRAROAD]) + routes = [ + { + "route_id": 3980827390, + "type": "route", + "start": { + "time": f"{current_date}T14:54:31Z", + "address": "M\u00e4likk\u00e4l\u00e4, 21280 Turku, Suomi", + "lat": 60.47185, + "lng": 22.21618, + }, + "avg_speed": 18, + "max_speed": 43, + "end": { + "time": f"{current_date}T15:05:56Z", + "address": "Ihalantie 7, 21200 Raisio, Suomi", + "lat": 60.47945, + "lng": 22.18221, + }, + "distance": 3565, + "polyline": "a|apJcbrfC@HEpQYdEi@rDeAnE{Sji@eOjp@qEpb@uUlkA?T@TFRHLNDb@@b@Cd@Sb@i@XeArCeJv@{@z@c@h@E~@JdC" + + "n@FCBE@C@G@G@K?KEsA@cBAI?K?}BBWF[DOBMJQBCB?D?JHFHDN@FBNBLBD@BDBES?EAEAEI[CM@EXt@Uw@Tz@BHA?EQEMO_@@HIII" + + "IAE?QAc@EXCECBEDAHWdBENAF?D?D?v@DhE@JCQ?M@M?_@@oFBKF]", + }, + { + "route_id": 3984019243, + "type": "route", + "start": { + "time": f"{current_date}T11:16:00Z", + "address": "Tuontikatu 180, 20200 Turku, Suomi", + "lat": 60.44447, + "lng": 22.21462, + }, + "avg_speed": 18, + "max_speed": 43, + "end": { + "time": f"{current_date}T11:21:55Z", + "address": "Tuontikatu, 20200 Turku, Suomi", + "lat": 60.44754, + "lng": 22.21391, + }, + "distance": 1799, + "polyline": "}p|oJkxqfCt@|AtAtELT@JFd@d@nBX`AfAlFLl@Rv@xHv[T^p@~@DJ@B?DDPD@KUACCIQUg@i@]q@m@_C{Loi@AUIKCEU" + + "_@Oc@{@yCw@aB_BaB_A_@_CI{F[IFGLI^A`@?d@IfCGxA", + }, + ] + + assert num_elements <= len(routes) + data = {"data": {"units": [{"routes": routes[:num_elements]}]}} + return data + + +def get_kuntec_units_mock_data(num_elements): + null = None + units = [ + { + "unit_id": 150635, + "box_id": 27953713, + "company_id": 5495, + "country_code": "FI", + "label": "1100781186", + "number": "HAKA 2", + "shortcut": "", + "vehicle_title": null, + "car_reg_certificate": "", + "vin": null, + "type": "car", + "icon": "tractor", + "lat": 60.46423, + "lng": 22.43703, + "direction": 161, + "speed": null, + "mileage": 11779869, + "last_update": "2023-02-25T13:20:56Z", + "ignition_total_time": 7683589, + "state": { + "name": "nodata", + "start": "2023-02-25T12:40:53Z", + "duration": 148756, + "debug_info": { + "boxId": 27953713, + "carId": 150635, + "msg": "POWEROFF", + "lastUpdate": 1677331256, + "lastValues": null, + }, + }, + "movement_state": { + "name": "nodata", + "start": "2023-02-25T12:40:53Z", + "duration": 151159, + }, + "fuel_type": "", + "avg_fuel_consumption": {"norm": 0, "measurement": "l/100km"}, + "created_at": "2019-11-05T10:10:38Z", + "io_din": [ + {"no": 1, "label": "Auraus", "state": 1}, + {"no": 2, "label": "Hiekoitus", "state": 1}, + {"no": 3, "label": "Muu ty\u00f6", "state": 0}, + ], + }, + { + "unit_id": 150662, + "box_id": 27953746, + "company_id": 5495, + "country_code": "FI", + "label": "1101049692", + "number": "TAKKU 1", + "shortcut": "", + "vehicle_title": null, + "car_reg_certificate": "", + "vin": null, + "type": "car", + "icon": "tractor", + "lat": 60.55185, + "lng": 22.20567, + "direction": 213, + "speed": null, + "mileage": 10537795, + "last_update": "2023-02-26T10:02:03Z", + "ignition_total_time": 0, + "state": { + "name": "nodata", + "start": "2023-02-26T09:33:37Z", + "duration": 74289, + "debug_info": { + "boxId": 27953746, + "carId": 150662, + "msg": "OTHER", + "lastUpdate": 1677405723, + "lastValues": {"VOLTAGE": "14289"}, + }, + }, + "movement_state": { + "name": "nodata", + "start": "2023-02-26T09:33:37Z", + "duration": 75995, + }, + "fuel_type": "", + "avg_fuel_consumption": {"norm": 0, "measurement": "l/100km"}, + "created_at": "2019-11-05T10:39:46Z", + "io_din": [ + {"no": 1, "label": "Auraus", "state": 1}, + {"no": 2, "label": "Hiekoitus", "state": 0}, + {"no": 3, "label": "Muu ty\u00f6", "state": 0}, + ], + }, + { + "unit_id": 150662, + "box_id": 27953746, + "company_id": 5495, + "country_code": "FI", + "label": "1101049692", + "number": "TAKKU 1", + "shortcut": "", + "vehicle_title": null, + "car_reg_certificate": "", + "vin": null, + "type": "car", + "icon": "tractor", + "lat": 60.55185, + "lng": 22.20567, + "direction": 213, + "speed": null, + "mileage": 10537795, + "last_update": "2023-02-26T10:02:03Z", + "ignition_total_time": 0, + "state": { + "name": "nodata", + "start": "2023-02-26T09:33:37Z", + "duration": 74289, + "debug_info": { + "boxId": 27953746, + "carId": 150662, + "msg": "OTHER", + "lastUpdate": 1677405723, + "lastValues": {"VOLTAGE": "14289"}, + }, + }, + "movement_state": { + "name": "nodata", + "start": "2023-02-26T09:33:37Z", + "duration": 75995, + }, + "fuel_type": "", + "avg_fuel_consumption": {"norm": 0, "measurement": "l/100km"}, + "created_at": "2019-11-05T10:39:46Z", + "io_din": [ + {"no": 1, "label": "Auraus", "state": 1}, + {"no": 2, "label": "Hiekoitus", "state": 0}, + {"no": 3, "label": "Muu ty\u00f6", "state": 0}, + ], + }, + ] + assert num_elements <= len(units) + data = {"data": {"units": units[:num_elements]}} + return data diff --git a/wiki_images/street_maintenance_kwargs_example.PNG b/wiki_images/street_maintenance_kwargs_example.PNG new file mode 100644 index 000000000..07642f52d Binary files /dev/null and b/wiki_images/street_maintenance_kwargs_example.PNG differ diff --git a/wiki_images/street_maintenance_kwargs_example_one_provider.PNG b/wiki_images/street_maintenance_kwargs_example_one_provider.PNG new file mode 100644 index 000000000..6c5f6b492 Binary files /dev/null and b/wiki_images/street_maintenance_kwargs_example_one_provider.PNG differ