diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ea950d9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,81 @@ +# Changelog + +All notable changes to the Infobús project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added - Storage and Data Access Layer (feat/storage-reading-dal) + +#### Storage Layer +- **Data Access Layer (DAL)** with repository pattern for GTFS schedule data + - `ScheduleRepository` interface defining contract for schedule data access + - `PostgresScheduleRepository` implementation using Django ORM + - `CachedScheduleRepository` decorator for Redis caching with configurable TTL + - `RedisCacheProvider` for cache operations + - Factory pattern (`get_schedule_repository()`) for obtaining configured repository instances + +#### API Endpoints +- **GET /api/schedule/departures/** - Retrieve scheduled departures for a stop + - Query parameters: + - `stop_id` (required): Stop identifier + - `feed_id` (optional): Feed identifier, defaults to current feed + - `date` (optional): Service date in YYYY-MM-DD format, defaults to today + - `time` (optional): Departure time in HH:MM or HH:MM:SS format, defaults to now + - `limit` (optional): Maximum number of results (1-100), defaults to 10 + - Returns enriched departure data with route information: + - Route short name and long name + - Trip headsign and direction + - Formatted arrival and departure times (HH:MM:SS) + - Validates stop existence (returns 404 if not found) + - Uses PostgreSQL as data source with Redis read-through caching + +#### Configuration +- `SCHEDULE_CACHE_TTL_SECONDS` environment variable for cache duration (default: 60 seconds) +- Cache key format: `schedule:next_departures:feed={FEED_ID}:stop={STOP_ID}:date={YYYY-MM-DD}:time={HHMMSS}:limit={N}:v1` + +#### Testing +- Comprehensive test suite for schedule departures endpoint + - Response structure validation + - Stop validation (404 handling) + - Time format validation (HH:MM:SS) + - Programmatic test dataset creation + +#### Documentation +- OpenAPI/Swagger schema generation with drf-spectacular +- API endpoint annotations for automatic documentation +- Architecture documentation for DAL strategy +- README updates with endpoint usage examples and cache configuration + +### Removed - Storage and Data Access Layer (feat/storage-reading-dal) + +#### Fuseki Implementation +- Removed Apache Jena Fuseki as optional SPARQL backend + - Deleted `storage/fuseki_schedule.py` implementation + - Removed `api/tests/test_fuseki_schedule.py` integration tests + - Removed Fuseki Docker service from docker-compose.yml + - Deleted `fuseki_data` Docker volume + - Removed `docker/fuseki/` configuration directory + - Deleted `docs/dev/fuseki.md` documentation +- Removed Fuseki-related configuration + - `FUSEKI_ENABLED` environment variable + - `FUSEKI_ENDPOINT` environment variable + - Fuseki references in `.env.local.example` +- Updated `storage/factory.py` to use only PostgreSQL repository +- PostgreSQL with Redis caching is now the sole storage backend + +### Changed - Storage and Data Access Layer (feat/storage-reading-dal) + +#### Documentation +- Updated README.md to document new DAL architecture and API endpoints +- Updated docs/architecture.md with storage strategy and repository pattern +- Added project structure documentation including `storage/` directory +- Removed all Fuseki references from documentation + +--- + +## [Previous Releases] + + diff --git a/README.md b/README.md index 9c7419c..7e158d5 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,51 @@ docker compose down ## 📚 API Documentation +### New: Schedule Departures (Data Access Layer) +An HTTP endpoint backed by the new DAL returns scheduled departures at a stop. It uses PostgreSQL as the source of truth and Redis for caching (read-through) by default. + +- Endpoint: GET /api/schedule/departures/ +- Query params: + - stop_id (required) + - feed_id (optional; defaults to current feed) + - date (optional; YYYY-MM-DD; defaults to today) + - time (optional; HH:MM or HH:MM:SS; defaults to now) + - limit (optional; default 10; max 100) + +Example: +```bash +curl "http://localhost:8000/api/schedule/departures/?stop_id=STOP_123&limit=5" +``` + +Response shape: +```json +{ + "feed_id": "FEED_1", + "stop_id": "STOP_123", + "service_date": "2025-09-28", + "from_time": "08:00:00", + "limit": 5, + "departures": [ + { + "route_id": "R1", + "route_short_name": "R1", + "route_long_name": "Ruta 1 - Centro", + "trip_id": "T1", + "stop_id": "STOP_123", + "headsign": "Terminal Central", + "direction_id": 0, + "arrival_time": "08:05:00", + "departure_time": "08:06:00" + } + ] +} +``` + +Caching (keys and TTLs): +- Key pattern: schedule:next_departures:feed={FEED_ID}:stop={STOP_ID}:date={YYYY-MM-DD}:time={HHMMSS}:limit={N}:v1 +- Default TTL: 60 seconds +- Configure TTL via env: SCHEDULE_CACHE_TTL_SECONDS=60 + ### REST API Endpoints - **`/api/`** - Main API endpoints with DRF browsable interface - **`/api/gtfs/`** - GTFS Schedule and Realtime data @@ -213,6 +258,7 @@ infobus/ ├── 📁 gtfs/ # GTFS data processing (submodule) ├── 📁 feed/ # Data feed management ├── 📁 api/ # REST API endpoints +├── 📁 storage/ # Data Access Layer (Postgres) and cache providers ├── 📦 docker-compose.yml # Development environment ├── 📦 docker-compose.production.yml # Production environment ├── 📄 Dockerfile # Multi-stage container build diff --git a/api/serializers.py b/api/serializers.py index 2e9df54..8601fda 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -198,6 +198,27 @@ class Meta: fields = "__all__" +class DalDepartureSerializer(serializers.Serializer): + route_id = serializers.CharField() + route_short_name = serializers.CharField(allow_null=True, required=False) + route_long_name = serializers.CharField(allow_null=True, required=False) + trip_id = serializers.CharField() + stop_id = serializers.CharField() + headsign = serializers.CharField(allow_null=True, required=False) + direction_id = serializers.IntegerField(allow_null=True, required=False) + arrival_time = serializers.CharField(allow_null=True, required=False) + departure_time = serializers.CharField(allow_null=True, required=False) + + +class DalDeparturesResponseSerializer(serializers.Serializer): + feed_id = serializers.CharField() + stop_id = serializers.CharField() + service_date = serializers.DateField() + from_time = serializers.CharField() + limit = serializers.IntegerField() + departures = DalDepartureSerializer(many=True) + + class FareAttributeSerializer(serializers.HyperlinkedModelSerializer): feed = serializers.PrimaryKeyRelatedField(read_only=True) diff --git a/api/tests/README.md b/api/tests/README.md new file mode 100644 index 0000000..ffc2356 --- /dev/null +++ b/api/tests/README.md @@ -0,0 +1,77 @@ +# API Tests + +This directory contains test suites for the Infobús API endpoints. + +## Test Structure + +### `test_schedule_departures.py` +Tests for the `/api/schedule/departures/` endpoint which provides scheduled departure information using the Data Access Layer (DAL). + +**Test Cases:** +- `ScheduleDeparturesTests`: Complete test suite for the schedule departures endpoint + - `test_returns_404_when_stop_missing`: Validates 404 error handling for non-existent stops + - `test_returns_departures_with_expected_shape`: Validates response structure and data format + +**What's Tested:** +- Endpoint returns proper HTTP status codes +- Response JSON structure matches API specification +- Required fields are present in response +- Time fields are formatted correctly (HH:MM:SS) +- Stop validation and error handling +- Integration with PostgreSQL via DAL +- Data enrichment (route names, trip information) + +## Running Tests + +### Run all API tests +```bash +docker compose exec web uv run python manage.py test api +``` + +### Run specific test file +```bash +docker compose exec web uv run python manage.py test api.tests.test_schedule_departures +``` + +### Run specific test class +```bash +docker compose exec web uv run python manage.py test api.tests.test_schedule_departures.ScheduleDeparturesTests +``` + +### Run specific test method +```bash +docker compose exec web uv run python manage.py test api.tests.test_schedule_departures.ScheduleDeparturesTests.test_returns_404_when_stop_missing +``` + +## Test Data + +Tests use Django's test database which is created and destroyed automatically. Each test case sets up its own minimal test data using: +- `Feed.objects.create()` for GTFS feeds +- `Stop.objects.create()` for stop locations +- `StopTime.objects.bulk_create()` for scheduled stop times + +## Test Dependencies + +- `rest_framework.test.APITestCase`: Base class for API testing +- `django.test.TestCase`: Django test framework +- `gtfs.models`: GTFS data models (Feed, Stop, StopTime) +- PostgreSQL test database with PostGIS extension + +## Coverage + +Current test coverage focuses on: +- ✅ Schedule departures endpoint functionality +- ✅ Error handling and validation +- ✅ Response format verification +- ✅ DAL integration (PostgreSQL) + +## Adding New Tests + +When adding new API endpoint tests: +1. Create a new test file named `test_.py` +2. Import necessary test base classes and models +3. Add class-level and method-level docstrings +4. Set up minimal test data in `setUp()` method +5. Test both success and error cases +6. Validate response structure and data types +7. Update this README with the new test file information diff --git a/api/tests/__init__.py b/api/tests/__init__.py new file mode 100644 index 0000000..2245dc8 --- /dev/null +++ b/api/tests/__init__.py @@ -0,0 +1 @@ +# makes tests a package for unittest discovery \ No newline at end of file diff --git a/api/tests/data/fuseki_sample.ttl b/api/tests/data/fuseki_sample.ttl new file mode 100644 index 0000000..471810d --- /dev/null +++ b/api/tests/data/fuseki_sample.ttl @@ -0,0 +1,17 @@ +@prefix ex: . + +# Minimal sample data for Fuseki integration tests +# One departure at stop S1 for feed TEST + +[] a ex:Departure ; + ex:feed_id "TEST" ; + ex:stop_id "S1" ; + ex:trip_id "T1" ; + ex:route_id "R1" ; + ex:route_short_name "R1" ; + ex:route_long_name "Ruta 1" ; + ex:headsign "Terminal" ; + ex:direction_id "0" ; + ex:service_date "2099-01-01" ; + ex:arrival_time "08:05:00" ; + ex:departure_time "08:06:00" . diff --git a/api/tests/test_schedule_departures.py b/api/tests/test_schedule_departures.py new file mode 100644 index 0000000..b79056c --- /dev/null +++ b/api/tests/test_schedule_departures.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +import re +from typing import List + +from django.urls import reverse +from django.test import TestCase +from rest_framework.test import APITestCase +from rest_framework import status + +from gtfs.models import Feed, Stop, StopTime + + +from django.contrib.gis.geos import Point +from datetime import time + + +class ScheduleDeparturesTests(APITestCase): + """Test suite for the /api/schedule/departures/ endpoint. + + This endpoint uses the Data Access Layer (DAL) to retrieve scheduled + departures from PostgreSQL with Redis caching. + """ + + def setUp(self): + """Set up minimal test data: feed, stop, and stop_time records.""" + # Minimal dataset for the endpoint + self.feed = Feed.objects.create( + feed_id="TEST", + is_current=True, + ) + self.stop = Stop.objects.create( + feed=self.feed, + stop_id="S1", + stop_name="Test Stop", + stop_point=Point(0.0, 0.0), + ) + # Create StopTime without triggering model save() logic that requires Trip + StopTime.objects.bulk_create( + [ + StopTime( + feed=self.feed, + trip_id="T1", + stop_id=self.stop.stop_id, + stop_sequence=1, + pickup_type=0, + drop_off_type=0, + arrival_time=time(8, 5, 0), + departure_time=time(8, 6, 0), + ) + ] + ) + + def test_returns_404_when_stop_missing(self): + """Verify endpoint returns 404 when querying a non-existent stop_id.""" + url = "/api/schedule/departures/?stop_id=THIS_DOES_NOT_EXIST&limit=1" + resp = self.client.get(url) + self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND) + self.assertIn("error", resp.json()) + + def test_returns_departures_with_expected_shape(self): + """Verify endpoint returns departures with expected JSON structure. + + Validates that all required fields are present in the response and + time fields are formatted correctly (HH:MM:SS). + """ + feed = Feed.objects.filter(is_current=True).first() or Feed.objects.first() + self.assertIsNotNone(feed, "Expected fixture to provide at least one feed") + + # Find a stop_id that actually has stoptimes + st = StopTime.objects.filter(feed=feed).order_by("departure_time").first() + self.assertIsNotNone(st, "Expected fixture to provide at least one StopTime") + stop_id = st.stop_id + + url = f"/api/schedule/departures/?stop_id={stop_id}&time=08:00:00&limit=1" + resp = self.client.get(url) + self.assertEqual(resp.status_code, status.HTTP_200_OK) + data = resp.json() + + # Top-level keys + for key in ["feed_id", "stop_id", "service_date", "from_time", "limit", "departures"]: + self.assertIn(key, data) + + self.assertIsInstance(data["departures"], list) + self.assertGreaterEqual(len(data["departures"]), 1) + + item = data["departures"][0] + for key in [ + "route_id", + "route_short_name", + "route_long_name", + "trip_id", + "stop_id", + "headsign", + "direction_id", + "arrival_time", + "departure_time", + ]: + self.assertIn(key, item) + + # Time fields formatted HH:MM:SS + time_pattern = re.compile(r"^\d{2}:\d{2}:\d{2}$") + if item["arrival_time"] is not None: + self.assertRegex(item["arrival_time"], time_pattern) + if item["departure_time"] is not None: + self.assertRegex(item["departure_time"], time_pattern) + + # from_time string formatted HH:MM:SS + self.assertRegex(data["from_time"], time_pattern) diff --git a/api/urls.py b/api/urls.py index 2bbf18c..2375e52 100644 --- a/api/urls.py +++ b/api/urls.py @@ -29,7 +29,8 @@ path("next-trips/", views.NextTripView.as_view(), name="next-trips"), path("next-stops/", views.NextStopView.as_view(), name="next-stops"), path("route-stops/", views.RouteStopView.as_view(), name="route-stops"), + path("schedule/departures/", views.ScheduleDeparturesView.as_view(), name="schedule-departures"), path("api-auth/", include("rest_framework.urls", namespace="rest_framework")), - path("docs/schema/", views.get_schema, name="schema"), + path("docs/schema/", SpectacularAPIView.as_view(), name="schema"), path("docs/", SpectacularRedocView.as_view(url_name="schema"), name="api_docs"), ] diff --git a/api/views.py b/api/views.py index 78e044f..b90abbe 100644 --- a/api/views.py +++ b/api/views.py @@ -22,6 +22,10 @@ from django.conf import settings from .serializers import * +from django.utils import timezone as dj_timezone +from storage.factory import get_schedule_repository +from gtfs.models import Feed, Stop +from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiTypes # from .serializers import InfoServiceSerializer, GTFSProviderSerializer, RouteSerializer, TripSerializer @@ -38,6 +42,102 @@ def get_filtered_queryset(self, allowed_query_params): return queryset.filter(**filter_args) +class ScheduleDeparturesView(APIView): + """Simple endpoint backed by the DAL to get next scheduled departures at a stop.""" + + @extend_schema( + parameters=[ + OpenApiParameter(name="stop_id", type=OpenApiTypes.STR, required=True, description="Stop identifier (must exist in Stop for the chosen feed)"), + OpenApiParameter(name="feed_id", type=OpenApiTypes.STR, required=False, description="Feed identifier (defaults to current feed)") , + OpenApiParameter(name="date", type=OpenApiTypes.DATE, required=False, description="Service date (YYYY-MM-DD, defaults to today)"), + OpenApiParameter(name="time", type=OpenApiTypes.STR, required=False, description="Start time (HH:MM or HH:MM:SS, defaults to now)"), + OpenApiParameter(name="limit", type=OpenApiTypes.INT, required=False, description="Number of results (default 10, max 100)"), + ], + responses={200: DalDeparturesResponseSerializer}, + description="Return next scheduled departures at a stop using the DAL (PostgreSQL + Redis cache).", + tags=["schedule"], + ) + def get(self, request): + stop_id = request.query_params.get("stop_id") + if not stop_id: + return Response({"error": "stop_id is required"}, status=status.HTTP_400_BAD_REQUEST) + + # Resolve feed_id + feed_id = request.query_params.get("feed_id") + if not feed_id: + try: + current_feed = Feed.objects.filter(is_current=True).latest("retrieved_at") + except Feed.DoesNotExist: + return Response( + {"error": "No GTFS feed configured as current (is_current=True). Load GTFS fixtures or import a feed and set one as current."}, + status=status.HTTP_404_NOT_FOUND, + ) + feed_id = current_feed.feed_id + else: + if not Feed.objects.filter(feed_id=feed_id).exists(): + return Response( + {"error": f"feed_id '{feed_id}' not found"}, status=status.HTTP_404_NOT_FOUND + ) + + # Validate stop exists for the chosen feed + if not Stop.objects.filter(feed__feed_id=feed_id, stop_id=stop_id).exists(): + return Response( + {"error": f"stop_id '{stop_id}' not found for feed '{feed_id}'"}, + status=status.HTTP_404_NOT_FOUND, + ) + + # Parse date/time with TZ defaults + try: + date_str = request.query_params.get("date") + if date_str: + service_date = datetime.strptime(date_str, "%Y-%m-%d").date() + else: + service_date = dj_timezone.localdate() + except Exception: + return Response({"error": "Invalid date format. Use YYYY-MM-DD"}, status=status.HTTP_400_BAD_REQUEST) + + try: + time_str = request.query_params.get("time") + if time_str: + fmt = "%H:%M:%S" if len(time_str.split(":")) == 3 else "%H:%M" + from_time = datetime.strptime(time_str, fmt).time() + else: + from_time = dj_timezone.localtime().time() + except Exception: + return Response({"error": "Invalid time format. Use HH:MM or HH:MM:SS"}, status=status.HTTP_400_BAD_REQUEST) + + try: + limit = int(request.query_params.get("limit", 10)) + if limit <= 0 or limit > 100: + return Response({"error": "limit must be between 1 and 100"}, status=status.HTTP_400_BAD_REQUEST) + except ValueError: + return Response({"error": "limit must be an integer"}, status=status.HTTP_400_BAD_REQUEST) + + # Build response using DAL + repo = get_schedule_repository(use_cache=True) + departures = repo.get_next_departures( + feed_id=feed_id, + stop_id=stop_id, + service_date=service_date, + from_time=from_time, + limit=limit, + ) + + # Format from_time as HH:MM:SS for a cleaner API response + from_time_str = from_time.strftime("%H:%M:%S") + + payload = { + "feed_id": feed_id, + "stop_id": stop_id, + "service_date": service_date, + "from_time": from_time_str, + "limit": limit, + "departures": departures, + } + serializer = DalDeparturesResponseSerializer(payload) + return Response(serializer.data) + + class GTFSProviderViewSet(viewsets.ModelViewSet): """ Proveedores de datos GTFS. diff --git a/datahub/settings.py b/datahub/settings.py index efba0f7..dedd355 100644 --- a/datahub/settings.py +++ b/datahub/settings.py @@ -131,6 +131,9 @@ REDIS_HOST = config("REDIS_HOST") REDIS_PORT = config("REDIS_PORT") +# DAL caching configuration +SCHEDULE_CACHE_TTL_SECONDS = config("SCHEDULE_CACHE_TTL_SECONDS", cast=int, default=60) + # Celery settings CELERY_BROKER_URL = f"redis://{REDIS_HOST}:{REDIS_PORT}/0" diff --git a/docs/architecture.md b/docs/architecture.md index 89dd9ac..57b3f21 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -48,6 +48,28 @@ Nota: las pantallas por ahora asumimos que son Raspberry Pi en [modo kiosko](htt ### Django app: `gtfs` +## Estrategia de almacenamiento y capa de acceso a datos (DAL) + +- PostgreSQL/PostGIS es la fuente de verdad para GTFS Schedule. +- Redis se utiliza como caché de alto desempeño (lecturas read-through/write-through donde aplique) y para mensajería (Channels, Celery). + +Se define una capa de acceso a datos (DAL) con interfaces claras: +- ScheduleRepository: obtiene salidas programadas (next departures) por parada. +- CacheProvider: wrapper de caché (implementación en Redis). + +Implementaciones actuales: +- PostgresScheduleRepository (Django ORM) +- CachedScheduleRepository (envoltorio con Redis) + +Endpoint nuevo (ejemplo): +- GET /api/schedule/departures/?stop_id=STOP_123&limit=5 + +### Capa de caché (Redis) +- Claves (key): + - schedule:next_departures:feed={FEED_ID}:stop={STOP_ID}:date={YYYY-MM-DD}:time={HHMMSS}:limit={N}:v1 +- TTL por defecto: 60 segundos +- Configuración por entorno: SCHEDULE_CACHE_TTL_SECONDS (entero) + > Páginas de administación de información GTFS Schedule y GTFS Realtime. - `/gtfs/`: diff --git a/storage/__init__.py b/storage/__init__.py new file mode 100644 index 0000000..c137c20 --- /dev/null +++ b/storage/__init__.py @@ -0,0 +1 @@ +# Storage/Data Access Layer package diff --git a/storage/cached_schedule.py b/storage/cached_schedule.py new file mode 100644 index 0000000..d999f1d --- /dev/null +++ b/storage/cached_schedule.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import json +from datetime import date, time +from typing import List + +from .interfaces import CacheProvider, Departure, ScheduleRepository + + +class CachedScheduleRepository(ScheduleRepository): + """Cache wrapper for any ScheduleRepository. + + Keys are namespaced to avoid collisions and include parameters for safety. + """ + + def __init__(self, repo: ScheduleRepository, cache: CacheProvider, *, ttl_seconds: int = 60): + self._repo = repo + self._cache = cache + self._ttl = ttl_seconds + + @staticmethod + def _key(*, feed_id: str, stop_id: str, service_date: date, from_time: time, limit: int) -> str: + return ( + f"schedule:next_departures:feed={feed_id}:stop={stop_id}:" + f"date={service_date.isoformat()}:time={from_time.strftime('%H%M%S')}:limit={limit}:v1" + ) + + def get_next_departures( + self, + *, + feed_id: str, + stop_id: str, + service_date: date, + from_time: time, + limit: int = 10, + ) -> List[Departure]: + key = self._key( + feed_id=feed_id, + stop_id=stop_id, + service_date=service_date, + from_time=from_time, + limit=limit, + ) + cached = self._cache.get(key) + if cached: + try: + return json.loads(cached) + except Exception: + # Fallback to fetching from source if cache content is invalid + pass + + result = self._repo.get_next_departures( + feed_id=feed_id, + stop_id=stop_id, + service_date=service_date, + from_time=from_time, + limit=limit, + ) + try: + self._cache.set(key, json.dumps(result), self._ttl) + except Exception: + pass + return result diff --git a/storage/factory.py b/storage/factory.py new file mode 100644 index 0000000..5997a9b --- /dev/null +++ b/storage/factory.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from datetime import date, time +from typing import List + +from django.conf import settings + +from .cached_schedule import CachedScheduleRepository +from .interfaces import ScheduleRepository +from .postgres_schedule import PostgresScheduleRepository +from .redis_cache import RedisCacheProvider + + +def get_schedule_repository(*, use_cache: bool = True) -> ScheduleRepository: + """Factory to obtain a ScheduleRepository according to settings. + + - Uses PostgreSQL (Django ORM) by default. + - Optionally wraps with Redis cache for improved performance. + """ + base_repo: ScheduleRepository = PostgresScheduleRepository() + + if use_cache: + cache = RedisCacheProvider() + ttl = getattr(settings, "SCHEDULE_CACHE_TTL_SECONDS", 60) + return CachedScheduleRepository(base_repo, cache, ttl_seconds=int(ttl)) + return base_repo diff --git a/storage/interfaces.py b/storage/interfaces.py new file mode 100644 index 0000000..f950659 --- /dev/null +++ b/storage/interfaces.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from typing import List, Optional, Protocol, TypedDict, runtime_checkable +from datetime import date, time + + +class Departure(TypedDict): + route_id: str + route_short_name: Optional[str] + route_long_name: Optional[str] + trip_id: str + stop_id: str + headsign: Optional[str] + direction_id: Optional[int] + arrival_time: Optional[str] # HH:MM:SS + departure_time: Optional[str] # HH:MM:SS + + +@runtime_checkable +class ScheduleRepository(Protocol): + """Abstract interface for reading scheduled service information.""" + + def get_next_departures( + self, + *, + feed_id: str, + stop_id: str, + service_date: date, + from_time: time, + limit: int = 10, + ) -> List[Departure]: + """Return the next scheduled departures at a stop. + + Notes: + - Implementations may approximate service availability and ignore + service_date exceptions initially; exact filtering can be added later. + """ + ... + + +@runtime_checkable +class CacheProvider(Protocol): + def get(self, key: str) -> Optional[str]: + ... + + def set(self, key: str, value: str, ttl_seconds: int) -> None: + ... diff --git a/storage/postgres_schedule.py b/storage/postgres_schedule.py new file mode 100644 index 0000000..5f1d373 --- /dev/null +++ b/storage/postgres_schedule.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from datetime import date, time +from typing import List + +from django.db.models import F + +from gtfs.models import StopTime, Trip, Route +from .interfaces import Departure, ScheduleRepository + + +class PostgresScheduleRepository(ScheduleRepository): + """PostgreSQL-backed schedule repository using Django ORM. + + NOTE: This initial implementation does not yet filter by service_date + (Calendar/CalendarDate). That logic can be layered in a future iteration. + """ + + def get_next_departures( + self, + *, + feed_id: str, + stop_id: str, + service_date: date, + from_time: time, + limit: int = 10, + ) -> List[Departure]: + qs = ( + StopTime.objects.select_related("_trip") + .filter( + feed__feed_id=feed_id, + stop_id=stop_id, + departure_time__isnull=False, + departure_time__gte=from_time, + ) + .order_by("departure_time") + ) + qs = qs[:limit] + + results: List[Departure] = [] + for st in qs: + # Ensure we can resolve the Trip, even if _trip is not populated + trip: Trip | None = getattr(st, "_trip", None) # type: ignore + if trip is None: + trip = Trip.objects.filter(feed=st.feed, trip_id=st.trip_id).first() + + route_id_val = trip.route_id if trip else "" + route_short_name = None + route_long_name = None + if route_id_val: + route = Route.objects.filter(feed=st.feed, route_id=route_id_val).only( + "route_short_name", "route_long_name" + ).first() + if route is not None: + route_short_name = route.route_short_name + route_long_name = route.route_long_name + + results.append( + { + "route_id": route_id_val, + "route_short_name": route_short_name, + "route_long_name": route_long_name, + "trip_id": st.trip_id, + "stop_id": st.stop_id, + "headsign": getattr(trip, "trip_headsign", None) if trip else None, + "direction_id": getattr(trip, "direction_id", None) if trip else None, + "arrival_time": st.arrival_time.strftime("%H:%M:%S") if st.arrival_time else None, + "departure_time": st.departure_time.strftime("%H:%M:%S") if st.departure_time else None, + } + ) + return results diff --git a/storage/redis_cache.py b/storage/redis_cache.py new file mode 100644 index 0000000..7c1c367 --- /dev/null +++ b/storage/redis_cache.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import json +from typing import Optional + +from django.conf import settings +import redis + +from .interfaces import CacheProvider + + +class RedisCacheProvider(CacheProvider): + """Simple Redis-backed cache for DAL results. + + Stores JSON-encoded strings under namespaced keys. + """ + + def __init__(self, *, host: Optional[str] = None, port: Optional[int] = None): + self._host = host or settings.REDIS_HOST + self._port = int(port or settings.REDIS_PORT) + # decode_responses=True to work with str values + self._client = redis.Redis(host=self._host, port=self._port, decode_responses=True) + + def get(self, key: str) -> Optional[str]: + try: + return self._client.get(key) + except Exception: + # Cache failures should not break the application + return None + + def set(self, key: str, value: str, ttl_seconds: int) -> None: + try: + self._client.setex(key, ttl_seconds, value) + except Exception: + # Best-effort cache set + pass