diff --git a/docs/graphql.md b/docs/graphql.md new file mode 100644 index 0000000..61e1426 --- /dev/null +++ b/docs/graphql.md @@ -0,0 +1,776 @@ +# Documentación del Proyecto + +## Endpoint + +La API de GraphQL está disponible en: + +``` +/graphql/ +``` +### Método de Autenticación + +1. **Session Authentication** - Para acceso desde navegador web +2. **Token Authentication** - Para clientes API (vía DRF Token Auth) + +### Ejemplo con Autenticación por Token + +```bash +curl -X POST http://localhost:8000/graphql/ \ + -H "Authorization: Token YOUR_TOKEN_HERE" \ + -H "Content-Type: application/json" \ + -d '{"query": "{ allFeeds { id name } }"}' +``` + +## Permisos + +- **Queries**: Requieren autenticación (cualquier usuario autenticado) +- **Mutations**: Requieren permisos de staff (`is_staff=True`) + +## Ejecutar el Endpoint de GraphQL + +### Desarrollo + +1. Iniciar el servidor de desarrollo de Django: + +```bash +python manage.py runserver +``` + +2. Ingrese a `http://localhost:8000/graphql/` en su navegador para acceder a la interfaz GraphiQL. + + +## Descripción General del Esquema + +### Tipos + +- **FeedType**: Representa un feed GTFS +- **AgencyType**: Agencias de transporte +- **RouteType**: Rutas de transporte +- **StopType**: Paradas/estaciones de transporte +- **TripType**: Viajes individuales +- **StopTimeType**: Horarios de paradas para viajes +- **CalendarType**: Calendarios de servicio + +### Paginación + +La mayoría de las consultas de listas devuelven resultados paginados con la siguiente estructura: + +```graphql +type Connection { + edges: [Type!]! + pageInfo: PageInfo! +} + +type PageInfo { + hasNextPage: Boolean! + hasPreviousPage: Boolean! + startCursor: String + endCursor: String + totalCount: Int! +} +``` + +## Ejemplos de Consultas + +### 1. Obtener Todos los Feeds + +```graphql +query { + allFeeds { + id + name + url + createdAt + updatedAt + } +} +``` + +### 2. Obtener un Feed por ID + +```graphql +query { + feed(id: 1) { + id + name + url + } +} +``` + +### 3. Obtener Todas las Agencias (con Paginación) + +```graphql +query { + allAgencies(offset: 0, limit: 20) { + edges { + id + agencyName + agencyUrl + agencyTimezone + agencyLang + feed { + name + } + } + pageInfo { + totalCount + hasNextPage + hasPreviousPage + } + } +} +``` + +### 4. Filtrar Agencias por Nombre + +```graphql +query { + allAgencies(nameContains: "Metro") { + edges { + agencyName + agencyUrl + } + pageInfo { + totalCount + } + } +} +``` + +### 5. Obtener Rutas con Filtros + +```graphql +query { + allRoutes(routeType: 3, shortNameContains: "1") { + edges { + routeShortName + routeLongName + routeType + routeColor + agency { + agencyName + } + } + pageInfo { + totalCount + } + } +} +``` + +**Route Types:** +- `0`: Odontología +- `1`: Educación +- `2`: Ingeniería +- `3`: Ciencias del Movimiento Humano +- `4`: Ciencias Sociales +- `5`: LANAMME +- `6`: Artes +- `7`: Microbiología + +### 6. Obtener Paradas Cercanas a una Ubicación + +```graphql +query { + allStops(nameContains: "Station", offset: 0, limit: 10) { + edges { + stopName + stopLat + stopLon + locationType + wheelchairBoarding + } + pageInfo { + totalCount + } + } +} +``` + +### 7. Obtener Viajes por Ruta + +```graphql +query { + tripsByRoute(routeId: 1, directionId: 0) { + edges { + tripId + tripHeadsign + directionId + serviceId + route { + routeShortName + routeLongName + } + } + pageInfo { + totalCount + } + } +} +``` + +### 8. Obtener Horarios de Paradas para un Viaje + +```graphql +query { + stopTimesByTrip(tripId: 1) { + edges { + stopSequence + arrivalTimeStr + departureTimeStr + stop { + stopName + stopLat + stopLon + } + } + pageInfo { + totalCount + } + } +} +``` + +### 9. Consulta Compleja Anidada + +```graphql +query { + trip(id: 1) { + tripId + tripHeadsign + directionId + route { + routeShortName + routeLongName + agency { + agencyName + agencyTimezone + } + } + } +} +``` + +## Ejemplos de Mutaciones + +### 1. Crear una Nueva Agencia + +```graphql +mutation { + createAgency( + input: { + feedId: 1 + agencyId: "NYC_MTA" + agencyName: "New York MTA" + agencyUrl: "https://www.ticabus.info" + agencyTimezone: "America/Costa Rica" + agencyLang: "en" + agencyPhone: "511" + agencyEmail: "info@ticabus.info" + } + ) { + success + errors + agency { + id + agencyName + agencyUrl + agencyTimezone + } + } +} +``` + +### 2. Crear Agencia con Campos Mínimos + +```graphql +mutation { + createAgency( + input: { + feedId: 1 + agencyId: "AGENCY_001" + agencyName: "Transit Agency" + agencyUrl: "https://transit.example.com" + agencyTimezone: "UTC" + } + ) { + success + errors + agency { + id + agencyName + } + } +} +``` + +### 3. Manejar Errores de Validación + +```graphql +mutation { + createAgency( + input: { + feedId: 999 # Feed inexistente + agencyId: "TEST" + agencyName: "" # Nombre vacío (inválido) + agencyUrl: "https://test.com" + agencyTimezone: "America/CostaRica" + } + ) { + success + errors + agency { + agencyName + } + } +} +``` + +**Respuesta:** +```json +{ + "data": { + "createAgency": { + "success": false, + "errors": [ + "Agency name is required and cannot be empty", + "Feed with id 999 does not exist" + ], + "agency": null + } + } +} +``` + +## Usar Variables + +Para consultas dinámicas, usar variables de GraphQL: + +```graphql +query GetAgency($id: Int!) { + agency(id: $id) { + agencyName + agencyUrl + } +} +``` + +**Variables:** +```json +{ + "id": 1 +} +``` + +## Patrones de Paginación + +### Paginación Basada en Offset + +La API usa paginación basada en offset (no basada en cursor): + +```graphql +# Primera página (elementos 0-19) +query { + allAgencies(offset: 0, limit: 20) { + edges { ... } + pageInfo { + totalCount + hasNextPage + } + } +} + +# Segunda página (elementos 20-39) +query { + allAgencies(offset: 20, limit: 20) { + edges { ... } + pageInfo { + totalCount + hasNextPage + } + } +} +``` + +**Límites Predeterminados:** +- La mayoría de consultas: `limit=20` (máx: `100`) +- Horarios de paradas: `limit=100` (máx: `200`) + +## Manejo de Errores + +### Errores de Autenticación + +```json +{ + "errors": [ + { + "message": "User is not authenticated", + "path": ["allAgencies"] + } + ] +} +``` + +### Errores de Permisos + +```json +{ + "errors": [ + { + "message": "User must be staff to perform this operation", + "path": ["createAgency"] + } + ] +} +``` + +### Errores de Validación + +Las mutaciones devuelven mensajes de error estructurados: + +```json +{ + "data": { + "createAgency": { + "success": false, + "errors": [ + "Agency name is required and cannot be empty", + "Agency URL is required and cannot be empty" + ], + "agency": null + } + } +} +``` + +## Extender el Esquema + +### Agregar una Nueva Consulta + +1. Definir la consulta en `graphql_api/queries.py`: + +```python +@strawberry.field(permission_classes=[IsAuthenticated]) +def my_custom_query(self, info: Info, param: str) -> List[MyType]: + """Descripción de consulta personalizada""" + return MyModel.objects.filter(field=param) +``` + +2. La consulta estará disponible automáticamente: + +```graphql +query { + myCustomQuery(param: "value") { + field1 + field2 + } +} +``` + +### Agregar una Nueva Mutación + +1. Definir el tipo de entrada en `graphql_api/types.py`: + +```python +@strawberry.input +class CreateMyEntityInput: + name: str + description: Optional[str] = None + +@strawberry.type +class CreateMyEntityPayload: + entity: Optional[MyEntityType] + errors: list[str] + success: bool +``` + +2. Definir la mutación en `graphql_api/mutations.py`: + +```python +@strawberry.mutation(permission_classes=[IsStaff]) +def create_my_entity( + self, info: Info, input: CreateMyEntityInput +) -> CreateMyEntityPayload: + """Descripción de la mutación""" + # Implementación + pass +``` + +### Agregar un Nuevo Tipo + +1. Definir el tipo en `graphql_api/types.py`: + +```python +@strawberry.django.type +class MyEntityType: + """Tipo GraphQL para el modelo MyEntity""" + + id: auto + name: str + description: Optional[str] + created_at: datetime.datetime +``` + +2. Usarlo en consultas y mutaciones. + +## Pruebas + +Ejecutar las pruebas de GraphQL: + +```bash +# Todas las pruebas de GraphQL +pytest tests/test_graphql/ + +# Archivo de prueba específico +pytest tests/test_graphql/test_queries.py + +# Prueba específica +pytest tests/test_graphql/test_queries.py::TestQueries::test_all_agencies_query + +# Con cobertura +pytest tests/test_graphql/ --cov=graphql_api +``` + +### Estructura de Pruebas + +``` +tests/test_graphql/ +├── __init__.py +├── conftest.py # Fixtures para las pruebas +├── test_schema.py # Pruebas de estructura del esquema +├── test_queries.py # Pruebas de funcionalidad de consultas +├── test_mutations.py # Pruebas de mutaciones +└── test_permissions.py # Pruebas de control de acceso +``` + +## Mejores Prácticas + +### 1. Usar Paginación + +Siempre usar paginación para consultas de listas para evitar problemas de rendimiento: + +```graphql +# ✅ Bien +query { + allStops(offset: 0, limit: 50) { + edges { ... } + pageInfo { totalCount } + } +} + +# ❌ Evitar (aún paginará con valores predeterminados, pero menos explícito) +query { + allStops { + edges { ... } + } +} +``` + +### 2. Solicitar Solo los Campos Necesarios + +GraphQL permite solicitar exactamente lo que necesitas: + +```graphql +# ✅ Bien - Solo solicitar campos necesarios +query { + allAgencies { + edges { + agencyName + agencyUrl + } + } +} + +# ❌ Menos eficiente - Solicitar todos los campos +query { + allAgencies { + edges { + id + agencyId + agencyName + agencyUrl + agencyTimezone + agencyLang + agencyPhone + agencyFareUrl + agencyEmail + feed { ... } + } + } +} +``` + +### 3. Usar Variables para Consultas Dinámicas + +```graphql +# ✅ Bien - Usar variables +query GetRoute($id: Int!) { + route(id: $id) { + routeShortName + } +} + +# ❌ Evitar - Interpolación de cadenas (riesgo de seguridad) +query { + route(id: ${userInput}) { + routeShortName + } +} +``` + +### 4. Manejar Errores Correctamente + +Siempre verificar tanto errores de GraphQL como errores de mutaciones: + +```javascript +const result = await fetch('/graphql/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Token ${token}` + }, + body: JSON.stringify({ query }) +}); + +const data = await result.json(); + +// Verificar errores de GraphQL +if (data.errors) { + console.error('Errores de GraphQL:', data.errors); +} + +// Verificar errores de mutación +if (data.data?.createAgency?.errors?.length > 0) { + console.error('Mutation errors:', data.data.createAgency.errors); +} +``` + +## Ejemplos de Integración + +### Cliente Python + +```python +import requests + +GRAPHQL_URL = "http://localhost:8000/graphql/" +TOKEN = "your-auth-token" + +def query_agencies(): + query = """ + query { + allAgencies(limit: 10) { + edges { + agencyName + agencyUrl + } + pageInfo { + totalCount + } + } + } + """ + + response = requests.post( + GRAPHQL_URL, + json={"query": query}, + headers={"Authorization": f"Token {TOKEN}"} + ) + + return response.json() + +result = query_agencies() +print(result["data"]["allAgencies"]) +``` + +### Cliente JavaScript + +```javascript +async function createAgency(feedId, agencyData) { + const mutation = ` + mutation CreateAgency($input: CreateAgencyInput!) { + createAgency(input: $input) { + success + errors + agency { + id + agencyName + } + } + } + `; + + const variables = { + input: { + feedId: feedId, + ...agencyData + } + }; + + const response = await fetch('/graphql/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Token ${yourToken}` + }, + body: JSON.stringify({ query: mutation, variables }) + }); + + const result = await response.json(); + + if (result.data.createAgency.success) { + console.log('Agencia creada:', result.data.createAgency.agency); + } else { + console.error('Errores:', result.data.createAgency.errors); + } +} +``` + +## Solución de Problemas + +### Problema: "User is not authenticated" + +**Solución:** Asegurarse de pasar las credenciales de autenticación: + +```bash +# Con token +curl -H "Authorization: Token YOUR_TOKEN" ... + +# Con sesión (navegador) +# Asegurarse de iniciar sesión primero +``` + +### Problema: "User must be staff to perform this operation" + +**Solución:** Las mutaciones requieren permisos de staff. Actualizar usuario: + +```python +user.is_staff = True +user.save() +``` + +### Problema: Consultas Lentas + +**Solución:** +1. Usar paginación con tamaños de página más pequeños +2. Solicitar solo los campos necesarios +3. Agregar índices de base de datos en campos filtrados frecuentemente + +### Problema: No se Puede Encontrar Tipo/Campo + +**Solución:** Verificar el explorador de documentación de la interfaz GraphiQL para ver los tipos y campos disponibles. + +## Recursos Adicionales + +- [Documentación de Strawberry GraphQL](https://strawberry.rocks/docs) +- [Documentación Oficial de GraphQL](https://graphql.org/) +- [Especificación GTFS](https://gtfs.org/schedule/reference/) +- [Autenticación de Django](https://docs.djangoproject.com/en/stable/topics/auth/) + +## Soporte + +Para problemas o preguntas: +1. Verificar la documentación de la interfaz GraphiQL +2. Revisar los archivos de prueba en `tests/test_graphql/` +3. Consultar la documentación de Strawberry GraphQL +4. Abrir un issue en el repositorio del proyecto diff --git a/graphql_api/apps.py b/graphql_api/apps.py new file mode 100644 index 0000000..8d5bf8d --- /dev/null +++ b/graphql_api/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class GraphqlApiConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "graphql_api" diff --git a/graphql_api/mutations.py b/graphql_api/mutations.py new file mode 100644 index 0000000..7e367a6 --- /dev/null +++ b/graphql_api/mutations.py @@ -0,0 +1,65 @@ +import strawberry +from strawberry.types import Info +from typing import List +from django.core.exceptions import ValidationError +from django.db import IntegrityError +from gtfs.models import Agency, Feed +from .types import CreateAgencyInput, CreateAgencyPayload, agency_to_type +from .permissions import IsStaff + + +@strawberry.type +class Mutation: + @strawberry.mutation(permission_classes=[IsStaff]) + def create_agency( + self, info: Info, input: CreateAgencyInput + ) -> CreateAgencyPayload: + ### aca crearemos nuevas agencias + errors: List[str] = [] + + # Requerimientos + if not input.agency_id or not input.agency_id.strip(): + errors.append("No introdujo el ID de la agencia") + + if not input.agency_url or not input.agency_url.strip(): + errors.append("No introdujo la URL de la agencia") + + # Check if feed exists + try: + feed = Feed.objects.get(id=input.feed_id) + except Feed.DoesNotExist: + errors.append(f"Este feed {input.feed_id} no existe") + return CreateAgencyPayload(agency=None, errors=errors, success=False) + + if errors: + return CreateAgencyPayload(agency=None, errors=errors, success=False) + + try: + # Create the agency + agency = Agency.objects.create( + feed=feed, + agency_id=input.agency_id, + agency_name=input.agency_name.strip(), + agency_url=input.agency_url.strip(), + agency_timezone=input.agency_timezone.strip(), + agency_lang=input.agency_lang.strip() if input.agency_lang else "", + agency_phone=input.agency_phone.strip() if input.agency_phone else "", + agency_fare_url=( + input.agency_fare_url.strip() if input.agency_fare_url else "" + ), + agency_email=input.agency_email.strip() if input.agency_email else "", + ) + + return CreateAgencyPayload(agency=agency_to_type(agency), errors=[], success=True) + + except IntegrityError as e: + errors.append(f"Esta agencia ya Existe: {str(e)}") + return CreateAgencyPayload(agency=None, errors=errors, success=False) + + except ValidationError as e: + errors.append(f"Error: {str(e)}") + return CreateAgencyPayload(agency=None, errors=errors, success=False) + + except Exception as e: + errors.append(f"No se pudo crear agencia: {str(e)}") + return CreateAgencyPayload(agency=None, errors=errors, success=False) diff --git a/graphql_api/permissions.py b/graphql_api/permissions.py new file mode 100644 index 0000000..c39b3bf --- /dev/null +++ b/graphql_api/permissions.py @@ -0,0 +1,46 @@ +from typing import Any +from strawberry.permission import BasePermission +from strawberry.types import Info +import strawberry + + +class IsAuthenticated(BasePermission): + """Permission class to check if user is authenticated""" + + message = "User is not authenticated" + + def has_permission(self, source: Any, info: Info, **kwargs) -> bool: + request = info.context.request + return request.user.is_authenticated + + +class IsStaff(BasePermission): + """Permission class to check if user is staff""" + + message = "User must be staff to perform this operation" + + def has_permission(self, source: Any, info: Info, **kwargs) -> bool: + request = info.context.request + return request.user.is_authenticated and request.user.is_staff + + +class HasModelPermission(BasePermission): + """Permission class to check if user has specific model permission""" + + def __init__(self, app_label: str, model_name: str, permission: str): + self.permission_string = f"{app_label}.{permission}_{model_name}" + self.message = f"User does not have permission: {self.permission_string}" + + def has_permission(self, source: Any, info: Info, **kwargs) -> bool: + request = info.context.request + return request.user.is_authenticated and request.user.has_perm( + self.permission_string + ) + + +@strawberry.type +class PermissionError: + """Error type for permission denied""" + + message: str + code: str = "PERMISSION_DENIED" diff --git a/graphql_api/queries.py b/graphql_api/queries.py new file mode 100644 index 0000000..c3c4285 --- /dev/null +++ b/graphql_api/queries.py @@ -0,0 +1,264 @@ +import strawberry +from strawberry.types import Info +from typing import Optional, List +from django.core.paginator import Paginator +from gtfs.models import Agency, Route, Stop, Trip, StopTime, Feed +from .types import ( + AgencyType, + RouteType, + StopType, + TripType, + StopTimeType, + FeedType, + AgencyConnection, + RouteConnection, + StopConnection, + TripConnection, + StopTimeConnection, + PageInfo, + agency_to_type, + feed_to_type, + route_to_type, + stop_to_type, + trip_to_type, + stop_time_to_type, +) +from .permissions import IsAuthenticated +import base64 + + +def create_page_info(paginator, page_obj, offset: int, limit: int) -> PageInfo: + """Helper function to create PageInfo object""" + total_count = paginator.count + has_next = page_obj.has_next() + has_previous = page_obj.has_previous() + + start_cursor = ( + base64.b64encode(f"{offset}".encode()).decode() if offset >= 0 else None + ) + end_cursor = ( + base64.b64encode(f"{offset + limit}".encode()).decode() + if offset + limit < total_count + else None + ) + + return PageInfo( + has_next_page=has_next, + has_previous_page=has_previous, + start_cursor=start_cursor, + end_cursor=end_cursor, + total_count=total_count, + ) + + +@strawberry.type +class Query: + """Root Query type for GraphQL API""" + + @strawberry.field(permission_classes=[IsAuthenticated]) + def all_feeds(self, info: Info) -> List[FeedType]: + """Get all feeds""" + return [feed_to_type(f) for f in Feed.objects.all()] + + @strawberry.field(permission_classes=[IsAuthenticated]) + def feed(self, info: Info, id: int) -> Optional[FeedType]: + """Get a single feed by ID""" + try: + return feed_to_type(Feed.objects.get(id=id)) + except Feed.DoesNotExist: + return None + + @strawberry.field(permission_classes=[IsAuthenticated]) + def all_agencies( + self, + info: Info, + offset: int = 0, + limit: int = 20, + feed_id: Optional[int] = None, + name_contains: Optional[str] = None, + timezone: Optional[str] = None, + ) -> AgencyConnection: + """Get all agencies with pagination and filtering""" + limit = min(limit, 100) + queryset = Agency.objects.select_related("feed").all() + + if feed_id is not None: + queryset = queryset.filter(feed_id=feed_id) + if name_contains: + queryset = queryset.filter(agency_name__icontains=name_contains) + if timezone: + queryset = queryset.filter(agency_timezone=timezone) + + queryset = queryset.order_by("agency_name") + paginator = Paginator(queryset, limit) + page_number = (offset // limit) + 1 + page_obj = paginator.get_page(page_number) + page_info = create_page_info(paginator, page_obj, offset, limit) + + return AgencyConnection( + edges=[agency_to_type(a) for a in page_obj], + page_info=page_info + ) + + @strawberry.field(permission_classes=[IsAuthenticated]) + def agency(self, info: Info, id: int) -> Optional[AgencyType]: + """Get a single agency by ID""" + try: + return agency_to_type(Agency.objects.select_related("feed").get(id=id)) + except Agency.DoesNotExist: + return None + + @strawberry.field(permission_classes=[IsAuthenticated]) + def all_routes( + self, + info: Info, + offset: int = 0, + limit: int = 20, + feed_id: Optional[int] = None, + agency_id: Optional[int] = None, + route_type: Optional[int] = None, + short_name_contains: Optional[str] = None, + ) -> RouteConnection: + """Get all routes with pagination and filtering""" + limit = min(limit, 100) + queryset = Route.objects.select_related("feed", "agency").all() + + if feed_id is not None: + queryset = queryset.filter(feed_id=feed_id) + if agency_id is not None: + queryset = queryset.filter(agency_id=agency_id) + if route_type is not None: + queryset = queryset.filter(route_type=route_type) + if short_name_contains: + queryset = queryset.filter(route_short_name__icontains=short_name_contains) + + queryset = queryset.order_by("route_short_name") + paginator = Paginator(queryset, limit) + page_number = (offset // limit) + 1 + page_obj = paginator.get_page(page_number) + page_info = create_page_info(paginator, page_obj, offset, limit) + + return RouteConnection( + edges=[route_to_type(r) for r in page_obj], + page_info=page_info + ) + + @strawberry.field(permission_classes=[IsAuthenticated]) + def route(self, info: Info, id: int) -> Optional[RouteType]: + """Get a single route by ID""" + try: + return route_to_type(Route.objects.select_related("feed", "agency").get(id=id)) + except Route.DoesNotExist: + return None + + @strawberry.field(permission_classes=[IsAuthenticated]) + def all_stops( + self, + info: Info, + offset: int = 0, + limit: int = 20, + feed_id: Optional[int] = None, + name_contains: Optional[str] = None, + location_type: Optional[int] = None, + ) -> StopConnection: + """Get all stops with pagination and filtering""" + limit = min(limit, 100) + queryset = Stop.objects.select_related("feed").all() + + if feed_id is not None: + queryset = queryset.filter(feed_id=feed_id) + if name_contains: + queryset = queryset.filter(stop_name__icontains=name_contains) + if location_type is not None: + queryset = queryset.filter(location_type=location_type) + + queryset = queryset.order_by("stop_name") + paginator = Paginator(queryset, limit) + page_number = (offset // limit) + 1 + page_obj = paginator.get_page(page_number) + page_info = create_page_info(paginator, page_obj, offset, limit) + + return StopConnection( + edges=[stop_to_type(s) for s in page_obj], + page_info=page_info + ) + + @strawberry.field(permission_classes=[IsAuthenticated]) + def stop(self, info: Info, id: int) -> Optional[StopType]: + """Get a single stop by ID""" + try: + return stop_to_type(Stop.objects.select_related("feed").get(id=id)) + except Stop.DoesNotExist: + return None + + @strawberry.field(permission_classes=[IsAuthenticated]) + def trips_by_route( + self, + info: Info, + route_id: int, + offset: int = 0, + limit: int = 20, + direction_id: Optional[int] = None, + service_id: Optional[str] = None, + ) -> TripConnection: + """Get trips for a specific route with pagination and filtering""" + limit = min(limit, 100) + queryset = Trip.objects.select_related("feed", "route").filter(route_id=route_id) + + if direction_id is not None: + queryset = queryset.filter(direction_id=direction_id) + if service_id: + queryset = queryset.filter(service_id=service_id) + + queryset = queryset.order_by("trip_id") + paginator = Paginator(queryset, limit) + page_number = (offset // limit) + 1 + page_obj = paginator.get_page(page_number) + page_info = create_page_info(paginator, page_obj, offset, limit) + + return TripConnection( + edges=[trip_to_type(t) for t in page_obj], + page_info=page_info + ) + + @strawberry.field(permission_classes=[IsAuthenticated]) + def trip(self, info: Info, id: int) -> Optional[TripType]: + """Get a single trip by ID""" + try: + return trip_to_type(Trip.objects.select_related("feed", "route").get(id=id)) + except Trip.DoesNotExist: + return None + + @strawberry.field(permission_classes=[IsAuthenticated]) + def stop_times_by_trip( + self, + info: Info, + trip_id: int, + offset: int = 0, + limit: int = 100, + ) -> StopTimeConnection: + """Get stop times for a specific trip, ordered by stop sequence""" + limit = min(limit, 200) + queryset = ( + StopTime.objects.select_related("feed", "trip", "stop") + .filter(trip_id=trip_id) + .order_by("stop_sequence") + ) + + paginator = Paginator(queryset, limit) + page_number = (offset // limit) + 1 + page_obj = paginator.get_page(page_number) + page_info = create_page_info(paginator, page_obj, offset, limit) + + return StopTimeConnection( + edges=[stop_time_to_type(st) for st in page_obj], + page_info=page_info + ) + + @strawberry.field(permission_classes=[IsAuthenticated]) + def stop_time(self, info: Info, id: int) -> Optional[StopTimeType]: + """Get a single stop time by ID""" + try: + return stop_time_to_type(StopTime.objects.select_related("feed", "trip", "stop").get(id=id)) + except StopTime.DoesNotExist: + return None diff --git a/graphql_api/schema.py b/graphql_api/schema.py new file mode 100644 index 0000000..be40562 --- /dev/null +++ b/graphql_api/schema.py @@ -0,0 +1,9 @@ +import strawberry +from .queries import Query +from .mutations import Mutation + + +schema = strawberry.Schema( + query=Query, + mutation=Mutation, +) diff --git a/graphql_api/types.py b/graphql_api/types.py new file mode 100644 index 0000000..670da37 --- /dev/null +++ b/graphql_api/types.py @@ -0,0 +1,345 @@ +import strawberry +from typing import Optional, TYPE_CHECKING +import datetime +from decimal import Decimal + +if TYPE_CHECKING: + from gtfs.models import Feed, Agency, Route, Stop, Trip, StopTime + + +@strawberry.type +class FeedType: + """GraphQL type for Feed model""" + + id: int + name: str + url: Optional[str] + created_at: datetime.datetime + updated_at: datetime.datetime + + +@strawberry.type +class AgencyType: + """GraphQL type for Agency model""" + + id: int + agency_id: str + agency_name: str + agency_url: str + agency_timezone: str + agency_lang: Optional[str] + agency_phone: Optional[str] + agency_fare_url: Optional[str] + agency_email: Optional[str] + feed_id: int + + +@strawberry.type +class StopType: + """GraphQL type for Stop model""" + + id: int + stop_id: str + stop_code: Optional[str] + stop_name: str + stop_desc: Optional[str] + stop_lat: float + stop_lon: float + zone_id: Optional[str] + stop_url: Optional[str] + location_type: int + parent_station: Optional[str] + stop_timezone: Optional[str] + wheelchair_boarding: Optional[int] + feed_id: int + + +@strawberry.type +class RouteType: + """GraphQL type for Route model""" + + id: int + route_id: str + route_short_name: str + route_long_name: str + route_desc: Optional[str] + route_type: int + route_url: Optional[str] + route_color: Optional[str] + route_text_color: Optional[str] + route_sort_order: Optional[int] + feed_id: int + agency_id: Optional[int] + + +@strawberry.type +class CalendarType: + """GraphQL type for Calendar model""" + + id: int + service_id: str + monday: bool + tuesday: bool + wednesday: bool + thursday: bool + friday: bool + saturday: bool + sunday: bool + start_date: datetime.date + end_date: datetime.date + feed_id: int + + +@strawberry.type +class TripType: + """GraphQL type for Trip model""" + + id: int + trip_id: str + trip_headsign: Optional[str] + trip_short_name: Optional[str] + direction_id: Optional[int] + block_id: Optional[str] + shape_id: Optional[str] + wheelchair_accessible: int + bikes_allowed: int + service_id: str + feed_id: int + route_id: int + + +@strawberry.type +class StopTimeType: + """GraphQL type for StopTime model""" + + id: int + stop_sequence: int + stop_headsign: Optional[str] + pickup_type: int + drop_off_type: int + shape_dist_traveled: Optional[float] + timepoint: int + feed_id: int + trip_id: int + stop_id: int + arrival_time_str: Optional[str] + departure_time_str: Optional[str] + + +@strawberry.type +class FareAttributeType: + """GraphQL type for FareAttribute model""" + + id: int + fare_id: str + price: str + currency_type: str + payment_method: int + transfers: Optional[int] + transfer_duration: Optional[int] + feed_id: int + + +@strawberry.type +class ShapeType: + """GraphQL type for Shape model""" + + id: int + shape_id: str + shape_pt_lat: float + shape_pt_lon: float + shape_pt_sequence: int + shape_dist_traveled: Optional[float] + feed_id: int + + +@strawberry.type +class PageInfo: + """Information about pagination""" + + has_next_page: bool + has_previous_page: bool + start_cursor: Optional[str] + end_cursor: Optional[str] + total_count: int + + +@strawberry.type +class AgencyConnection: + """Paginated list of agencies""" + + edges: list[AgencyType] + page_info: PageInfo + + +@strawberry.type +class RouteConnection: + """Paginated list of routes""" + + edges: list[RouteType] + page_info: PageInfo + + +@strawberry.type +class StopConnection: + """Paginated list of stops""" + + edges: list[StopType] + page_info: PageInfo + + +@strawberry.type +class TripConnection: + """Paginated list of trips""" + + edges: list[TripType] + page_info: PageInfo + + +@strawberry.type +class StopTimeConnection: + """Paginated list of stop times""" + + edges: list[StopTimeType] + page_info: PageInfo + + +@strawberry.input +class CreateAgencyInput: + """Input for creating an agency""" + + feed_id: int + agency_id: str + agency_name: str + agency_url: str + agency_timezone: str + agency_lang: Optional[str] = None + agency_phone: Optional[str] = None + agency_fare_url: Optional[str] = None + agency_email: Optional[str] = None + + +@strawberry.type +class CreateAgencyPayload: + """Result of creating an agency""" + + agency: Optional[AgencyType] + errors: list[str] + success: bool + + +def agency_to_type(agency) -> AgencyType: + """Convert Agency model to AgencyType""" + return AgencyType( + id=agency.id, + agency_id=agency.agency_id, + agency_name=agency.agency_name, + agency_url=agency.agency_url, + agency_timezone=agency.agency_timezone, + agency_lang=agency.agency_lang or None, + agency_phone=agency.agency_phone or None, + agency_fare_url=agency.agency_fare_url or None, + agency_email=agency.agency_email or None, + feed_id=agency.feed_id, + ) + + +def feed_to_type(feed) -> FeedType: + """Convert Feed model to FeedType""" + return FeedType( + id=feed.id, + name=feed.name, + url=feed.url, + created_at=feed.created_at, + updated_at=feed.updated_at, + ) + + +def route_to_type(route) -> RouteType: + """Convert Route model to RouteType""" + return RouteType( + id=route.id, + route_id=route.route_id, + route_short_name=route.route_short_name, + route_long_name=route.route_long_name, + route_desc=route.route_desc or None, + route_type=route.route_type, + route_url=route.route_url or None, + route_color=route.route_color or None, + route_text_color=route.route_text_color or None, + route_sort_order=route.route_sort_order, + feed_id=route.feed_id, + agency_id=route.agency_id, + ) + + +def stop_to_type(stop) -> StopType: + """Convert Stop model to StopType""" + return StopType( + id=stop.id, + stop_id=stop.stop_id, + stop_code=stop.stop_code or None, + stop_name=stop.stop_name, + stop_desc=stop.stop_desc or None, + stop_lat=stop.stop_lat, + stop_lon=stop.stop_lon, + zone_id=stop.zone_id or None, + stop_url=stop.stop_url or None, + location_type=stop.location_type, + parent_station=stop.parent_station or None, + stop_timezone=stop.stop_timezone or None, + wheelchair_boarding=stop.wheelchair_boarding, + feed_id=stop.feed_id, + ) + + +def trip_to_type(trip) -> TripType: + """Convert Trip model to TripType""" + return TripType( + id=trip.id, + trip_id=trip.trip_id, + trip_headsign=trip.trip_headsign or None, + trip_short_name=trip.trip_short_name or None, + direction_id=trip.direction_id, + block_id=trip.block_id or None, + shape_id=trip.shape_id or None, + wheelchair_accessible=trip.wheelchair_accessible, + bikes_allowed=trip.bikes_allowed, + service_id=trip.service_id, + feed_id=trip.feed_id, + route_id=trip.route_id, + ) + + +def stop_time_to_type(stop_time) -> StopTimeType: + """Convert StopTime model to StopTimeType""" + arrival_time_str = None + if stop_time.arrival_time: + total_seconds = int(stop_time.arrival_time.total_seconds()) + hours = total_seconds // 3600 + minutes = (total_seconds % 3600) // 60 + seconds = total_seconds % 60 + arrival_time_str = f"{hours:02d}:{minutes:02d}:{seconds:02d}" + + departure_time_str = None + if stop_time.departure_time: + total_seconds = int(stop_time.departure_time.total_seconds()) + hours = total_seconds // 3600 + minutes = (total_seconds % 3600) // 60 + seconds = total_seconds % 60 + departure_time_str = f"{hours:02d}:{minutes:02d}:{seconds:02d}" + + return StopTimeType( + id=stop_time.id, + stop_sequence=stop_time.stop_sequence, + stop_headsign=stop_time.stop_headsign or None, + pickup_type=stop_time.pickup_type, + drop_off_type=stop_time.drop_off_type, + shape_dist_traveled=stop_time.shape_dist_traveled, + timepoint=stop_time.timepoint, + feed_id=stop_time.feed_id, + trip_id=stop_time.trip_id, + stop_id=stop_time.stop_id, + arrival_time_str=arrival_time_str, + departure_time_str=departure_time_str, + ) diff --git a/graphql_api/urls.py b/graphql_api/urls.py new file mode 100644 index 0000000..73693f4 --- /dev/null +++ b/graphql_api/urls.py @@ -0,0 +1,7 @@ +from django.urls import path +from strawberry.django.views import GraphQLView +from .schema import schema + +urlpatterns = [ + path("", GraphQLView.as_view(schema=schema), name="graphql"), +] diff --git a/pyproject.toml b/pyproject.toml index ac8f4ff..573cb3d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ dependencies = [ "python-decouple>=3.8", "redis>=5.2.1", "requests>=2.32.5", + "strawberry-graphql[django]>=0.287.0", ] [dependency-groups] @@ -38,3 +39,11 @@ dev = [ "ruff>=0.12.11", "watchfiles>=0.24.0", ] + +[tool.pytest.ini_options] +DJANGO_SETTINGS_MODULE = "realtime.settings" +python_files = ["test_*.py", "*_test.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = "-v --tb=short" +testpaths = ["tests"] diff --git a/realtime/settings.py b/realtime/settings.py index 187de2c..7562b2f 100644 --- a/realtime/settings.py +++ b/realtime/settings.py @@ -40,6 +40,7 @@ "feed.apps.FeedConfig", "website.apps.WebsiteConfig", "api.apps.ApiConfig", + "graphql_api.apps.GraphqlApiConfig", "rest_framework", "rest_framework.authtoken", "drf_spectacular", diff --git a/realtime/urls.py b/realtime/urls.py index 1d8aa12..d658cc0 100644 --- a/realtime/urls.py +++ b/realtime/urls.py @@ -27,6 +27,7 @@ path("gtfs/", include("gtfs.urls")), path("api/", include("api.urls")), path("feed/", include("feed.urls")), + path("graphql/", include("graphql_api.urls")), ] serve_static_flag = os.environ.get("DJANGO_SERVE_STATIC", "").lower() in ("1", "true", "yes", "on") diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..102cb72 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,21 @@ +import os +import django +from django.conf import settings + +# Set minimal environment variables for testing +os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing-only") +os.environ.setdefault("DEBUG", "True") +os.environ.setdefault("ALLOWED_HOSTS", "localhost,127.0.0.1") +os.environ.setdefault("DB_NAME", "test_db") +os.environ.setdefault("DB_USER", "test_user") +os.environ.setdefault("DB_PASSWORD", "test_password") +os.environ.setdefault("DB_HOST", "localhost") +os.environ.setdefault("DB_PORT", "5432") +os.environ.setdefault("REDIS_HOST", "localhost") +os.environ.setdefault("REDIS_PORT", "6379") + +# Set up Django settings +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "realtime.settings") + +# Configure Django +django.setup() diff --git a/tests/test_graphql/conftest.py b/tests/test_graphql/conftest.py new file mode 100644 index 0000000..7c6b668 --- /dev/null +++ b/tests/test_graphql/conftest.py @@ -0,0 +1,145 @@ +import pytest +from django.contrib.auth.models import User +from gtfs.models import Feed, Agency, Route, Stop, Trip, StopTime +from datetime import timedelta + + +@pytest.fixture +def create_user(): + """Factory fixture to create users""" + + def _create_user(username="testuser", is_staff=False, is_superuser=False): + user = User.objects.create_user( + username=username, + email=f"{username}@example.com", + password="testpass123", + ) + user.is_staff = is_staff + user.is_superuser = is_superuser + user.save() + return user + + return _create_user + + +@pytest.fixture +def auth_user(create_user): + """Create a regular authenticated user""" + return create_user("authuser", is_staff=False) + + +@pytest.fixture +def staff_user(create_user): + """Create a staff user""" + return create_user("staffuser", is_staff=True) + + +@pytest.fixture +def feed(): + """Create a test feed""" + return Feed.objects.create(name="Test Feed", url="https://example.com/gtfs.zip") + + +@pytest.fixture +def agency(feed): + """Create a test agency""" + return Agency.objects.create( + feed=feed, + agency_id="TEST_AGENCY", + agency_name="Test Transit Agency", + agency_url="https://testtransit.com", + agency_timezone="America/Los_Angeles", + agency_lang="en", + agency_phone="555-0100", + ) + + +@pytest.fixture +def route(feed, agency): + """Create a test route""" + return Route.objects.create( + feed=feed, + route_id="ROUTE_1", + agency=agency, + route_short_name="1", + route_long_name="Downtown Express", + route_type=3, # Ciencias del Movimiento Humano + route_color="FF0000", + route_text_color="FFFFFF", + ) + + +@pytest.fixture +def stop(feed): + """Create a test stop""" + return Stop.objects.create( + feed=feed, + stop_id="STOP_1", + stop_code="S1", + stop_name="Main Street Station", + stop_lat=37.7749, + stop_lon=-122.4194, + location_type=0, + ) + + +@pytest.fixture +def trip(feed, route): + """Create a test trip""" + return Trip.objects.create( + feed=feed, + route=route, + service_id="WEEKDAY", + trip_id="TRIP_1", + trip_headsign="Downtown", + direction_id=0, + shape_id="SHAPE_1", + ) + + +@pytest.fixture +def stop_time(feed, trip, stop): + """Create a test stop time""" + return StopTime.objects.create( + feed=feed, + trip=trip, + stop=stop, + arrival_time=timedelta(hours=8, minutes=30), + departure_time=timedelta(hours=8, minutes=31), + stop_sequence=1, + pickup_type=0, + drop_off_type=0, + ) + + +@pytest.fixture +def multiple_agencies(feed): + """Create multiple test agencies""" + agencies = [] + for i in range(5): + agency = Agency.objects.create( + feed=feed, + agency_id=f"AGENCY_{i}", + agency_name=f"Transit Agency {i}", + agency_url=f"https://transit{i}.com", + agency_timezone="America/New_York", + ) + agencies.append(agency) + return agencies + + +@pytest.fixture +def multiple_routes(feed, agency): + """Create multiple test routes""" + routes = [] + for i in range(10): + route = Route.objects.create( + feed=feed, + route_id=f"ROUTE_{i}", + agency=agency, + route_short_name=str(i), + route_long_name=f"Route {i} Line", + route_type=3, + ) + routes.append(route) + return routes diff --git a/tests/test_graphql/test_mutations.py b/tests/test_graphql/test_mutations.py new file mode 100644 index 0000000..6ea7d7c --- /dev/null +++ b/tests/test_graphql/test_mutations.py @@ -0,0 +1,268 @@ +import pytest +from django.test import RequestFactory +from graphql_api.schema import schema +from strawberry.types import ExecutionContext +from gtfs.models import Agency + + +@pytest.mark.django_db +class TestMutations: + """Test GraphQL mutations""" + + def setup_method(self): + """Setup test client and request factory""" + self.factory = RequestFactory() + + def execute_mutation(self, mutation, user=None, variables=None): + """Helper to execute GraphQL mutations""" + request = self.factory.post("/graphql/") + request.user = user + context = ExecutionContext(context={"request": request}, operation_name=None) + result = schema.execute_sync( + mutation, variable_values=variables, context_value=context.context + ) + return result + + def test_create_agency_mutation_requires_staff(self, auth_user, feed): + """Test that createAgency mutation requires staff permission""" + mutation = """ + mutation($input: CreateAgencyInput!) { + createAgency(input: $input) { + success + errors + agency { + agencyName + } + } + } + """ + variables = { + "input": { + "feedId": feed.id, + "agencyId": "NEW_AGENCY", + "agencyName": "New Agency", + "agencyUrl": "https://newagency.com", + "agencyTimezone": "America/New_York", + } + } + result = self.execute_mutation(mutation, user=auth_user, variables=variables) + assert result.errors is not None + assert "staff" in str(result.errors[0]).lower() + + def test_create_agency_mutation_anonymous(self, feed): + """Test that createAgency mutation requires authentication""" + mutation = """ + mutation($input: CreateAgencyInput!) { + createAgency(input: $input) { + success + errors + agency { + agencyName + } + } + } + """ + variables = { + "input": { + "feedId": feed.id, + "agencyId": "NEW_AGENCY", + "agencyName": "New Agency", + "agencyUrl": "https://newagency.com", + "agencyTimezone": "America/New_York", + } + } + result = self.execute_mutation(mutation, user=None, variables=variables) + assert result.errors is not None + + def test_create_agency_mutation_success(self, staff_user, feed): + """Test successful agency creation""" + mutation = """ + mutation($input: CreateAgencyInput!) { + createAgency(input: $input) { + success + errors + agency { + id + agencyId + agencyName + agencyUrl + agencyTimezone + } + } + } + """ + variables = { + "input": { + "feedId": feed.id, + "agencyId": "NEW_AGENCY", + "agencyName": "New Transit Agency", + "agencyUrl": "https://newtransit.com", + "agencyTimezone": "America/New_York", + "agencyLang": "en", + "agencyPhone": "555-1234", + } + } + result = self.execute_mutation(mutation, user=staff_user, variables=variables) + assert result.errors is None + assert result.data["createAgency"]["success"] is True + assert result.data["createAgency"]["errors"] == [] + assert ( + result.data["createAgency"]["agency"]["agencyName"] == "New Transit Agency" + ) + assert ( + result.data["createAgency"]["agency"]["agencyTimezone"] + == "America/New_York" + ) + + # Verify agency was created in database + agency = Agency.objects.get(agency_id="NEW_AGENCY") + assert agency.agency_name == "New Transit Agency" + assert agency.agency_phone == "555-1234" + + def test_create_agency_mutation_missing_required_field(self, staff_user, feed): + """Test agency creation with missing required field""" + mutation = """ + mutation($input: CreateAgencyInput!) { + createAgency(input: $input) { + success + errors + agency { + agencyName + } + } + } + """ + variables = { + "input": { + "feedId": feed.id, + "agencyId": "INVALID", + "agencyName": "", # Empty name + "agencyUrl": "https://test.com", + "agencyTimezone": "America/New_York", + } + } + result = self.execute_mutation(mutation, user=staff_user, variables=variables) + assert result.errors is None + assert result.data["createAgency"]["success"] is False + assert len(result.data["createAgency"]["errors"]) > 0 + assert "name" in result.data["createAgency"]["errors"][0].lower() + + def test_create_agency_mutation_invalid_feed(self, staff_user): + """Test agency creation with non-existent feed""" + mutation = """ + mutation($input: CreateAgencyInput!) { + createAgency(input: $input) { + success + errors + agency { + agencyName + } + } + } + """ + variables = { + "input": { + "feedId": 99999, # Non-existent feed + "agencyId": "TEST", + "agencyName": "Test Agency", + "agencyUrl": "https://test.com", + "agencyTimezone": "America/New_York", + } + } + result = self.execute_mutation(mutation, user=staff_user, variables=variables) + assert result.errors is None + assert result.data["createAgency"]["success"] is False + assert len(result.data["createAgency"]["errors"]) > 0 + assert "feed" in result.data["createAgency"]["errors"][0].lower() + + def test_create_agency_mutation_duplicate(self, staff_user, feed, agency): + """Test agency creation with duplicate agency_id""" + mutation = """ + mutation($input: CreateAgencyInput!) { + createAgency(input: $input) { + success + errors + agency { + agencyName + } + } + } + """ + variables = { + "input": { + "feedId": feed.id, + "agencyId": "TEST_AGENCY", # Same as existing agency + "agencyName": "Duplicate Agency", + "agencyUrl": "https://duplicate.com", + "agencyTimezone": "America/New_York", + } + } + result = self.execute_mutation(mutation, user=staff_user, variables=variables) + assert result.errors is None + assert result.data["createAgency"]["success"] is False + assert len(result.data["createAgency"]["errors"]) > 0 + assert "already exists" in result.data["createAgency"]["errors"][0].lower() + + def test_create_agency_mutation_with_optional_fields(self, staff_user, feed): + """Test agency creation with all optional fields""" + mutation = """ + mutation($input: CreateAgencyInput!) { + createAgency(input: $input) { + success + errors + agency { + agencyId + agencyName + agencyLang + agencyPhone + agencyEmail + } + } + } + """ + variables = { + "input": { + "feedId": feed.id, + "agencyId": "FULL_AGENCY", + "agencyName": "Complete Agency", + "agencyUrl": "https://complete.com", + "agencyTimezone": "Europe/London", + "agencyLang": "es", + "agencyPhone": "555-9999", + "agencyFareUrl": "https://complete.com/fares", + "agencyEmail": "info@complete.com", + } + } + result = self.execute_mutation(mutation, user=staff_user, variables=variables) + assert result.errors is None + assert result.data["createAgency"]["success"] is True + assert result.data["createAgency"]["agency"]["agencyLang"] == "es" + assert result.data["createAgency"]["agency"]["agencyPhone"] == "555-9999" + assert ( + result.data["createAgency"]["agency"]["agencyEmail"] == "info@complete.com" + ) + + def test_create_agency_mutation_validation_errors(self, staff_user, feed): + """Test multiple validation errors""" + mutation = """ + mutation($input: CreateAgencyInput!) { + createAgency(input: $input) { + success + errors + } + } + """ + variables = { + "input": { + "feedId": feed.id, + "agencyId": "INVALID", + "agencyName": "", # Invalid + "agencyUrl": "", # Invalid + "agencyTimezone": "", # Invalid + } + } + result = self.execute_mutation(mutation, user=staff_user, variables=variables) + assert result.errors is None + assert result.data["createAgency"]["success"] is False + # Should have multiple errors + assert len(result.data["createAgency"]["errors"]) >= 3 diff --git a/tests/test_graphql/test_permissions.py b/tests/test_graphql/test_permissions.py new file mode 100644 index 0000000..7a24caf --- /dev/null +++ b/tests/test_graphql/test_permissions.py @@ -0,0 +1,199 @@ +import pytest +from django.test import RequestFactory +from graphql_api.schema import schema +from strawberry.types import ExecutionContext + + +@pytest.mark.django_db +class TestPermissions: + """Test GraphQL permissions and access control""" + + def setup_method(self): + """Setup test client and request factory""" + self.factory = RequestFactory() + + def execute_query(self, query, user=None, variables=None): + """Helper to execute GraphQL queries""" + request = self.factory.post("/graphql/") + request.user = user + context = ExecutionContext(context={"request": request}, operation_name=None) + result = schema.execute_sync( + query, variable_values=variables, context_value=context.context + ) + return result + + def test_anonymous_user_cannot_query(self, feed): + """Test that anonymous users cannot access queries""" + query = """ + query { + allFeeds { + id + name + } + } + """ + result = self.execute_query(query, user=None) + assert result.errors is not None + assert "not authenticated" in str(result.errors[0]).lower() + + def test_authenticated_user_can_query(self, auth_user, feed): + """Test that authenticated users can access queries""" + query = """ + query { + allFeeds { + id + name + } + } + """ + result = self.execute_query(query, user=auth_user) + assert result.errors is None + assert result.data is not None + + def test_non_staff_user_cannot_mutate(self, auth_user, feed): + """Test that non-staff users cannot perform mutations""" + mutation = """ + mutation($input: CreateAgencyInput!) { + createAgency(input: $input) { + success + errors + } + } + """ + variables = { + "input": { + "feedId": feed.id, + "agencyId": "TEST", + "agencyName": "Test", + "agencyUrl": "https://test.com", + "agencyTimezone": "UTC", + } + } + result = self.execute_query(mutation, user=auth_user, variables=variables) + assert result.errors is not None + assert "staff" in str(result.errors[0]).lower() + + def test_staff_user_can_mutate(self, staff_user, feed): + """Test that staff users can perform mutations""" + mutation = """ + mutation($input: CreateAgencyInput!) { + createAgency(input: $input) { + success + errors + } + } + """ + variables = { + "input": { + "feedId": feed.id, + "agencyId": "STAFF_TEST", + "agencyName": "Staff Test Agency", + "agencyUrl": "https://stafftest.com", + "agencyTimezone": "America/New_York", + } + } + result = self.execute_query(mutation, user=staff_user, variables=variables) + assert result.errors is None + assert result.data["createAgency"]["success"] is True + + def test_multiple_queries_with_different_auth_states( + self, auth_user, staff_user, feed + ): + """Test multiple queries with different authentication states""" + query = """ + query { + allFeeds { + id + name + } + } + """ + + # Anonymous - should fail + result = self.execute_query(query, user=None) + assert result.errors is not None + + # Authenticated - should succeed + result = self.execute_query(query, user=auth_user) + assert result.errors is None + + # Staff - should succeed + result = self.execute_query(query, user=staff_user) + assert result.errors is None + + def test_permission_error_message_format(self, feed): + """Test that permission errors have proper format""" + query = """ + query { + allAgencies { + edges { + agencyName + } + } + } + """ + result = self.execute_query(query, user=None) + assert result.errors is not None + assert len(result.errors) > 0 + error = result.errors[0] + assert "authenticated" in str(error).lower() + + def test_partial_query_permissions(self, auth_user): + """Test query with both permitted and non-permitted fields""" + # All fields should be accessible to authenticated users in queries + query = """ + query { + allFeeds { + id + name + url + createdAt + } + } + """ + result = self.execute_query(query, user=auth_user) + assert result.errors is None + + def test_nested_query_permissions(self, auth_user, agency): + """Test that permissions apply to nested queries""" + query = """ + query($id: Int!) { + agency(id: $id) { + agencyName + feed { + name + } + } + } + """ + result = self.execute_query(query, user=auth_user, variables={"id": agency.id}) + assert result.errors is None + assert result.data["agency"]["feed"]["name"] is not None + + def test_anonymous_user_all_endpoints(self): + """Test that anonymous users are blocked from all protected endpoints""" + queries = [ + "query { allFeeds { id } }", + "query { allAgencies { edges { id } } }", + "query { allRoutes { edges { id } } }", + "query { allStops { edges { id } } }", + ] + + for query in queries: + result = self.execute_query(query, user=None) + assert result.errors is not None, f"Query should fail: {query}" + assert "authenticated" in str(result.errors[0]).lower() + + def test_authenticated_user_all_read_endpoints(self, auth_user, feed): + """Test that authenticated users can access all read endpoints""" + queries = [ + "query { allFeeds { id } }", + "query { allAgencies { edges { id } pageInfo { totalCount } } }", + "query { allRoutes { edges { id } pageInfo { totalCount } } }", + "query { allStops { edges { id } pageInfo { totalCount } } }", + ] + + for query in queries: + result = self.execute_query(query, user=auth_user) + assert result.errors is None, f"Query should succeed: {query}" + assert result.data is not None diff --git a/tests/test_graphql/test_queries.py b/tests/test_graphql/test_queries.py new file mode 100644 index 0000000..516080b --- /dev/null +++ b/tests/test_graphql/test_queries.py @@ -0,0 +1,332 @@ +import pytest +from django.test import RequestFactory +from graphql_api.schema import schema +from strawberry.types import ExecutionContext + + +@pytest.mark.django_db +class TestQueries: + """Test GraphQL queries""" + + def setup_method(self): + """Setup test client and request factory""" + self.factory = RequestFactory() + + def execute_query(self, query, user=None, variables=None): + """Helper to execute GraphQL queries""" + request = self.factory.post("/graphql/") + request.user = user + context = ExecutionContext(context={"request": request}, operation_name=None) + result = schema.execute_sync( + query, variable_values=variables, context_value=context.context + ) + return result + + def test_all_feeds_query_requires_auth(self): + """Test that allFeeds query requires authentication""" + query = """ + query { + allFeeds { + id + name + url + } + } + """ + result = self.execute_query(query, user=None) + assert result.errors is not None + assert "not authenticated" in str(result.errors[0]).lower() + + def test_all_feeds_query_authenticated(self, auth_user, feed): + """Test allFeeds query with authenticated user""" + query = """ + query { + allFeeds { + id + name + url + } + } + """ + result = self.execute_query(query, user=auth_user) + assert result.errors is None + assert result.data is not None + assert "allFeeds" in result.data + assert len(result.data["allFeeds"]) == 1 + assert result.data["allFeeds"][0]["name"] == "Test Feed" + + def test_feed_query_by_id(self, auth_user, feed): + """Test feed query by ID""" + query = """ + query($id: Int!) { + feed(id: $id) { + id + name + url + } + } + """ + result = self.execute_query(query, user=auth_user, variables={"id": feed.id}) + assert result.errors is None + assert result.data["feed"]["name"] == "Test Feed" + + def test_all_agencies_query(self, auth_user, agency): + """Test allAgencies query""" + query = """ + query { + allAgencies { + edges { + id + agencyName + agencyTimezone + } + pageInfo { + totalCount + hasNextPage + hasPreviousPage + } + } + } + """ + result = self.execute_query(query, user=auth_user) + assert result.errors is None + assert result.data["allAgencies"]["pageInfo"]["totalCount"] == 1 + assert ( + result.data["allAgencies"]["edges"][0]["agencyName"] + == "Test Transit Agency" + ) + + def test_all_agencies_pagination(self, auth_user, multiple_agencies): + """Test allAgencies query with pagination""" + query = """ + query($offset: Int, $limit: Int) { + allAgencies(offset: $offset, limit: $limit) { + edges { + id + agencyName + } + pageInfo { + totalCount + hasNextPage + hasPreviousPage + } + } + } + """ + # First page + result = self.execute_query( + query, user=auth_user, variables={"offset": 0, "limit": 2} + ) + assert result.errors is None + assert len(result.data["allAgencies"]["edges"]) == 2 + assert result.data["allAgencies"]["pageInfo"]["totalCount"] == 5 + assert result.data["allAgencies"]["pageInfo"]["hasNextPage"] is True + + # Second page + result = self.execute_query( + query, user=auth_user, variables={"offset": 2, "limit": 2} + ) + assert len(result.data["allAgencies"]["edges"]) == 2 + assert result.data["allAgencies"]["pageInfo"]["hasNextPage"] is True + + def test_all_agencies_filtering(self, auth_user, multiple_agencies): + """Test allAgencies query with filtering""" + query = """ + query($nameContains: String) { + allAgencies(nameContains: $nameContains) { + edges { + id + agencyName + } + pageInfo { + totalCount + } + } + } + """ + result = self.execute_query( + query, user=auth_user, variables={"nameContains": "Agency 1"} + ) + assert result.errors is None + assert result.data["allAgencies"]["pageInfo"]["totalCount"] == 1 + assert "Agency 1" in result.data["allAgencies"]["edges"][0]["agencyName"] + + def test_agency_query_by_id(self, auth_user, agency): + """Test agency query by ID""" + query = """ + query($id: Int!) { + agency(id: $id) { + id + agencyName + agencyUrl + agencyTimezone + } + } + """ + result = self.execute_query(query, user=auth_user, variables={"id": agency.id}) + assert result.errors is None + assert result.data["agency"]["agencyName"] == "Test Transit Agency" + + def test_all_routes_query(self, auth_user, route): + """Test allRoutes query""" + query = """ + query { + allRoutes { + edges { + id + routeShortName + routeLongName + routeType + } + pageInfo { + totalCount + } + } + } + """ + result = self.execute_query(query, user=auth_user) + assert result.errors is None + assert result.data["allRoutes"]["pageInfo"]["totalCount"] == 1 + assert result.data["allRoutes"]["edges"][0]["routeShortName"] == "1" + + def test_all_routes_filtering(self, auth_user, multiple_routes): + """Test allRoutes query with filtering""" + query = """ + query($routeType: Int) { + allRoutes(routeType: $routeType) { + edges { + routeShortName + routeType + } + pageInfo { + totalCount + } + } + } + """ + result = self.execute_query(query, user=auth_user, variables={"routeType": 3}) + assert result.errors is None + assert result.data["allRoutes"]["pageInfo"]["totalCount"] == 10 + + def test_all_stops_query(self, auth_user, stop): + """Test allStops query""" + query = """ + query { + allStops { + edges { + id + stopName + stopLat + stopLon + } + pageInfo { + totalCount + } + } + } + """ + result = self.execute_query(query, user=auth_user) + assert result.errors is None + assert result.data["allStops"]["pageInfo"]["totalCount"] == 1 + assert result.data["allStops"]["edges"][0]["stopName"] == "Main Street Station" + + def test_trips_by_route_query(self, auth_user, trip, route): + """Test tripsByRoute query""" + query = """ + query($routeId: Int!) { + tripsByRoute(routeId: $routeId) { + edges { + id + tripId + tripHeadsign + directionId + } + pageInfo { + totalCount + } + } + } + """ + result = self.execute_query( + query, user=auth_user, variables={"routeId": route.id} + ) + assert result.errors is None + assert result.data["tripsByRoute"]["pageInfo"]["totalCount"] == 1 + assert result.data["tripsByRoute"]["edges"][0]["tripId"] == "TRIP_1" + + def test_stop_times_by_trip_query(self, auth_user, stop_time, trip): + """Test stopTimesByTrip query""" + query = """ + query($tripId: Int!) { + stopTimesByTrip(tripId: $tripId) { + edges { + id + stopSequence + arrivalTimeStr + departureTimeStr + stop { + stopName + } + } + pageInfo { + totalCount + } + } + } + """ + result = self.execute_query( + query, user=auth_user, variables={"tripId": trip.id} + ) + assert result.errors is None + assert result.data["stopTimesByTrip"]["pageInfo"]["totalCount"] == 1 + assert result.data["stopTimesByTrip"]["edges"][0]["stopSequence"] == 1 + assert ( + result.data["stopTimesByTrip"]["edges"][0]["arrivalTimeStr"] == "08:30:00" + ) + assert ( + result.data["stopTimesByTrip"]["edges"][0]["stop"]["stopName"] + == "Main Street Station" + ) + + def test_query_without_authentication(self): + """Test that queries without authentication are rejected""" + query = """ + query { + allAgencies { + edges { + agencyName + } + } + } + """ + result = self.execute_query(query, user=None) + assert result.errors is not None + assert "not authenticated" in str(result.errors[0]).lower() + + def test_nested_query(self, auth_user, trip, route, agency): + """Test nested query with related objects""" + query = """ + query($tripId: Int!) { + trip(id: $tripId) { + tripId + tripHeadsign + route { + routeShortName + routeLongName + agency { + agencyName + } + } + } + } + """ + result = self.execute_query( + query, user=auth_user, variables={"tripId": trip.id} + ) + assert result.errors is None + assert result.data["trip"]["tripId"] == "TRIP_1" + assert result.data["trip"]["route"]["routeShortName"] == "1" + assert ( + result.data["trip"]["route"]["agency"]["agencyName"] + == "Test Transit Agency" + ) diff --git a/tests/test_graphql/test_schema.py b/tests/test_graphql/test_schema.py new file mode 100644 index 0000000..886a2d9 --- /dev/null +++ b/tests/test_graphql/test_schema.py @@ -0,0 +1,85 @@ +import pytest +from graphql_api.schema import schema + + +@pytest.mark.django_db +class TestSchema: + """Test GraphQL schema structure""" + + def test_schema_loads_successfully(self): + """Test that schema can be loaded without errors""" + assert schema is not None + assert hasattr(schema, "query_type") + assert hasattr(schema, "mutation_type") + + def test_query_type_exists(self): + """Test that Query type is defined""" + query_type = schema.query_type + assert query_type is not None + assert query_type.name == "Query" + + def test_mutation_type_exists(self): + """Test that Mutation type is defined""" + mutation_type = schema.mutation_type + assert mutation_type is not None + assert mutation_type.name == "Mutation" + + def test_query_fields_exist(self): + """Test that expected query fields are present""" + query_type = schema.query_type + field_names = [field.name for field in query_type.fields] + + expected_fields = [ + "allFeeds", + "feed", + "allAgencies", + "agency", + "allRoutes", + "route", + "allStops", + "stop", + "tripsByRoute", + "trip", + "stopTimesByTrip", + "stopTime", + ] + + for field in expected_fields: + assert field in field_names, f"Field {field} not found in Query type" + + def test_mutation_fields_exist(self): + """Test that expected mutation fields are present""" + mutation_type = schema.mutation_type + field_names = [field.name for field in mutation_type.fields] + + expected_mutations = ["createAgency"] + + for mutation in expected_mutations: + assert mutation in field_names, ( + f"Mutation {mutation} not found in Mutation type" + ) + + def test_types_are_registered(self): + """Test that custom types are registered in schema""" + type_map = schema.schema_converter.type_map + + expected_types = [ + "AgencyType", + "RouteType", + "StopType", + "TripType", + "StopTimeType", + "FeedType", + "PageInfo", + "AgencyConnection", + "RouteConnection", + "StopConnection", + "TripConnection", + "StopTimeConnection", + ] + + for type_name in expected_types: + # Check if type exists in schema (Strawberry may modify names) + assert any( + type_name.lower() in str(t).lower() for t in type_map.values() + ), f"Type {type_name} not found in schema" diff --git a/tests/validate_graphql.py b/tests/validate_graphql.py new file mode 100755 index 0000000..eff7c26 --- /dev/null +++ b/tests/validate_graphql.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python +""" +Script de validación para la implementación de la API GraphQL. +Verifica que todos los módulos puedan ser importados y que el esquema pueda ser construido. +""" + +import sys +import os + + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# Configurar variables de entorno para pruebas +os.environ.setdefault("SECRET_KEY", "test-secret-key") +os.environ.setdefault("DEBUG", "True") +os.environ.setdefault("ALLOWED_HOSTS", "localhost") +os.environ.setdefault("DB_NAME", "test_db") +os.environ.setdefault("DB_USER", "test_user") +os.environ.setdefault("DB_PASSWORD", "test_password") +os.environ.setdefault("DB_HOST", "localhost") +os.environ.setdefault("DB_PORT", "5432") +os.environ.setdefault("REDIS_HOST", "localhost") +os.environ.setdefault("REDIS_PORT", "6379") +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "realtime.settings") + +print("=" * 60) +print(" Validando API GraphQL") +print("=" * 60) + +# Test 1: Importar Django y configuración +print("\n1. Testeando el Django setup...") +try: + import django + + django.setup() + print(" ✓ Django setup exitoso") +except Exception as e: + print(f" ✗ Django setup fallido: {e}") + sys.exit(1) + +# Test 2: Importar modelos +print("\n2. Testeando los modelos GTFS...") +try: + from gtfs.models import ( + Agency, + Feed, + Route, + Stop, + Trip, + StopTime, + Calendar, + CalendarDate, + ) + + print(" ✓ Modelos GTFS importados exitosamente") +except Exception as e: + print(f" ✗ Fallo al importar los modelos GTFS: {e}") + sys.exit(1) + +# Test 3: Importar tipos GraphQL +print("\n3. Testeando los tipos GraphQL...") +try: + from graphql_api.types import ( + AgencyType, + RouteType, + StopType, + TripType, + StopTimeType, + FeedType, + PageInfo, + AgencyConnection, + CreateAgencyInput, + CreateAgencyPayload, + ) + + print(" ✓ Tipos GraphQL importados exitosamente") +except Exception as e: + print(f" ✗ Fallo al importar los tipos GraphQL: {e}") + sys.exit(1) + +# Test 4: Importar permisos +print("\n4. Testeando los permisos...") +try: + from graphql_api.permissions import IsAuthenticated, IsStaff + + print(" ✓ Permisos importados exitosamente") +except Exception as e: + print(f" ✗ Fallo al importar los permisos: {e}") + sys.exit(1) + +# Test 5: Importar consultas +print("\n5. Testeando las consultas...") +try: + from graphql_api.queries import Query + + print(" ✓ Consultas importadas exitosamente") +except Exception as e: + print(f" ✗ Fallo al importar las consultas: {e}") + sys.exit(1) + +# Test 6: Importar mutaciones +print("\n6. Testeando las mutaciones...") +try: + from graphql_api.mutations import Mutation + + print(" ✓ Mutaciones importadas exitosamente") +except Exception as e: + print(f" ✗ Fallo al importar las mutaciones: {e}") + sys.exit(1) + +# Test 7: Construcción del esquema +print("\n7. Testeando la construcción del esquema...") +try: + from graphql_api.schema import schema + + print(" ✓ Esquema construido exitosamente") +except Exception as e: + print(f" ✗ Fallo en la construcción del esquema: {e}") + sys.exit(1) + +# Test 8: Verify schema structure +print("\n8. Testeando la estructura del esquema...") +try: + # Check that schema has graphql_schema attribute + assert hasattr(schema, "graphql_schema"), "El esquema no tiene graphql_schema" + graphql_schema = schema.graphql_schema + + # Check query type + query_type = graphql_schema.query_type + assert query_type is not None, "El tipo de consulta es None" + assert query_type.name == "Query", f"El nombre del tipo de consulta es {query_type.name}" + print(" ✓ El tipo de consulta es válido") + + # Check mutation type + mutation_type = graphql_schema.mutation_type + assert mutation_type is not None, "El tipo de mutación es None" + assert ( + mutation_type.name == "Mutation" + ), f"El nombre del tipo de mutación es {mutation_type.name}" + print(" ✓ El tipo de mutación es válido") + # Check query fields + query_fields = list(query_type.fields.keys()) + expected_queries = [ + "allFeeds", + "feed", + "allAgencies", + "agency", + "allRoutes", + "route", + "allStops", + "stop", + "tripsByRoute", + "trip", + "stopTimesByTrip", + "stopTime", + ] + + for expected in expected_queries: + assert expected in query_fields, f"Query {expected} no encontrado" + print(f" ✓ Todas las {len(expected_queries)} consultas esperadas están presentes") + + # Check mutation fields + mutation_fields = list(mutation_type.fields.keys()) + expected_mutations = ["createAgency"] + + for expected in expected_mutations: + assert expected in mutation_fields, f"Mutación {expected} no encontrada" + print(f" ✓ Todas las {len(expected_mutations)} mutaciones esperadas están presentes") + +except Exception as e: + print(f" ✗ Fallo en la validación de la estructura del esquema: {e}") + sys.exit(1) + +# Test 9: Import URL configuration +print("\n9. Testeando la configuración de URL...") +try: + from graphql_api.urls import urlpatterns + + assert len(urlpatterns) > 0, "No hay patrones de URL definidos" + print(" ✓ Configuración de URL válida") +except Exception as e: + print(f" ✗ Fallo en la configuración de URL: {e}") + sys.exit(1) + +# Test 10: Comprobando la configuración de la aplicación +print("\n10. Testeando la configuración de la aplicación...") +try: + from django.apps import apps + + graphql_app = apps.get_app_config("graphql_api") + assert graphql_app is not None, "La aplicación GraphQL no está registrada" + print(" ✓ La aplicación GraphQL está registrada exitosamente") + + gtfs_app = apps.get_app_config("gtfs") + assert gtfs_app is not None, "La aplicación GTFS no está registrada" + print(" ✓ La aplicación GTFS está registrada exitosamente") +except Exception as e: + print(f" ✗ Fallo en la configuración de la aplicación: {e}") + sys.exit(1) + +print("\n" + "=" * 60) +print("✓ Todas las comprobaciones de validación pasaron!") +print("=" * 60) +print("\nPróximos pasos:") +print("1. Ejecutar migraciones: python manage.py makemigrations && python manage.py migrate") +print("2. Crear un superusuario: python manage.py createsuperuser") +print("3. Ejecutar el servidor: python manage.py runserver") +print("4. Acceder a GraphQL en: http://localhost:8000/graphql/") +print("5. Ejecutar pruebas: pytest tests/test_graphql/") +print() diff --git a/uv.lock b/uv.lock index 22ea9e2..f872b69 100644 --- a/uv.lock +++ b/uv.lock @@ -435,6 +435,7 @@ dependencies = [ { name = "python-decouple" }, { name = "redis" }, { name = "requests" }, + { name = "strawberry-graphql", extra = ["django"] }, ] [package.dev-dependencies] @@ -471,6 +472,7 @@ requires-dist = [ { name = "python-decouple", specifier = ">=3.8" }, { name = "redis", specifier = ">=5.2.1" }, { name = "requests", specifier = ">=2.32.5" }, + { name = "strawberry-graphql", extras = ["django"], specifier = ">=0.287.0" }, ] [package.metadata.requires-dev] @@ -652,6 +654,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, ] +[[package]] +name = "graphql-core" +version = "3.2.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/9b/037a640a2983b09aed4a823f9cf1729e6d780b0671f854efa4727a7affbe/graphql_core-3.2.7.tar.gz", hash = "sha256:27b6904bdd3b43f2a0556dad5d579bdfdeab1f38e8e8788e555bdcb586a6f62c", size = 513484, upload-time = "2025-11-01T22:30:40.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/14/933037032608787fb92e365883ad6a741c235e0ff992865ec5d904a38f1e/graphql_core-3.2.7-py3-none-any.whl", hash = "sha256:17fc8f3ca4a42913d8e24d9ac9f08deddf0a0b2483076575757f6c412ead2ec0", size = 207262, upload-time = "2025-11-01T22:30:38.912Z" }, +] + [[package]] name = "gtfs-realtime-bindings" version = "1.0.0" @@ -793,6 +804,18 @@ redis = [ { name = "redis" }, ] +[[package]] +name = "lia-web" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1e/4e/847404ca9d36e3f5468c9e460aed565a02cbca0fdf81247da9f87fabc1b8/lia_web-0.2.3.tar.gz", hash = "sha256:ccc9d24cdc200806ea96a20b22fb68f4759e6becdb901bd36024df7921e848d7", size = 156761, upload-time = "2025-08-11T10:23:21.003Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/f2/c68a97c727c795119f1056ad2b7e716c23f26f004292517c435accf90b5c/lia_web-0.2.3-py3-none-any.whl", hash = "sha256:237c779c943cd4341527fc0adfcc3d8068f992ee051f4ef059b8474ee087f641", size = 13965, upload-time = "2025-08-11T10:23:20.215Z" }, +] + [[package]] name = "markdown" version = "3.8.2" @@ -1887,6 +1910,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a9/5c/bfd6bd0bf979426d405cc6e71eceb8701b148b16c21d2dc3c261efc61c7b/sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca", size = 44415, upload-time = "2024-12-10T12:05:27.824Z" }, ] +[[package]] +name = "strawberry-graphql" +version = "0.287.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "graphql-core" }, + { name = "lia-web" }, + { name = "packaging" }, + { name = "python-dateutil" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/66/dfdb0f9b1f58123e1a00597931d46cc25e4d310f44dbb1e24cffa905ebc2/strawberry_graphql-0.287.0.tar.gz", hash = "sha256:4da748f82684f3dc914fc9ed88184e715903d7fa06248f8f702bbbab55f7ed36", size = 211808, upload-time = "2025-11-22T13:00:45.802Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/21/954ed4a43d8ddafcc022b4f212937606249d9ab7c47e77988ecf949a46c2/strawberry_graphql-0.287.0-py3-none-any.whl", hash = "sha256:d85cc90101d322a641fe8f4adb8f3c4a1aa5dddb61ba1a4bbb7bf42aa4dbad8c", size = 309017, upload-time = "2025-11-22T13:00:44.005Z" }, +] + +[package.optional-dependencies] +django = [ + { name = "asgiref" }, + { name = "django" }, +] + [[package]] name = "tornado" version = "6.5.2"