Skip to content

Commit

Permalink
Merge pull request City-of-Helsinki#49 from suutari-ai/monitoring-api
Browse files Browse the repository at this point in the history
Add monitoring API and JWT auth for Dashboard
  • Loading branch information
suutari-ai authored Feb 12, 2018
2 parents ef70ec8 + 771ef01 commit bb073e5
Show file tree
Hide file tree
Showing 51 changed files with 1,299 additions and 40 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
7 changes: 6 additions & 1 deletion parkings/admin.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from django.contrib import admin
from django.contrib.gis.admin import OSMGeoAdmin

from .models import Operator, Parking, ParkingArea, ParkingTerminal
from .models import Operator, Parking, ParkingArea, ParkingTerminal, Region


class OperatorAdmin(admin.ModelAdmin):
Expand All @@ -18,6 +18,11 @@ class ParkingAdmin(OSMGeoAdmin):
ordering = ('-created_at',)


@admin.register(Region)
class RegionAdmin(OSMGeoAdmin):
ordering = ('name',)


class ParkingAreaAdmin(OSMGeoAdmin):
ordering = ('origin_id',)

Expand Down
10 changes: 10 additions & 0 deletions parkings/api/auth/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import drf_jwt_2fa.urls
from django.conf.urls import include, url

v1_urlpatterns = [
url(r'^', include(drf_jwt_2fa.urls, namespace='auth')),
]

urlpatterns = [
url(r'^', include(v1_urlpatterns, namespace='v1')),
]
4 changes: 1 addition & 3 deletions parkings/api/enforcement/operator.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from rest_framework import permissions, serializers, viewsets

from ...authentication import ApiKeyAuthentication
from ...models import Operator


Expand All @@ -16,7 +15,6 @@ class Meta:


class OperatorViewSet(viewsets.ReadOnlyModelViewSet):
permission_classes = [permissions.IsAdminUser]
queryset = Operator.objects.order_by('name')
serializer_class = OperatorSerializer
authentication_classes = [ApiKeyAuthentication]
permission_classes = [permissions.IsAdminUser]
4 changes: 1 addition & 3 deletions parkings/api/enforcement/valid_parking.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
from django.utils.translation import gettext_lazy as _
from rest_framework import permissions, serializers, viewsets

from ...authentication import ApiKeyAuthentication
from ...models import Parking


Expand Down Expand Up @@ -90,8 +89,7 @@ def get_time_old_parkings_visible(default=datetime.timedelta(minutes=15)):


class ValidParkingViewSet(viewsets.ReadOnlyModelViewSet):
permission_classes = [permissions.IsAdminUser]
queryset = Parking.objects.order_by('-time_end')
serializer_class = ValidParkingSerializer
filter_class = ValidParkingFilter
authentication_classes = [ApiKeyAuthentication]
permission_classes = [permissions.IsAdminUser]
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: 1 addition & 3 deletions parkings/api/operator/parking.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
from django.utils.translation import ugettext_lazy as _
from rest_framework import mixins, permissions, serializers, viewsets

from parkings.authentication import ApiKeyAuthentication
from parkings.models import Operator, Parking

from ..common import ParkingException
Expand Down Expand Up @@ -87,10 +86,9 @@ def has_object_permission(self, request, view, obj):

class OperatorAPIParkingViewSet(mixins.CreateModelMixin, mixins.UpdateModelMixin, mixins.DestroyModelMixin,
viewsets.GenericViewSet):
permission_classes = [OperatorAPIParkingPermission]
queryset = Parking.objects.order_by('time_start')
serializer_class = OperatorAPIParkingSerializer
authentication_classes = (ApiKeyAuthentication,)
permission_classes = (OperatorAPIParkingPermission,)

def perform_create(self, serializer):
serializer.save(operator=self.request.user.operator)
Expand Down
3 changes: 2 additions & 1 deletion parkings/api/public/parking_area.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from rest_framework import viewsets
from rest_framework import permissions, viewsets
from rest_framework_gis.pagination import GeoJsonPagination
from rest_framework_gis.serializers import (
GeoFeatureModelSerializer, GeometrySerializerMethodField)
Expand All @@ -24,6 +24,7 @@ class Meta:


class PublicAPIParkingAreaViewSet(viewsets.ReadOnlyModelViewSet):
permission_classes = [permissions.AllowAny]
queryset = ParkingArea.objects.order_by('origin_id')
serializer_class = ParkingAreaSerializer
pagination_class = GeoJsonPagination
Expand Down
7 changes: 4 additions & 3 deletions parkings/api/public/parking_area_statistics.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
from django.db.models import Case, Count, Q, When
from django.utils import timezone
from rest_framework import serializers, viewsets
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 @@ -33,9 +33,10 @@ class Meta:


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
14 changes: 12 additions & 2 deletions parkings/api/public/urls.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
from django.conf.urls import include, url
from rest_framework.routers import DefaultRouter
from rest_framework import permissions
from rest_framework.routers import APIRootView, DefaultRouter

from .parking_area import PublicAPIParkingAreaViewSet
from .parking_area_statistics import PublicAPIParkingAreaStatisticsViewSet

router = DefaultRouter()

class PublicApiRootView(APIRootView):
permission_classes = [permissions.AllowAny]


class Router(DefaultRouter):
APIRootView = PublicApiRootView


router = Router()
router.register(r'parking_area', PublicAPIParkingAreaViewSet, base_name='parkingarea')
router.register(r'parking_area_statistics', PublicAPIParkingAreaStatisticsViewSet, base_name='parkingareastatistics')

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)
55 changes: 55 additions & 0 deletions parkings/importers/regions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import sys

from django.contrib.gis.gdal import DataSource
from django.contrib.gis.utils import LayerMapping

from ..models import Region


class ShapeFileToRegionImporter(object):
"""
Importer from ESRI Shapefiles to Region model in the database.
"""
field_mapping = {
# Region model field -> Field in the file
'geom': 'MULTIPOLYGON',
}

def __init__(self, filename, encoding='utf-8',
output_stream=sys.stderr, verbose=True):
self.filename = filename
self.encoding = encoding
self.data_source = DataSource(filename, encoding=encoding)
self.output_stream = output_stream
self.verbose = verbose

def set_field_mapping(self, mapping):
self.field_mapping = dict(self.field_mapping, **mapping)

def get_layer_names(self):
return [layer.name for layer in self.data_source]

def get_layer_fields(self, name):
layer = self.data_source[self._get_layer_index(name)]
return layer.fields

def import_from_layer(self, layer_name):
layer_mapping = LayerMapping(
model=Region,
data=self.filename,
mapping=self.field_mapping,
layer=self._get_layer_index(layer_name),
encoding=self.encoding)
silent = (self.output_stream is None)
layer_mapping.save(
strict=True,
stream=self.output_stream,
silent=silent,
verbose=(not silent and self.verbose))

def _get_layer_index(self, name):
layer_names = self.get_layer_names()
try:
return layer_names.index(name)
except ValueError:
raise ValueError('No such layer: {!r}'.format(name))
Loading

0 comments on commit bb073e5

Please sign in to comment.