diff --git a/README.md b/README.md index bdbbe01e..80a666f0 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ Create a basic file for development as follows #### Parkkihubi settings - `PARKKIHUBI_PUBLIC_API_ENABLED` default `True` +- `PARKKIHUBI_MONITORING_API_ENABLED` default `True` - `PARKKIHUBI_OPERATOR_API_ENABLED` default `True` - `PARKKIHUBI_ENFORCEMENT_API_ENABLED` default `True` diff --git a/parkings/api/monitoring/permissions.py b/parkings/api/monitoring/permissions.py new file mode 100644 index 00000000..6d823dea --- /dev/null +++ b/parkings/api/monitoring/permissions.py @@ -0,0 +1,15 @@ +from django.conf import settings +from rest_framework import permissions + + +class MonitoringApiPermission(permissions.IsAuthenticated): + def has_permission(self, request, view): + if not super().has_permission(request, view): + return False + + user_groups = request.user.groups + group_name = getattr(settings, 'MONITORING_GROUP_NAME', 'monitoring') + + is_in_monitoring_group = (user_groups.filter(name=group_name).exists()) + + return is_in_monitoring_group diff --git a/parkings/api/monitoring/region.py b/parkings/api/monitoring/region.py new file mode 100644 index 00000000..e7a74ec1 --- /dev/null +++ b/parkings/api/monitoring/region.py @@ -0,0 +1,54 @@ +import rest_framework_gis.pagination as gis_pagination +import rest_framework_gis.serializers as gis_serializers +from rest_framework import serializers, viewsets + +from ...models import ParkingArea, Region +from ..common import WGS84InBBoxFilter +from .permissions import MonitoringApiPermission + +WGS84_SRID = 4326 + +# Square meters in square kilometer +M2_PER_KM2 = 1000000.0 + + +class RegionSerializer(gis_serializers.GeoFeatureModelSerializer): + wgs84_geometry = gis_serializers.GeometrySerializerMethodField() + area_km2 = serializers.SerializerMethodField() + spots_per_km2 = serializers.SerializerMethodField() + parking_areas = serializers.SerializerMethodField() + + def get_wgs84_geometry(self, instance): + return instance.geom.transform(WGS84_SRID, clone=True) + + def get_area_km2(self, instance): + return instance.geom.area / M2_PER_KM2 + + def get_spots_per_km2(self, instance): + return M2_PER_KM2 * instance.capacity_estimate / instance.geom.area + + def get_parking_areas(self, instance): + parking_areas = ParkingArea.objects.intersecting_region(instance) + return [x.pk for x in parking_areas] + + class Meta: + model = Region + geo_field = 'wgs84_geometry' + fields = [ + 'id', + 'name', + 'capacity_estimate', + 'area_km2', + 'spots_per_km2', + 'parking_areas', + ] + + +class RegionViewSet(viewsets.ReadOnlyModelViewSet): + permission_classes = [MonitoringApiPermission] + queryset = Region.objects.all().order_by('id') + serializer_class = RegionSerializer + pagination_class = gis_pagination.GeoJsonPagination + bbox_filter_field = 'geom' + filter_backends = [WGS84InBBoxFilter] + bbox_filter_include_overlapping = True diff --git a/parkings/api/monitoring/region_statistics.py b/parkings/api/monitoring/region_statistics.py new file mode 100644 index 00000000..e45022cb --- /dev/null +++ b/parkings/api/monitoring/region_statistics.py @@ -0,0 +1,37 @@ +from rest_framework import serializers, viewsets + +from ...models import Region +from ...pagination import Pagination +from ..common import WGS84InBBoxFilter +from ..utils import parse_timestamp_or_now +from .permissions import MonitoringApiPermission + + +class RegionStatisticsSerializer(serializers.ModelSerializer): + parking_count = serializers.IntegerField(read_only=True) + + class Meta: + model = Region + fields = ( + 'id', + 'parking_count', + ) + + +class RegionStatisticsViewSet(viewsets.ReadOnlyModelViewSet): + permission_classes = [MonitoringApiPermission] + queryset = Region.objects.all() + serializer_class = RegionStatisticsSerializer + pagination_class = Pagination + bbox_filter_field = 'geom' + filter_backends = [WGS84InBBoxFilter] + bbox_filter_include_overlapping = True + + def get_queryset(self): + time = parse_timestamp_or_now(self.request.query_params.get('time')) + return ( + super().get_queryset() + .with_parking_count(time) + .values('id', 'parking_count') + .order_by('id') + .filter(parking_count__gt=0)) diff --git a/parkings/api/monitoring/urls.py b/parkings/api/monitoring/urls.py new file mode 100644 index 00000000..2fd4c849 --- /dev/null +++ b/parkings/api/monitoring/urls.py @@ -0,0 +1,14 @@ +from django.conf.urls import include, url +from rest_framework.routers import DefaultRouter + +from .region import RegionViewSet +from .region_statistics import RegionStatisticsViewSet + +router = DefaultRouter() +router.register(r'region', RegionViewSet, base_name='region') +router.register(r'region_statistics', RegionStatisticsViewSet, + base_name='regionstatistics') + +urlpatterns = [ + url(r'^', include(router.urls, namespace='v1')), +] diff --git a/parkings/api/public/parking_area_statistics.py b/parkings/api/public/parking_area_statistics.py index 9a6a9d36..c9fe2bb0 100644 --- a/parkings/api/public/parking_area_statistics.py +++ b/parkings/api/public/parking_area_statistics.py @@ -3,7 +3,7 @@ from rest_framework import permissions, serializers, viewsets from parkings.models import ParkingArea -from parkings.pagination import PublicAPIPagination +from parkings.pagination import Pagination from ..common import WGS84InBBoxFilter @@ -36,7 +36,7 @@ class PublicAPIParkingAreaStatisticsViewSet(viewsets.ReadOnlyModelViewSet): permission_classes = [permissions.AllowAny] queryset = ParkingArea.objects.all() serializer_class = ParkingAreaStatisticsSerializer - pagination_class = PublicAPIPagination + pagination_class = Pagination bbox_filter_field = 'geom' filter_backends = (WGS84InBBoxFilter,) bbox_filter_include_overlapping = True diff --git a/parkings/api/utils.py b/parkings/api/utils.py new file mode 100644 index 00000000..24f2cc49 --- /dev/null +++ b/parkings/api/utils.py @@ -0,0 +1,26 @@ +import dateutil.parser +from django.utils import timezone +from rest_framework.exceptions import ValidationError + + +def parse_timestamp_or_now(timestamp_string): + """ + Parse given timestamp string or return current time. + + If the timestamp string is falsy, return current time, otherwise try + to parse the string and return the parsed value. + + :type timestamp_string: str + :rtype: datetime.datetime + :raises rest_framework.exceptions.ValidationError: on parse error + """ + if not timestamp_string: + return timezone.now() + return parse_timestamp(timestamp_string) + + +def parse_timestamp(datetime_string): + try: + return dateutil.parser.parse(datetime_string) + except (ValueError, OverflowError): + raise ValidationError('Invalid timestamp: {}'.format(datetime_string)) diff --git a/parkings/factories/__init__.py b/parkings/factories/__init__.py index 5521e8ec..d4b3891e 100644 --- a/parkings/factories/__init__.py +++ b/parkings/factories/__init__.py @@ -1,4 +1,16 @@ from .operator import OperatorFactory # noqa from .parking import HistoryParkingFactory, ParkingFactory # noqa from .parking_area import ParkingAreaFactory # noqa +from .region import RegionFactory from .user import AdminUserFactory, StaffUserFactory, UserFactory # noqa + +__all__ = [ + 'AdminUserFactory', + 'HistoryParkingFactory', + 'OperatorFactory', + 'ParkingAreaFactory', + 'ParkingFactory', + 'RegionFactory', + 'StaffUserFactory', + 'UserFactory', +] diff --git a/parkings/factories/region.py b/parkings/factories/region.py new file mode 100644 index 00000000..85c293c5 --- /dev/null +++ b/parkings/factories/region.py @@ -0,0 +1,15 @@ +import factory + +from parkings.models import Region + +from .faker import fake +from .parking_area import generate_multi_polygon + + +class RegionFactory(factory.django.DjangoModelFactory): + class Meta: + model = Region + + geom = factory.LazyFunction(generate_multi_polygon) + capacity_estimate = fake.random.randint(0, 500) + name = factory.LazyFunction(fake.city) diff --git a/parkings/pagination.py b/parkings/pagination.py index 26aefbd2..67a60745 100644 --- a/parkings/pagination.py +++ b/parkings/pagination.py @@ -1,5 +1,5 @@ from rest_framework.pagination import PageNumberPagination -class PublicAPIPagination(PageNumberPagination): +class Pagination(PageNumberPagination): page_size_query_param = 'page_size' diff --git a/parkings/tests/api/conftest.py b/parkings/tests/api/conftest.py index 63b709c8..fe32cfdd 100644 --- a/parkings/tests/api/conftest.py +++ b/parkings/tests/api/conftest.py @@ -14,6 +14,15 @@ def api_client(): return APIClient() +@pytest.fixture +def monitoring_api_client(user_factory): + api_client = APIClient() + user = user_factory() + user.groups.get_or_create(name='monitoring') + api_client.force_authenticate(user) + return api_client + + @pytest.fixture def user_api_client(user_factory): api_client = APIClient() diff --git a/parkings/tests/api/monitoring/__init__.py b/parkings/tests/api/monitoring/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/parkings/tests/api/monitoring/test_permissions.py b/parkings/tests/api/monitoring/test_permissions.py new file mode 100644 index 00000000..360a34fe --- /dev/null +++ b/parkings/tests/api/monitoring/test_permissions.py @@ -0,0 +1,38 @@ +import pytest +from django.contrib.auth.models import AnonymousUser, User + +from parkings.api.monitoring.permissions import MonitoringApiPermission + + +@pytest.mark.django_db +def test_monitoring_api_permission_anonymous(rf): + request = rf.get('/') + request.user = None + perm = MonitoringApiPermission() + assert perm.has_permission(request, None) is False + request.user = AnonymousUser() + assert perm.has_permission(request, None) is False + + +@pytest.mark.django_db +def test_monitoring_api_permission_not_in_group(rf, user): + request = rf.get('/') + request.user = user + perm = MonitoringApiPermission() + assert perm.has_permission(request, None) is False + + +@pytest.mark.django_db +@pytest.mark.parametrize('group,allowed', [ + ('monitoring', True), ('othergrp', False)]) +def test_monitoring_api_permission_by_group(group, allowed, rf): + user = User.objects.create_user('dummy-user') + user.groups.create(name=group) + try: + request = rf.get('/') + request.user = user + perm = MonitoringApiPermission() + assert perm.has_permission(request, None) is allowed + finally: + user.groups.all().delete() + user.delete() diff --git a/parkings/tests/api/monitoring/test_region.py b/parkings/tests/api/monitoring/test_region.py new file mode 100644 index 00000000..7de288f8 --- /dev/null +++ b/parkings/tests/api/monitoring/test_region.py @@ -0,0 +1,58 @@ +import json + +from django.urls import reverse +from rest_framework import status + +WGS84_SRID = 4326 + + +list_url = reverse('monitoring:v1:region-list') + + +def test_get_regions_empty(monitoring_api_client): + result = monitoring_api_client.get(list_url) + assert result.data == { + 'type': 'FeatureCollection', + 'features': [], + 'count': 0, + 'next': None, + 'previous': None, + } + assert result.status_code == status.HTTP_200_OK + + +def test_get_regions_with_data(monitoring_api_client, region, parking_area): + parking_area.geom = region.geom + parking_area.save() + region.save() # Update capacity_estimate + + result = monitoring_api_client.get(list_url) + features = result.data.pop('features', None) + assert result.data == { + 'type': 'FeatureCollection', + 'count': 1, + 'next': None, + 'previous': None, + } + assert result.status_code == status.HTTP_200_OK + assert isinstance(features, list) + assert len(features) == 1 + geometry = features[0].pop('geometry', None) + properties = features[0].pop('properties', None) + assert features[0] == {'id': str(region.id), 'type': 'Feature'} + km2 = region.geom.area / 1000000.0 + assert properties == { + 'name': region.name, + 'capacity_estimate': region.capacity_estimate, + 'area_km2': km2, + 'spots_per_km2': region.capacity_estimate / km2, + 'parking_areas': [parking_area.id], + } + coordinates = geometry.pop('coordinates', None) + assert geometry == {'type': 'MultiPolygon'} + wgs84_geom = region.geom.transform(WGS84_SRID, clone=True) + assert coordinates == tuples_to_lists(wgs84_geom.coords) + + +def tuples_to_lists(tuples_of_tuples): + return json.loads(json.dumps(tuples_of_tuples)) diff --git a/parkings/tests/api/monitoring/test_region_statistics.py b/parkings/tests/api/monitoring/test_region_statistics.py new file mode 100644 index 00000000..fd08e242 --- /dev/null +++ b/parkings/tests/api/monitoring/test_region_statistics.py @@ -0,0 +1,86 @@ +from collections import OrderedDict + +import pytz +from django.urls import reverse +from rest_framework import status + +from parkings.factories.faker import fake + +from ...utils import create_parkings_and_regions, intersects + +list_url = reverse('monitoring:v1:regionstatistics-list') + + +def test_empty(monitoring_api_client): + result = monitoring_api_client.get(list_url) + assert result.data == { + 'count': 0, + 'next': None, + 'previous': None, + 'results': [], + } + assert result.status_code == status.HTTP_200_OK + + +def test_with_single_parking(monitoring_api_client, region, parking): + point_in_region = region.geom.centroid + parking.location = point_in_region + parking.save() + assert intersects(point_in_region, region) + + result = monitoring_api_client.get(list_url) + assert result.data == { + 'count': 1, + 'next': None, + 'previous': None, + 'results': [ + OrderedDict([ + ('id', str(region.id)), + ('parking_count', 1)]), + ] + } + assert result.status_code == status.HTTP_200_OK + + +def test_with_many_parkings_and_specified_time(monitoring_api_client): + (parkings, regions) = create_parkings_and_regions( + parking_count=20, region_count=5) + + # Pick a point in time that is in between the parking times + earliest_end_time = min(x.time_end for x in parkings) + latest_end_time = max(x.time_end for x in parkings) + time = fake.date_time_between(earliest_end_time, latest_end_time, + tzinfo=pytz.utc) + + # Calculate some lookup containers + valid_parkings_at_time = [ + parking for parking in parkings + if parking.time_start <= time and (parking.time_end is None + or parking.time_end >= time)] + regions_with_valid_parkings = { + parking.region for parking in valid_parkings_at_time + if parking.region + } + regions_by_id = {str(region.id): region for region in regions} + + # Do the API call + api_result = monitoring_api_client.get(list_url, {'time': str(time)}) + + # Check the results + results = api_result.data.pop('results', None) + assert api_result.data == { + 'count': len(regions_with_valid_parkings), + 'next': None, # No paging, since parking count < page size + 'previous': None, + } + assert len(results) == len(regions_with_valid_parkings) + for result in results: + assert isinstance(result, OrderedDict) + assert set(result.keys()) == {'id', 'parking_count'} + assert result['id'] in regions_by_id + region = regions_by_id[result['id']] + expected_count = sum( + 1 for parking in valid_parkings_at_time + if parking.region == region) + assert result['parking_count'] == expected_count + assert api_result.status_code == status.HTTP_200_OK diff --git a/parkings/tests/api/test_utils.py b/parkings/tests/api/test_utils.py new file mode 100644 index 00000000..aa7829b2 --- /dev/null +++ b/parkings/tests/api/test_utils.py @@ -0,0 +1,39 @@ +import datetime + +import pytest +from freezegun import freeze_time +from rest_framework.exceptions import ValidationError as DrfValidationError + +from parkings.api.utils import parse_timestamp, parse_timestamp_or_now + + +def test_parse_timestamp_or_now_with_val(): + parsed = parse_timestamp_or_now('2000-01-01T20:01:05+0200') + assert isinstance(parsed, datetime.datetime) + assert str(parsed) == '2000-01-01 20:01:05+02:00' + + +@freeze_time('2000-02-29T10:15:22Z') +def test_parse_timestamp_or_now_with_empty(): + parsed = parse_timestamp_or_now('') + assert isinstance(parsed, datetime.datetime) + assert str(parsed) == '2000-02-29 10:15:22+00:00' + + +@freeze_time('2000-02-29T10:15:22Z') +def test_parse_timestamp_or_now_with_none(): + parsed = parse_timestamp_or_now(None) + assert isinstance(parsed, datetime.datetime) + assert str(parsed) == '2000-02-29 10:15:22+00:00' + + +def test_parse_timestamp_with_valid_data(): + parsed = parse_timestamp('2000-02-29T10:15:22-12:00') + assert isinstance(parsed, datetime.datetime) + assert str(parsed) == '2000-02-29 10:15:22-12:00' + + +def test_parse_timestamp_with_invalid_data(): + with pytest.raises(DrfValidationError) as excinfo: + parse_timestamp('foobar') + assert str(excinfo.value) == "['Invalid timestamp: foobar']" diff --git a/parkings/tests/conftest.py b/parkings/tests/conftest.py index c676bc46..4eefb9cc 100644 --- a/parkings/tests/conftest.py +++ b/parkings/tests/conftest.py @@ -3,7 +3,8 @@ from parkings.factories import ( AdminUserFactory, HistoryParkingFactory, OperatorFactory, - ParkingAreaFactory, ParkingFactory, StaffUserFactory, UserFactory) + ParkingAreaFactory, ParkingFactory, RegionFactory, StaffUserFactory, + UserFactory) register(OperatorFactory) register(ParkingFactory, 'parking') @@ -12,6 +13,7 @@ register(StaffUserFactory, 'staff_user') register(UserFactory) register(ParkingAreaFactory) +register(RegionFactory) @pytest.fixture(autouse=True) diff --git a/parkkihubi/settings.py b/parkkihubi/settings.py index 3dddd2fe..47242753 100644 --- a/parkkihubi/settings.py +++ b/parkkihubi/settings.py @@ -183,9 +183,12 @@ ############## # Parkkihubi # ############## +MONITORING_GROUP_NAME = 'monitoring' PARKKIHUBI_TIME_PARKINGS_EDITABLE = timedelta(minutes=2) PARKKIHUBI_TIME_OLD_PARKINGS_VISIBLE = timedelta(minutes=15) PARKKIHUBI_PUBLIC_API_ENABLED = env.bool('PARKKIHUBI_PUBLIC_API_ENABLED', True) +PARKKIHUBI_MONITORING_API_ENABLED = env.bool( + 'PARKKIHUBI_MONITORING_API_ENABLED', True) PARKKIHUBI_OPERATOR_API_ENABLED = env.bool('PARKKIHUBI_OPERATOR_API_ENABLED', True) PARKKIHUBI_ENFORCEMENT_API_ENABLED = ( env.bool('PARKKIHUBI_ENFORCEMENT_API_ENABLED', True)) diff --git a/parkkihubi/urls.py b/parkkihubi/urls.py index 45fd7d7a..a62a91eb 100644 --- a/parkkihubi/urls.py +++ b/parkkihubi/urls.py @@ -3,6 +3,7 @@ from django.contrib import admin from parkings.api.enforcement import urls as enforcement_urls +from parkings.api.monitoring import urls as monitoring_urls from parkings.api.operator import urls as operator_urls from parkings.api.public import urls as public_urls @@ -11,6 +12,11 @@ if getattr(settings, 'PARKKIHUBI_PUBLIC_API_ENABLED', False): urlpatterns.append(url(r'^public/v1/', include(public_urls, namespace='public'))) +if getattr(settings, 'PARKKIHUBI_MONITORING_API_ENABLED', False): + urlpatterns.append( + url(r'^monitoring/v1/', + include(monitoring_urls, namespace='monitoring'))) + if getattr(settings, 'PARKKIHUBI_OPERATOR_API_ENABLED', False): urlpatterns.append(url(r'^operator/v1/', include(operator_urls, namespace='operator')))