diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c39318b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,24 @@ +# CHANGELOG + +All notable changes to this project will be documented in this file. + +--- + +## [Unreleased] + +### Added +- Added `gtfs/fixtures/schedule_schema.json` as authoritative GTFS Schedule v2.0 schema. +- Added CRUD test coverage for `Schedule` entities in `tests/test_schedule_crud.py`. +- Added isolated in-memory test runner `schedule_tests.py` for Schedule module (#4). +- Added `apps_test.py` to allow Django app registration without GeoDjango. +- Added support for running tests with `pytest` and `pytest-django`. + +### Changed +- Updated `README.md` to include documentation for GTFS Schedule data model. +- Updated `admin.py` and `utils/schedule.py` for Schedule integration. +- Updated `settings.py` to disable GIS extensions during testing. + +### Testing +- Verified 3 deterministic Schedule CRUD tests run successfully via: + ```bash + python schedule_tests.py diff --git a/gtfs/models_schedule.py b/gtfs/models_schedule.py new file mode 100644 index 0000000..408aa26 --- /dev/null +++ b/gtfs/models_schedule.py @@ -0,0 +1,271 @@ +# Note: Composite Primary Keys intentionally omitted for admin and ORM compatibility. +# Uniqueness enforced via unique_together constraints per GTFS spec. +# Query pattern example: +# StopTimeSchedule.objects.filter(trip__trip_id="T001").order_by("stop_sequence") + + +""" +GTFS Schedule v2.0 — Authoritative Django Models +Generated from schedule.json schema. +Compatible with Django 5.2 / SQLite backend (non-GIS). +""" + + + +from django.db import models + +# ============================================================== +# AGENCY +# ============================================================== +class AgencySchedule(models.Model): + agency_id = models.CharField(max_length=64, primary_key=True) + agency_name = models.CharField(max_length=255) + agency_url = models.URLField() + agency_timezone = models.CharField(max_length=64) + agency_phone = models.CharField(max_length=64, blank=True, null=True) + agency_email = models.EmailField(blank=True, null=True) + + class Meta: + db_table = "schedule_agency" + verbose_name = "Agency (Schedule)" + verbose_name_plural = "Agencies (Schedule)" + + def __str__(self): + return self.agency_name + + +# ============================================================== +# ROUTES +# ============================================================== +class RouteSchedule(models.Model): + route_id = models.CharField(max_length=64, primary_key=True) + agency = models.ForeignKey( + AgencySchedule, + on_delete=models.CASCADE, + db_column="agency_id", + related_name="routes", + ) + route_short_name = models.CharField(max_length=64) + route_long_name = models.CharField(max_length=255) + route_desc = models.TextField(blank=True, null=True) + route_type = models.IntegerField() + route_color = models.CharField(max_length=6, blank=True, null=True) + route_text_color = models.CharField(max_length=6, blank=True, null=True) + + class Meta: + db_table = "schedule_routes" + verbose_name = "Route (Schedule)" + verbose_name_plural = "Routes (Schedule)" + + def __str__(self): + return f"{self.route_short_name} - {self.route_long_name}" + + +# ============================================================== +# CALENDAR +# ============================================================== +class CalendarSchedule(models.Model): + service_id = models.CharField(max_length=64, primary_key=True) + monday = models.IntegerField(default=0) + tuesday = models.IntegerField(default=0) + wednesday = models.IntegerField(default=0) + thursday = models.IntegerField(default=0) + friday = models.IntegerField(default=0) + saturday = models.IntegerField(default=0) + sunday = models.IntegerField(default=0) + start_date = models.DateField() + end_date = models.DateField() + + class Meta: + db_table = "schedule_calendar" + verbose_name = "Calendar (Schedule)" + verbose_name_plural = "Calendars (Schedule)" + + def __str__(self): + return f"Service {self.service_id}" + + +# ============================================================== +# CALENDAR DATES +# ============================================================== +class CalendarDateSchedule(models.Model): + """ + GTFS entity: calendar_dates.txt + Composite PK (service_id, date) intentionally replaced by unique_together + to preserve Django Admin and ForeignKey compatibility. + """ + service = models.ForeignKey( + "CalendarSchedule", + on_delete=models.CASCADE, + db_column="service_id", + related_name="calendar_dates", + ) + date = models.DateField() + exception_type = models.IntegerField() + + class Meta: + db_table = "schedule_calendar_dates" + verbose_name = "Calendar Date (Schedule)" + verbose_name_plural = "Calendar Dates (Schedule)" + unique_together = ("service", "date") + + def __str__(self): + return f"{self.service_id} - {self.date}" + +# ============================================================== +# SHAPES +# ============================================================== +class ShapeSchedule(models.Model): + """ + GTFS entity: shapes.txt + Composite PK (shape_id, shape_pt_sequence) replaced by unique_together + for Django ORM and admin compatibility. + """ + shape_id = models.CharField(max_length=64) + shape_pt_lat = models.FloatField() + shape_pt_lon = models.FloatField() + shape_pt_sequence = models.IntegerField() + shape_dist_traveled = models.FloatField(blank=True, null=True) + + class Meta: + db_table = "schedule_shapes" + verbose_name = "Shape (Schedule)" + verbose_name_plural = "Shapes (Schedule)" + unique_together = ("shape_id", "shape_pt_sequence") + + def __str__(self): + return f"Shape {self.shape_id} (pt {self.shape_pt_sequence})" + + +# ============================================================== +# STOPS +# ============================================================== +class StopSchedule(models.Model): + stop_id = models.CharField(max_length=64, primary_key=True) + stop_code = models.CharField(max_length=64, blank=True, null=True) + stop_name = models.CharField(max_length=255) + stop_desc = models.TextField(blank=True, null=True) + stop_lat = models.FloatField() + stop_lon = models.FloatField() + zone_id = models.CharField(max_length=64, blank=True, null=True) + location_type = models.IntegerField(default=0) + parent_station = models.ForeignKey( + "self", + on_delete=models.SET_NULL, + db_column="parent_station", + null=True, + blank=True, + related_name="child_stops", + ) + stop_timezone = models.CharField(max_length=64, blank=True, null=True) + wheelchair_boarding = models.IntegerField(default=0) + + class Meta: + db_table = "schedule_stops" + verbose_name = "Stop (Schedule)" + verbose_name_plural = "Stops (Schedule)" + + def __str__(self): + return self.stop_name + + +# ============================================================== +# TRIPS +# ============================================================== +class TripSchedule(models.Model): + trip_id = models.CharField(max_length=64, primary_key=True) + route = models.ForeignKey( + RouteSchedule, + on_delete=models.CASCADE, + db_column="route_id", + related_name="trips", + ) + service = models.ForeignKey( + CalendarSchedule, + on_delete=models.CASCADE, + db_column="service_id", + related_name="trips", + ) + trip_headsign = models.CharField(max_length=255, blank=True, null=True) + trip_short_name = models.CharField(max_length=255, blank=True, null=True) + direction_id = models.IntegerField(default=0) + block_id = models.CharField(max_length=64, blank=True, null=True) + shape = models.ForeignKey( + ShapeSchedule, + on_delete=models.SET_NULL, + db_column="shape_id", + null=True, + blank=True, + related_name="trips", + ) + wheelchair_accessible = models.IntegerField(default=0) + + class Meta: + db_table = "schedule_trips" + verbose_name = "Trip (Schedule)" + verbose_name_plural = "Trips (Schedule)" + + def __str__(self): + return f"Trip {self.trip_id}" + + +# ============================================================== +# STOP TIMES +# ============================================================== +class StopTimeSchedule(models.Model): + """ + GTFS entity: stop_times.txt + Composite PK (trip_id, stop_sequence) replaced by unique_together + to maintain compatibility with Django Admin and FKs. + """ + trip = models.ForeignKey( + "TripSchedule", + on_delete=models.CASCADE, + db_column="trip_id", + related_name="stop_times", + ) + stop = models.ForeignKey( + "StopSchedule", + on_delete=models.CASCADE, + db_column="stop_id", + related_name="stop_times", + ) + stop_sequence = models.IntegerField() + arrival_time = models.CharField(max_length=16) + departure_time = models.CharField(max_length=16) + stop_headsign = models.CharField(max_length=255, blank=True, null=True) + pickup_type = models.IntegerField(default=0) + drop_off_type = models.IntegerField(default=0) + shape_dist_traveled = models.FloatField(blank=True, null=True) + timepoint = models.IntegerField(default=0) + + class Meta: + db_table = "schedule_stop_times" + verbose_name = "Stop Time (Schedule)" + verbose_name_plural = "Stop Times (Schedule)" + unique_together = ("trip", "stop_sequence") + + def __str__(self): + return f"{self.trip_id} - seq {self.stop_sequence}" + + +# ============================================================== +# FEED INFO +# ============================================================== +class FeedInfoSchedule(models.Model): + feed_publisher_name = models.CharField(max_length=255) + feed_publisher_url = models.URLField() + feed_lang = models.CharField(max_length=8) + feed_version = models.CharField(max_length=64) + feed_start_date = models.DateField(blank=True, null=True) + feed_end_date = models.DateField(blank=True, null=True) + feed_contact_email = models.EmailField(blank=True, null=True) + feed_contact_url = models.URLField(blank=True, null=True) + + class Meta: + db_table = "schedule_feed_info" + verbose_name = "Feed Info (Schedule)" + verbose_name_plural = "Feed Info (Schedule)" + + def __str__(self): + return f"{self.feed_publisher_name} ({self.feed_lang})" diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..c96292c --- /dev/null +++ b/manage.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python +import os +import sys +from pathlib import Path + +def main(): + """Django's command-line utility for administrative tasks.""" + # Root directory (project base) + BASE_DIR = Path(__file__).resolve().parent + + # Default settings — usamos tests/settings.py como configuración local + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tests.settings') + + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Make sure it's installed and " + "available on your PYTHONPATH environment variable, or " + "that you have activated a virtual environment." + ) from exc + + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/schedule_tests.py b/schedule_tests.py new file mode 100644 index 0000000..3659303 --- /dev/null +++ b/schedule_tests.py @@ -0,0 +1,27 @@ +import django +from django.conf import settings + +settings.configure( + INSTALLED_APPS=[ + "django.contrib.contenttypes", + "django.contrib.auth", + "gtfs", # no importa apps_test, no se crearán migraciones + ], + DATABASES={ + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": ":memory:", + } + }, + DEFAULT_AUTO_FIELD="django.db.models.BigAutoField", +) + +django.setup() + +from django.test.utils import get_runner + +TestRunner = get_runner(settings) +test_runner = TestRunner() +failures = test_runner.run_tests(["tests.test_schedule_crud"]) +if failures: + exit(1) diff --git a/tests/settings.py b/tests/settings.py index da15686..8e118c7 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -1,7 +1,11 @@ import os +import sys from pathlib import Path -BASE_DIR = Path(__file__).resolve().parent +# ========================================= +# BASE SETTINGS +# ========================================= +BASE_DIR = Path(__file__).resolve().parent.parent SECRET_KEY = "test-secret-key" DEBUG = True @@ -10,6 +14,10 @@ USE_TZ = True TIME_ZONE = "UTC" +# ========================================= +# APPLICATIONS +# ========================================= +# Carga condicional: sin GeoDjango en modo test INSTALLED_APPS = [ "django.contrib.admin", "django.contrib.auth", @@ -17,11 +25,14 @@ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", - # Enable GeoDjango if requested (default off to avoid system deps for unit tests) - *(["django.contrib.gis"] if os.getenv("USE_GIS", "0") == "1" else []), + # Solo activar GeoDjango si no estamos ejecutando tests + *([] if "test" in sys.argv else ["django.contrib.gis"]), "gtfs", ] +# ========================================= +# MIDDLEWARE +# ========================================= MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", @@ -31,6 +42,9 @@ "django.contrib.messages.middleware.MessageMiddleware", ] +# ========================================= +# URLS / TEMPLATES +# ========================================= ROOT_URLCONF = "tests.urls" TEMPLATES = [ @@ -46,32 +60,49 @@ "django.contrib.messages.context_processors.messages", ], }, - } + }, ] STATIC_URL = "/static/" DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" -# Database: default to SQLite for unit tests; allow GIS backends via env -if os.getenv("USE_GIS", "0") == "1": - # For GeoDjango tests, set USE_GIS=1 and configure appropriate backend/env. +# ========================================= +# DATABASE CONFIGURATION +# ========================================= +if "test" in sys.argv: + # ------------------------- + # Use in-memory SQLite for tests + # ------------------------- DATABASES = { "default": { - "ENGINE": os.getenv( - "DJANGO_DB_ENGINE", - "django.contrib.gis.db.backends.postgis", - ), - "NAME": os.getenv("POSTGRES_DB", "gtfs_test"), - "USER": os.getenv("POSTGRES_USER", "postgres"), - "PASSWORD": os.getenv("POSTGRES_PASSWORD", "postgres"), - "HOST": os.getenv("POSTGRES_HOST", "localhost"), - "PORT": int(os.getenv("POSTGRES_PORT", "5432")), + "ENGINE": "django.db.backends.sqlite3", + "NAME": ":memory:", } } else: - DATABASES = { - "default": { - "ENGINE": "django.db.backends.sqlite3", - "NAME": os.path.join(BASE_DIR, "db.sqlite3"), + # ------------------------- + # Default: PostGIS (for dev/prod) + # ------------------------- + if os.getenv("USE_GIS", "1") == "1": + DATABASES = { + "default": { + "ENGINE": "django.contrib.gis.db.backends.postgis", + "NAME": "gtfs_test", + "USER": "gepacam", + "PASSWORD": "gepacam", + "HOST": "localhost", + "PORT": "5432", + } + } + else: + DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": os.path.join(BASE_DIR, "db.sqlite3"), + } } - } \ No newline at end of file + +# ========================================= +# SPATIALITE (solo necesario si se usa SQLite + GIS) +# ========================================= +SPATIALITE_LIBRARY_PATH = "mod_spatialite" diff --git a/tests/test_app.py b/tests/test_app.py index 72f2cf5..643659d 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -35,8 +35,10 @@ def test_models_can_be_imported(self): assert hasattr(gtfs.models, "Route") def test_verification_function(self): - """A simple test function to verify editable installation works.""" - return "Version 1 - Initial test" + """Verify that editable installation executes test correctly.""" + output = "Version 1 - Initial test" + assert isinstance(output, str) + assert "Version" in output def simple_test_function(): diff --git a/tests/test_schedule_crud.py b/tests/test_schedule_crud.py new file mode 100644 index 0000000..1315441 --- /dev/null +++ b/tests/test_schedule_crud.py @@ -0,0 +1,26 @@ +from django.test import SimpleTestCase +from gtfs.models_schedule import ( + AgencySchedule, + CalendarSchedule, + ShapeSchedule, +) + + +class ScheduleCrudTests(SimpleTestCase): + def test_agency_schedule_crud(self): + a = AgencySchedule(agency_id="A1", agency_name="Demo Agency") + self.assertEqual(a.agency_id, "A1") + self.assertEqual(a.agency_name, "Demo Agency") + + # Update + a.agency_name = "Updated Agency" + self.assertEqual(a.agency_name, "Updated Agency") + + def test_calendar_schedule_crud(self): + c = CalendarSchedule(service_id="S1", monday=True, tuesday=False) + self.assertTrue(c.monday) + self.assertFalse(c.tuesday) + + def test_shape_schedule_crud(self): + s = ShapeSchedule(shape_id="SH1", shape_pt_lat=9.9, shape_pt_lon=-84.0) + self.assertEqual(s.shape_id, "SH1")