Skip to content

Commit

Permalink
Add API for regions and their parking statistics
Browse files Browse the repository at this point in the history
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
suutari-ai committed Feb 12, 2018
1 parent 0c30425 commit 5839c11
Show file tree
Hide file tree
Showing 19 changed files with 419 additions and 4 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
15 changes: 15 additions & 0 deletions parkings/api/monitoring/permissions.py
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
54 changes: 54 additions & 0 deletions parkings/api/monitoring/region.py
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
37 changes: 37 additions & 0 deletions parkings/api/monitoring/region_statistics.py
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))
14 changes: 14 additions & 0 deletions parkings/api/monitoring/urls.py
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')),
]
4 changes: 2 additions & 2 deletions parkings/api/public/parking_area_statistics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions parkings/api/utils.py
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))
12 changes: 12 additions & 0 deletions parkings/factories/__init__.py
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',
]
15 changes: 15 additions & 0 deletions parkings/factories/region.py
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)
2 changes: 1 addition & 1 deletion parkings/pagination.py
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'
9 changes: 9 additions & 0 deletions parkings/tests/api/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Empty file.
38 changes: 38 additions & 0 deletions parkings/tests/api/monitoring/test_permissions.py
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()
58 changes: 58 additions & 0 deletions parkings/tests/api/monitoring/test_region.py
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))
Loading

0 comments on commit 5839c11

Please sign in to comment.