Compare commits

..

2 Commits

Author SHA1 Message Date
bc23d63c43
Fix transfers 2024-08-12 20:49:27 +02:00
bd8d39fc1e
Visual prototype to filter routes 2024-08-12 20:49:17 +02:00
15 changed files with 723 additions and 20 deletions

View File

@ -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",

View File

@ -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",

View File

@ -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>

View File

@ -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]
}

View 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

View File

@ -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

View File

@ -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)) \

View File

@ -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)

View File

@ -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": []
}
}
]

View 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()

View File

@ -377,11 +377,30 @@ class Command(BaseCommand):
to_stop_id = transfer_dict['to_stop_id']
from_stop_id = f"{gtfs_code}-{from_stop_id}"
to_stop_id = f"{gtfs_code}-{to_stop_id}"
from_route_id = transfer_dict.get('from_route_id', None)
from_route_id = f"{gtfs_code}-{from_route_id}" if from_route_id else None
to_route_id = transfer_dict.get('to_route_id', None)
to_route_id = f"{gtfs_code}-{to_route_id}" if to_route_id else None
from_trip_id = transfer_dict.get('from_trip_id', None)
from_trip_id = f"{gtfs_code}-{from_trip_id}" if from_trip_id else None
to_trip_id = transfer_dict.get('to_trip_id', None)
to_trip_id = f"{gtfs_code}-{to_trip_id}" if to_trip_id else None
transfer_id = f"{gtfs_code}-{transfer_dict['from_stop_id']}-{transfer_dict['to_stop_id']}"
if from_route_id and to_route_id:
transfer_id += f"-{from_route_id}-{to_route_id}"
if from_trip_id and to_trip_id:
transfer_id += f"-{from_trip_id}-{to_trip_id}"
transfer_id += f"-{transfer_dict['transfer_type']}"
transfer = Transfer(
id=f"{gtfs_code}-{transfer_dict['from_stop_id']}-{transfer_dict['to_stop_id']}",
id=transfer_id,
from_stop_id=from_stop_id,
to_stop_id=to_stop_id,
from_route_id=from_route_id,
to_route_id=to_route_id,
from_trip_id=from_trip_id,
to_trip_id=to_trip_id,
transfer_type=transfer_dict['transfer_type'],
min_transfer_time=transfer_dict.get('min_transfer_time', 0) or 0,
)

View File

@ -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",
),
),
]

View File

@ -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"
),
),
]

View File

@ -0,0 +1,73 @@
# Generated by Django 5.0.6 on 2024-08-12 18:33
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("gtfs", "0004_trip_long_distance_trip_route_name_trip_route_type_and_more"),
]
operations = [
migrations.AddField(
model_name="transfer",
name="from_route",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="transfers_from",
to="gtfs.route",
verbose_name="From route",
),
),
migrations.AddField(
model_name="transfer",
name="from_trip",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="transfers_from",
to="gtfs.trip",
verbose_name="From trip",
),
),
migrations.AddField(
model_name="transfer",
name="to_route",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="transfers_to",
to="gtfs.route",
verbose_name="To route",
),
),
migrations.AddField(
model_name="transfer",
name="to_trip",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="transfers_to",
to="gtfs.trip",
verbose_name="To trip",
),
),
migrations.AlterField(
model_name="transfer",
name="min_transfer_time",
field=models.IntegerField(
blank=True, default=None, verbose_name="Minimum transfer time"
),
),
]

View File

@ -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: