From 198fbffd7dbc1b84c36f65eaf1666599d7bc4c3e Mon Sep 17 00:00:00 2001 From: Elise Chin Date: Wed, 23 Sep 2026 15:19:12 +0200 Subject: [PATCH 1/2] editoast: fix work_schedules pagination by ordering on id instead of obj_id WorkSchedule::OBJ_ID is not unique: a work schedule spanning several days (e.g. a multi-night work) is represented as several rows sharing the same obj_id and track ranges, but with distinct id, start_date and end_date. Ordering by obj_id therefore sorts the column with ties, and Postgres gives no guarantee that row order is consistent across queries. This caused two bugs on GET /work_schedules/group/{id}: 1. Rows tied on obj_id could be ordered differently between two consecutive queries, so a row could appear on two consecutive results (duplicate) or be skipped entirely. 2. On the last (partial) page, it causes PaginationStats::new's consistency assertion to panic, and surface as a 502 error. I suppose because the cumulative count no longer add up to total_count. Fix: order by WorkSchedule::ID instead of OBJ_ID, same as TrainSchedule already does. Signed-off-by: Elise Chin --- editoast/openapi.yaml | 12 ------------ .../src/views/operational_studies/hierarchy.rs | 9 --------- editoast/src/views/work_schedules.rs | 15 ++------------- front/src/common/api/generatedEditoastApi.ts | 8 -------- 4 files changed, 2 insertions(+), 42 deletions(-) diff --git a/editoast/openapi.yaml b/editoast/openapi.yaml index 33877a0567c..84e6fce6858 100644 --- a/editoast/openapi.yaml +++ b/editoast/openapi.yaml @@ -5073,18 +5073,6 @@ paths: schema: type: integer format: int64 - - name: ordering - in: query - required: false - schema: - type: string - enum: - - NameAsc - - NameDesc - - CreationDateAsc - - CreationDateDesc - - LastModifiedDesc - - LastModifiedAsc responses: '200': description: The work schedules in the group diff --git a/editoast/src/views/operational_studies/hierarchy.rs b/editoast/src/views/operational_studies/hierarchy.rs index 99c70fbe82d..8260efecb7f 100644 --- a/editoast/src/views/operational_studies/hierarchy.rs +++ b/editoast/src/views/operational_studies/hierarchy.rs @@ -2,7 +2,6 @@ pub mod project; pub mod scenario; pub mod study; -use models::WorkSchedule; use models::prelude::*; use models::project::Project; use models::scenario::Scenario; @@ -60,12 +59,4 @@ impl Ordering { Ordering::LastModifiedDesc => Scenario::LAST_MODIFICATION.desc(), } } - - pub fn as_work_schedule_ordering(&self) -> SortSetting { - match *self { - Ordering::NameAsc => WorkSchedule::OBJ_ID.asc(), - Ordering::NameDesc => WorkSchedule::OBJ_ID.desc(), - _ => WorkSchedule::OBJ_ID.asc(), - } - } } diff --git a/editoast/src/views/work_schedules.rs b/editoast/src/views/work_schedules.rs index 1e86d478878..9bf4047c2cc 100644 --- a/editoast/src/views/work_schedules.rs +++ b/editoast/src/views/work_schedules.rs @@ -1,6 +1,5 @@ use super::pagination::PaginatedList; use crate::error::Result; -use crate::views::operational_studies::hierarchy::Ordering; use crate::views::pagination::PaginationQueryParams; use crate::views::pagination::PaginationStats; use crate::views::path::projection::Intersection; @@ -391,19 +390,11 @@ pub(in crate::views) struct GroupContentResponse { stats: PaginationStats, } -#[derive(Debug, Clone, serde::Deserialize, utoipa::IntoParams)] -#[into_params(parameter_in = Query)] -pub struct WorkScheduleOrderingParam { - #[serde(default)] - #[param(inline)] - pub ordering: Ordering, // FIXME: use a dedicated enum, that one is for projects/studies/scenarios and has superfluous variants -} - #[editoast_derive::route] #[utoipa::path( get, path = "", tag = "work_schedules", - params(PaginationQueryParams<100>, WorkScheduleGroupIdParam, WorkScheduleOrderingParam), + params(PaginationQueryParams<100>, WorkScheduleGroupIdParam), responses( (status = 200, description = "The work schedules in the group", body = inline(GroupContentResponse)), (status = 404, description = "Work schedule group not found"), @@ -413,13 +404,11 @@ pub(in crate::views) async fn get_group( State(db_pool): State>, Path(WorkScheduleGroupIdParam { id: group_id }): Path, Query(pagination_params): Query>, - Query(ordering_params): Query, ) -> Result> { - let ordering = ordering_params.ordering; let settings = pagination_params .into_selection_settings() .filter(move || WorkSchedule::WORK_SCHEDULE_GROUP_ID.eq(group_id)) - .order_by(move || ordering.as_work_schedule_ordering()); + .order_by(move || WorkSchedule::ID.asc()); let conn = &mut db_pool.get().await?; diff --git a/front/src/common/api/generatedEditoastApi.ts b/front/src/common/api/generatedEditoastApi.ts index d00d4d84243..98d241abfca 100644 --- a/front/src/common/api/generatedEditoastApi.ts +++ b/front/src/common/api/generatedEditoastApi.ts @@ -1598,7 +1598,6 @@ const injectedRtkApi = api params: { page: queryArg.page, page_size: queryArg.pageSize, - ordering: queryArg.ordering, }, }), providesTags: ['work_schedules'], @@ -2943,13 +2942,6 @@ export type GetWorkSchedulesGroupByIdApiArg = { pageSize?: number; /** A work schedule group ID */ id: number; - ordering?: - | 'NameAsc' - | 'NameDesc' - | 'CreationDateAsc' - | 'CreationDateDesc' - | 'LastModifiedDesc' - | 'LastModifiedAsc'; }; export type PutWorkSchedulesGroupByIdApiResponse = /** status 200 The work schedules have been created */ WorkSchedule[]; From eb172c4034a1317d8436a352d572a78cf53a02a3 Mon Sep 17 00:00:00 2001 From: Elise Chin Date: Wed, 23 Sep 2026 16:17:41 +0200 Subject: [PATCH 2/2] core: scripts: adapt download-work-schedules script to run locally - Skip editoast authentification - The local gateway_url does not have api in it, so remove it Signed-off-by: Elise Chin --- core/scripts/download-work-schedules.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/core/scripts/download-work-schedules.py b/core/scripts/download-work-schedules.py index 41632c9b986..befdcdac97b 100644 --- a/core/scripts/download-work-schedules.py +++ b/core/scripts/download-work-schedules.py @@ -16,10 +16,14 @@ async def download_work_schedules( page_size: int, gateway_cookie: str, ) -> List[Dict]: - url = f"{gateway_url}api/work_schedules/group/{group_id}/?page=$page&{page_size=}" + url = f"{gateway_url}work_schedules/group/{group_id}/?page=$page&{page_size=}" cookies, connector = make_connector(gateway_cookie) async with aiohttp.ClientSession( - trust_env=True, raise_for_status=True, cookies=cookies, connector=connector + trust_env=True, + raise_for_status=True, + cookies=cookies, + connector=connector, + headers={"x-osrd-skip-authz": "true"}, ) as session: return await get_paginated(url, session) @@ -29,7 +33,7 @@ async def download_work_schedules( Downloads a full work schedule group. """ ) -@click.option("--gateway-url", "-u", default="https://demo.osrd.fr/") +@click.option("--gateway-url", "-u", default="https://demo.osrd.fr/api/") @click.option("--group-id", "-g", required=True, type=int) @click.option("--path", "-p", default="work_schedules.json") @click.option("--gateway-cookie", "-c", envvar="GATEWAY_COOKIE")