Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions gtfs/fixtures/realtime_schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
{
"entities": {
"feed_message": {
"primary_key": "id",
"fields": {
"id": "string",
"header": "feed_header",
"entity": "array<feed_entity>"
}
},
"feed_header": {
"primary_key": "header_id",
"fields": {
"header_id": "string",
"gtfs_realtime_version": "string",
"incrementality": "string",
"timestamp": "integer"
}
},
"feed_entity": {
"primary_key": "entity_id",
"fields": {
"entity_id": "string",
"is_deleted": "boolean",
"vehicle": "vehicle_position",
"trip_update": "trip_update",
"alert": "alert"
}
},
"vehicle_position": {
"primary_key": "id",
"fields": {
"id": "string",
"trip_id": "string",
"vehicle_id": "string",
"vehicle_label": "string",
"vehicle_license": "string",
"position_lat": "float",
"position_lon": "float",
"position_bearing": "float",
"timestamp": "integer",
"stop_id": "string"
},
"foreign_keys": {
"trip_id": "trip_update.trip_id"
}
},
"trip_update": {
"primary_key": "trip_update_id",
"fields": {
"trip_update_id": "string",
"trip_id": "string",
"vehicle_id": "string",
"stop_time_updates": "array<stop_time_update>",
"timestamp": "integer",
"delay": "integer"
}
},
"stop_time_update": {
"primary_key": "stop_time_update_id",
"fields": {
"stop_time_update_id": "string",
"stop_sequence": "integer",
"stop_id": "string",
"arrival_time": "integer",
"departure_time": "integer",
"schedule_relationship": "string"
}
},
"alert": {
"primary_key": "alert_id",
"fields": {
"alert_id": "string",
"active_period": "array<period>",
"informed_entity": "array<entity_selector>",
"cause": "string",
"effect": "string",
"url": "string",
"header_text": "string",
"description_text": "string"
}
}
},
"version": "1.0.0",
"spec": "GTFS Realtime v2.0"
}
185 changes: 156 additions & 29 deletions gtfs/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from django.core.exceptions import ValidationError
from django.contrib.gis.db import models
from django.contrib.gis.geos import Point
from django.db.models import Q, F


def validate_no_spaces_or_special_symbols(value):
Expand Down Expand Up @@ -851,11 +852,50 @@ class FeedMessage(models.Model):

class Meta:
ordering = ["-timestamp"]
constraints = [
models.UniqueConstraint(
fields=["feed_message_id", "timestamp"],
name="unique_feedmessage_timestamp"
)
]
indexes = [
models.Index(fields=["timestamp", "entity_type"]),
models.Index(fields=["provider_id", "timestamp"]),
]

def __str__(self):
return f"{self.entity_type} ({self.timestamp})"
def clean(self):
"""Model-level validation for timestamp monotonicity and ID consistency."""
# Timestamp monotonicity
previous = (
FeedMessage.objects.filter(feed_message_id=self.feed_message_id)
.exclude(pk=self.pk)
.order_by('-timestamp')
.first()
)
if previous and previous.timestamp >= self.timestamp:
raise ValidationError(
{"timestamp": "Timestamp must be greater than previous FeedMessage timestamp."}
)

# Identifier consistency
if self.entity_type == "trip_update" and not hasattr(self, "trip"):
raise ValidationError(
{"entity_type": "TripUpdate must have a valid trip reference."}
)

def to_json(self):
"""Converts this model instance into a JSON-serializable dictionary."""
return {
"feed_message_id": self.feed_message_id,
"timestamp": self.timestamp.isoformat(),
"entity_type": self.entity_type,
"incrementality": self.incrementality,
"gtfs_realtime_version": self.gtfs_realtime_version,
}

def __str__(self):
return f"{self.entity_type} ({self.timestamp})"

class TripUpdate(models.Model):
"""
GTFS Realtime TripUpdate entity v2.0 (normalized).
Expand Down Expand Up @@ -893,6 +933,23 @@ class TripUpdate(models.Model):
# Delay (int32)
delay = models.IntegerField(blank=True, null=True)

class Meta:
constraints = [
models.UniqueConstraint(
fields=["entity_id", "feed_message"],
name="unique_tripupdate_per_feed"
),
models.CheckConstraint(
check=Q(delay__gte=-86400), # -24 hours in seconds
name="valid_delay_range"
),
]
indexes = [
models.Index(fields=["trip_trip_id", "timestamp"]),
models.Index(fields=["vehicle_id", "timestamp"]),
models.Index(fields=["delay", "timestamp"]),
]

def __str__(self):
return f"{self.entity_id} ({self.feed_message})"

Expand Down Expand Up @@ -930,10 +987,22 @@ class StopTimeUpdate(models.Model):
# ScheduleRelationship (enum)
schedule_relationship = models.CharField(max_length=255, blank=True, null=True)

class Meta:
constraints = [
models.CheckConstraint(
check=Q(arrival_time__lte=F("departure_time")),
name="valid_time_order"
),
]
indexes = [
models.Index(fields=["stop_id", "arrival_time"]),
models.Index(fields=["stop_id", "departure_time"]),
models.Index(fields=["stop_sequence", "trip_update_id"]),
]

def __str__(self):
return f"{self.stop_id} ({self.trip_update})"


class VehiclePosition(models.Model):
"""
GTFS Realtime VehiclePosition entity v2.0 (normalized).
Expand Down Expand Up @@ -1000,30 +1069,69 @@ class VehiclePosition(models.Model):

