From 5839c11e7b86f121310613589d90dc5923447a3d Mon Sep 17 00:00:00 2001 From: Tuomas Suutari Date: Fri, 9 Feb 2018 11:34:12 +0200 Subject: [PATCH] Add API for regions and their parking statistics Add a new API namespace "monitoring" which will be used by the Dashboard. Then add an API endpoint to that namespace for getting basic geometry and metadata information about Regions and another endpoint for getting the parking counts for each region at specified time. --- README.md | 1 + parkings/api/monitoring/permissions.py | 15 ++++ parkings/api/monitoring/region.py | 54 ++++++++++++ parkings/api/monitoring/region_statistics.py | 37 ++++++++ parkings/api/monitoring/urls.py | 14 +++ .../api/public/parking_area_statistics.py | 4 +- parkings/api/utils.py | 26 ++++++ parkings/factories/__init__.py | 12 +++ parkings/factories/region.py | 15 ++++ parkings/pagination.py | 2 +- parkings/tests/api/conftest.py | 9 ++ parkings/tests/api/monitoring/__init__.py | 0 .../tests/api/monitoring/test_permissions.py | 38 ++++++++ parkings/tests/api/monitoring/test_region.py | 58 +++++++++++++ .../api/monitoring/test_region_statistics.py | 86 +++++++++++++++++++ parkings/tests/api/test_utils.py | 39 +++++++++ parkings/tests/conftest.py | 4 +- parkkihubi/settings.py | 3 + parkkihubi/urls.py | 6 ++ 19 files changed, 419 insertions(+), 4 deletions(-) create mode 100644 parkings/api/monitoring/permissions.py create mode 100644 parkings/api/monitoring/region.py create mode 100644 parkings/api/monitoring/region_statistics.py create mode 100644 parkings/api/monitoring/urls.py create mode 100644 parkings/api/utils.py create mode 100644 parkings/factories/region.py create mode 100644 parkings/tests/api/monitoring/__init__.py create mode 100644 parkings/tests/api/monitoring/test_permissions.py create mode 100644 parkings/tests/api/monitoring/test_region.py create mode 100644 parkings/tests/api/monitoring/test_region_statistics.py create mode 100644 parkings/tests/api/test_utils.py 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')))