forked from City-of-Helsinki/parkkihubi
-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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.
- Loading branch information
1 parent
0c30425
commit 5839c11
Showing
19 changed files
with
419 additions
and
4 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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)) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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')), | ||
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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)) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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', | ||
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,5 +1,5 @@ | ||
from rest_framework.pagination import PageNumberPagination | ||
|
||
|
||
class PublicAPIPagination(PageNumberPagination): | ||
class Pagination(PageNumberPagination): | ||
page_size_query_param = 'page_size' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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)) |
Oops, something went wrong.