Visual prototype to filter routes
This commit is contained in:
parent
a4a8cd9e9f
commit
bd8d39fc1e
26
trainvel-front/package-lock.json
generated
26
trainvel-front/package-lock.json
generated
@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.3",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@mui/icons-material": "^5.15.17",
|
||||
"@mui/material": "^5.15.6",
|
||||
"@mui/x-date-pickers": "^6.19.2",
|
||||
"@tanstack/query-sync-storage-persister": "^5.18.0",
|
||||
@ -3476,6 +3477,31 @@
|
||||
"url": "https://opencollective.com/mui-org"
|
||||
}
|
||||
},
|
||||
"node_modules/@mui/icons-material": {
|
||||
"version": "5.15.17",
|
||||
"resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.15.17.tgz",
|
||||
"integrity": "sha512-xVzl2De7IY36s/keHX45YMiCpsIx3mNv2xwDgtBkRSnZQtVk+Gqufwj1ktUxEyjzEhBl0+PiNJqYC31C+n1n6A==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.23.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/mui-org"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@mui/material": "^5.0.0",
|
||||
"@types/react": "^17.0.0 || ^18.0.0",
|
||||
"react": "^17.0.0 || ^18.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@mui/material": {
|
||||
"version": "5.15.6",
|
||||
"resolved": "https://registry.npmjs.org/@mui/material/-/material-5.15.6.tgz",
|
||||
|
@ -5,6 +5,7 @@
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.3",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@mui/icons-material": "^5.15.17",
|
||||
"@mui/material": "^5.15.6",
|
||||
"@mui/x-date-pickers": "^6.19.2",
|
||||
"@tanstack/query-sync-storage-persister": "^5.18.0",
|
||||
|
@ -1,5 +1,6 @@
|
||||
import {useNavigate, useParams, useSearchParams} from "react-router-dom"
|
||||
import TrainsTable from "./TrainsTable"
|
||||
import TripsFilter from "./TripsFilter"
|
||||
import {useState} from "react";
|
||||
import {Box, Button, FormLabel} from "@mui/material";
|
||||
import {DatePicker, TimePicker} from "@mui/x-date-pickers";
|
||||
@ -67,6 +68,7 @@ function Station() {
|
||||
|
||||
<main>
|
||||
<DateTimeSelector station={station} date={date} time={time} />
|
||||
<TripsFilter />
|
||||
<TrainsTable station={station} date={date} time={time} tableType="departures" />
|
||||
<TrainsTable station={station} date={date} time={time} tableType="arrivals" />
|
||||
</main>
|
||||
|
@ -257,6 +257,8 @@ function getTrainType(train, trip, route) {
|
||||
if (trip.short_name?.startsWith("NJ"))
|
||||
return "NJ"
|
||||
return "ÖBB"
|
||||
case "CH-ALL":
|
||||
return route.desc
|
||||
default:
|
||||
return trip.short_name?.split(" ")[0]
|
||||
}
|
||||
|
165
trainvel-front/src/TripsFilter.js
Normal file
165
trainvel-front/src/TripsFilter.js
Normal file
@ -0,0 +1,165 @@
|
||||
import {useState} from "react"
|
||||
import {
|
||||
Box, Button,
|
||||
Checkbox, Chip, FormControl,
|
||||
FormControlLabel,
|
||||
InputLabel, MenuItem, OutlinedInput, Select
|
||||
} from "@mui/material"
|
||||
import DirectionsBusTwoToneIcon from '@mui/icons-material/DirectionsBusTwoTone'
|
||||
import SubwayTwoToneIcon from '@mui/icons-material/SubwayTwoTone'
|
||||
import TrainTwoToneIcon from '@mui/icons-material/TrainTwoTone'
|
||||
import TramTwoToneIcon from '@mui/icons-material/TramTwoTone'
|
||||
|
||||
function TripsFilter() {
|
||||
const [transportModeFilter, setTransportModeFilter] = useState(
|
||||
{longDistanceTrain: true, regionalTrain: true, metro: true, tram: true, bus: true})
|
||||
const transportModeNames = {
|
||||
train: "Trains",
|
||||
longDistanceTrain: "Trains longue distance",
|
||||
regionalTrain: "Trains régionaux",
|
||||
metro: "Métro",
|
||||
tram: "Tram",
|
||||
bus: "Bus",
|
||||
}
|
||||
|
||||
const trainCheckbox = <>
|
||||
<TrainTwoToneIcon />
|
||||
<Checkbox
|
||||
checked={transportModeFilter.longDistanceTrain && transportModeFilter.regionalTrain}
|
||||
indeterminate={transportModeFilter.longDistanceTrain !== transportModeFilter.regionalTrain}
|
||||
onChange={(event) =>
|
||||
setTransportModeFilter(
|
||||
{...transportModeFilter, longDistanceTrain: event.target.checked, regionalTrain: event.target.checked})}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
/>
|
||||
</>
|
||||
|
||||
const longDistanceTrainCheckbox = <>
|
||||
<TrainTwoToneIcon />
|
||||
<Checkbox
|
||||
checked={transportModeFilter.longDistanceTrain}
|
||||
onChange={(event) =>
|
||||
setTransportModeFilter({...transportModeFilter, longDistanceTrain: event.target.checked})} />
|
||||
</>
|
||||
|
||||
const regionalTrainCheckbox = <>
|
||||
<TrainTwoToneIcon />
|
||||
<Checkbox
|
||||
checked={transportModeFilter.regionalTrain}
|
||||
onChange={(event) =>
|
||||
setTransportModeFilter({...transportModeFilter, regionalTrain: event.target.checked})} />
|
||||
</>
|
||||
|
||||
const metroCheckbox = <>
|
||||
<SubwayTwoToneIcon />
|
||||
<Checkbox
|
||||
checked={transportModeFilter.metro}
|
||||
onChange={(event) =>
|
||||
setTransportModeFilter({...transportModeFilter, metro: event.target.checked})} />
|
||||
</>
|
||||
|
||||
const tramCheckbox = <>
|
||||
<TramTwoToneIcon />
|
||||
<Checkbox
|
||||
checked={transportModeFilter.tram}
|
||||
onChange={(event) =>
|
||||
setTransportModeFilter({...transportModeFilter, tram: event.target.checked})} />
|
||||
</>
|
||||
|
||||
const busCheckbox = <>
|
||||
<DirectionsBusTwoToneIcon />
|
||||
<Checkbox
|
||||
checked={transportModeFilter.bus}
|
||||
onChange={(event) =>
|
||||
setTransportModeFilter({...transportModeFilter, bus: event.target.checked})} />
|
||||
</>
|
||||
|
||||
// TODO Fetch routes that are accessible from one stop
|
||||
// For now, we have the tram and bus routes accessible in Strasbourg main station
|
||||
const routesList = [
|
||||
{name: "Tous"},
|
||||
{name: "A", bgColor: "#E10D19", color: "#FFFFFF"},
|
||||
{name: "C", bgColor: "#F29400", color: "#FFFFFF"},
|
||||
{name: "D", bgColor: "#009933", color: "#FFFFFF"},
|
||||
{name: "G", bgColor: "#F6C900", color: "#000000"},
|
||||
{name: "H", bgColor: "#A62341", color: "#FFFFFF"},
|
||||
{name: "2", bgColor: "#FF0000", color: "#FFFFFF"},
|
||||
{name: "10", bgColor: "#FFAA00", color: "#000000"},
|
||||
]
|
||||
const routesDict = {}
|
||||
for (const route of routesList) {
|
||||
routesDict[route.name] = route
|
||||
}
|
||||
|
||||
const [selectedRoutes, setSelectedRoutes] = useState(["Tous"])
|
||||
|
||||
return <>
|
||||
<h2>Filtres</h2>
|
||||
<Box display="flex" alignItems="center" sx={{mb: 3}}>
|
||||
<FormControl>
|
||||
<InputLabel>Mode de transport</InputLabel>
|
||||
<Select
|
||||
multiple
|
||||
value={selectedRoutes}
|
||||
input={<OutlinedInput id="select-multiple-chip" label="Lignes" />}
|
||||
renderValue={(selected) => (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{Object.keys(transportModeFilter).filter(key => transportModeFilter[key]).map((filterType) => (
|
||||
<Chip key={filterType} label={transportModeNames[filterType]} sx={{fontWeight: "bold"}} />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
>
|
||||
<MenuItem key="train" value="train">
|
||||
<FormControlLabel label={transportModeNames["train"]} control={trainCheckbox} />
|
||||
</MenuItem>
|
||||
<MenuItem key="longDistanceTrain" value="longDistanceTrain">
|
||||
<FormControlLabel label={transportModeNames["longDistanceTrain"]} sx={{pl: 4}} control={longDistanceTrainCheckbox} />
|
||||
</MenuItem>
|
||||
<MenuItem key="regionalTrain" value="regionalTrain">
|
||||
<FormControlLabel label={transportModeNames["regionalTrain"]} sx={{pl: 4}} control={regionalTrainCheckbox} />
|
||||
</MenuItem>
|
||||
<MenuItem key="metro" value="metro">
|
||||
<FormControlLabel label={transportModeNames["metro"]} control={metroCheckbox} />
|
||||
</MenuItem>
|
||||
<MenuItem key="tram" value="tram">
|
||||
<FormControlLabel label={transportModeNames["tram"]} control={tramCheckbox} />
|
||||
</MenuItem>
|
||||
<MenuItem key="bus" value="bus">
|
||||
<FormControlLabel label={transportModeNames["bus"]} control={busCheckbox} />
|
||||
</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<InputLabel>Ligne</InputLabel>
|
||||
<Select
|
||||
multiple
|
||||
value={selectedRoutes}
|
||||
onChange={(event) => setSelectedRoutes(event.target.value)}
|
||||
input={<OutlinedInput id="select-multiple-chip" label="Lignes" />}
|
||||
renderValue={(selected) => (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{selected.map(routeName => routesDict[routeName]).map((route) => (
|
||||
<Chip key={route.name} label={route.name} sx={{backgroundColor: route.bgColor, color: route.color, fontWeight: "bold"}} />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
>
|
||||
{routesList.map((route) =>
|
||||
<MenuItem key={route.name} value={route.name}>
|
||||
<Checkbox checked={selectedRoutes.includes(route.name)} />
|
||||
<Chip label={route.name} sx={{backgroundColor: route.bgColor, color: route.color, fontWeight: "bold"}} />
|
||||
</MenuItem>
|
||||
)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Button>
|
||||
Filtrer
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
}
|
||||
|
||||
export default TripsFilter
|
@ -1,8 +1,8 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from trainvel.core.models import Station
|
||||
from trainvel.gtfs.models import Agency, Stop, Route, Trip, StopTime, Calendar, CalendarDate, \
|
||||
Transfer, FeedInfo, TripUpdate, StopTimeUpdate
|
||||
from trainvel.gtfs.models import Agency, Calendar, CalendarDate, FeedInfo, GTFSFeed, Route, \
|
||||
Stop, StopTime, StopTimeUpdate, Transfer, Trip, TripUpdate
|
||||
|
||||
|
||||
class StationSerializer(serializers.ModelSerializer):
|
||||
@ -13,6 +13,12 @@ class StationSerializer(serializers.ModelSerializer):
|
||||
'country_hint', 'main_station_hint',)
|
||||
|
||||
|
||||
class GTFSFeedSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = GTFSFeed
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class AgencySerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Agency
|
||||
|
@ -8,13 +8,12 @@ from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.filters import OrderingFilter, SearchFilter
|
||||
|
||||
from trainvel.api.serializers import AgencySerializer, StopSerializer, RouteSerializer, TripSerializer, \
|
||||
StopTimeSerializer, CalendarSerializer, CalendarDateSerializer, TransferSerializer, \
|
||||
FeedInfoSerializer, TripUpdateSerializer, StopTimeUpdateSerializer, StationSerializer
|
||||
from trainvel.api.serializers import AgencySerializer, CalendarDateSerializer, CalendarSerializer, \
|
||||
FeedInfoSerializer, GTFSFeedSerializer, RouteSerializer, StationSerializer, StopSerializer, StopTimeSerializer, \
|
||||
StopTimeUpdateSerializer, TransferSerializer, TripSerializer, TripUpdateSerializer
|
||||
from trainvel.core.models import Station
|
||||
from trainvel.gtfs.models import Agency, Calendar, CalendarDate, FeedInfo, GTFSFeed, Route, Stop, StopTime, \
|
||||
StopTimeUpdate, \
|
||||
Transfer, Trip, TripUpdate, PickupType
|
||||
StopTimeUpdate, Transfer, Trip, TripUpdate, PickupType
|
||||
|
||||
CACHE_CONTROL = cache_control(max_age=30)
|
||||
LAST_MODIFIED = last_modified(lambda *args, **kwargs: GTFSFeed.objects.order_by('-last_modified').first().last_modified)
|
||||
@ -34,6 +33,16 @@ class StationViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
lookup_value_regex = LOOKUP_VALUE_REGEX
|
||||
|
||||
|
||||
@method_decorator(name='list', decorator=[CACHE_CONTROL, LAST_MODIFIED])
|
||||
@method_decorator(name='retrieve', decorator=[CACHE_CONTROL, LAST_MODIFIED])
|
||||
class GTFSFeedViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
queryset = GTFSFeed.objects.all()
|
||||
serializer_class = GTFSFeedSerializer
|
||||
filter_backends = [DjangoFilterBackend]
|
||||
filterset_fields = '__all__'
|
||||
lookup_value_regex = LOOKUP_VALUE_REGEX
|
||||
|
||||
|
||||
@method_decorator(name='list', decorator=[CACHE_CONTROL, LAST_MODIFIED])
|
||||
@method_decorator(name='retrieve', decorator=[CACHE_CONTROL, LAST_MODIFIED])
|
||||
class AgencyViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
@ -145,7 +154,6 @@ class StopTimeUpdateViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
class NextDeparturesViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
queryset = StopTime.objects.none()
|
||||
serializer_class = StopTimeSerializer
|
||||
filter_backends = [DjangoFilterBackend]
|
||||
|
||||
def get_queryset(self):
|
||||
now = datetime.now()
|
||||
@ -167,10 +175,21 @@ class NextDeparturesViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
near_stops = station.get_near_stops()
|
||||
stop_filter = Q(stop_id__in=near_stops.values_list('id', flat=True))
|
||||
|
||||
excluded_agencies = ~Q(trip__route__gtfs_feed__excluded_agencies=F('trip__route__agency_id'))
|
||||
|
||||
not_last_stop = ~Q(stop_sequence=StopTime.objects.filter(trip_id=OuterRef('trip_id'))
|
||||
.filter(pickup_type=PickupType.REGULAR)
|
||||
.order_by('-stop_sequence')[:1].values_list('stop_sequence'))
|
||||
|
||||
trip_filter = Q()
|
||||
if self.request.query_params.get('route_name', None):
|
||||
trip_filter &= Q(trip__route_name__in=self.request.query_params.get('route_name').split(','))
|
||||
if self.request.query_params.get('transport_type', None):
|
||||
trip_filter &= Q(trip__route__type__in=self.request.query_params.get('transport_type').split(','))
|
||||
if self.request.query_params.get('long_distance', None) is not None:
|
||||
long_distance = str(self.request.query_params.get('long_distance')) == 'true'
|
||||
trip_filter &= Q(trip__long_distance=long_distance)
|
||||
|
||||
def calendar_filter(d: date):
|
||||
return Q(trip__service_id__in=CalendarDate.objects.filter(date=d, exception_type=1)
|
||||
.values_list('service_id')) \
|
||||
@ -199,7 +218,9 @@ class NextDeparturesViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
return Exists(stop_time_update_qs(d).filter(Q(schedule_relationship=1) | Q(schedule_relationship=3)))
|
||||
|
||||
qs_today = StopTime.objects.filter(stop_filter) \
|
||||
.filter(excluded_agencies) \
|
||||
.filter(not_last_stop) \
|
||||
.filter(trip_filter) \
|
||||
.annotate(departure_time_real=departure_time_real(query_date)) \
|
||||
.filter(departure_time_real__gte=query_time) \
|
||||
.filter(Q(pickup_type=PickupType.REGULAR) | canceled_filter(query_date)) \
|
||||
@ -208,7 +229,9 @@ class NextDeparturesViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
.annotate(departure_time_24h=F('departure_time'))
|
||||
|
||||
qs_yesterday = StopTime.objects.filter(stop_filter) \
|
||||
.filter(excluded_agencies) \
|
||||
.filter(not_last_stop) \
|
||||
.filter(trip_filter) \
|
||||
.annotate(departure_time_real=departure_time_real(query_date)) \
|
||||
.filter(departure_time_real__gte=time_yesterday) \
|
||||
.filter(Q(pickup_type=PickupType.REGULAR) | canceled_filter(yesterday)) \
|
||||
@ -217,7 +240,9 @@ class NextDeparturesViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
.annotate(departure_time_24h=F('departure_time') - timedelta(days=1))
|
||||
|
||||
qs_tomorrow = StopTime.objects.filter(stop_filter) \
|
||||
.filter(excluded_agencies) \
|
||||
.filter(not_last_stop) \
|
||||
.filter(trip_filter) \
|
||||
.annotate(departure_time_real=departure_time_real(query_date)) \
|
||||
.filter(departure_time_real__gte=timedelta(0)) \
|
||||
.filter(Q(pickup_type=PickupType.REGULAR) | canceled_filter(tomorrow)) \
|
||||
@ -254,10 +279,21 @@ class NextArrivalsViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
near_stops = station.get_near_stops()
|
||||
stop_filter = Q(stop_id__in=near_stops.values_list('id', flat=True))
|
||||
|
||||
excluded_agencies = ~Q(trip__route__gtfs_feed__excluded_agencies=F('trip__route__agency_id'))
|
||||
|
||||
not_first_stop = ~Q(stop_sequence=StopTime.objects.filter(trip_id=OuterRef('trip_id'))
|
||||
.filter(drop_off_type=PickupType.REGULAR)
|
||||
.order_by('stop_sequence')[:1].values_list('stop_sequence'))
|
||||
|
||||
trip_filter = Q()
|
||||
if self.request.query_params.get('route_name', None):
|
||||
trip_filter &= Q(trip__route_name__in=self.request.query_params.get('route_name').split(','))
|
||||
if self.request.query_params.get('transport_type', None):
|
||||
trip_filter &= Q(trip__route__type__in=self.request.query_params.get('transport_type').split(','))
|
||||
if self.request.query_params.get('long_distance', None) is not None:
|
||||
long_distance = str(self.request.query_params.get('long_distance')) == 'true'
|
||||
trip_filter &= Q(trip__long_distance=long_distance)
|
||||
|
||||
def calendar_filter(d: date):
|
||||
return Q(trip__service_id__in=CalendarDate.objects.filter(date=d, exception_type=1)
|
||||
.values_list('service_id')) \
|
||||
@ -286,7 +322,9 @@ class NextArrivalsViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
return Exists(stop_time_update_qs(d).filter(Q(schedule_relationship=1) | Q(schedule_relationship=3)))
|
||||
|
||||
qs_today = StopTime.objects.filter(stop_filter) \
|
||||
.filter(excluded_agencies) \
|
||||
.filter(not_first_stop) \
|
||||
.filter(trip_filter) \
|
||||
.annotate(arrival_time_real=arrival_time_real(query_date)) \
|
||||
.filter(arrival_time_real__gte=query_time) \
|
||||
.filter(Q(drop_off_type=PickupType.REGULAR) | canceled_filter(query_date)) \
|
||||
@ -295,7 +333,9 @@ class NextArrivalsViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
.annotate(arrival_time_24h=F('arrival_time'))
|
||||
|
||||
qs_yesterday = StopTime.objects.filter(stop_filter) \
|
||||
.filter(excluded_agencies) \
|
||||
.filter(not_first_stop) \
|
||||
.filter(trip_filter) \
|
||||
.annotate(arrival_time_real=arrival_time_real(yesterday)) \
|
||||
.filter(arrival_time_real__gte=time_yesterday) \
|
||||
.filter(Q(drop_off_type=PickupType.REGULAR) | canceled_filter(yesterday)) \
|
||||
@ -304,7 +344,9 @@ class NextArrivalsViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
.annotate(arrival_time_24h=F('arrival_time') - timedelta(days=1))
|
||||
|
||||
qs_tomorrow = StopTime.objects.filter(stop_filter) \
|
||||
.filter(excluded_agencies) \
|
||||
.filter(not_first_stop) \
|
||||
.filter(trip_filter) \
|
||||
.annotate(arrival_time_real=arrival_time_real(tomorrow)) \
|
||||
.filter(arrival_time_real__gte=timedelta(0)) \
|
||||
.filter(Q(drop_off_type=PickupType.REGULAR) | canceled_filter(tomorrow)) \
|
||||
|
@ -57,6 +57,7 @@ class GTFSFeedAdmin(admin.ModelAdmin):
|
||||
list_filter = ('country', 'last_modified',)
|
||||
search_fields = ('name', 'code',)
|
||||
readonly_fields = ('code',)
|
||||
autocomplete_fields = ('excluded_agencies',)
|
||||
|
||||
|
||||
@admin.register(Agency)
|
||||
|
@ -6,7 +6,13 @@
|
||||
"name": "SNCF - TGV",
|
||||
"country": "FR",
|
||||
"feed_url": "https://eu.ftp.opendatasoft.com/sncf/gtfs/export_gtfs_voyages.zip",
|
||||
"rt_feed_url": "https://proxy.transport.data.gouv.fr/resource/sncf-tgv-gtfs-rt-trip-updates"
|
||||
"rt_feed_url": "https://proxy.transport.data.gouv.fr/resource/sncf-tgv-gtfs-rt-trip-updates",
|
||||
"categorize_routes": false,
|
||||
"route_name_regex": "route_short_name:([\\w\\s]+)",
|
||||
"route_type_regex": "SNCF-&stop:FR-SNCF-TGV-StopPoint:OCE([\\w\\s]+)-\\d+",
|
||||
"trip_number_regex": "trip_headsign:(\\d+)",
|
||||
"long_distance_regex": "",
|
||||
"excluded_agencies": []
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -16,7 +22,13 @@
|
||||
"name": "SNCF - Intercités",
|
||||
"country": "FR",
|
||||
"feed_url": "https://eu.ftp.opendatasoft.com/sncf/gtfs/export-intercites-gtfs-last.zip",
|
||||
"rt_feed_url": "https://proxy.transport.data.gouv.fr/resource/sncf-ic-gtfs-rt-trip-updates"
|
||||
"rt_feed_url": "https://proxy.transport.data.gouv.fr/resource/sncf-ic-gtfs-rt-trip-updates",
|
||||
"categorize_routes": false,
|
||||
"route_name_regex": "route_short_name:([\\w\\s]+)",
|
||||
"route_type_regex": "SNCF-&stop:FR-SNCF-IC-StopPoint:OCE([\\w\\s]+)-\\d+",
|
||||
"trip_number_regex": "trip_headsign:(\\d+)",
|
||||
"long_distance_regex": "",
|
||||
"excluded_agencies": []
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -26,7 +38,13 @@
|
||||
"name": "SNCF - TER",
|
||||
"country": "FR",
|
||||
"feed_url": "https://eu.ftp.opendatasoft.com/sncf/gtfs/export-ter-gtfs-last.zip",
|
||||
"rt_feed_url": "https://proxy.transport.data.gouv.fr/resource/sncf-ter-gtfs-rt-trip-updates"
|
||||
"rt_feed_url": "https://proxy.transport.data.gouv.fr/resource/sncf-ter-gtfs-rt-trip-updates",
|
||||
"categorize_routes": false,
|
||||
"route_name_regex": "route_short_name:([\\w\\s]+)",
|
||||
"route_type_regex": "SNCF-&stop:FR-SNCF-TER-StopPoint:OCE([\\w\\s]+)-\\d+",
|
||||
"trip_number_regex": "trip_headsign:(\\d+)",
|
||||
"long_distance_regex": "^$",
|
||||
"excluded_agencies": []
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -36,7 +54,13 @@
|
||||
"name": "Île-de-France Mobilités",
|
||||
"country": "FR",
|
||||
"feed_url": "https://eu.ftp.opendatasoft.com/stif/GTFS/IDFM-gtfs.zip",
|
||||
"rt_feed_url": ""
|
||||
"rt_feed_url": "",
|
||||
"categorize_routes": true,
|
||||
"route_name_regex": "route_short_name:([\\w]+)",
|
||||
"route_type_regex": "IDFM",
|
||||
"trip_number_regex": "trip_short_name:([\\d\\w-]*)§",
|
||||
"long_distance_regex": "^$",
|
||||
"excluded_agencies": []
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -46,7 +70,13 @@
|
||||
"name": "Compagnie des Transports Strasbourgeois (CTS)",
|
||||
"country": "FR",
|
||||
"feed_url": "https://opendata.cts-strasbourg.eu/google_transit.zip",
|
||||
"rt_feed_url": ""
|
||||
"rt_feed_url": "",
|
||||
"categorize_routes": true,
|
||||
"route_name_regex": "route_short_name:([\\w\\d]+)",
|
||||
"route_type_regex": "CTS",
|
||||
"trip_number_regex": "",
|
||||
"long_distance_regex": "^$",
|
||||
"excluded_agencies": []
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -56,7 +86,13 @@
|
||||
"name": "Eurostar",
|
||||
"country": "FR",
|
||||
"feed_url": "https://www.data.gouv.fr/fr/datasets/r/9089b550-696e-4ae0-87b5-40ea55a14292",
|
||||
"rt_feed_url": ""
|
||||
"rt_feed_url": "",
|
||||
"categorize_routes": false,
|
||||
"route_name_regex": "route_short_name:([\\w\\s]+)",
|
||||
"route_type_regex": "Eurostar",
|
||||
"trip_number_regex": "trip_short_name:(\\d+)",
|
||||
"long_distance_regex": "",
|
||||
"excluded_agencies": []
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -66,7 +102,13 @@
|
||||
"name": "Trenitalia France",
|
||||
"country": "FR",
|
||||
"feed_url": "https://thello.axelor.com/public/gtfs/gtfs.zip",
|
||||
"rt_feed_url": "https://thello.axelor.com/public/gtfs/GTFS-RT.bin"
|
||||
"rt_feed_url": "https://thello.axelor.com/public/gtfs/GTFS-RT.bin",
|
||||
"categorize_routes": false,
|
||||
"route_name_regex": "route_short_name:([\\w\\s/-]+)",
|
||||
"route_type_regex": "Trenitalia France",
|
||||
"trip_number_regex": "trip_id:IT-FRA-TI-(\\d+)",
|
||||
"long_distance_regex": "",
|
||||
"excluded_agencies": []
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -76,7 +118,13 @@
|
||||
"name": "Renfe",
|
||||
"country": "ES",
|
||||
"feed_url": "https://ssl.renfe.com/gtransit/Fichero_AV_LD/google_transit.zip",
|
||||
"rt_feed_url": ""
|
||||
"rt_feed_url": "",
|
||||
"categorize_routes": true,
|
||||
"route_name_regex": "route_short_name:([\\w\\s]+)",
|
||||
"route_type_regex": "RENFE-&route_short_name:([\\w\\s]+)",
|
||||
"trip_number_regex": "trip_short_name:(\\d+)",
|
||||
"long_distance_regex": "",
|
||||
"excluded_agencies": []
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -86,7 +134,13 @@
|
||||
"name": "ÖBB",
|
||||
"country": "AT",
|
||||
"feed_url": "https://static.oebb.at/open-data/soll-fahrplan-gtfs/GTFS_OP_2024_obb.zip",
|
||||
"rt_feed_url": ""
|
||||
"rt_feed_url": "",
|
||||
"categorize_routes": true,
|
||||
"route_name_regex": "route_short_name:([\\w\\s]+)",
|
||||
"route_type_regex": "ÖBB-&trip_short_name:(\\w+)\\s",
|
||||
"trip_number_regex": "trip_short_name:\\w+\\s(\\d+)",
|
||||
"long_distance_regex": "",
|
||||
"excluded_agencies": []
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -96,7 +150,15 @@
|
||||
"name": "Transports suisses",
|
||||
"country": "CH",
|
||||
"feed_url": "https://opentransportdata.swiss/fr/dataset/timetable-2024-gtfs2020/permalink",
|
||||
"rt_feed_url": "https://api.opentransportdata.swiss/gtfsrt2020"
|
||||
"rt_feed_url": "https://api.opentransportdata.swiss/gtfsrt2020",
|
||||
"categorize_routes": true,
|
||||
"route_name_regex": "route_short_name:([\\w\\s]*)",
|
||||
"route_type_regex": "CH-&route_desc:([\\w\\s]*)",
|
||||
"trip_number_regex": "trip_short_name:(\\d+)",
|
||||
"long_distance_regex": "",
|
||||
"excluded_agencies": [
|
||||
"CH-ALL-87_LEX"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -106,7 +168,13 @@
|
||||
"name": "CFL",
|
||||
"country": "LU",
|
||||
"feed_url": "https://data.public.lu/fr/datasets/r/aab2922d-27ff-4e53-a789-d990cf1ceb1e",
|
||||
"rt_feed_url": ""
|
||||
"rt_feed_url": "",
|
||||
"categorize_routes": true,
|
||||
"route_name_regex": "route_short_name:([\\w\\s]+)",
|
||||
"route_type_regex": "CFL-&route_short_name:([\\w\\s]+)",
|
||||
"trip_number_regex": "trip_short_name:(\\d+)",
|
||||
"long_distance_regex": "route_type:3",
|
||||
"excluded_agencies": []
|
||||
}
|
||||
}
|
||||
]
|
||||
|
20
trainvel/gtfs/management/commands/augment_data.py
Normal file
20
trainvel/gtfs/management/commands/augment_data.py
Normal file
@ -0,0 +1,20 @@
|
||||
from django.core.management import BaseCommand
|
||||
from django.db import transaction
|
||||
|
||||
from tqdm import tqdm
|
||||
from trainvel.gtfs.models import Trip
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('--feed', type=str, help='GTFS Feed code')
|
||||
|
||||
def handle(self, *args, **options):
|
||||
trips = Trip.objects.filter(route_name="").prefetch_related('gtfs_feed', 'route')
|
||||
if options['feed']:
|
||||
trips = trips.filter(gtfs_feed__code=options['feed'])
|
||||
|
||||
with transaction.atomic():
|
||||
for trip in tqdm(trips.all()):
|
||||
trip.augment_data()
|
||||
trip.save()
|
@ -0,0 +1,72 @@
|
||||
# Generated by Django 5.0.6 on 2024-05-12 17:18
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("gtfs", "0002_alter_stop_parent_station"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="gtfsfeed",
|
||||
name="categorize_routes",
|
||||
field=models.BooleanField(
|
||||
default=False,
|
||||
help_text="If checked, trips can be categorized by route type.",
|
||||
verbose_name="categorize routes",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="gtfsfeed",
|
||||
name="excluded_agencies",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
help_text="Agencies that are part of another feed and shouldn't be displayed with this feed.",
|
||||
to="gtfs.agency",
|
||||
verbose_name="excluded agencies",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="gtfsfeed",
|
||||
name="long_distance_regex",
|
||||
field=models.TextField(
|
||||
blank=True,
|
||||
default="",
|
||||
help_text="Regular expression that filters long distance trips.",
|
||||
verbose_name="long distance regex",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="gtfsfeed",
|
||||
name="route_name_regex",
|
||||
field=models.TextField(
|
||||
blank=True,
|
||||
default="route_short_name:([^§]+)",
|
||||
help_text="Regular expression that catches the route name from a trip.",
|
||||
verbose_name="route name regex",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="gtfsfeed",
|
||||
name="route_type_regex",
|
||||
field=models.TextField(
|
||||
blank=True,
|
||||
default="gtfs_feed:([^§]+)&-&route_type:([^§]+)",
|
||||
help_text="Regular expression that catches the route type from a trip.",
|
||||
verbose_name="route name regex",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="gtfsfeed",
|
||||
name="trip_number_regex",
|
||||
field=models.TextField(
|
||||
blank=True,
|
||||
default="trip_short_name:([^§]+)",
|
||||
help_text="Regular expression that catches the trip number from a trip.",
|
||||
verbose_name="route name regex",
|
||||
),
|
||||
),
|
||||
]
|
@ -0,0 +1,39 @@
|
||||
# Generated by Django 5.0.6 on 2024-05-12 18:33
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("gtfs", "0003_gtfsfeed_categorize_routes_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="trip",
|
||||
name="long_distance",
|
||||
field=models.BooleanField(default=True, verbose_name="Long distance trip"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="trip",
|
||||
name="route_name",
|
||||
field=models.CharField(
|
||||
blank=True, default="", max_length=255, verbose_name="Route name"
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="trip",
|
||||
name="route_type",
|
||||
field=models.CharField(
|
||||
blank=True, default="", max_length=255, verbose_name="Route type"
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="trip",
|
||||
name="trip_number",
|
||||
field=models.CharField(
|
||||
blank=True, default="", max_length=255, verbose_name="Trip number"
|
||||
),
|
||||
),
|
||||
]
|
@ -1,3 +1,5 @@
|
||||
import re
|
||||
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
@ -56,6 +58,15 @@ class Country(models.TextChoices):
|
||||
UKRAINE = "UA", _("Ukraine")
|
||||
|
||||
|
||||
class CategorizeTrips(models.TextChoices):
|
||||
"""
|
||||
Make trips be categorized by route type.
|
||||
"""
|
||||
GTFS_FEED = "gtfs_feed", _("GTFS Feed (all trips of the dataset)")
|
||||
ROUTE_SHORT_NAME = "route_short_name", _("By route short name (e.g. A, S9,…)")
|
||||
TRIP_SHORT_NAME_SPLIT = "route_short_name_split", _("By first character of the route short name (e.g. RJ, NJ,…)")
|
||||
|
||||
|
||||
class LocationType(models.IntegerChoices):
|
||||
STOP_PLATFORM = 0, _("Stop/platform")
|
||||
STATION = 1, _("Station")
|
||||
@ -172,6 +183,47 @@ class GTFSFeed(models.Model):
|
||||
"If it is not modified, the file is the same."),
|
||||
)
|
||||
|
||||
excluded_agencies = models.ManyToManyField(
|
||||
to="Agency",
|
||||
verbose_name=_("excluded agencies"),
|
||||
blank=True,
|
||||
help_text=_("Agencies that are part of another feed and shouldn't be displayed with this feed."),
|
||||
)
|
||||
|
||||
categorize_routes = models.BooleanField(
|
||||
verbose_name=_("categorize routes"),
|
||||
default=False,
|
||||
help_text=_("If checked, trips can be categorized by route type."),
|
||||
)
|
||||
|
||||
route_name_regex = models.TextField(
|
||||
verbose_name=_("route name regex"),
|
||||
blank=True,
|
||||
default=r"route_short_name:([^§]+)",
|
||||
help_text=_("Regular expression that catches the route name from a trip."),
|
||||
)
|
||||
|
||||
route_type_regex = models.TextField(
|
||||
verbose_name=_("route name regex"),
|
||||
blank=True,
|
||||
default=r"gtfs_feed:([^§]+)&-&route_type:([^§]+)",
|
||||
help_text=_("Regular expression that catches the route type from a trip."),
|
||||
)
|
||||
|
||||
trip_number_regex = models.TextField(
|
||||
verbose_name=_("route name regex"),
|
||||
blank=True,
|
||||
default=r"trip_short_name:([^§]+)",
|
||||
help_text=_("Regular expression that catches the trip number from a trip."),
|
||||
)
|
||||
|
||||
long_distance_regex = models.TextField(
|
||||
verbose_name=_("long distance regex"),
|
||||
blank=True,
|
||||
default="",
|
||||
help_text=_("Regular expression that filters long distance trips."),
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.code})"
|
||||
|
||||
@ -486,6 +538,36 @@ class Trip(models.Model):
|
||||
verbose_name=_("GTFS feed"),
|
||||
)
|
||||
|
||||
"""
|
||||
Augmented data
|
||||
"""
|
||||
|
||||
route_name = models.CharField(
|
||||
max_length=255,
|
||||
verbose_name=_("Route name"),
|
||||
blank=True,
|
||||
default="",
|
||||
)
|
||||
|
||||
trip_number = models.CharField(
|
||||
max_length=255,
|
||||
verbose_name=_("Trip number"),
|
||||
blank=True,
|
||||
default="",
|
||||
)
|
||||
|
||||
route_type = models.CharField(
|
||||
max_length=255,
|
||||
verbose_name=_("Route type"),
|
||||
blank=True,
|
||||
default="",
|
||||
)
|
||||
|
||||
long_distance = models.BooleanField(
|
||||
verbose_name=_("Long distance trip"),
|
||||
default=True,
|
||||
)
|
||||
|
||||
@property
|
||||
def origin(self) -> Stop | None:
|
||||
return self.stop_times.order_by('stop_sequence').first().stop if self.stop_times.exists() else None
|
||||
@ -556,6 +638,28 @@ class Trip(models.Model):
|
||||
|
||||
origin_destination.fget.short_description = _("Origin → Destination")
|
||||
|
||||
def augment_data(self):
|
||||
first_stop = self.stop_times.first()
|
||||
if not first_stop:
|
||||
return
|
||||
desc_line = first_stop.description_line
|
||||
|
||||
for key in ['route_name', 'route_type', 'trip_number']:
|
||||
value = ""
|
||||
full_pattern = getattr(self.gtfs_feed, f"{key}_regex")
|
||||
for pattern in full_pattern.split('&'):
|
||||
if '(' not in pattern:
|
||||
value += pattern
|
||||
continue
|
||||
|
||||
groups = re.findall(pattern, desc_line)
|
||||
if not any(group is not None for group in groups):
|
||||
raise ValueError(f"Pattern {pattern} not found in {desc_line}")
|
||||
value += "".join(group for group in groups if group)
|
||||
setattr(self, key, value)
|
||||
|
||||
self.long_distance = bool(re.match(self.gtfs_feed.long_distance_regex, desc_line))
|
||||
|
||||
def __str__(self):
|
||||
return self.origin_destination
|
||||
|
||||
@ -638,6 +742,28 @@ class StopTime(models.Model):
|
||||
minutes = int((seconds % 3600) // 60)
|
||||
return f"{hours:02}:{minutes:02}"
|
||||
|
||||
@property
|
||||
def description_line(self) -> str:
|
||||
return (f"gtfs_feed:{self.trip.gtfs_feed_id}"
|
||||
f"§stop:{self.stop_id}"
|
||||
f"§trip_id:{self.trip_id}"
|
||||
f"§trip_short_name:{self.trip.short_name}"
|
||||
f"§trip_headsign:{self.trip.headsign}"
|
||||
f"§route_short_name:{self.trip.route.short_name}"
|
||||
f"§route_desc:{self.trip.route.desc}"
|
||||
f"§route_type:{self.trip.route.type}")
|
||||
|
||||
@property
|
||||
def description_line_expr(self):
|
||||
return "gtfs_feed:" + models.F('trip__gtfs_feed_id') \
|
||||
+ "§stop:" + models.F('stop_id') \
|
||||
+ "§trip_id:" + models.F('trip_id') \
|
||||
+ "§trip_short_name:" + models.F('trip__short_name') \
|
||||
+ "§trip_headsign:" + models.F('trip__headsign') \
|
||||
+ "§route_short_name:" + models.F('trip__route__short_name') \
|
||||
+ "§route_desc:" + models.F('trip__route__desc') \
|
||||
+ "§route_type:" + models.F('trip__route__type')
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.stop.name} - {self.trip_id}"
|
||||
|
||||
@ -760,6 +886,46 @@ class Transfer(models.Model):
|
||||
related_name="transfers_to",
|
||||
)
|
||||
|
||||
from_route = models.ForeignKey(
|
||||
to="Route",
|
||||
on_delete=models.CASCADE,
|
||||
verbose_name=_("From route"),
|
||||
related_name="transfers_from",
|
||||
null=True,
|
||||
blank=True,
|
||||
default=None,
|
||||
)
|
||||
|
||||
to_route = models.ForeignKey(
|
||||
to="Route",
|
||||
on_delete=models.CASCADE,
|
||||
verbose_name=_("To route"),
|
||||
related_name="transfers_to",
|
||||
null=True,
|
||||
blank=True,
|
||||
default=None,
|
||||
)
|
||||
|
||||
from_trip = models.ForeignKey(
|
||||
to="Trip",
|
||||
on_delete=models.CASCADE,
|
||||
verbose_name=_("From trip"),
|
||||
related_name="transfers_from",
|
||||
null=True,
|
||||
blank=True,
|
||||
default=None,
|
||||
)
|
||||
|
||||
to_trip = models.ForeignKey(
|
||||
to="Trip",
|
||||
on_delete=models.CASCADE,
|
||||
verbose_name=_("To trip"),
|
||||
related_name="transfers_to",
|
||||
null=True,
|
||||
blank=True,
|
||||
default=None,
|
||||
)
|
||||
|
||||
transfer_type = models.IntegerField(
|
||||
verbose_name=_("Transfer type"),
|
||||
choices=TransferType,
|
||||
@ -769,6 +935,7 @@ class Transfer(models.Model):
|
||||
min_transfer_time = models.IntegerField(
|
||||
verbose_name=_("Minimum transfer time"),
|
||||
blank=True,
|
||||
default=None,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
Loading…
Reference in New Issue
Block a user