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/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/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/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) +} 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); 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; 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 +} 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), ] 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 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 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 #