# CarriageDetails (message): not implemented

class Meta:
constraints = [
models.UniqueConstraint(
fields=["entity_id", "feed_message"],
name="unique_vehicleposition_per_feed"
),
models.CheckConstraint(
check=Q(vehicle_occupancy_percentage__gte=0) &
Q(vehicle_occupancy_percentage__lte=100),
name="valid_occupancy"
),
]
indexes = [
models.Index(fields=["vehicle_trip_route_id", "vehicle_timestamp"]),
models.Index(fields=["vehicle_current_stop_sequence", "vehicle_timestamp"]),
models.Index(fields=["vehicle_position_point"]),
]

def save(self, *args, **kwargs):
self.vehicle_position_point = Point(
self.vehicle_position_longitude, self.vehicle_position_latitude
)
super(VehiclePosition, self).save(*args, **kwargs)
if self.vehicle_position_longitude and self.vehicle_position_latitude:
self.vehicle_position_point = Point(
self.vehicle_position_longitude, self.vehicle_position_latitude
)
super().save(*args, **kwargs)

def __str__(self):
return f"{self.entity_id} ({self.feed_message})"


class Alert(models.Model):
"""Alerts and warnings about the service.
Maps to alerts.txt in the GTFS feed.

TODO: ajustar con Alerts de GTFS Realtime
"""
GTFS Realtime Alert entity (v2.0)
Combines GTFS static alerts.txt with Realtime alerts feed.
"""

id = models.BigAutoField(primary_key=True)

# Relation to Feed model
feed = models.ForeignKey(Feed, on_delete=models.CASCADE)

# ID alert
alert_id = models.CharField(
max_length=255, help_text="Identificador único de la alerta."
max_length=255,
help_text="Identificador único de la alerta (según FeedMessage.entity.id)."
)

# Relation with schedule entities (Realtime 'informed_entity')
route = models.ForeignKey(
Route,
on_delete=models.SET_NULL,
null=True,
blank=True,
help_text="Ruta afectada por la alerta."
)
trip = models.ForeignKey(
Trip,
on_delete=models.SET_NULL,
null=True,
blank=True,
help_text="Viaje afectado por la alerta."
)
route_id = models.CharField(max_length=255, help_text="Identificador de la ruta.")
trip_id = models.CharField(max_length=255, help_text="Identificador del viaje.")

# Fields for temporal validity (Realtime 'active_period')
service_date = models.DateField(
help_text="Fecha del servicio descrito por la alerta."
)
Expand All @@ -1033,11 +1141,13 @@ class Alert(models.Model):
service_end_time = models.TimeField(
help_text="Hora de finalización del servicio descrito por la alerta."
)
alert_header = models.CharField(
max_length=255, help_text="Encabezado de la alerta."
)
alert_description = models.TextField(help_text="Descripción de la alerta.")
alert_url = models.URLField(blank=True, null=True, help_text="URL de la alerta.")

# Description (Realtime 'header_text', 'description_text', 'url')
alert_header = models.CharField(max_length=255, help_text="Encabezado de la alerta.")
alert_description = models.TextField(help_text="Descripción detallada de la alerta.")
alert_url = models.URLField(blank=True, null=True, help_text="URL con más información sobre la alerta.")

# Fields for classification (Realtime 'cause' and 'effect')
cause = models.PositiveIntegerField(
choices=(
(1, "Otra causa"),
Expand All @@ -1051,7 +1161,7 @@ class Alert(models.Model):
(9, "Demora"),
(10, "Cierre"),
),
help_text="Causa de la alerta.",
help_text="Causa de la alerta según GTFS Realtime."
)
effect = models.PositiveIntegerField(
choices=(
Expand All @@ -1064,7 +1174,7 @@ class Alert(models.Model):
(7, "Detención"),
(8, "Desconocido"),
),
help_text="Efecto de la alerta.",
help_text="Efecto de la alerta sobre el servicio."
)
severity = models.PositiveIntegerField(
choices=(
Expand All @@ -1074,15 +1184,32 @@ class Alert(models.Model):
(4, "Grave"),
(5, "Muy grave"),
),
help_text="Severidad de la alerta.",
help_text="Severidad de la alerta."
)
published = models.DateTimeField(
help_text="Fecha y hora de publicación de la alerta."
)
updated = models.DateTimeField(
help_text="Fecha y hora de actualización de la alerta."

# Data in realtime feed
published = models.DateTimeField(help_text="Fecha y hora de publicación de la alerta.")
updated = models.DateTimeField(help_text="Fecha y hora de actualización de la alerta.")

# Form entities (Realtime 'informed_entity' como JSON)
informed_entity = models.JSONField(
help_text="Entidades afectadas (rutas, viajes, paradas, etc.) según el feed Realtime."
)
informed_entity = models.JSONField(help_text="Entidades informadas por la alerta.")

class Meta:
verbose_name = "Alerta del servicio (GTFS Realtime)"
verbose_name_plural = "Alertas del servicio (GTFS Realtime)"
constraints = [
models.UniqueConstraint(
fields=["alert_id", "feed"],
name="unique_alert_per_feed"
)
]
indexes = [
models.Index(fields=["route_id"]),
models.Index(fields=["trip_id"]),
models.Index(fields=["service_date"]),
]

def __str__(self):
return self.alert_id
return f"{self.alert_id} ({self.route or 'sin ruta'})"
Empty file added gtfs/tests/__init__.py
Empty file.
Loading