From 7bfa2e50b4b382b610cacdf324561cb007f3d614 Mon Sep 17 00:00:00 2001 From: Kevin Seestrand Date: Fri, 4 Feb 2022 16:23:02 +0200 Subject: [PATCH 1/8] Monitoring: Implement endpoint for CSV export New endpoints: - /monitoring/v1/export/download/ Responds with a string that contains all the csv lines. Start time and end time are required fields. Create a custom CSVRenderer for this endpoints. When it comes to filtering data with the time we do it by having a time range where any parking that was active during the range gets exported. This means that any parking that started before range and ended before range OR started after range will not get filtered in. One important thing to mention is that if the queryset ends up being very big (>100k objs) ram usage may suffer and the user will have to wait a long time for a response from the server since we don't stream the response. --- parkings/api/monitoring/export_parkings.py | 112 +++++++++++++++++++++ parkings/api/monitoring/urls.py | 4 + 2 files changed, 116 insertions(+) create mode 100644 parkings/api/monitoring/export_parkings.py diff --git a/parkings/api/monitoring/export_parkings.py b/parkings/api/monitoring/export_parkings.py new file mode 100644 index 00000000..db4bf75c --- /dev/null +++ b/parkings/api/monitoring/export_parkings.py @@ -0,0 +1,112 @@ +import csv + +from rest_framework import renderers, serializers, viewsets +from rest_framework.decorators import action +from rest_framework.response import Response +from six import StringIO + +from ...models import Parking, ParkingCheck +from .permissions import IsMonitor + + +class CSVRenderer(renderers.BaseRenderer): + media_type = "text/csv" + format = "csv" + render_style = 'binary' + + def render(self, filters, media_type=None, renderer_context=None): + time_start = filters["time_start"] + time_end = filters["time_end"] + operators = filters.get("operators") + payment_zones = filters.get("payment_zones") + parking_check = filters.get("parking_check") + + kwargs = { + "time_start__lte": time_end, + "time_end__gte": time_start, + } + + if payment_zones: + kwargs["zone__code__in"] = payment_zones + if operators: + kwargs["operator__id__in"] = operators + + parkings = ( + Parking.objects + .filter(**kwargs) + .order_by("-time_start") + .prefetch_related("operator", "zone") + ) + + if parking_check: + parking_check_parking_ids = ( + ParkingCheck.objects + .filter(found_parking__id__in=parkings) + .order_by("found_parking") + .values_list("found_parking") + .distinct() + ) + parkings = parkings.filter(id__in=parking_check_parking_ids) + + buffer = StringIO() + writer = csv.writer(buffer) + + for park in parkings: + writer.writerow([ + ("%s, %s" % (park.location.x, park.location.y)) if park.location else " ", + park.terminal_number, + park.time_start.strftime("%d.%m.%Y %H.%M"), + park.time_end.strftime("%d.%m.%Y %H.%M"), + park.created_at.strftime("%d.%m.%Y %H.%M"), + park.modified_at.strftime("%d.%m.%Y %H.%M"), + park.operator.name, + park.zone.name + ]) + + return buffer.getvalue() + + +class ExportFilterSerializer(serializers.Serializer): + operators = serializers.ListSerializer(child=serializers.CharField(), required=False) + payment_zones = serializers.ListSerializer(child=serializers.CharField(), required=False) + parking_check = serializers.BooleanField(required=False) + time_start = serializers.DateTimeField( + input_formats=["%d.%m.%Y %H.%M"], + ) + time_end = serializers.DateTimeField( + input_formats=["%d.%m.%Y %H.%M"], + ) + + def validate(self, data): + time_start = data.get("time_start") + time_end = data.get("time_end") + + if not time_start or not time_end: + raise serializers.ValidationError("You must provde start date and end date") + elif time_start > time_end: + raise serializers.ValidationError("End date must be after start date.") + + return data + + +class ExportViewSet(viewsets.ViewSet): + queryset = Parking.objects.all() + permission_classes = [IsMonitor, ] + + @action(detail=False, methods=['post'], renderer_classes=[CSVRenderer]) + def download(self, request): + serializer = ExportFilterSerializer(data=request.data) + if serializer.is_valid(raise_exception=True): + time_start = serializer.validated_data["time_start"] + time_end = serializer.validated_data["time_end"] + file_name = "parkings_{}_{}.csv".format( + time_start.date(), time_end.date() + ) + response = Response( + serializer.validated_data, + content_type="text/csv", + headers={"X-Suggested-Filename": file_name} + ) + response['Content-Disposition'] = 'attachment; filename="%s"' % file_name + + return response diff --git a/parkings/api/monitoring/urls.py b/parkings/api/monitoring/urls.py index 20553920..63d25984 100644 --- a/parkings/api/monitoring/urls.py +++ b/parkings/api/monitoring/urls.py @@ -1,6 +1,7 @@ from rest_framework.routers import DefaultRouter from ..url_utils import versioned_url +from .export_parkings import ExportViewSet from .region import RegionViewSet from .region_statistics import RegionStatisticsViewSet from .valid_parking import ValidParkingViewSet @@ -11,8 +12,11 @@ basename='regionstatistics') router.register(r'valid_parking', ValidParkingViewSet, basename='valid_parking') +router.register(r'export', ExportViewSet, basename="export") + app_name = 'monitoring' + urlpatterns = [ versioned_url('v1', router.urls), ] From 54aaf99f6bcfe5f3dec48fe346f61095c66357d3 Mon Sep 17 00:00:00 2001 From: Kevin Seestrand Date: Fri, 4 Feb 2022 16:43:53 +0200 Subject: [PATCH 2/8] Monitor: CSVRenderer exception handling In a case of an action that uses the CSVRenderer raises an exception, change the renderer to a JSONRenderer so the server can respond with an error message with correct data format. We have to bypass the custom CSVRenderer because otherwise the validation error message gets passed over to the CSVRenderer and the CSVRenderer can't really do anything with the error message. --- parkings/exception_handler.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/parkings/exception_handler.py b/parkings/exception_handler.py index efa9f2d6..011bc23a 100644 --- a/parkings/exception_handler.py +++ b/parkings/exception_handler.py @@ -1,7 +1,9 @@ from rest_framework.exceptions import PermissionDenied from rest_framework.views import exception_handler +from rest_framework.renderers import JSONRenderer from parkings.api.common import ParkingException +from parkings.api.monitoring.export_parkings import CSVRenderer def parkings_exception_handler(exc, context): @@ -13,4 +15,7 @@ def parkings_exception_handler(exc, context): elif isinstance(exc, PermissionDenied): response.data['code'] = 'permission_denied' + if isinstance(context["request"].accepted_renderer, CSVRenderer): + context["request"].accepted_renderer = JSONRenderer() + return response From 77d449c5960ce007a5751743a66345cba0666f07 Mon Sep 17 00:00:00 2001 From: Kevin Seestrand Date: Fri, 4 Feb 2022 16:48:41 +0200 Subject: [PATCH 3/8] Expose custom CORS header for local dev Expose custom header 'x-suggested-filename' so that the dashboard can use it for naming the csv file. --- parkkihubi/settings.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/parkkihubi/settings.py b/parkkihubi/settings.py index 090e12b0..722f6c14 100644 --- a/parkkihubi/settings.py +++ b/parkkihubi/settings.py @@ -200,6 +200,9 @@ } CORS_ORIGIN_ALLOW_ALL = True +CORS_EXPOSE_HEADERS = [ + "X-Suggested-Filename", # Custom header for export CSV file name +] ############## # Parkkihubi # From 53c23f4b3bd6d6acd048d1f6eb27f52ac724addc Mon Sep 17 00:00:00 2001 From: Kevin Seestrand Date: Fri, 4 Feb 2022 16:50:09 +0200 Subject: [PATCH 4/8] Monitoring: Add tests for export endpoint --- .../api/monitoring/test_parkings_export.py | 275 ++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 parkings/tests/api/monitoring/test_parkings_export.py diff --git a/parkings/tests/api/monitoring/test_parkings_export.py b/parkings/tests/api/monitoring/test_parkings_export.py new file mode 100644 index 00000000..9ea56f6c --- /dev/null +++ b/parkings/tests/api/monitoring/test_parkings_export.py @@ -0,0 +1,275 @@ +import csv +import datetime + +from django.urls import reverse +from django.utils.timezone import utc +from rest_framework import status +from six import StringIO + +from parkings.factories import ParkingCheckFactory +from parkings.factories.parking import create_payment_zone + +LOCATION = 0 +TERMINAL_NUMBER = 1 +TIME_START = 2 +TIME_END = 3 +CREATED_AT = 4 +MODIFIED_AT = 5 +OPERATOR = 6 +ZONE = 7 + +export_url = reverse('monitoring:v1:export-download') + + +def test_export_filtering_no_data_if_starts_before_range_and_ends_before_range(monitoring_api_client, parking_factory): + start = datetime.datetime(2018, 7, 6, 15, 15, tzinfo=utc) + end = datetime.datetime(2018, 7, 6, 18, 45, tzinfo=utc) + parking_factory(time_start=start, time_end=end) + data = { + "time_start": "05.07.2018 13.45", + "time_end": "06.07.2018 15.00", + } + + result = monitoring_api_client.post(export_url, data=data) + assert result.status_code == status.HTTP_200_OK + + # Data doesn't get rendered by the custom renderer in tests for whatever reason. So render it manually. + renderer = result.accepted_renderer + reader = csv.reader(StringIO(renderer.render(result.data))) + + parking_row = None + + for row in reader: + parking_row = row + + assert parking_row is None + + +def test_export_filtering_yes_data_if_starts_before_range_and_ends_in_range(monitoring_api_client, parking_factory): + start = datetime.datetime(2018, 7, 6, 15, 15, tzinfo=utc) + end = datetime.datetime(2018, 7, 6, 18, 45, tzinfo=utc) + parking_factory(time_start=start, time_end=end) + data = { + "time_start": "05.07.2018 13.45", + "time_end": "06.07.2018 15.15", + } + + result = monitoring_api_client.post(export_url, data=data) + assert result.status_code == status.HTTP_200_OK + + # Data doesn't get rendered by the custom renderer in tests for whatever reason. So render it manually. + renderer = result.accepted_renderer + reader = csv.reader(StringIO(renderer.render(result.data))) + + parking_row = None + + for row in reader: + parking_row = row + + assert parking_row is not None + + +def test_export_filtering_yes_data_if_starts_in_range_and_ends_in_range(monitoring_api_client, parking_factory): + start = datetime.datetime(2018, 7, 6, 15, 15, tzinfo=utc) + end = datetime.datetime(2018, 7, 6, 18, 45, tzinfo=utc) + parking_factory(time_start=start, time_end=end) + data = { + "time_start": "06.07.2018 15.45", + "time_end": "06.07.2018 18.00", + } + + result = monitoring_api_client.post(export_url, data=data) + assert result.status_code == status.HTTP_200_OK + + # Data doesn't get rendered by the custom renderer in tests for whatever reason. So render it manually. + renderer = result.accepted_renderer + reader = csv.reader(StringIO(renderer.render(result.data))) + + parking_row = None + + for row in reader: + parking_row = row + + assert parking_row is not None + + +def test_export_filtering_yes_data_if_starts_in_range_and_ends_after_range(monitoring_api_client, parking_factory): + start = datetime.datetime(2018, 7, 6, 15, 15, tzinfo=utc) + end = datetime.datetime(2018, 7, 6, 18, 45, tzinfo=utc) + parking_factory(time_start=start, time_end=end) + data = { + "time_start": "06.07.2018 15.45", + "time_end": "07.07.2018 18.00", + } + + result = monitoring_api_client.post(export_url, data=data) + assert result.status_code == status.HTTP_200_OK + + # Data doesn't get rendered by the custom renderer in tests for whatever reason. So render it manually. + renderer = result.accepted_renderer + reader = csv.reader(StringIO(renderer.render(result.data))) + + parking_row = None + + for row in reader: + parking_row = row + + assert parking_row is not None + + +def test_export_filtering_no_data_if_starts_after_range_and_ends_after_range(monitoring_api_client, parking_factory): + start = datetime.datetime(2018, 7, 6, 15, 15, tzinfo=utc) + end = datetime.datetime(2018, 7, 6, 18, 45, tzinfo=utc) + parking_factory(time_start=start, time_end=end) + data = { + "time_start": "07.07.2018 15.45", + "time_end": "08.07.2018 18.00", + } + + result = monitoring_api_client.post(export_url, data=data) + assert result.status_code == status.HTTP_200_OK + + # Data doesn't get rendered by the custom renderer in tests for whatever reason. So render it manually. + renderer = result.accepted_renderer + reader = csv.reader(StringIO(renderer.render(result.data))) + + parking_row = None + + for row in reader: + parking_row = row + + assert parking_row is None + + +def test_export_filtering_yes_data_if_starts_before_range_and_ends_after_range(monitoring_api_client, parking_factory): + start = datetime.datetime(2018, 7, 6, 15, 15, tzinfo=utc) + end = datetime.datetime(2018, 7, 6, 18, 45, tzinfo=utc) + parking_factory(time_start=start, time_end=end) + data = { + "time_start": "05.07.2018 15.45", + "time_end": "08.07.2018 18.00", + } + + result = monitoring_api_client.post(export_url, data=data) + assert result.status_code == status.HTTP_200_OK + + # Data doesn't get rendered by the custom renderer in tests for whatever reason. So render it manually. + renderer = result.accepted_renderer + reader = csv.reader(StringIO(renderer.render(result.data))) + + parking_row = None + + for row in reader: + parking_row = row + + assert parking_row is not None + + +def test_export_filtering_correct_data_no_parking_check(monitoring_api_client, parking_factory, operator_factory): + zone1 = create_payment_zone(code=1, name="MV1") + operator1 = operator_factory(name="op1") + start1 = datetime.datetime(2018, 7, 6, 15, 15, tzinfo=utc) + end1 = datetime.datetime(2018, 7, 6, 18, 45, tzinfo=utc) + parking_factory( + time_start=start1, + time_end=end1, + operator=operator1, + zone=zone1 + ) + + zone2 = create_payment_zone(code=2, name="MV2") + operator2 = operator_factory(name="op2") + start2 = datetime.datetime(2018, 7, 6, 15, 15, tzinfo=utc) + end2 = datetime.datetime(2018, 7, 6, 18, 45, tzinfo=utc) + parking_factory( + time_start=start2, + time_end=end2, + operator=operator2, + zone=zone2 + ) + + data = { + "time_start": "05.07.2018 15.45", + "time_end": "08.07.2018 18.00", + "payment_zones": [zone1.code], + "operators": [operator1.id], + "parking_check": False + } + + result = monitoring_api_client.post(export_url, data=data) + assert result.status_code == status.HTTP_200_OK + + # Data doesn't get rendered by the custom renderer in tests for whatever reason. So render it manually. + renderer = result.accepted_renderer + reader = csv.reader(StringIO(renderer.render(result.data))) + + parking_row = None + row_count = 0 + + for row in reader: + parking_row = row + row_count += 1 + + assert parking_row is not None + assert parking_row[OPERATOR] == operator1.name + assert parking_row[ZONE] == zone1.name + assert row_count == 1 + + +def test_export_filtering_correct_data_with_parking_check(monitoring_api_client, parking_factory, operator_factory): + zone1 = create_payment_zone(code=1, name="MV1") + operator1 = operator_factory(name="op1") + start1 = datetime.datetime(2018, 7, 6, 15, 15, tzinfo=utc) + end1 = datetime.datetime(2018, 7, 6, 18, 45, tzinfo=utc) + parking_factory( + time_start=start1, + time_end=end1, + operator=operator1, + zone=zone1 + ) + + zone2 = create_payment_zone(code=2, name="MV2") + operator2 = operator_factory(name="op2") + start2 = datetime.datetime(2018, 7, 6, 15, 15, tzinfo=utc) + end2 = datetime.datetime(2018, 7, 6, 18, 45, tzinfo=utc) + parking2 = parking_factory( + time_start=start2, + time_end=end2, + operator=operator2, + zone=zone2 + ) + + ParkingCheckFactory(found_parking=parking2) + + data = { + "time_start": "05.07.2018 15.45", + "time_end": "08.07.2018 18.00", + "payment_zones": [ + zone1.code, + zone2.code, + ], + "operators": [ + operator1.id, + operator2.id + ], + "parking_check": True + } + + result = monitoring_api_client.post(export_url, data=data) + assert result.status_code == status.HTTP_200_OK + + # Data doesn't get rendered by the custom renderer in tests for whatever reason. So render it manually. + renderer = result.accepted_renderer + reader = csv.reader(StringIO(renderer.render(result.data))) + + parking_row = None + row_count = 0 + + for row in reader: + parking_row = row + row_count += 1 + + assert parking_row is not None + assert parking_row[OPERATOR] == operator2.name + assert parking_row[ZONE] == zone2.name + assert row_count == 1 From 4bb44307d36667d87b8567b261b2c51f5c4721ad Mon Sep 17 00:00:00 2001 From: Kevin Seestrand Date: Fri, 4 Feb 2022 16:53:51 +0200 Subject: [PATCH 5/8] Dashboard: Add types --- dashboard/src/api/types.ts | 30 ++++++++++++++++++++++++++++++ dashboard/src/types.ts | 21 +++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/dashboard/src/api/types.ts b/dashboard/src/api/types.ts index b40a098e..44568c83 100644 --- a/dashboard/src/api/types.ts +++ b/dashboard/src/api/types.ts @@ -90,3 +90,33 @@ export interface ParkingProperties { created_at: string; modified_at: string; } + + +export interface Operator { + id: string; + name?: string; + created_at?: string; + modified_at?: string; +} + +export interface OperatorList extends PaginatedList { + results: Operator[]; +} + +export interface PaymentZone { + code: string; + name?: string; + domain?: string; +} + +export interface PaymentZoneList extends PaginatedList { + results: PaymentZone[]; +} + +export interface ExportFilters { + operators?: string[]; + payment_zones?: string[]; + parking_check?: boolean; + time_start: string; + time_end: string; +} diff --git a/dashboard/src/types.ts b/dashboard/src/types.ts index bbb3ceed..8ad98f67 100644 --- a/dashboard/src/types.ts +++ b/dashboard/src/types.ts @@ -19,6 +19,9 @@ export interface RootState { parkings: ParkingsMap; + operators: Operators; + paymentZones: PaymentZones; + regionUsageHistory: RegionUsageHistory; validParkingsHistory: ValidParkingsHistory; } @@ -85,3 +88,21 @@ export interface RegionUsageInfo { export interface ValidParkingsHistory { [time: number]: ParkingId[]; } + +export interface Operator { + id: string; + name: string; +} + +export interface PaymentZone { + code: string; + name: string; +} + +export interface Operators { + [key: string]: Operator +} + +export interface PaymentZones { + [key: string]: PaymentZone +} From 5f9b865816111cf08a7781eeb750072b6eadbc31 Mon Sep 17 00:00:00 2001 From: Kevin Seestrand Date: Fri, 4 Feb 2022 17:04:53 +0200 Subject: [PATCH 6/8] Dashboard: Add export endpoint to API Also create a utility download function that downloads the CSV file as a blob. --- dashboard/src/api/index.ts | 36 ++++++++++++++++++++++++++++++++++-- dashboard/src/api/utils.ts | 16 ++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 dashboard/src/api/utils.ts diff --git a/dashboard/src/api/index.ts b/dashboard/src/api/index.ts index b68f3534..1e35214e 100644 --- a/dashboard/src/api/index.ts +++ b/dashboard/src/api/index.ts @@ -2,7 +2,8 @@ import * as axios from 'axios'; import { Moment } from 'moment'; import AuthManager from './auth-manager'; -import { ParkingList, RegionList, RegionStatsList } from './types'; +import { download } from './utils'; +import { ExportFilters, ParkingList, RegionList, RegionStatsList, OperatorList, PaymentZoneList } from './types'; interface SuccessCallback { (response: axios.AxiosResponse): void; @@ -20,6 +21,9 @@ export class Api { regions: '/monitoring/v1/region/', regionStats: '/monitoring/v1/region_statistics/', validParkings: '/monitoring/v1/valid_parking/', + exportDownload: '/monitoring/v1/export/download/', + operators: '/enforcement/v1/operator/', + paymentZones: '/operator/v1/payment_zone/', }; public auth: AuthManager; @@ -62,9 +66,37 @@ export class Api { callback, errorHandler); } + downloadCSV( + filters: ExportFilters, + callback: SuccessCallback, + errorHandler: ErrorHandler, + ) : void { + this.axios.post(this.endpoints.exportDownload, filters) + .then((response) => { + callback(response); + const fileName = response.headers["x-suggested-filename"] + download(response.data, fileName); + }) + .catch(errorHandler); + } + + fetchOperators( + callback: SuccessCallback, + errorHandler: ErrorHandler + ) { + this._fetchAllPages(this.endpoints.operators, callback, errorHandler); + } + + fetchPaymentZones( + callback: SuccessCallback, + errorHandler: ErrorHandler + ) { + this._fetchAllPages(this.endpoints.paymentZones, callback, errorHandler); + } + private _fetchAllPages( url: string, - callback: SuccessCallback | SuccessCallback | SuccessCallback, + callback: SuccessCallback | SuccessCallback | SuccessCallback | SuccessCallback | SuccessCallback, errorHandler: ErrorHandler ) { this.axios.get(url) diff --git a/dashboard/src/api/utils.ts b/dashboard/src/api/utils.ts new file mode 100644 index 00000000..fd6c1f88 --- /dev/null +++ b/dashboard/src/api/utils.ts @@ -0,0 +1,16 @@ +export function download(data: string, fileName: string): void { + const blob: Blob = new Blob([data], { type: 'text/csv' }); + const blobURL: string = window.URL.createObjectURL(blob) + + const downloadLink: HTMLAnchorElement = document.createElement('a'); + downloadLink.style.display = 'none'; + downloadLink.href = blobURL; + downloadLink.setAttribute('download', fileName); + + document.body.appendChild(downloadLink); + downloadLink.click(); + setTimeout(function() { + document.body.removeChild(downloadLink); + window.URL.revokeObjectURL(blobURL); + }, 200) +} From 54050079dc5329420a7fb56a1dc5d5bfc64403ec Mon Sep 17 00:00:00 2001 From: Kevin Seestrand Date: Fri, 4 Feb 2022 17:09:17 +0200 Subject: [PATCH 7/8] Dashboard: Create reducers, actions and dispatchers Operators and PaymentZones get stored in root state. --- dashboard/src/actions.ts | 40 ++++++++++++++++++++++++++++++-- dashboard/src/converters.ts | 14 +++++++++++ dashboard/src/dispatchers.ts | 45 ++++++++++++++++++++++++++++++++++++ dashboard/src/reducers.ts | 20 +++++++++++++++- 4 files changed, 116 insertions(+), 3 deletions(-) diff --git a/dashboard/src/actions.ts b/dashboard/src/actions.ts index 8800a400..30b6b9da 100644 --- a/dashboard/src/actions.ts +++ b/dashboard/src/actions.ts @@ -1,6 +1,6 @@ import { Moment } from 'moment'; -import { ParkingList, RegionList, RegionStatsList } from './api/types'; +import { ParkingList, RegionList, RegionStatsList, PaymentZoneList, OperatorList } from './api/types'; import { MapViewport } from './components/types'; interface CheckExistingLoginAction { @@ -139,6 +139,39 @@ export function receiveValidParkings( return {type: 'RECEIVE_VALID_PARKINGS', data, time}; } +interface ReceiveCsvAction { + type: 'RECEIVE_CSV'; + data: string; +} + +export function receiveCSV( + data: string +): ReceiveCsvAction { + return {type: 'RECEIVE_CSV', data} +} + +interface ReceiveOperatorsAction { + type: 'RECEIVE_OPERATORS'; + data: OperatorList; +} + +export function receiveOperators( + data: OperatorList +): ReceiveOperatorsAction { + return {type: 'RECEIVE_OPERATORS', data} +} + +interface ReceivePaymentZonesAction { + type: 'RECEIVE_PAYMENT_ZONES'; + data: PaymentZoneList; +} + +export function receivePaymentZones( + data: PaymentZoneList +): ReceivePaymentZonesAction { + return {type: 'RECEIVE_PAYMENT_ZONES', data} +} + export type Action = CheckExistingLoginAction | ResolveExistingLoginCheckAction | @@ -155,4 +188,7 @@ export type Action = SetSelectedRegionAction | ReceiveRegionStatsAction | ReceiveRegionInfoAction | - ReceiveValidParkingsAction; + ReceiveValidParkingsAction | + ReceiveOperatorsAction | + ReceivePaymentZonesAction | + ReceiveCsvAction; diff --git a/dashboard/src/converters.ts b/dashboard/src/converters.ts index 69a8813c..5b5e1247 100644 --- a/dashboard/src/converters.ts +++ b/dashboard/src/converters.ts @@ -30,6 +30,20 @@ export function convertRegionStats( }; } +export function convertOperator(operator: api.Operator): ui.Operator { + return { + id: operator.id, + name: operator.name!, + } +} + +export function convertPaymentZone(paymentZone: api.PaymentZone): ui.PaymentZone { + return { + code: paymentZone.code, + name: paymentZone.name!, + } +} + export function convertParking(parking: api.Parking): ui.Parking { const props = parking.properties; return { diff --git a/dashboard/src/dispatchers.ts b/dashboard/src/dispatchers.ts index 665d572b..15fc603c 100644 --- a/dashboard/src/dispatchers.ts +++ b/dashboard/src/dispatchers.ts @@ -5,6 +5,7 @@ import * as actions from './actions'; import { Action } from './actions'; import api from './api'; import { MapViewport } from './components/types'; +import { ExportFilters } from './api/types'; import { RootState } from './types'; const updateInterval = 5 * 60 * 1000; // 5 minutes in ms @@ -91,6 +92,13 @@ export function setDataTime(time?: moment.Moment) { }; } +export function fetchData() { + return (dispatch: Dispatch) => { + dispatch(fetchOperators()); + dispatch(fetchPaymentZones()); + }; +} + function fetchMissingData(time: moment.Moment) { return (dispatch: Dispatch, getState: () => RootState) => { const timestamp = time.valueOf(); @@ -164,3 +172,40 @@ export function fetchValidParkings(time: moment.Moment) { }); }; } + +export function downloadCSV(filters: ExportFilters) { + return (dispatch: Dispatch) => { + api.downloadCSV( + filters, + (response) => { + dispatch(actions.receiveCSV(response.data)); + }, + (error) => { + alert('CSV download failed: ' + error); + }); + }; +} + +export function fetchOperators() { + return (dispatch: Dispatch) => { + api.fetchOperators( + (response) => { + dispatch(actions.receiveOperators(response.data)); + }, + (error) => { + alert('Operator fetch failed: ' + error); + }); + }; +} + +export function fetchPaymentZones() { + return (dispatch: Dispatch) => { + api.fetchPaymentZones( + (response) => { + dispatch(actions.receivePaymentZones(response.data)); + }, + (error) => { + alert('Payment zone fetch failed: ' + error); + }); + }; +} diff --git a/dashboard/src/reducers.ts b/dashboard/src/reducers.ts index d9e33619..c00e0fdd 100644 --- a/dashboard/src/reducers.ts +++ b/dashboard/src/reducers.ts @@ -6,7 +6,7 @@ import { centerCoordinates } from './config'; import * as conv from './converters'; import { AuthenticationState, ParkingRegionMapState, ParkingsMap, RegionsMap, - RegionUsageHistory, ValidParkingsHistory, + RegionUsageHistory, ValidParkingsHistory, PaymentZones, Operators, ViewState } from './types'; // Auth state reducer //////////////////////////////////////////////// @@ -151,6 +151,22 @@ function parkings(state: ParkingsMap = {}, action: Action): ParkingsMap { return state; } +function operators(state: any = {}, action: Action): Operators { + if (action.type === 'RECEIVE_OPERATORS') { + const newOperators = mapByIdAndApply(action.data.results, conv.convertOperator as any); + return {...state, ...newOperators } as Operators; + } + return state; +} + +function paymentZones(state: any = {}, action: Action): PaymentZones { + if (action.type === 'RECEIVE_PAYMENT_ZONES') { + const newPaymentZones = _.assign({}, ...action.data.results.map((item: any) => ({[item.code]: conv.convertPaymentZone(item)}))); + return {...state, ...newPaymentZones} as PaymentZones; + } + return state; +} + function regionUsageHistory( state: RegionUsageHistory = {}, action: Action @@ -202,6 +218,8 @@ const rootReducer = () => combineReducers({ parkings, regionUsageHistory, validParkingsHistory, + operators, + paymentZones, }); export default rootReducer; From e1d76f71c354a5fa4bc4f4214f34e7ef0011282a Mon Sep 17 00:00:00 2001 From: Kevin Seestrand Date: Fri, 4 Feb 2022 17:13:19 +0200 Subject: [PATCH 8/8] Dashboard: Create Export component Create Export component. Create new 'onMount' dispatch. This calls the 'fetchData' function. The data in question which is Operators and Payment zones only need to get fetched once. --- dashboard/src/components/Export.css | 91 +++++++++++++ dashboard/src/components/Export.tsx | 180 +++++++++++++++++++++++++ dashboard/src/containers/Dashboard.tsx | 7 + dashboard/src/containers/Export.ts | 19 +++ 4 files changed, 297 insertions(+) create mode 100644 dashboard/src/components/Export.css create mode 100644 dashboard/src/components/Export.tsx create mode 100644 dashboard/src/containers/Export.ts diff --git a/dashboard/src/components/Export.css b/dashboard/src/components/Export.css new file mode 100644 index 00000000..aed11d3f --- /dev/null +++ b/dashboard/src/components/Export.css @@ -0,0 +1,91 @@ +i { + margin-right: 3px !important; +} + +.filter-card { + display: table !important; + width: 100% !important; + margin-left: 0 !important; + padding-left: 0 !important +} + +.row { + display: table !important; + width: 100% !important; + margin-left: 0 !important; + flex: 1 !important; + box-sizing: border-box; +} + +.export-button { + color: #515455 !important; + box-shadow: none !important; + background: hsl(32, 39%, 81%) !important; + border-color: #e1cfbb !important; + margin-top: 4px; +} + +.export-button:hover { + background: #f0e5dd !important; + border-color: #f0e5dd !important; +} + +.download-button { + color: #515455 !important; + box-shadow: none !important; + background: #e1cfbb !important; + border-color: #e1cfbb !important; + float: right; + margin-right: 4px; +} + +.download-button:hover { + background: #f0e5dd !important; + border-color: #f0e5dd !important; +} + +.filter-field { + display: table-cell !important; + padding: 5px !important; +} + +.multi-select-field { + width: 50% !important; +} + +.datepicker-field { + width: 160px !important; +} + +.parking-check-button { + color: #515455 !important; + box-shadow: none !important; + background: #f0e5dd !important; + border-color: #e1cfbb !important; +} + +.parking-check-button:hover { + background: #e1cfbb !important; + border-color: #f0e5dd !important; +} + +.parking-check-active { + background: #e1cfbb !important; +} + +@media screen and (max-width: 28rem) { + .export-button .text { + display: none; + margin-right: auto !important; + } + + .download-button .text { + display: none; + margin-right: auto !important; + } + + .parking-check-button .text { + display: none; + margin-right: auto !important; + } +} diff --git a/dashboard/src/components/Export.tsx b/dashboard/src/components/Export.tsx new file mode 100644 index 00000000..3229d9da --- /dev/null +++ b/dashboard/src/components/Export.tsx @@ -0,0 +1,180 @@ +import DateTime from "react-datetime"; +import Select from "react-select"; +import moment, { Moment } from "moment"; +import { Component } from "react"; +import { Button } from "reactstrap"; +import { ExportFilters } from "../api/types"; +import { Operators, PaymentZones } from "../types"; + +import "./Export.css"; + +type Option = { + value: string; + label: string; +}; + +interface State { + operatorSelections: string[]; + paymentZoneSelections: string[]; + startTime: Moment; + endTime: Moment; + parking_check: boolean; + showOptions: boolean; +} + +export interface Props { + operators: Operators, + paymentZones: PaymentZones, + downloadCSV?: (filters: ExportFilters) => void; +} + +const initialState: State = { + showOptions: false, + operatorSelections: [], + paymentZoneSelections: [], + startTime: moment().subtract(30, 'days'), + endTime: moment(), + parking_check: false, +} + +class Export extends Component { + constructor(props: Props) { + super(props); + this.state = initialState; + } + + handleExportButtonClick = () => { + this.setState((prevState) => ({...initialState, showOptions: !prevState.showOptions})); + } + + handleDownloadClick = () => { + if (this.props.downloadCSV) { + const filters: ExportFilters = { + ...(this.state.operatorSelections.length && { operators: this.state.operatorSelections }), + ...(this.state.paymentZoneSelections.length && { payment_zones: this.state.paymentZoneSelections }), + time_start: this.state.startTime.format("DD.MM.YYYY HH.mm"), + time_end: this.state.endTime.format("DD.MM.YYYY HH.mm"), + parking_check: this.state.parking_check, + }; + + this.props.downloadCSV(filters); + } + }; + + handleDateChange = (time: moment.Moment | string, name: string) => { + this.setState({ + [name]: moment(time) + } as any); + }; + + handleOperatorSelect = (options: Option[]) => { + this.setState({ + operatorSelections: options.map((option) => option.value), + }); + }; + + handlePaymentZoneSelect = (options: Option[]) => { + this.setState({ + paymentZoneSelections: options.map((option) => option.value), + }); + }; + + handleCheckParkingCheckBoxChange = () => { + this.setState((prevState) => ({ + parking_check: !prevState.parking_check, + })); + }; + + render() { + const { + operators, + paymentZones + } = this.props; + const operatorOptions = Object.keys(operators).map((k) => ({value: operators[k].id, label: operators[k].name})); + const paymentZoneOptions = Object.keys(paymentZones).map((k) => ({value: paymentZones[k].code, label: paymentZones[k].name})); + + return ( + <> + + {this.state.showOptions === true ? ( +
+
+ + this.handlePaymentZoneSelect(option as Option[]) + } + placeholder="Valitse maksuvyöhyke" + /> +
+
+ + this.handleDateChange(moment, "startTime") + } + initialValue={this.state.startTime} + inputProps={{readOnly: true}} + /> + + this.handleDateChange(moment, "endTime") + } + initialValue={this.state.endTime} + inputProps={{readOnly: true}} + /> + + +
+
+ ) : null} + + ); + } +} + +export default Export; diff --git a/dashboard/src/containers/Dashboard.tsx b/dashboard/src/containers/Dashboard.tsx index 0ddb5fb4..9e13738b 100644 --- a/dashboard/src/containers/Dashboard.tsx +++ b/dashboard/src/containers/Dashboard.tsx @@ -3,6 +3,7 @@ import { connect } from 'react-redux'; import { Button } from 'reactstrap'; +import Export from './Export'; import RegionSelector from './RegionSelector'; import * as dispatchers from '../dispatchers'; import { RootState } from '../types'; @@ -14,6 +15,7 @@ import LastParkingsTable from './LastParkingsTable'; interface Props { autoUpdate?: boolean; + onMount?: () => void; onUpdate?: () => void; onLogout?: () => void; } @@ -25,6 +27,9 @@ class Dashboard extends Component { timerInterval: number = 1000; // 1 second componentDidMount() { + if(this.props.onMount) { + this.props.onMount(); + } if (this.props.autoUpdate && !this.timer) { this.enableAutoUpdate(); } @@ -82,6 +87,7 @@ class Dashboard extends Component {
+
@@ -104,6 +110,7 @@ const mapStateToProps = (state: RootState): Props => ({ }); const mapDispatchToProps = (dispatch: any): Props => ({ + onMount: () => dispatch(dispatchers.fetchData()), onUpdate: () => dispatch(dispatchers.updateData()), onLogout: () => dispatch(dispatchers.logout()), }); diff --git a/dashboard/src/containers/Export.ts b/dashboard/src/containers/Export.ts new file mode 100644 index 00000000..b7ac08fa --- /dev/null +++ b/dashboard/src/containers/Export.ts @@ -0,0 +1,19 @@ +import { connect } from 'react-redux'; + +import { RootState } from '../types'; +import { ExportFilters } from '../api/types' +import Export, { Props } from '../components/Export'; +import * as dispatchers from '../dispatchers'; + +function mapStateToProps(state: RootState): Props { + return { + operators: state.operators, + paymentZones: state.paymentZones, + }; +} + +const mapDispatchToProps = (dispatch: any) => ({ + downloadCSV: (filters: ExportFilters) => dispatch(dispatchers.downloadCSV(filters)), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(Export);