diff --git a/config_dev.env.example b/config_dev.env.example index dc66fbe57..fac34648a 100644 --- a/config_dev.env.example +++ b/config_dev.env.example @@ -159,8 +159,8 @@ ECO_COUNTER_LOG_LEVEL= MOBILITY_DATA_LOG_LEVEL= # Bicycle networks APP, default INFO BICYCLE_NETWORK_LOG_LEVEL= -# Street maintenance, default INFO -STREET_MAINTENANCE_LOG_LEVEL= +# Maintenance +MAINTENANCE_LOG_LEVEL=INFO # Settings needed for enabling Turku area: #ADDITIONAL_INSTALLED_APPS=smbackend_turku,ptv @@ -202,3 +202,8 @@ KUNTEC_KEY= # Telraam API token, required when fetching Telraam data to csv (import_telraam_to_csv.py) # https://telraam.helpspace-docs.io/article/27/you-wish-more-data-and-statistics-telraam-api TELRAAM_TOKEN= +# Optionally, define the urls, from where the ski trails and maintenance histories are retrieved from. +# The default values are in the examples. +# SKI_TRAILS_URL=https://api.paikannuspalvelu.fi/v1/public/track/?data_key=cftqHZ8mjwf3uYpXz9HAUH3nXY6IjrvrvYmRMnbZ&author=?&format=geojson +# SKI_TRAILS_MAINTENANCE_HISTORY_URL=https://api.paikannuspalvelu.fi/v1/public/location/lastvisit/?data_key=cftqHZ8mjwf3uYpXz9HAUH3nXY6IjrvrvYmRMnbZ&author=?&format=geojson&max_distance=50 +# ICE_TRACKS_MAINTENANCE_HISTORY_URL=https://api.paikannuspalvelu.fi/v1/public/location/32/?data_key=cftqHZ8mjwf3uYpXz9HAUH3nXY6IjrvrvYmRMnbZ&author=?&format=geojson \ No newline at end of file diff --git a/street_maintenance/README.md b/maintenance/README.md similarity index 62% rename from street_maintenance/README.md rename to maintenance/README.md index adc27f18e..33e2a54c3 100644 --- a/street_maintenance/README.md +++ b/maintenance/README.md @@ -1,8 +1,9 @@ -# Street Maintenance history +# Maintenance history -Django app for importing and serving street maintenance data. +Django app for importing, processing and serving maintenance data. -## Importer +## Street maintenance history +### Importer Name: import_street_maintenance_history @@ -32,12 +33,30 @@ Note, only the MaintenanceWorks and MaintenanceUnits for the given provider from 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 ``` -## API -See: specificatin.swagger.yaml \ No newline at end of file +## Ski trails +To periodically import ski trails maintenance history use Celery, for more information [see](https://github.com/City-of-Turku/smbackend/wiki/Celery-Tasks#unit-maintenance). + +### Ski trails +Before importing the ski trails maintenance history the ski trails must be imported. To import type: +``` +./manage.py import_ski_trails +``` + +### Ski trails maintenance history +To import the maintenance history, type: +``` +./manage.py import_ski_trails_maintenance_history +``` + +## Ice tracks maintenance history +To import the maintenance history, type: +``` +./manage.py import_ice_tracks_maintenance_history +``` \ No newline at end of file diff --git a/street_maintenance/tests/__init__.py b/maintenance/__init__.py similarity index 100% rename from street_maintenance/tests/__init__.py rename to maintenance/__init__.py diff --git a/maintenance/admin.py b/maintenance/admin.py new file mode 100644 index 000000000..29f0bdcb6 --- /dev/null +++ b/maintenance/admin.py @@ -0,0 +1,13 @@ +from django.contrib import admin + +from maintenance.models import ( + MaintenanceUnit, + MaintenanceWork, + UnitMaintenance, + UnitMaintenanceGeometry, +) + +admin.site.register(MaintenanceWork) +admin.site.register(MaintenanceUnit) +admin.site.register(UnitMaintenance) +admin.site.register(UnitMaintenanceGeometry) diff --git a/street_maintenance/api/serializers.py b/maintenance/api/serializers.py similarity index 62% rename from street_maintenance/api/serializers.py rename to maintenance/api/serializers.py index 6ae513acd..b937f637c 100644 --- a/street_maintenance/api/serializers.py +++ b/maintenance/api/serializers.py @@ -3,7 +3,46 @@ from drf_spectacular.utils import extend_schema_field from rest_framework import serializers -from street_maintenance.models import GeometryHistory, MaintenanceUnit, MaintenanceWork +from maintenance.models import ( + GeometryHistory, + MaintenanceUnit, + MaintenanceWork, + UnitMaintenance, + UnitMaintenanceGeometry, +) + + +class UnitMaintenanceGeometrySerializer(serializers.ModelSerializer): + + class Meta: + model = UnitMaintenanceGeometry + fields = ["id", "geometry", "geometry_id", "unit_maintenance"] + + def to_representation(self, instance): + ret = super().to_representation(instance) + # If nested in UnitMaintenanceSerializer + if ( + self.context.get("request", False) + and "/maintenance/unit_maintenance/" in self.context.get("request", "").path + ): + ret.pop("unit_maintenance", None) + return ret + + +class UnitMaintenanceSerializer(serializers.ModelSerializer): + geometries = UnitMaintenanceGeometrySerializer(many=True, read_only=True) + + class Meta: + model = UnitMaintenance + fields = [ + "id", + "unit", + "target", + "condition", + "maintained_at", + "last_imported_time", + "geometries", + ] class GeometryHistorySerializer(serializers.ModelSerializer): diff --git a/maintenance/api/urls.py b/maintenance/api/urls.py new file mode 100644 index 000000000..cc1f48637 --- /dev/null +++ b/maintenance/api/urls.py @@ -0,0 +1,30 @@ +from django.urls import include, path +from rest_framework import routers + +from . import views + +app_name = "maintenance" + +router = routers.DefaultRouter() +router.register("active_events", views.ActiveEventsViewSet, basename="active_events") + +router.register( + "maintenance_works", views.MaintenanceWorkViewSet, basename="maintenance_works" +) +router.register( + "maintenance_units", views.MaintenanceUnitViewSet, basename="maintenance_units" +) +router.register( + "geometry_history", views.GeometryHitoryViewSet, basename="geometry_history" +) +router.register( + "unit_maintenance", views.UnitMaintenanceViewSet, basename="unit_maintenance" +) +router.register( + "unit_maintenance_geometry", + views.UnitMaintenanceGeometryViewSet, + basename="unit_maintenance_geometry", +) +urlpatterns = [ + path("", include(router.urls), name="maintenance"), +] diff --git a/street_maintenance/api/urls.py b/maintenance/api/urls_street_maintenance.py similarity index 90% rename from street_maintenance/api/urls.py rename to maintenance/api/urls_street_maintenance.py index 473e6e3c9..f84e10801 100644 --- a/street_maintenance/api/urls.py +++ b/maintenance/api/urls_street_maintenance.py @@ -1,3 +1,7 @@ +""" +Keep backward compatibility to street_maintenance API. +""" + from django.urls import include, path from rest_framework import routers @@ -20,6 +24,5 @@ ) urlpatterns = [ - # re_path("^street_maintenance/active_events", ) path("", include(router.urls), name="street_maintenance"), ] diff --git a/street_maintenance/api/views.py b/maintenance/api/views.py similarity index 79% rename from street_maintenance/api/views.py rename to maintenance/api/views.py index 87f6f9cef..18fd30b13 100644 --- a/street_maintenance/api/views.py +++ b/maintenance/api/views.py @@ -1,25 +1,35 @@ from datetime import datetime +import django_filters from django.utils.decorators import method_decorator from django.utils.timezone import make_aware from django.views.decorators.cache import cache_page +from django_filters.rest_framework import DjangoFilterBackend from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter from rest_framework import mixins, viewsets from rest_framework.exceptions import ParseError from rest_framework.pagination import PageNumberPagination -from street_maintenance.api.serializers import ( +from maintenance.api.serializers import ( ActiveEventSerializer, GeometryHistorySerializer, MaintenanceUnitSerializer, MaintenanceWorkSerializer, + UnitMaintenanceGeometrySerializer, + UnitMaintenanceSerializer, ) -from street_maintenance.management.commands.constants import ( +from maintenance.management.commands.constants import ( EVENT_CHOICES, PROVIDERS, START_DATE_TIME_FORMAT, ) -from street_maintenance.models import GeometryHistory, MaintenanceUnit, MaintenanceWork +from maintenance.models import ( + GeometryHistory, + MaintenanceUnit, + MaintenanceWork, + UnitMaintenance, + UnitMaintenanceGeometry, +) EXAMPLE_TIME_FORMAT = "YYYY-MM-DD HH:MM:SS" EXAMPLE_TIME = "2022-09-18 10:00:00" @@ -64,6 +74,45 @@ class LargeResultsSetPagination(PageNumberPagination): max_page_size = 200_000 +class UnitMaintenaceFilterSet(django_filters.FilterSet): + maintained_at__gte = django_filters.DateTimeFilter( + method="filter_maintained_at__gte" + ) + maintained_at__lte = django_filters.DateTimeFilter( + method="filter_maintained_at__lte" + ) + + class Meta: + model = UnitMaintenance + fields = {"target": ["iexact"], "unit": ["exact"]} + + def filter_maintained_at__gte(self, queryset, fields, maintained_at): + return queryset.filter(maintained_at__gte=maintained_at) + + def filter_maintained_at__lte(self, queryset, fields, maintained_at): + return queryset.filter(maintained_at__lte=maintained_at) + + +class UnitMaintenanceViewSet(viewsets.ReadOnlyModelViewSet): + queryset = UnitMaintenance.objects.all() + serializer_class = UnitMaintenanceSerializer + filter_backends = [DjangoFilterBackend] + filterset_class = UnitMaintenaceFilterSet + + @method_decorator(cache_page(60 * 15)) + def list(self, request, *args, **kwargs): + return super().list(request, *args, **kwargs) + + +class UnitMaintenanceGeometryViewSet(viewsets.ReadOnlyModelViewSet): + queryset = UnitMaintenanceGeometry.objects.all() + serializer_class = UnitMaintenanceGeometrySerializer + + @method_decorator(cache_page(60 * 15)) + def list(self, request, *args, **kwargs): + return super().list(request, *args, **kwargs) + + class ActiveEventsViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): queryset = MaintenanceWork.objects.order_by().distinct("events") serializer_class = ActiveEventSerializer @@ -133,7 +182,7 @@ def list(self, request): list=extend_schema( 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", + "can be a machine or an event.", ), ) class MaintenanceUnitViewSet(viewsets.ReadOnlyModelViewSet): diff --git a/maintenance/apps.py b/maintenance/apps.py new file mode 100644 index 000000000..c386a4051 --- /dev/null +++ b/maintenance/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class MaintenanceConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "maintenance" diff --git a/street_maintenance/management/commands/constants.py b/maintenance/management/commands/constants.py similarity index 98% rename from street_maintenance/management/commands/constants.py rename to maintenance/management/commands/constants.py index beb9adde5..f94e93943 100644 --- a/street_maintenance/management/commands/constants.py +++ b/maintenance/management/commands/constants.py @@ -191,3 +191,6 @@ KUNTEC: {HISTORY_SIZE: KUNTEC_DEFAULT_WORKS_HISTORY_SIZE}, YIT: {HISTORY_SIZE: YIT_DEFAULT_WORKS_HISTORY_SIZE}, } + +SKI_TRAILS_DATE_FIELD_FORMAT = "%Y-%m-%d %H:%M" +ICE_TRACKS_DATE_FIELD_FORMAT = "%Y-%m-%d %H:%M:%S" diff --git a/street_maintenance/management/commands/delete_street_maintenance_history.py b/maintenance/management/commands/delete_street_maintenance_history.py similarity index 94% rename from street_maintenance/management/commands/delete_street_maintenance_history.py rename to maintenance/management/commands/delete_street_maintenance_history.py index ec91b0193..c946b6db5 100644 --- a/street_maintenance/management/commands/delete_street_maintenance_history.py +++ b/maintenance/management/commands/delete_street_maintenance_history.py @@ -2,11 +2,11 @@ from django.core.management import BaseCommand -from street_maintenance.models import GeometryHistory, MaintenanceUnit +from maintenance.models import GeometryHistory, MaintenanceUnit from .constants import PROVIDERS -logger = logging.getLogger("mobility_data") +logger = logging.getLogger("maintenance") # Add deprecated provider name 'AUTORI' PROVIDERS.append("AUTORI") diff --git a/maintenance/management/commands/import_ice_tracks_maintenance_history.py b/maintenance/management/commands/import_ice_tracks_maintenance_history.py new file mode 100644 index 000000000..bb7185a8f --- /dev/null +++ b/maintenance/management/commands/import_ice_tracks_maintenance_history.py @@ -0,0 +1,134 @@ +import logging +from datetime import datetime + +import pytz +from django.conf import settings +from django.contrib.gis.geos import Point +from django.core.management.base import BaseCommand + +from maintenance.models import DEFAULT_SRID, UnitMaintenance, UnitMaintenanceGeometry +from services.models import Unit + +from .constants import ICE_TRACKS_DATE_FIELD_FORMAT +from .utils import get_json_data, get_unit_maintenance_instance + +logger = logging.getLogger(__name__) + +TIMEZONE = pytz.timezone("Europe/Helsinki") + + +def save_maintenance_history(json_data): + objs_to_delete = list( + UnitMaintenance.objects.filter(target=UnitMaintenance.ICE_TRACK).values_list( + "id", flat=True + ) + ) + num_created = 0 + num_updated = 0 + features = json_data.get("features", None) + if not features: + logger.error("No features found in JSON response.") + return + + for feature in features: + properties = feature.get("properties", None) + if not properties: + logger.warning( + f"'properties' not found for feature: {feature}, skipping..." + ) + continue + + external_id = properties.get("external_id", None) + if external_id: + try: + unit = Unit.objects.get(id=external_id) + except Unit.DoesNotExist: + logger.error(f"Unit {external_id} not found, skipping...") + continue + + filter = { + "unit": unit, + "target": UnitMaintenance.ICE_TRACK, + } + + unit_maintenance, is_created = get_unit_maintenance_instance(filter) + maintained_at = properties.get("conditioned_at", None) + if maintained_at: + try: + maintained_at = TIMEZONE.localize( + datetime.strptime(maintained_at, ICE_TRACKS_DATE_FIELD_FORMAT) + ) + except Exception as exp: + logger.warning( + f"Feature {feature}, invalid 'maintained_at' field, reason {exp}." + ) + condition_val = properties.get("conditioned", None) + + if condition_val is None: + condition = UnitMaintenance.UNDEFINED + elif condition_val is True: + condition = UnitMaintenance.USABLE + else: + condition = UnitMaintenance.UNUSABLE + + unit_maintenance.condition = condition + unit_maintenance.maintained_at = maintained_at + unit_maintenance.last_imported_time = TIMEZONE.localize( + datetime.now().replace(microsecond=0) + ) + try: + unit_maintenance.save() + except Exception as exp: + logger.error(f"unable to save ice track maintenance history, reason: {exp}") + continue + + geometry = feature.get("geometry", None) + if geometry: + coordinates = geometry.get("coordinates", None) + if coordinates and len(coordinates) == 2: + lon = coordinates[0] + lat = coordinates[1] + point = Point(lon, lat, srid=DEFAULT_SRID) + unit_maintenance_geometry, _ = ( + UnitMaintenanceGeometry.objects.get_or_create( + unit_maintenance=unit_maintenance + ) + ) + unit_maintenance_geometry.geometry = point + unit_maintenance_geometry.save() + else: + logger.error( + f"Missing or invalid field 'coordinates' for feature {feature}, skipping geometry..." + ) + else: + logger.error( + f"Missing 'geometry' field for featrure {feature}, skipping geometry..." + ) + + if is_created: + num_created += 1 + else: + num_updated += 1 + if unit_maintenance.id in objs_to_delete: + objs_to_delete.remove(unit_maintenance.id) + + UnitMaintenance.objects.filter(id__in=objs_to_delete).delete() + logger.info( + f"Created {num_created}, updated {num_updated}, deleted {len(objs_to_delete)} ice track maintenance histories" + ) + + +class Command(BaseCommand): + def add_arguments(self, parser): + parser.add_argument( + "--delete", + action="store_true", + help="Delete ice tracks maintenance history.", + ) + + def handle(self, *args, **options): + if options.get("delete", False): + UnitMaintenance.objects.filter(target=UnitMaintenance.ICE_TRACK).delete() + + json_data = get_json_data(settings.ICE_TRACKS_MAINTENANCE_HISTORY_URL) + save_maintenance_history(json_data) diff --git a/maintenance/management/commands/import_ski_trails.py b/maintenance/management/commands/import_ski_trails.py new file mode 100644 index 000000000..af9e16d25 --- /dev/null +++ b/maintenance/management/commands/import_ski_trails.py @@ -0,0 +1,74 @@ +import logging + +from django.conf import settings +from django.contrib.gis.geos import GEOSGeometry +from django.contrib.gis.geos.error import GEOSException +from django.core.management.base import BaseCommand +from django.db.utils import IntegrityError + +from maintenance.models import UnitMaintenance, UnitMaintenanceGeometry + +from .utils import get_data_layer + +logger = logging.getLogger(__name__) + + +def save_trails(layer): + num_created = 0 + num_updated = 0 + for feature in layer: + is_created = False + is_updated = False + try: + geometry = None + + try: + geometry = GEOSGeometry(feature.geom.wkt, srid=feature.geom.srid) + except GEOSException: + logger.error(f"Invalid geometry {feature.geom.wkt}, skipping...") + continue + + geometry_id = feature["construction_point_id"].as_int() + filter = {"geometry_id": geometry_id} + queryset = UnitMaintenanceGeometry.objects.filter(**filter) + + if queryset.count() == 0: + unit_maintenance_geometry = UnitMaintenanceGeometry(**filter) + unit_maintenance_geometry.geometry = geometry + is_created = True + else: + unit_maintenance_geometry = queryset.first() + if not unit_maintenance_geometry.geometry.equals(geometry): + unit_maintenance_geometry.geometry = geometry + is_updated = True + + try: + unit_maintenance_geometry.save() + except IntegrityError: + logger.error(f"geometry id {geometry.geometry_id} exists, skipping...") + + except Exception as exp: + logger.error(f"Could not save ski trail {feature}, reason {exp}") + num_created = num_created + 1 if is_created else num_created + num_updated = num_updated + 1 if is_updated else num_updated + return num_created, num_updated + + +class Command(BaseCommand): + + def add_arguments(self, parser): + parser.add_argument( + "--delete", + action="store_true", + help="Delete all ski trails that have a unit maintenance relationship, before importing.", + ) + + def handle(self, *args, **options): + if options.get("delete", False): + UnitMaintenanceGeometry.objects.filter( + unit_maintenance__target=UnitMaintenance.SKI_TRAIL + ).delete() + + layer = get_data_layer(settings.SKI_TRAILS_URL) + num_created, num_updated = save_trails(layer) + logger.info(f"Created {num_created}, updated {num_updated} ski trails.") diff --git a/maintenance/management/commands/import_ski_trails_maintenance_history.py b/maintenance/management/commands/import_ski_trails_maintenance_history.py new file mode 100644 index 000000000..464c75521 --- /dev/null +++ b/maintenance/management/commands/import_ski_trails_maintenance_history.py @@ -0,0 +1,131 @@ +import logging +from datetime import datetime + +import pytz +from django import db +from django.conf import settings +from django.core.management.base import BaseCommand + +from maintenance.models import UnitMaintenance, UnitMaintenanceGeometry +from services.models import Unit + +from .constants import SKI_TRAILS_DATE_FIELD_FORMAT +from .utils import get_json_data, get_unit_maintenance_instance + +logger = logging.getLogger(__name__) + +SKITRAIL_TO_UNIT_ID_MAPPINGS = { + "Impivaara-Isosuo": 805, + "Impivaara-Mälikkälä": 804, + "Kuloisten reitti": 803, + "Oriketo-Räntämäki": 801, + "Oriketo-Halinen": 800, + "Ispoinen-Harittu-Lauste": 799, + "Orikedon kuntorata": 790, + "Nunnavuoren kuntoreitti": 789, + "Luolavuori-Ispoinen": 796, + "Ispoisten kuntoreitti": None, + "Ispoisten kuntorata (Wibeliuksenpuisto)": 785, + "Lausteen kuntorata": 786, + "Hirvensalo": 783, + "Härkämäki": 784, + "Maaria": 787, + "Moisio": 788, + "Pansio": 791, + "Suikkila": 792, + "Varissuo": 795, +} +TIMEZONE = pytz.timezone("Europe/Helsinki") + + +def get_unit(name): + try: + return Unit.objects.get(id=SKITRAIL_TO_UNIT_ID_MAPPINGS[name]) + except (Unit.DoesNotExist, KeyError): + return None + + +def save_maintenance_history(json_data): + objs_to_delete = list( + UnitMaintenance.objects.filter(target=UnitMaintenance.SKI_TRAIL).values_list( + "id", flat=True + ) + ) + num_created = 0 + num_updated = 0 + features = json_data.get("features", None) + if not features: + logger.error("No features found in JSON response.") + return + + for feature in features: + properties = feature.get("properties", None) + if not properties: + logger.warning( + f"'properties' not found for feature: {feature}, skipping..." + ) + continue + name = properties.get("name", None) + maintained_at = None + try: + maintained_at = TIMEZONE.localize( + datetime.strptime( + properties.get("date", ""), SKI_TRAILS_DATE_FIELD_FORMAT + ) + ) + except ValueError as exp: + logger.error( + f"Skipping feature {feature}, missing or invalid 'date' field, reason {exp}." + ) + continue + + unit = get_unit(name) + if not unit: + logger.warning(f"Unit not found for {name}") + + filter = { + "unit": unit, + "target": UnitMaintenance.SKI_TRAIL, + } + + unit_maintenance, is_created = get_unit_maintenance_instance(filter) + unit_maintenance.maintained_at = maintained_at + unit_maintenance.last_imported_time = TIMEZONE.localize( + datetime.now().replace(microsecond=0) + ) + + try: + unit_maintenance.save() + except Exception as exp: + logger.error(f"unable to save ski trail maintenance history, reason: {exp}") + continue + + geometry_id = properties.get("location_id", None) + try: + geometry = UnitMaintenanceGeometry.objects.get(geometry_id=geometry_id) + geometry.unit_maintenance = unit_maintenance + geometry.save() + except UnitMaintenanceGeometry.DoesNotExist: + logger.warning( + f"'geometry not set, cause: geometry {geometry_id} not found." + ) + + if is_created: + num_created += 1 + else: + num_updated += 1 + if unit_maintenance.id in objs_to_delete: + objs_to_delete.remove(unit_maintenance.id) + + UnitMaintenance.objects.filter(id__in=objs_to_delete).delete() + logger.info( + f"Created {num_created}, updated {num_updated}, deleted {len(objs_to_delete)} ski trail maintenance histories" + ) + + +class Command(BaseCommand): + + @db.transaction.atomic + def handle(self, *args, **options): + json_data = get_json_data(settings.SKI_TRAILS_MAINTENANCE_HISTORY_URL) + save_maintenance_history(json_data) diff --git a/street_maintenance/management/commands/import_street_maintenance_history.py b/maintenance/management/commands/import_street_maintenance_history.py similarity index 97% rename from street_maintenance/management/commands/import_street_maintenance_history.py rename to maintenance/management/commands/import_street_maintenance_history.py index 2512840f1..db936b3b3 100644 --- a/street_maintenance/management/commands/import_street_maintenance_history.py +++ b/maintenance/management/commands/import_street_maintenance_history.py @@ -3,7 +3,7 @@ from django.core.management import BaseCommand -from street_maintenance.models import MaintenanceUnit, MaintenanceWork +from maintenance.models import MaintenanceUnit, MaintenanceWork from .constants import ( FETCH_SIZE, @@ -23,7 +23,7 @@ precalculate_geometry_history, ) -logger = logging.getLogger("street_maintenance") +logger = logging.getLogger("maintenance") class Command(BaseCommand): diff --git a/street_maintenance/management/commands/utils.py b/maintenance/management/commands/utils.py similarity index 96% rename from street_maintenance/management/commands/utils.py rename to maintenance/management/commands/utils.py index 0a7adec3e..0e6e635f1 100644 --- a/street_maintenance/management/commands/utils.py +++ b/maintenance/management/commands/utils.py @@ -8,14 +8,16 @@ import requests from django import db from django.conf import settings +from django.contrib.gis.gdal import DataSource from django.contrib.gis.geos import LineString, Point from munigeo.models import AdministrativeDivision, AdministrativeDivisionGeometry -from street_maintenance.models import ( +from maintenance.models import ( DEFAULT_SRID, GeometryHistory, MaintenanceUnit, MaintenanceWork, + UnitMaintenance, ) from .constants import ( @@ -34,14 +36,17 @@ YIT, ) -logger = logging.getLogger("street_maintenance") +logger = logging.getLogger("maintenance") # In seconds MAX_WORK_LENGTH = 60 VALID_LINESTRING_MAX_POINT_DISTANCE = 0.01 def get_turku_boundary(): - division_turku = AdministrativeDivision.objects.get(name="Turku") + try: + division_turku = AdministrativeDivision.objects.get(name="Turku") + except AdministrativeDivision.DoesNotExist: + return None turku_boundary = AdministrativeDivisionGeometry.objects.get( division=division_turku ).boundary @@ -641,3 +646,24 @@ def get_yit_access_token(): def is_nested_coordinates(coordinates): return bool(np.array(coordinates).ndim > 1) + + +def get_data_layer(url): + ds = DataSource(url) + assert len(ds) == 1 + return ds[0] + + +def get_unit_maintenance_instance(filter): + is_created = False + queryset = UnitMaintenance.objects.filter(**filter) + + if queryset.count() == 0: + unit_maintenance = UnitMaintenance(**filter) + is_created = True + else: + unit_maintenance = UnitMaintenance.objects.filter(**filter).first() + if queryset.count() > 1: + logger.warning(f"Found duplicate UnitMaintenance {filter}") + + return unit_maintenance, is_created diff --git a/maintenance/migrations/0001_initial.py b/maintenance/migrations/0001_initial.py new file mode 100644 index 000000000..ba551a92f --- /dev/null +++ b/maintenance/migrations/0001_initial.py @@ -0,0 +1,156 @@ +# Generated by Django 4.2.15 on 2024-09-17 07:26 + +import django.contrib.gis.db.models.fields +import django.contrib.postgres.fields +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="GeometryHistory", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("timestamp", models.DateTimeField()), + ( + "geometry", + django.contrib.gis.db.models.fields.GeometryField( + null=True, srid=4326 + ), + ), + ( + "coordinates", + django.contrib.postgres.fields.ArrayField( + base_field=django.contrib.postgres.fields.ArrayField( + base_field=models.FloatField(), size=None + ), + default=list, + size=None, + ), + ), + ( + "events", + django.contrib.postgres.fields.ArrayField( + base_field=models.CharField(max_length=64), + default=list, + size=None, + ), + ), + ( + "provider", + models.CharField( + choices=[ + ("INFRAROAD", "Infraroad"), + ("YIT", "YIT"), + ("KUNTEC", "Kuntec"), + ("DESTIA", "Destia"), + ], + max_length=16, + null=True, + ), + ), + ], + options={ + "ordering": ["-timestamp"], + }, + ), + migrations.CreateModel( + name="MaintenanceUnit", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("unit_id", models.CharField(max_length=64, null=True)), + ( + "provider", + models.CharField( + choices=[ + ("INFRAROAD", "Infraroad"), + ("YIT", "YIT"), + ("KUNTEC", "Kuntec"), + ("DESTIA", "Destia"), + ], + max_length=16, + null=True, + ), + ), + ( + "names", + django.contrib.postgres.fields.ArrayField( + base_field=models.CharField(max_length=64), + default=list, + size=None, + ), + ), + ], + ), + migrations.CreateModel( + name="MaintenanceWork", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "geometry", + django.contrib.gis.db.models.fields.GeometryField( + null=True, srid=4326 + ), + ), + ( + "events", + django.contrib.postgres.fields.ArrayField( + base_field=models.CharField(max_length=64), + default=list, + size=None, + ), + ), + ( + "original_event_names", + django.contrib.postgres.fields.ArrayField( + base_field=models.CharField(max_length=64), + default=list, + size=None, + ), + ), + ("timestamp", models.DateTimeField()), + ( + "maintenance_unit", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="maintenance_work", + to="maintenance.maintenanceunit", + ), + ), + ], + options={ + "ordering": ["-timestamp"], + }, + ), + ] diff --git a/maintenance/migrations/0002_unitmaintenance.py b/maintenance/migrations/0002_unitmaintenance.py new file mode 100644 index 000000000..4816b64af --- /dev/null +++ b/maintenance/migrations/0002_unitmaintenance.py @@ -0,0 +1,70 @@ +# Generated by Django 4.2.15 on 2024-09-24 10:08 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("services", "0103_unit_geometry_3d"), + ("maintenance", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="UnitMaintenance", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "condition", + models.CharField( + choices=[ + ("UNDEFINED", "undefined"), + ("USABLE", "usable"), + ("UNUSABLE", "unusable"), + ], + default="UNDEFINED", + max_length=16, + ), + ), + ( + "target", + models.CharField( + choices=[ + ("SKI_TRAIL", "ski_trail"), + ("ICE_TRACK", "ice_track"), + ], + default="SKI_TRAIL", + max_length=16, + ), + ), + ("maintained_at", models.DateTimeField()), + ( + "last_imported_time", + models.DateTimeField(help_text="Time of last data import"), + ), + ( + "unit", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="maintenance", + to="services.unit", + ), + ), + ], + options={ + "ordering": ["-maintained_at"], + }, + ), + ] diff --git a/maintenance/migrations/0003_unitmaintenancegeometry.py b/maintenance/migrations/0003_unitmaintenancegeometry.py new file mode 100644 index 000000000..fe90ce8f3 --- /dev/null +++ b/maintenance/migrations/0003_unitmaintenancegeometry.py @@ -0,0 +1,49 @@ +# Generated by Django 4.2.15 on 2024-09-24 10:08 + +import django.contrib.gis.db.models.fields +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("maintenance", "0002_unitmaintenance"), + ] + + operations = [ + migrations.CreateModel( + name="UnitMaintenanceGeometry", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "geometry_id", + models.IntegerField(blank=True, null=True, unique=True), + ), + ( + "geometry", + django.contrib.gis.db.models.fields.GeometryField( + null=True, srid=4326 + ), + ), + ( + "unit_maintenance", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="geometries", + to="maintenance.unitmaintenance", + ), + ), + ], + ), + ] diff --git a/maintenance/migrations/0004_alter_unitmaintenancegeometry_unit_maintenance_cascade_deletion.py b/maintenance/migrations/0004_alter_unitmaintenancegeometry_unit_maintenance_cascade_deletion.py new file mode 100644 index 000000000..98618facf --- /dev/null +++ b/maintenance/migrations/0004_alter_unitmaintenancegeometry_unit_maintenance_cascade_deletion.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.15 on 2024-10-02 06:41 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("maintenance", "0003_unitmaintenancegeometry"), + ] + + operations = [ + migrations.AlterField( + model_name="unitmaintenancegeometry", + name="unit_maintenance", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="geometries", + to="maintenance.unitmaintenance", + ), + ), + ] diff --git a/maintenance/migrations/0005_alter_unitmaintenance_maintained_at_to_nullable.py b/maintenance/migrations/0005_alter_unitmaintenance_maintained_at_to_nullable.py new file mode 100644 index 000000000..1b953c526 --- /dev/null +++ b/maintenance/migrations/0005_alter_unitmaintenance_maintained_at_to_nullable.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.15 on 2024-10-09 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "maintenance", + "0004_alter_unitmaintenancegeometry_unit_maintenance_cascade_deletion", + ), + ] + + operations = [ + migrations.AlterField( + model_name="unitmaintenance", + name="maintained_at", + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/maintenance/migrations/__init__.py b/maintenance/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/street_maintenance/models.py b/maintenance/models.py similarity index 50% rename from street_maintenance/models.py rename to maintenance/models.py index 3591c3117..2128b249c 100644 --- a/street_maintenance/models.py +++ b/maintenance/models.py @@ -1,11 +1,58 @@ from django.contrib.gis.db import models from django.contrib.postgres.fields import ArrayField -from street_maintenance.management.commands.constants import PROVIDER_CHOICES +from maintenance.management.commands.constants import PROVIDER_CHOICES +from services.models import Unit DEFAULT_SRID = 4326 +class UnitMaintenance(models.Model): + """ + Model for storing maintenance information for service_unit entities. + """ + + UNDEFINED = "UNDEFINED" + USABLE = "USABLE" + UNUSABLE = "UNUSABLE" + CONDITION_CHOICES = [ + (UNDEFINED, "undefined"), + (USABLE, "usable"), + (UNUSABLE, "unusable"), + ] + SKI_TRAIL = "SKI_TRAIL" + ICE_TRACK = "ICE_TRACK" + TARGET_CHOICES = [(SKI_TRAIL, "ski_trail"), (ICE_TRACK, "ice_track")] + condition = models.CharField( + max_length=16, choices=CONDITION_CHOICES, default=UNDEFINED + ) + target = models.CharField(max_length=16, choices=TARGET_CHOICES, default=SKI_TRAIL) + maintained_at = models.DateTimeField(null=True, blank=True) + last_imported_time = models.DateTimeField(help_text="Time of last data import") + unit = models.ForeignKey( + Unit, + on_delete=models.CASCADE, + related_name="maintenance", + null=True, + blank=True, + ) + + class Meta: + ordering = ["-maintained_at"] + + +class UnitMaintenanceGeometry(models.Model): + geometry_id = models.IntegerField(unique=True, blank=True, null=True) + geometry = models.GeometryField(srid=DEFAULT_SRID, null=True) + unit_maintenance = models.ForeignKey( + UnitMaintenance, + on_delete=models.CASCADE, + related_name="geometries", + null=True, + blank=True, + ) + + class MaintenanceUnit(models.Model): unit_id = models.CharField(max_length=64, null=True) diff --git a/street_maintenance/specificatio.swagger.yaml b/maintenance/specificatio.swagger.yaml similarity index 97% rename from street_maintenance/specificatio.swagger.yaml rename to maintenance/specificatio.swagger.yaml index c1064ebdc..229078bb9 100644 --- a/street_maintenance/specificatio.swagger.yaml +++ b/maintenance/specificatio.swagger.yaml @@ -1,9 +1,9 @@ swagger: "2.0" info: - description: "Street maintenance API that serves history data of maintenance works, active events and provides history as generated geometries." + description: "Maintenance API that serves history data of maintenance works, active events and provides history as generated geometries." version: "1.0.0" - title: "Street Maintenance History" + title: Maintenance History" schemes: - "https" diff --git a/street_maintenance/tasks.py b/maintenance/tasks.py similarity index 63% rename from street_maintenance/tasks.py rename to maintenance/tasks.py index 245186e93..790a7e6d5 100644 --- a/street_maintenance/tasks.py +++ b/maintenance/tasks.py @@ -3,6 +3,23 @@ from smbackend.utils import shared_task_email +@shared_task_email +def import_ski_trails_maintenance_history(name="import_ski_trails_maintenance_history"): + management.call_command("import_ski_trails_maintenance_history") + + +@shared_task_email +def import_ski_trails(*args, name="import_ski_trails"): + management.call_command("import_ski_trails", *args) + + +@shared_task_email +def import_ice_tracks_maintenance_history( + *args, name="import_ice_tracks_maintenance_history" +): + management.call_command("import_ice_tracks_maintenance_history", *args) + + @shared_task_email def delete_street_maintenance_history(*args, name="delete_street_maintenance_history"): management.call_command("delete_street_maintenance_history", *args) diff --git a/maintenance/tests/__init__.py b/maintenance/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/street_maintenance/tests/conftest.py b/maintenance/tests/conftest.py similarity index 54% rename from street_maintenance/tests/conftest.py rename to maintenance/tests/conftest.py index cd4b5fdc2..2b74f6390 100644 --- a/street_maintenance/tests/conftest.py +++ b/maintenance/tests/conftest.py @@ -1,8 +1,8 @@ from datetime import datetime, timedelta import pytest -import pytz from django.contrib.gis.geos import GEOSGeometry, LineString +from django.utils import timezone from munigeo.models import ( AdministrativeDivision, AdministrativeDivisionGeometry, @@ -10,16 +10,20 @@ ) from rest_framework.test import APIClient -from mobility_data.tests.conftest import TURKU_WKT -from street_maintenance.management.commands.constants import ( +from maintenance.management.commands.constants import ( AURAUS, INFRAROAD, KUNTEC, LIUKKAUDENTORJUNTA, ) -from street_maintenance.models import DEFAULT_SRID, GeometryHistory - -UTC_TIMEZONE = pytz.timezone("UTC") +from maintenance.models import ( + DEFAULT_SRID, + GeometryHistory, + UnitMaintenance, + UnitMaintenanceGeometry, +) +from mobility_data.tests.conftest import TURKU_WKT +from services.models import Unit @pytest.fixture @@ -27,52 +31,51 @@ def api_client(): return APIClient() +@pytest.fixture +def now(): + return datetime.now().replace(tzinfo=timezone.get_default_timezone()) + + @pytest.mark.django_db @pytest.fixture -def geometry_historys(): - geometry_historys = [] - now = datetime.now(UTC_TIMEZONE) +def geometry_historys(now): geometry = LineString((0, 0), (0, 50), (50, 50), (50, 0), (0, 0), sird=DEFAULT_SRID) - obj = GeometryHistory.objects.create( + GeometryHistory.objects.create( timestamp=now, geometry=geometry, coordinates=geometry.coords, provider=INFRAROAD, events=[AURAUS], ) - geometry_historys.append(obj) - obj = GeometryHistory.objects.create( + GeometryHistory.objects.create( timestamp=now - timedelta(days=1), geometry=geometry, coordinates=geometry.coords, provider=INFRAROAD, events=[AURAUS], ) - geometry_historys.append(obj) - obj = GeometryHistory.objects.create( + GeometryHistory.objects.create( timestamp=now - timedelta(days=2), geometry=geometry, coordinates=geometry.coords, provider=INFRAROAD, events=[LIUKKAUDENTORJUNTA], ) - geometry_historys.append(obj) - obj = GeometryHistory.objects.create( + GeometryHistory.objects.create( timestamp=now - timedelta(days=1), geometry=geometry, coordinates=geometry.coords, provider=KUNTEC, events=[AURAUS], ) - geometry_historys.append(obj) - obj = GeometryHistory.objects.create( + GeometryHistory.objects.create( timestamp=now - timedelta(days=2), geometry=geometry, coordinates=geometry.coords, provider=KUNTEC, events=[AURAUS, LIUKKAUDENTORJUNTA], ) - geometry_historys.append(obj) + return GeometryHistory.objects.all() @pytest.mark.django_db @@ -101,3 +104,41 @@ def administrative_division_geometry(administrative_division): id=1, division_id=1, boundary=turku_multipoly ) return adm_div_geom + + +@pytest.fixture +def unit_maintenance_geometries(): + geometry = GEOSGeometry("LINESTRING(0 0, 1 1, 2 2)") + UnitMaintenanceGeometry.objects.create(geometry_id=863, geometry=geometry) + UnitMaintenanceGeometry.objects.create(geometry_id=864, geometry=geometry) + return UnitMaintenanceGeometry.objects.all() + + +@pytest.fixture +def units(now): + Unit.objects.create( + id=801, name="Oriketo-Räntämäki -kuntorata", last_modified_time=now + ) + Unit.objects.create(id=784, name="Härkämäen kuntorata", last_modified_time=now) + Unit.objects.create(id=462, name="Frantsinkenttä", last_modified_time=now) + Unit.objects.create( + id=767, name="Pienpelikokoinen hiekkakenttä", last_modified_time=now + ) + return Unit.objects.all() + + +@pytest.fixture +def unit_maintenances(now, units): + UnitMaintenance.objects.create( + target=UnitMaintenance.SKI_TRAIL, + unit=units.get(id=801), + last_imported_time=now, + maintained_at=now + timedelta(days=1), + ) + UnitMaintenance.objects.create( + target=UnitMaintenance.SKI_TRAIL, + unit=units.get(id=784), + last_imported_time=now, + maintained_at=now - timedelta(days=1), + ) + return UnitMaintenance.objects.all() diff --git a/maintenance/tests/data/ski_trails.geojson b/maintenance/tests/data/ski_trails.geojson new file mode 100644 index 000000000..c3d9beb26 --- /dev/null +++ b/maintenance/tests/data/ski_trails.geojson @@ -0,0 +1,110 @@ +{ + "__comment": "Paikannuspalvelu.fi API, please contact: info [at] paikannuspalvelu [dot] fi for description.", + "__format": "geojson", + "__generated": "2024-09-23 16:11:44", + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": { + "track_id": 1, + "name": "Broken", + "construction_broken_id": 898989 + }, + "geometry": { + "type": "LineString", + "coordinates": [ + [0, 0] + ] + } + }, + { + "type": "Feature", + "properties": { + "track_id": 1, + "name": "Oriketo-Räntämäki", + "construction_point_id": 863 + }, + "geometry": { + "type": "LineString", + "coordinates": [ + [22.3149967484872, 60.4802232613912], + [22.3147906754579, 60.4801233028375], + [22.3145429234268, 60.4800857695957], + [22.3143929961208, 60.4800227127172], + [22.3124718920739, 60.4792771008403], + [22.3119577687763, 60.4790840095889], + [22.3114046783066, 60.478895710334], + [22.310957751619, 60.4788360419992], + [22.3103921680913, 60.4788431739947], + [22.3102104886824, 60.4788197788662], + [22.3099897126849, 60.4787270163438], + [22.3097819870241, 60.4786675944077], + [22.3095240191994, 60.4784943624448], + [22.309016838922, 60.4783512594607], + [22.3088082533975, 60.4782784339478], + [22.3085504749605, 60.4782851386108], + [22.3083731514688, 60.4782912508959], + [22.3078829028877, 60.4786656995758], + [22.3077048566751, 60.4787873434822], + [22.3072939854885, 60.4791111791221], + [22.3065606153373, 60.4796171838217], + [22.305450374535, 60.4796359468219], + [22.3053190896709, 60.480171539005], + [22.3052212480635, 60.4805084209146], + [22.3049755490792, 60.4808935261734], + [22.3052247049333, 60.4804963760325], + [22.3053716545553, 60.4800106030839], + [22.3055734438911, 60.4790766786728], + [22.3054114281505, 60.4786822532053], + [22.304864564618, 60.4784586634528], + [22.304080364548, 60.4782444335458] + ] + } + }, + { + "type": "Feature", + "properties": { + "track_id": 1, + "name": "Duplicate construction point id", + "construction_point_id": 863 + }, + "geometry": { + "type": "LineString", + "coordinates": [ + [22.3149967484872, 60.4802232613912], + [22.3147906754579, 60.4801233028375], + [22.3145429234268, 60.4800857695957], + [22.3143929961208, 60.4800227127172], + [22.3124718920739, 60.4792771008403], + [22.3119577687763, 60.4790840095889], + [22.3114046783066, 60.478895710334], + [22.310957751619, 60.4788360419992], + [22.3103921680913, 60.4788431739947], + [22.3102104886824, 60.4788197788662], + [22.3099897126849, 60.4787270163438], + [22.3097819870241, 60.4786675944077], + [22.3095240191994, 60.4784943624448], + [22.309016838922, 60.4783512594607], + [22.3088082533975, 60.4782784339478], + [22.3085504749605, 60.4782851386108], + [22.3083731514688, 60.4782912508959], + [22.3078829028877, 60.4786656995758], + [22.3077048566751, 60.4787873434822], + [22.3072939854885, 60.4791111791221], + [22.3065606153373, 60.4796171838217], + [22.305450374535, 60.4796359468219], + [22.3053190896709, 60.480171539005], + [22.3052212480635, 60.4805084209146], + [22.3049755490792, 60.4808935261734], + [22.3052247049333, 60.4804963760325], + [22.3053716545553, 60.4800106030839], + [22.3055734438911, 60.4790766786728], + [22.3054114281505, 60.4786822532053], + [22.304864564618, 60.4784586634528], + [22.304080364548, 60.4782444335458] + ] + } + } + ] +} \ No newline at end of file diff --git a/maintenance/tests/data/ski_trails_geometry_change.geojson b/maintenance/tests/data/ski_trails_geometry_change.geojson new file mode 100644 index 000000000..8bc16a838 --- /dev/null +++ b/maintenance/tests/data/ski_trails_geometry_change.geojson @@ -0,0 +1,51 @@ +{ + "__comment": "Paikannuspalvelu.fi API, please contact: info [at] paikannuspalvelu [dot] fi for description.", + "__format": "geojson", + "__generated": "2024-09-23 16:11:44", + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": { + "track_id": 1, + "name": "Oriketo-Räntämäki", + "construction_point_id": 863 + }, + "geometry": { + "type": "LineString", + "coordinates": [ + [22.3149967484872, 60.4802232613912], + [22.3147906754579, 60.4801233028375], + [22.3145429234268, 60.4800857695957], + [22.3143929961208, 60.4800227127172], + [22.3124718920739, 60.4792771008403], + [22.3119577687763, 60.4790840095889], + [22.3114046783066, 60.478895710334], + [22.310957751619, 60.4788360419992], + [22.3103921680913, 60.4788431739947], + [22.3102104886824, 60.4788197788662], + [22.3099897126849, 60.4787270163438], + [22.3097819870241, 60.4786675944077], + [22.3095240191994, 60.4784943624448], + [22.309016838922, 60.4783512594607], + [22.3088082533975, 60.4782784339478], + [22.3085504749605, 60.4782851386108], + [22.3083731514688, 60.4782912508959], + [22.3078829028877, 60.4786656995758], + [22.3077048566751, 60.4787873434822], + [22.3072939854885, 60.4791111791221], + [22.3065606153373, 60.4796171838217], + [22.305450374535, 60.4796359468219], + [22.3053190896709, 60.480171539005], + [22.3052212480635, 60.4805084209146], + [22.3049755490792, 60.4808935261734], + [22.3052247049333, 60.4804963760325], + [22.3053716545553, 60.4800106030839], + [22.3055734438911, 60.4790766786728], + [22.3054114281505, 60.4786822532053], + [22.304080364548, 60.4782444335458] + ] + } + } + ] +} \ No newline at end of file diff --git a/maintenance/tests/test_api.py b/maintenance/tests/test_api.py new file mode 100644 index 000000000..1b0b2688b --- /dev/null +++ b/maintenance/tests/test_api.py @@ -0,0 +1,140 @@ +from datetime import timedelta + +import pytest +from django.utils import timezone +from rest_framework.reverse import reverse + +from maintenance.management.commands.constants import ( + AURAUS, + INFRAROAD, + KUNTEC, + LIUKKAUDENTORJUNTA, + SKI_TRAILS_DATE_FIELD_FORMAT, + START_DATE_TIME_FORMAT, +) +from maintenance.models import UnitMaintenance + + +@pytest.mark.django_db +def test_unit_maintenance_geometry_list(api_client, unit_maintenance_geometries): + url = reverse("maintenance:unit_maintenance_geometry-list") + response = api_client.get(url) + assert response.json()["count"] == 2 + unit_maintenance = response.json()["results"][0] + assert unit_maintenance.keys() == { + "id", + "geometry", + "geometry_id", + "unit_maintenance", + } + + +@pytest.mark.django_db +def test_unit_maintenance_list(api_client, unit_maintenances): + url = reverse("maintenance:unit_maintenance-list") + response = api_client.get(url) + assert response.json()["count"] == 2 + unit_maintenance = response.json()["results"][0] + assert unit_maintenance.keys() == { + "id", + "unit", + "target", + "condition", + "maintained_at", + "last_imported_time", + "geometries", + } + + +@pytest.mark.django_db +def test_unit_maintenance_list_unit_parameter(api_client, unit_maintenances): + url = reverse("maintenance:unit_maintenance-list") + "?unit=801" + response = api_client.get(url) + assert response.json()["count"] == 1 + assert response.json()["results"][0]["unit"] == 801 + + +@pytest.mark.django_db +def test_unit_maintenance_list_target_parameter(api_client, unit_maintenances): + url = ( + reverse("maintenance:unit_maintenance-list") + + f"?target__iexact={UnitMaintenance.SKI_TRAIL}" + ) + response = api_client.get(url) + assert response.json()["count"] == 2 + assert response.json()["results"][0]["target"] == UnitMaintenance.SKI_TRAIL + assert response.json()["results"][1]["target"] == UnitMaintenance.SKI_TRAIL + + +@pytest.mark.django_db +def test_unit_maintenance_list_maintained_at_parameter( + api_client, now, unit_maintenances +): + url = ( + reverse("maintenance:unit_maintenance-list") + + f"?maintained_at__gte={now.strftime(SKI_TRAILS_DATE_FIELD_FORMAT)}" + ) + response = api_client.get(url) + assert response.json()["results"][0]["unit"] == 801 + url = ( + reverse("maintenance:unit_maintenance-list") + + f"?maintained_at__lte={now.strftime(SKI_TRAILS_DATE_FIELD_FORMAT)}" + ) + response = api_client.get(url) + assert response.json()["results"][0]["unit"] == 784 + + +@pytest.mark.django_db +def test_geometry_history_list(api_client, geometry_historys): + url = reverse("maintenance:geometry_history-list") + response = api_client.get(url) + assert response.json()["count"] == 5 + + +@pytest.mark.django_db +def test_geometry_history_list_provider_parameter(api_client, geometry_historys): + url = reverse("maintenance:geometry_history-list") + f"?provider={KUNTEC}" + response = api_client.get(url) + # Fixture data contains 2 KUNTEC GeometryHistroy rows + assert response.json()["count"] == 2 + + +@pytest.mark.django_db +def test_geometry_history_list_event_parameter(api_client, geometry_historys): + url = reverse("maintenance:geometry_history-list") + f"?event={AURAUS}" + response = api_client.get(url) + # 3 INFRAROAD AURAUS events and 1 KUNTEC + assert response.json()["count"] == 4 + + +@pytest.mark.django_db +def test_geometry_history_list_event_and_provider_parameter( + api_client, geometry_historys +): + url = ( + reverse("maintenance:geometry_history-list") + + f"?provider={KUNTEC}&event={LIUKKAUDENTORJUNTA}" + ) + response = api_client.get(url) + assert response.json()["count"] == 1 + + +@pytest.mark.django_db +def test_geometry_history_list_start_date_time_parameter(api_client, geometry_historys): + start_date_time = timezone.now() - timedelta(hours=1) + url = ( + reverse("maintenance:geometry_history-list") + + f"?start_date_time={start_date_time.strftime(START_DATE_TIME_FORMAT)}" + ) + response = api_client.get(url) + assert response.json()["count"] == 1 + geometry_history = response.json()["results"][0] + assert geometry_history["geometry_type"] == "LineString" + assert geometry_history["provider"] == INFRAROAD + start_date_time = timezone.now() - timedelta(days=1, hours=2) + url = ( + reverse("maintenance:geometry_history-list") + + f"?start_date_time={start_date_time.strftime(START_DATE_TIME_FORMAT)}" + ) + response = api_client.get(url) + assert response.json()["count"] == 3 diff --git a/maintenance/tests/test_import_ice_tracks_maintenance_history.py b/maintenance/tests/test_import_ice_tracks_maintenance_history.py new file mode 100644 index 000000000..09873b098 --- /dev/null +++ b/maintenance/tests/test_import_ice_tracks_maintenance_history.py @@ -0,0 +1,48 @@ +from datetime import datetime +from unittest.mock import patch + +import pytest + +from maintenance.management.commands.constants import ICE_TRACKS_DATE_FIELD_FORMAT +from maintenance.models import UnitMaintenance + +from .utils import get_ice_tracks_maintenance_history_mock_data + + +@pytest.mark.django_db(transaction=True) +@patch("maintenance.management.commands.utils.get_json_data") +def test_import_ski_trails_maintenance_history( + get_json_data_mock, unit_maintenance_geometries, units +): + from maintenance.management.commands.import_ice_tracks_maintenance_history import ( + save_maintenance_history, + TIMEZONE, + ) + + json_data = get_ice_tracks_maintenance_history_mock_data() + get_json_data_mock.return_value = json_data + save_maintenance_history(get_json_data_mock.return_value) + assert len(json_data["features"]) == 3 + assert UnitMaintenance.objects.count() == 2 + + assert ( + UnitMaintenance.objects.filter(condition=UnitMaintenance.UNUSABLE).count() == 1 + ) + assert UnitMaintenance.objects.filter(condition=UnitMaintenance.USABLE).count() == 1 + + um1 = UnitMaintenance.objects.filter(condition=UnitMaintenance.USABLE).first() + assert um1.target == UnitMaintenance.ICE_TRACK + assert um1.unit.id == 462 + assert um1.maintained_at == TIMEZONE.localize( + datetime.strptime("2024-10-04 14:30:06", ICE_TRACKS_DATE_FIELD_FORMAT) + ) + assert ( + um1.geometries.first().geometry.wkt + == "POINT (22.316604400000024 60.47026100000005)" + ) + # Test that no duplicates are created and imported instanced are preserved + save_maintenance_history(get_json_data_mock.return_value) + assert len(json_data["features"]) == 3 + assert UnitMaintenance.objects.count() == 2 + um2 = UnitMaintenance.objects.filter(condition=UnitMaintenance.USABLE).first() + assert um1 == um2 diff --git a/maintenance/tests/test_import_ski_trails.py b/maintenance/tests/test_import_ski_trails.py new file mode 100644 index 000000000..fb6772cd8 --- /dev/null +++ b/maintenance/tests/test_import_ski_trails.py @@ -0,0 +1,40 @@ +from unittest.mock import patch + +import pytest +from django.contrib.gis.geos import LineString + +from maintenance.models import UnitMaintenanceGeometry +from maintenance.tests.utils import get_test_fixture_data_layer + + +@pytest.mark.django_db(transaction=True) +@patch("maintenance.management.commands.utils.get_data_layer") +def test_import_skitrails( + get_data_layer_mock, +): + from maintenance.management.commands.import_ski_trails import save_trails + + get_data_layer_mock.return_value = get_test_fixture_data_layer("ski_trails.geojson") + num_created, num_updated = save_trails(get_data_layer_mock.return_value) + assert num_created == 1 + assert num_updated == 0 + assert UnitMaintenanceGeometry.objects.count() == 1 + umg = UnitMaintenanceGeometry.objects.first() + assert umg.geometry_id == 863 + assert umg.geometry.srid == 4326 + assert len(umg.geometry) == 31 + assert isinstance(umg.geometry, LineString) is True + # Test geometry update + get_data_layer_mock.return_value = get_test_fixture_data_layer( + "ski_trails_geometry_change.geojson" + ) + num_created, num_updated = save_trails(get_data_layer_mock.return_value) + assert num_created == 0 + assert num_updated == 1 + assert UnitMaintenanceGeometry.objects.count() == 1 + umg_updated = UnitMaintenanceGeometry.objects.first() + assert umg.id == umg_updated.id + assert umg_updated.geometry_id == 863 + assert umg_updated.geometry.srid == 4326 + assert len(umg_updated.geometry) == 30 + assert isinstance(umg_updated.geometry, LineString) is True diff --git a/maintenance/tests/test_import_ski_trails_maintenance_history.py b/maintenance/tests/test_import_ski_trails_maintenance_history.py new file mode 100644 index 000000000..ed1d18eaf --- /dev/null +++ b/maintenance/tests/test_import_ski_trails_maintenance_history.py @@ -0,0 +1,29 @@ +from unittest.mock import patch + +import pytest + +from maintenance.models import UnitMaintenance + +from .utils import get_ski_trails_maintenance_history_mock_data + + +@pytest.mark.django_db(transaction=True) +@patch("maintenance.management.commands.utils.get_json_data") +def test_import_ski_trails_maintenance_history( + get_json_data_mock, unit_maintenance_geometries, units +): + from maintenance.management.commands.import_ski_trails_maintenance_history import ( + save_maintenance_history, + ) + + json_data = get_ski_trails_maintenance_history_mock_data() + get_json_data_mock.return_value = json_data + save_maintenance_history(get_json_data_mock.return_value) + # Note, the mock data contains features with invalid date, missing date, invalid location_id, these are discared + assert len(json_data["features"]) == 4 + assert UnitMaintenance.objects.count() == 1 + um = UnitMaintenance.objects.first() + unit_maintenance_geometry = unit_maintenance_geometries.get(geometry_id=863) + assert unit_maintenance_geometry.unit_maintenance == um + assert um.unit == units.get(id=801) + assert um.target == UnitMaintenance.SKI_TRAIL diff --git a/street_maintenance/tests/test_importers.py b/maintenance/tests/test_street_maintenance_history_importers.py similarity index 90% rename from street_maintenance/tests/test_importers.py rename to maintenance/tests/test_street_maintenance_history_importers.py index 888c3f026..51dd96353 100644 --- a/street_maintenance/tests/test_importers.py +++ b/maintenance/tests/test_street_maintenance_history_importers.py @@ -2,8 +2,8 @@ import pytest -from street_maintenance.management.commands.constants import DESTIA, INFRAROAD -from street_maintenance.models import MaintenanceUnit, MaintenanceWork +from maintenance.management.commands.constants import DESTIA, INFRAROAD +from maintenance.models import MaintenanceUnit, MaintenanceWork from .utils import ( get_fluentprogress_units_mock_data, @@ -18,15 +18,13 @@ @pytest.mark.django_db -@patch("street_maintenance.management.commands.utils.get_yit_vehicles") +@patch("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, - ) + from 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") @@ -54,25 +52,25 @@ def test_yit_units( @pytest.mark.django_db @patch( - "street_maintenance.management.commands.utils.get_yit_vehicles", + "maintenance.management.commands.utils.get_yit_vehicles", return_value=get_yit_vehicles_mock_data(2), ) @patch( - "street_maintenance.management.commands.utils.get_yit_contract", + "maintenance.management.commands.utils.get_yit_contract", return_value=get_yit_contract_mock_data(), ) @patch( - "street_maintenance.management.commands.utils.get_yit_event_types", + "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") +@patch("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 ( + from maintenance.management.commands.utils import ( create_yit_maintenance_units, create_yit_maintenance_works, ) @@ -109,11 +107,11 @@ def test_yit_works( @pytest.mark.django_db -@patch("street_maintenance.management.commands.utils.get_json_data") +@patch("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 ( + from maintenance.management.commands.utils import ( create_kuntec_maintenance_units, create_kuntec_maintenance_works, ) @@ -169,11 +167,11 @@ def test_kuntec( @pytest.mark.django_db -@patch("street_maintenance.management.commands.utils.get_json_data") +@patch("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 ( + from maintenance.management.commands.utils import ( create_maintenance_units, create_maintenance_works, ) @@ -228,11 +226,11 @@ def test_infraroad( @pytest.mark.django_db -@patch("street_maintenance.management.commands.utils.get_json_data") +@patch("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 ( + from maintenance.management.commands.utils import ( create_maintenance_units, create_maintenance_works, ) diff --git a/street_maintenance/tests/utils.py b/maintenance/tests/utils.py similarity index 66% rename from street_maintenance/tests/utils.py rename to maintenance/tests/utils.py index 078807e51..8cff18d8b 100644 --- a/street_maintenance/tests/utils.py +++ b/maintenance/tests/utils.py @@ -1,10 +1,9 @@ +import os from datetime import datetime -from street_maintenance.management.commands.constants import ( - DATE_FORMATS, - INFRAROAD, - YIT, -) +from django.contrib.gis.gdal import DataSource + +from maintenance.management.commands.constants import DATE_FORMATS, INFRAROAD, YIT def get_yit_vehicles_mock_data(num_elements): @@ -358,3 +357,187 @@ def get_kuntec_units_mock_data(num_elements): assert num_elements <= len(units) data = {"data": {"units": units[:num_elements]}} return data + + +def get_ski_trails_maintenance_history_mock_data(): + null = None + data = { + "__comment": "Paikannuspalvelu.fi API, please contact: info [at] paikannuspalvelu [dot] fi for description.", + "__format": "geojson", + "__generated": "2024-09-25 11:56:17", + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": { + "speed": 11, + "missing_date": "2024-09-24 10:53", + "device_id": 861774052275250, + "location_id": 866, + "name": "Missing date", + "distance": 0, + "hours_ago": 25.1, + "days_ago": 1, + }, + "geometry": { + "coordinates": [22.249045608908, 60.486433601894], + "type": "Point", + }, + }, + { + "type": "Feature", + "properties": { + "speed": 0, + "date": "26-08-2024 11:56", + "device_id": null, + "location_id": 862, + "name": "Invalid date", + "distance": 0, + "hours_ago": 720, + "days_ago": 30, + }, + "geometry": { + "coordinates": [22.322459622776, 60.477908123479], + "type": "Point", + }, + }, + { + "type": "Feature", + "properties": { + "speed": 0, + "date": "26-08-2024 11:56", + "device_id": null, + "location_id": 424242, + "name": "Invalid locatioin id", + "distance": 0, + "hours_ago": 720, + "days_ago": 30, + }, + "geometry": { + "coordinates": [22.322459622776, 60.477908123479], + "type": "Point", + }, + }, + { + "type": "Feature", + "properties": { + "speed": 0, + "date": "2024-08-26 11:56", + "device_id": null, + "location_id": 863, + "name": "Oriketo-Räntämäki", + "distance": 0, + "hours_ago": 720, + "days_ago": 30, + }, + "geometry": { + "coordinates": [22.312831834656, 60.47943016396], + "type": "Point", + }, + }, + ], + } + return data + + +def get_ice_tracks_maintenance_history_mock_data(): + null = None + false = False + true = True + data = { + "type": "FeatureCollection", + "count": 47, + "total_count": 47, + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [22.316604400000024, 60.47026100000005], + }, + "properties": { + "id": 928, + "name": "Frantsinkenttä", + "description": "", + "group": "Luistelukentät", + "external_id": 462, + "conditioned": true, + "conditioned_at": "2024-10-04 14:30:06", + "conditioned_by": "Österlund Joni", + "address": "Prelaatinpolku 7", + "zip": "20540", + "city": "Turku", + }, + "crs": { + "type": "name", + "properties": {"name": "urn:ogc:def:crs:OGC:1.3:CRS84"}, + }, + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [22.269447858259024, 60.439084253951066], + }, + "properties": { + "id": 929, + "name": "Purokadun kenttä", + "description": "Pienpelikokoinen hiekkakenttä", + "group": "Luistelukentät", + "external_id": 767, + "conditioned": false, + "conditioned_at": null, + "conditioned_by": null, + "address": "Kunnallissairaalantie 33", + "zip": "20810", + "city": "Turku", + }, + "crs": { + "type": "name", + "properties": {"name": "urn:ogc:def:crs:OGC:1.3:CRS84"}, + }, + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [22.298647600000024, 60.458995100000045], + }, + "properties": { + "id": 930, + "name": "Non existing unit", + "description": "Test non existing unit", + "group": "Luistelukentät", + "external_id": 424242, + "conditioned": false, + "conditioned_at": null, + "conditioned_by": null, + "address": "Ruukuntekijänkuja 2", + "zip": "20540", + "city": "Turku", + }, + "crs": { + "type": "name", + "properties": {"name": "urn:ogc:def:crs:OGC:1.3:CRS84"}, + }, + }, + ], + "response": {"format": "geojson", "message": []}, + } + return data + + +def get_data_source(file_name): + """ + Returns the given file_name as a GDAL Datasource, + the file must be located in /maintenance/tests/data/ + """ + data_path = os.path.join(os.path.dirname(__file__), "data") + file = os.path.join(data_path, file_name) + return DataSource(file) + + +def get_test_fixture_data_layer(file_name): + ds = get_data_source(file_name) + assert len(ds) == 1 + return ds[0] diff --git a/services/api.py b/services/api.py index d58f1f4bc..8bd7acd94 100644 --- a/services/api.py +++ b/services/api.py @@ -28,6 +28,7 @@ from rest_framework.exceptions import ParseError from rest_framework.response import Response +from maintenance.api.views import UnitMaintenanceSerializer from observations.models import Observation from services.accessibility import RULES from services.models import ( @@ -61,6 +62,7 @@ LATITUDE_PARAMETER, LEVEL_PARAMETER, LONGITUDE_PARAMETER, + MAINTENANCE_PARAMETER, MUNICIPALITY_PARAMETER, OCD_ID_PARAMETER, OCD_MUNICIPALITY_PARAMETER, @@ -817,6 +819,12 @@ def to_representation(self, obj): if qparams.get("accessibility_description", "").lower() in ("true", "1"): ret["accessibility_description"] = shortcomings.accessibility_description + + if qparams.get("maintenance", "true").lower() in ("true", "1"): + ret["maintenance"] = UnitMaintenanceSerializer( + obj.maintenance, many=True + ).data + return ret class Meta: @@ -890,6 +898,7 @@ def render(self, data, media_type=None, renderer_context=None): LEVEL_PARAMETER, UNIT_GEOMETRY_PARAMETER, UNIT_GEOMETRY_3D_PARAMETER, + MAINTENANCE_PARAMETER, ] ) class UnitViewSet( diff --git a/services/open_api_parameters.py b/services/open_api_parameters.py index 83e57fde4..a637958ac 100644 --- a/services/open_api_parameters.py +++ b/services/open_api_parameters.py @@ -196,3 +196,12 @@ required=False, type=bool, ) + +MAINTENANCE_PARAMETER = OpenApiParameter( + name="maintenance", + location=OpenApiParameter.QUERY, + description="Display units maintenance information.", + required=False, + type=bool, + default=True, +) diff --git a/smbackend/settings.py b/smbackend/settings.py index 3fa06f3cb..a03d7c9d5 100644 --- a/smbackend/settings.py +++ b/smbackend/settings.py @@ -14,6 +14,18 @@ # Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = str(Path(__file__).resolve().parent.parent) +SKI_TRAILS_DEFAULT_URL = ( + "https://api.paikannuspalvelu.fi/v1/public/track/" + "?data_key=cftqHZ8mjwf3uYpXz9HAUH3nXY6IjrvrvYmRMnbZ&author=?&format=geojson" +) +SKI_TRAILS_MAINTENANCE_HISTORY_DEFAULT_URL = ( + "https://api.paikannuspalvelu.fi/v1/public/location/lastvisit/" + "?data_key=cftqHZ8mjwf3uYpXz9HAUH3nXY6IjrvrvYmRMnbZ&author=?&format=geojson&max_distance=50" +) +ICE_TRACKS_MAINTENANCE_HISTORY_DEFAULT_URL = ( + "https://api.paikannuspalvelu.fi/v1/public/location/32/" + "?data_key=cftqHZ8mjwf3uYpXz9HAUH3nXY6IjrvrvYmRMnbZ&author=?&format=geojson" +) env = environ.Env( DEBUG=(bool, False), LANGUAGES=(list, ["fi", "sv", "en"]), @@ -77,9 +89,18 @@ ECO_COUNTER_LOG_LEVEL=(str, "INFO"), MOBILITY_DATA_LOG_LEVEL=(str, "INFO"), BICYCLE_NETWORK_LOG_LEVEL=(str, "INFO"), - STREET_MAINTENANCE_LOG_LEVEL=(str, "INFO"), + MAINTENANCE_LOG_LEVEL=(str, "INFO"), ENVIRONMENT_DATA_LOG_LEVEL=(str, "INFO"), EXCEPTIONAL_SITUATIONS_LOG_LEVEL=(str, "INFO"), + SKI_TRAILS_URL=(str, SKI_TRAILS_DEFAULT_URL), + SKI_TRAILS_MAINTENANCE_HISTORY_URL=( + str, + SKI_TRAILS_MAINTENANCE_HISTORY_DEFAULT_URL, + ), + ICE_TRACKS_MAINTENANCE_HISTORY_URL=( + str, + ICE_TRACKS_MAINTENANCE_HISTORY_DEFAULT_URL, + ), ) @@ -102,7 +123,7 @@ ECO_COUNTER_LOG_LEVEL = env("ECO_COUNTER_LOG_LEVEL") MOBILITY_DATA_LOG_LEVEL = env("MOBILITY_DATA_LOG_LEVEL") BICYCLE_NETWORK_LOG_LEVEL = env("BICYCLE_NETWORK_LOG_LEVEL") -STREET_MAINTENANCE_LOG_LEVEL = env("STREET_MAINTENANCE_LOG_LEVEL") +MAINTENANCE_LOG_LEVEL = env("MAINTENANCE_LOG_LEVEL") ENVIRONMENT_DATA_LOG_LEVEL = env("ENVIRONMENT_DATA_LOG_LEVEL") EXCEPTIONAL_SITUATIONS_LOG_LEVEL = env("EXCEPTIONAL_SITUATIONS_LOG_LEVEL") @@ -134,6 +155,7 @@ "bicycle_network.apps.BicycleNetworkConfig", "iot.apps.IotConfig", "street_maintenance.apps.StreetMaintenanceConfig", + "maintenance.apps.MaintenanceConfig", "environment_data.apps.EnvironmentDataConfig", "exceptional_situations.apps.ExceptionalSituationsConfig", ] @@ -331,9 +353,9 @@ def gettext(s): "handlers": ["console"], "level": BICYCLE_NETWORK_LOG_LEVEL, }, - "street_maintenance": { + "maintenance": { "handlers": ["console"], - "level": STREET_MAINTENANCE_LOG_LEVEL, + "level": MAINTENANCE_LOG_LEVEL, }, "environment_data": { "handlers": ["console"], @@ -349,9 +371,10 @@ def gettext(s): # Define the endpoints for API documentation with drf-spectacular. DOC_ENDPOINTS = [ - "/street_maintenance/geometry_history/", - "/street_maintenance/maintenance_works/", - "/street_maintenance/maintenance_units/", + "/maintenance/geometry_history/", + "/maintenance/maintenance_works/", + "/maintenance/maintenance_units/", + "/maintenance/unit_maintenance/", "/environment_data/api/v1/stations/", "/environment_data/api/v1/parameters/", "/environment_data/api/v1/data/", @@ -454,3 +477,6 @@ def preprocessing_filter_spec(endpoints): YIT_TOKEN_URL = env("YIT_TOKEN_URL") KUNTEC_KEY = env("KUNTEC_KEY") TELRAAM_TOKEN = env("TELRAAM_TOKEN") +SKI_TRAILS_URL = env("SKI_TRAILS_URL") +SKI_TRAILS_MAINTENANCE_HISTORY_URL = env("SKI_TRAILS_MAINTENANCE_HISTORY_URL") +ICE_TRACKS_MAINTENANCE_HISTORY_URL = env("ICE_TRACKS_MAINTENANCE_HISTORY_URL") diff --git a/smbackend/urls.py b/smbackend/urls.py index 94555aa30..a0a3b0aaf 100644 --- a/smbackend/urls.py +++ b/smbackend/urls.py @@ -11,8 +11,9 @@ import eco_counter.api.urls import environment_data.api.urls import exceptional_situations.api.urls +import maintenance.api.urls +import maintenance.api.urls_street_maintenance import mobility_data.api.urls -import street_maintenance.api.urls from iot.api import IoTViewSet from observations.api import views as observations_views from observations.views import obtain_auth_token @@ -77,9 +78,15 @@ include(exceptional_situations.api.urls), name="exceptional_situations", ), + re_path( + r"^maintenance/", + include(maintenance.api.urls), + name="maintenance", + ), + # Keep backward compatibility to street_maintenance API re_path( r"^street_maintenance/", - include(street_maintenance.api.urls), + include(maintenance.api.urls_street_maintenance), name="street_maintenance", ), re_path(r"", include(shortcutter_urls)), diff --git a/street_maintenance/admin.py b/street_maintenance/admin.py deleted file mode 100644 index 19154fd8d..000000000 --- a/street_maintenance/admin.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.contrib import admin - -from street_maintenance.models import MaintenanceUnit, MaintenanceWork - -admin.site.register(MaintenanceWork) -admin.site.register(MaintenanceUnit) diff --git a/street_maintenance/migrations/0014_delete_geometryhistory_and_more.py b/street_maintenance/migrations/0014_delete_geometryhistory_and_more.py new file mode 100644 index 000000000..cde893e9e --- /dev/null +++ b/street_maintenance/migrations/0014_delete_geometryhistory_and_more.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.15 on 2024-09-18 07:48 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("street_maintenance", "0013_maintenancework_change_related_name"), + ] + + operations = [ + migrations.DeleteModel( + name="GeometryHistory", + ), + migrations.RemoveField( + model_name="maintenancework", + name="maintenance_unit", + ), + migrations.DeleteModel( + name="MaintenanceUnit", + ), + migrations.DeleteModel( + name="MaintenanceWork", + ), + ] diff --git a/street_maintenance/tests/test_api.py b/street_maintenance/tests/test_api.py deleted file mode 100644 index b56048572..000000000 --- a/street_maintenance/tests/test_api.py +++ /dev/null @@ -1,69 +0,0 @@ -from datetime import timedelta - -import pytest -from django.utils import timezone -from rest_framework.reverse import reverse - -from street_maintenance.management.commands.constants import ( - AURAUS, - INFRAROAD, - KUNTEC, - LIUKKAUDENTORJUNTA, - START_DATE_TIME_FORMAT, -) - - -@pytest.mark.django_db -def test_geometry_history_list(api_client, geometry_historys): - url = reverse("street_maintenance:geometry_history-list") - response = api_client.get(url) - assert response.json()["count"] == 5 - - -@pytest.mark.django_db -def test_geometry_history_list_provider_parameter(api_client, geometry_historys): - url = reverse("street_maintenance:geometry_history-list") + f"?provider={KUNTEC}" - response = api_client.get(url) - # Fixture data contains 2 KUNTEC GeometryHistroy rows - assert response.json()["count"] == 2 - - -@pytest.mark.django_db -def test_geometry_history_list_event_parameter(api_client, geometry_historys): - url = reverse("street_maintenance:geometry_history-list") + f"?event={AURAUS}" - response = api_client.get(url) - # 3 INFRAROAD AURAUS events and 1 KUNTEC - assert response.json()["count"] == 4 - - -@pytest.mark.django_db -def test_geometry_history_list_event_and_provider_parameter( - api_client, geometry_historys -): - url = ( - reverse("street_maintenance:geometry_history-list") - + f"?provider={KUNTEC}&event={LIUKKAUDENTORJUNTA}" - ) - response = api_client.get(url) - assert response.json()["count"] == 1 - - -@pytest.mark.django_db -def test_geometry_history_list_start_date_time_parameter(api_client, geometry_historys): - start_date_time = timezone.now() - timedelta(hours=1) - url = ( - reverse("street_maintenance:geometry_history-list") - + f"?start_date_time={start_date_time.strftime(START_DATE_TIME_FORMAT)}" - ) - response = api_client.get(url) - assert response.json()["count"] == 1 - geometry_history = response.json()["results"][0] - assert geometry_history["geometry_type"] == "LineString" - assert geometry_history["provider"] == INFRAROAD - start_date_time = timezone.now() - timedelta(days=1, hours=2) - url = ( - reverse("street_maintenance:geometry_history-list") - + f"?start_date_time={start_date_time.strftime(START_DATE_TIME_FORMAT)}" - ) - response = api_client.get(url) - assert response.json()["count"] == 3