diff --git a/editoast/editoast_common/src/tracing.rs b/editoast/editoast_common/src/tracing.rs index 0054b8ab576..0ff3e4d960b 100644 --- a/editoast/editoast_common/src/tracing.rs +++ b/editoast/editoast_common/src/tracing.rs @@ -31,11 +31,12 @@ pub struct TracingConfig { pub fn create_tracing_subscriber( tracing_config: TracingConfig, + log_level: tracing_subscriber::filter::LevelFilter, exporter: T, ) -> impl tracing::Subscriber { let env_filter_layer = tracing_subscriber::EnvFilter::builder() // Set the default log level to 'info' - .with_default_directive(tracing_subscriber::filter::LevelFilter::INFO.into()) + .with_default_directive(log_level.into()) .from_env_lossy(); let fmt_layer = tracing_subscriber::fmt::layer() .pretty() diff --git a/editoast/editoast_schemas/src/train_schedule/margins.rs b/editoast/editoast_schemas/src/train_schedule/margins.rs index d8994c0abde..01497b58f1b 100644 --- a/editoast/editoast_schemas/src/train_schedule/margins.rs +++ b/editoast/editoast_schemas/src/train_schedule/margins.rs @@ -44,7 +44,7 @@ impl<'de> Deserialize<'de> for Margins { } } -#[derive(Debug, Copy, Clone, PartialEq, Derivative)] +#[derive(Debug, Copy, Clone, PartialEq, Derivative, ToSchema)] #[derivative(Hash, Default)] pub enum MarginValue { #[derivative(Default)] diff --git a/editoast/migrations/2025-01-07-163822_index_stdcm_log_trace_id/down.sql b/editoast/migrations/2025-01-07-163822_index_stdcm_log_trace_id/down.sql new file mode 100644 index 00000000000..d8d227ec325 --- /dev/null +++ b/editoast/migrations/2025-01-07-163822_index_stdcm_log_trace_id/down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS stdcm_logs_trace_id; diff --git a/editoast/migrations/2025-01-07-163822_index_stdcm_log_trace_id/up.sql b/editoast/migrations/2025-01-07-163822_index_stdcm_log_trace_id/up.sql new file mode 100644 index 00000000000..dd8dfef85c2 --- /dev/null +++ b/editoast/migrations/2025-01-07-163822_index_stdcm_log_trace_id/up.sql @@ -0,0 +1 @@ +CREATE INDEX IF NOT EXISTS stdcm_logs_trace_id ON stdcm_logs (trace_id); diff --git a/editoast/openapi.yaml b/editoast/openapi.yaml index df21a9065db..a5aa15f0d1f 100644 --- a/editoast/openapi.yaml +++ b/editoast/openapi.yaml @@ -2590,6 +2590,61 @@ paths: application/json: schema: $ref: '#/components/schemas/StdcmSearchEnvironment' + /stdcm_logs: + get: + tags: + - stdcm_log + parameters: + - name: page + in: query + required: false + schema: + type: integer + format: int64 + default: 1 + minimum: 1 + - name: page_size + in: query + required: false + schema: + type: integer + format: int64 + default: 25 + nullable: true + minimum: 1 + responses: + '200': + description: The list of Stdcm Logs + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/PaginationStats' + - type: object + required: + - results + properties: + results: + type: array + items: + $ref: '#/components/schemas/StdcmLog' + /stdcm_logs/{trace_id}: + get: + tags: + - stdcm_log + parameters: + - name: trace_id + in: path + required: true + schema: + type: string + responses: + '200': + description: The stdcm log + content: + application/json: + schema: + $ref: '#/components/schemas/StdcmLog' /temporary_speed_limit_group: post: tags: @@ -4741,6 +4796,7 @@ components: - $ref: '#/components/schemas/EditoastStdcmErrorRollingStockNotFound' - $ref: '#/components/schemas/EditoastStdcmErrorTimetableNotFound' - $ref: '#/components/schemas/EditoastStdcmErrorTowedRollingStockNotFound' + - $ref: '#/components/schemas/EditoastStdcmLogErrorNotFound' - $ref: '#/components/schemas/EditoastStudyErrorNotFound' - $ref: '#/components/schemas/EditoastStudyErrorStartDateAfterEndDate' - $ref: '#/components/schemas/EditoastTemporarySpeedLimitErrorNameAlreadyUsed' @@ -5960,6 +6016,30 @@ components: type: string enum: - editoast:stdcm_v2:TowedRollingStockNotFound + EditoastStdcmLogErrorNotFound: + type: object + required: + - type + - status + - message + properties: + context: + type: object + required: + - trace_id + properties: + trace_id: + type: string + message: + type: string + status: + type: integer + enum: + - 404 + type: + type: string + enum: + - editoast:stdcm_log:NotFound EditoastStudyErrorNotFound: type: object required: @@ -8967,45 +9047,6 @@ components: type: integer format: int64 nullable: true - Response: - oneOf: - - type: object - required: - - simulation - - path - - departure_time - - status - properties: - departure_time: - type: string - format: date-time - path: - $ref: '#/components/schemas/PathfindingResultSuccess' - simulation: - $ref: '#/components/schemas/SimulationResponse' - status: - type: string - enum: - - success - - type: object - required: - - status - properties: - status: - type: string - enum: - - path_not_found - - type: object - required: - - error - - status - properties: - error: - $ref: '#/components/schemas/SimulationResponse' - status: - type: string - enum: - - preprocessing_simulation_error RjsPowerRestrictionRange: type: object description: A range along the train path where a power restriction is applied. @@ -10633,9 +10674,252 @@ components: type: integer format: int64 request: - $ref: '#/components/schemas/Request' + type: object + required: + - infra + - expected_version + - path_items + - rolling_stock_loading_gauge + - rolling_stock_supported_signaling_systems + - comfort + - physics_consist + - trains_requirements + - start_time + - maximum_departure_delay + - maximum_run_time + - time_gap_before + - time_gap_after + - work_schedules + - temporary_speed_limits + properties: + comfort: + $ref: '#/components/schemas/Comfort' + expected_version: + type: string + description: Infrastructure expected version + infra: + type: integer + format: int64 + description: Infrastructure id + margin: + allOf: + - oneOf: + - type: object + required: + - Percentage + properties: + Percentage: + type: number + format: double + - type: object + required: + - MinPer100Km + properties: + MinPer100Km: + type: number + format: double + nullable: true + maximum_departure_delay: + type: integer + format: int64 + description: Maximum departure delay in milliseconds. + minimum: 0 + maximum_run_time: + type: integer + format: int64 + description: Maximum run time of the simulation in milliseconds + minimum: 0 + path_items: + type: array + items: + $ref: '#/components/schemas/PathItem' + description: List of waypoints. Each waypoint is a list of track offset. + physics_consist: + type: object + required: + - effort_curves + - length + - max_speed + - startup_time + - startup_acceleration + - comfort_acceleration + - const_gamma + - inertia_coefficient + - mass + - rolling_resistance + properties: + base_power_class: + type: string + nullable: true + comfort_acceleration: + type: number + format: double + description: In m/s² + const_gamma: + type: number + format: double + description: |- + The constant gamma braking coefficient used when NOT circulating + under ETCS/ERTMS signaling system in m/s^2 + effort_curves: + $ref: '#/components/schemas/EffortCurves' + electrical_power_startup_time: + type: integer + format: int64 + description: |- + The time the train takes before actually using electrical power (in milliseconds). + Is null if the train is not electric or the value not specified. + nullable: true + minimum: 0 + inertia_coefficient: + type: number + format: double + length: + type: integer + format: int64 + description: Length of the rolling stock in mm + minimum: 0 + mass: + type: integer + format: int64 + description: Mass of the rolling stock in kg + minimum: 0 + max_speed: + type: number + format: double + description: Maximum speed of the rolling stock in m/s + power_restrictions: + type: object + description: Mapping of power restriction code to power class + additionalProperties: + type: string + raise_pantograph_time: + type: integer + format: int64 + description: |- + The time it takes to raise this train's pantograph in milliseconds. + Is null if the train is not electric or the value not specified. + nullable: true + minimum: 0 + rolling_resistance: + $ref: '#/components/schemas/RollingResistance' + startup_acceleration: + type: number + format: double + description: In m/s² + startup_time: + type: integer + format: int64 + minimum: 0 + rolling_stock_loading_gauge: + $ref: '#/components/schemas/LoadingGaugeType' + rolling_stock_supported_signaling_systems: + $ref: '#/components/schemas/RollingStockSupportedSignalingSystems' + speed_limit_tag: + type: string + nullable: true + start_time: + type: string + format: date-time + temporary_speed_limits: + type: array + items: + type: object + description: Lighter description of a work schedule with only the relevant information for core + required: + - speed_limit + - track_ranges + properties: + speed_limit: + type: number + format: double + description: Speed limitation in m/s + track_ranges: + type: array + items: + $ref: '#/components/schemas/TrackRange' + description: Track ranges on which the speed limitation applies + description: List of applicable temporary speed limits between the train departure and arrival + time_gap_after: + type: integer + format: int64 + description: Gap between the created train and following trains in milliseconds + minimum: 0 + time_gap_before: + type: integer + format: int64 + description: Gap between the created train and previous trains in milliseconds + minimum: 0 + time_step: + type: integer + format: int64 + description: Numerical integration time step in milliseconds. Use default value if not specified. + nullable: true + minimum: 0 + trains_requirements: + type: object + additionalProperties: + type: object + required: + - start_time + - spacing_requirements + - routing_requirements + properties: + routing_requirements: + type: array + items: + $ref: '#/components/schemas/RoutingRequirement' + spacing_requirements: + type: array + items: + $ref: '#/components/schemas/SpacingRequirement' + start_time: + type: string + format: date-time + work_schedules: + type: array + items: + $ref: '#/components/schemas/WorkSchedule' + description: List of planned work schedules response: - $ref: '#/components/schemas/Response' + oneOf: + - type: object + required: + - simulation + - path + - departure_time + - status + properties: + departure_time: + type: string + format: date-time + path: + $ref: '#/components/schemas/PathfindingResultSuccess' + simulation: + $ref: '#/components/schemas/SimulationResponse' + status: + type: string + enum: + - success + - type: object + required: + - status + properties: + status: + type: string + enum: + - path_not_found + - type: object + required: + - error + - status + properties: + error: + $ref: '#/components/schemas/SimulationResponse' + status: + type: string + enum: + - preprocessing_simulation_error trace_id: type: string user_id: diff --git a/editoast/src/core/conflict_detection.rs b/editoast/src/core/conflict_detection.rs index 45689d7f083..0f5e1c118d4 100644 --- a/editoast/src/core/conflict_detection.rs +++ b/editoast/src/core/conflict_detection.rs @@ -29,7 +29,7 @@ pub struct ConflictDetectionRequest { pub work_schedules: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct TrainRequirements { pub start_time: DateTime, pub spacing_requirements: Vec, diff --git a/editoast/src/core/mod.rs b/editoast/src/core/mod.rs index ebf5fdbfece..7c5830bfc79 100644 --- a/editoast/src/core/mod.rs +++ b/editoast/src/core/mod.rs @@ -36,7 +36,6 @@ editoast_common::schemas! { simulation::schemas(), pathfinding::schemas(), conflict_detection::schemas(), - stdcm::schemas(), } #[derive(Debug, Clone)] diff --git a/editoast/src/core/simulation.rs b/editoast/src/core/simulation.rs index c06d5da88bf..1919e80b30a 100644 --- a/editoast/src/core/simulation.rs +++ b/editoast/src/core/simulation.rs @@ -33,7 +33,7 @@ editoast_common::schemas! { SimulationResponse, } -#[derive(Debug, Clone, Serialize, Deserialize, Derivative)] +#[derive(Debug, Clone, Serialize, Deserialize, Derivative, ToSchema)] #[derivative(Hash)] pub struct PhysicsConsist { pub effort_curves: EffortCurves, diff --git a/editoast/src/core/stdcm.rs b/editoast/src/core/stdcm.rs index 654635e3626..5193c1d3b83 100644 --- a/editoast/src/core/stdcm.rs +++ b/editoast/src/core/stdcm.rs @@ -19,11 +19,7 @@ use super::simulation::SimulationResponse; use crate::core::AsCoreRequest; use crate::core::Json; -editoast_common::schemas! { - Response, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct Request { /// Infrastructure id pub infra: i64, @@ -42,9 +38,11 @@ pub struct Request { /// The comfort of the train pub comfort: Comfort, pub speed_limit_tag: Option, + #[schema(inline)] pub physics_consist: PhysicsConsist, // STDCM search parameters + #[schema(inline)] pub trains_requirements: HashMap, /// Numerical integration time step in milliseconds. Use default value if not specified. pub time_step: Option, @@ -58,10 +56,12 @@ pub struct Request { /// Gap between the created train and following trains in milliseconds pub time_gap_after: u64, /// Margin to apply to the whole train + #[schema(inline)] pub margin: Option, /// List of planned work schedules pub work_schedules: Vec, /// List of applicable temporary speed limits between the train departure and arrival + #[schema(inline)] pub temporary_speed_limits: Vec, } @@ -98,7 +98,7 @@ pub struct WorkSchedule { } /// Lighter description of a work schedule with only the relevant information for core -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct TemporarySpeedLimit { /// Speed limitation in m/s pub speed_limit: f64, diff --git a/editoast/src/main.rs b/editoast/src/main.rs index 754c6999d67..022bc5c88c4 100644 --- a/editoast/src/main.rs +++ b/editoast/src/main.rs @@ -115,7 +115,12 @@ async fn run() -> Result<(), Box> { stream: EditoastMode::from_client(&client).into(), telemetry, }; - create_tracing_subscriber(tracing_config, exporter).init(); + create_tracing_subscriber( + tracing_config, + tracing_subscriber::filter::LevelFilter::INFO, + exporter, + ) + .init(); let pg_config = client.postgres_config; let db_pool = diff --git a/editoast/src/models/stdcm_log.rs b/editoast/src/models/stdcm_log.rs index ae77062bd6c..81872dc8738 100644 --- a/editoast/src/models/stdcm_log.rs +++ b/editoast/src/models/stdcm_log.rs @@ -1,11 +1,15 @@ +use std::ops::DerefMut; + use chrono::DateTime; use chrono::Utc; +use diesel::ExpressionMethods; +use diesel::OptionalExtension; +use diesel::QueryDsl; +use diesel_async::RunQueryDsl; use editoast_derive::Model; use editoast_models::DbConnection; -use opentelemetry::trace::TraceContextExt; use serde::Deserialize; use serde::Serialize; -use tracing_opentelemetry::OpenTelemetrySpanExt; use utoipa::ToSchema; use crate::core::stdcm::Request; @@ -18,13 +22,16 @@ editoast_common::schemas! { #[derive(Clone, Debug, Serialize, Deserialize, Model, ToSchema)] #[model(table = editoast_models::tables::stdcm_logs)] -#[model(gen(ops = c))] +#[model(gen(ops = cd, list))] pub struct StdcmLog { pub id: i64, + #[model(identifier)] pub trace_id: String, #[model(json)] + #[schema(inline)] pub request: Request, #[model(json)] + #[schema(inline)] pub response: Response, pub created: DateTime, pub user_id: Option, @@ -33,17 +40,13 @@ pub struct StdcmLog { impl StdcmLog { pub async fn log( mut conn: DbConnection, + trace_id: String, request: Request, response: Response, user_id: Option, ) { - let trace_id = tracing::Span::current() - .context() - .span() - .span_context() - .trace_id(); let stdcm_log_changeset = StdcmLog::changeset() - .trace_id(trace_id.to_string()) + .trace_id(trace_id) .request(request) .response(response.clone()) .user_id(user_id); @@ -51,4 +54,19 @@ impl StdcmLog { tracing::error!("Failed during log operation: {e}"); } } + + pub async fn find_by_trace_id( + conn: &mut DbConnection, + trace_id: &str, + ) -> crate::error::Result> { + use editoast_models::tables::stdcm_logs::dsl; + + dsl::stdcm_logs + .filter(dsl::trace_id.eq(trace_id)) + .first::(conn.write().await.deref_mut()) + .await + .map(Into::into) + .optional() + .map_err(Into::into) + } } diff --git a/editoast/src/views/fixtures.rs b/editoast/src/views/fixtures.rs new file mode 100644 index 00000000000..acf6673ab42 --- /dev/null +++ b/editoast/src/views/fixtures.rs @@ -0,0 +1,100 @@ +use axum::http::StatusCode; +use serde_json::json; + +use crate::core::mocking::MockingClient; +use crate::core::pathfinding::PathfindingResultSuccess; +use crate::core::simulation::CompleteReportTrain; +use crate::core::simulation::ElectricalProfiles; +use crate::core::simulation::ReportTrain; +use crate::core::simulation::SimulationResponse; +use crate::core::simulation::SpeedLimitProperties; + +use super::path::pathfinding::PathfindingResult; + +pub fn pathfinding_result_success() -> PathfindingResult { + PathfindingResult::Success(PathfindingResultSuccess { + blocks: vec![], + routes: vec![], + track_section_ranges: vec![], + length: 0, + path_item_positions: vec![0, 10], + }) +} + +pub fn simulation_response() -> SimulationResponse { + SimulationResponse::Success { + base: ReportTrain { + positions: vec![], + times: vec![], + speeds: vec![], + energy_consumption: 0.0, + path_item_times: vec![0, 10], + }, + provisional: ReportTrain { + positions: vec![], + times: vec![0, 10], + speeds: vec![], + energy_consumption: 0.0, + path_item_times: vec![0, 10], + }, + final_output: CompleteReportTrain { + report_train: ReportTrain { + positions: vec![], + times: vec![], + speeds: vec![], + energy_consumption: 0.0, + path_item_times: vec![0, 10], + }, + signal_critical_positions: vec![], + zone_updates: vec![], + spacing_requirements: vec![], + routing_requirements: vec![], + }, + mrsp: SpeedLimitProperties { + boundaries: vec![], + values: vec![], + }, + electrical_profiles: ElectricalProfiles { + boundaries: vec![], + values: vec![], + }, + } +} + +pub fn stdcm_payload(rolling_stock_id: i64) -> serde_json::Value { + json!({ + "comfort": "STANDARD", + "margin": "4.5min/100km", + "rolling_stock_id": rolling_stock_id, + "speed_limit_tags": "AR120", + "steps": [ + { + "duration": 0, + "location": { "trigram": "WS", "secondary_code": "BV" }, + "timing_data": { + "arrival_time": "2024-09-17T20:05:00+02:00", + "arrival_time_tolerance_before": 0, + "arrival_time_tolerance_after": 0 + } + }, + { "duration": 0, "location": { "trigram": "MWS", "secondary_code": "BV" } } + ], + "time_gap_after": 35000, + "time_gap_before": 35000 + }) +} + +pub fn core_mocking_client() -> MockingClient { + let mut core = MockingClient::new(); + core.stub("/v2/pathfinding/blocks") + .method(reqwest::Method::POST) + .response(StatusCode::OK) + .json(pathfinding_result_success()) + .finish(); + core.stub("/v2/standalone_simulation") + .method(reqwest::Method::POST) + .response(StatusCode::OK) + .json(serde_json::to_value(simulation_response()).unwrap()) + .finish(); + core +} diff --git a/editoast/src/views/mod.rs b/editoast/src/views/mod.rs index e82231dd5bb..b9a194f657d 100644 --- a/editoast/src/views/mod.rs +++ b/editoast/src/views/mod.rs @@ -14,6 +14,7 @@ pub mod scenario; pub mod search; pub mod speed_limit_tags; pub mod sprites; +pub mod stdcm_logs; pub mod stdcm_search_environment; pub mod study; pub mod temporary_speed_limits; @@ -21,6 +22,8 @@ pub mod timetable; pub mod train_schedule; pub mod work_schedules; +#[cfg(test)] +mod fixtures; #[cfg(test)] mod test_app; @@ -111,6 +114,7 @@ crate::routes! { &train_schedule, &timetable, &path, + &stdcm_logs, &scenario, } diff --git a/editoast/src/views/stdcm_logs.rs b/editoast/src/views/stdcm_logs.rs new file mode 100644 index 00000000000..59e8c5880aa --- /dev/null +++ b/editoast/src/views/stdcm_logs.rs @@ -0,0 +1,223 @@ +use axum::extract::Path; +use axum::extract::Query; +use axum::extract::State; +use axum::Extension; +use axum::Json; +use editoast_authz::BuiltinRole; +use editoast_derive::EditoastError; +use editoast_models::DbConnectionPoolV2; +use serde::Deserialize; +use serde::Serialize; +use thiserror::Error; +use utoipa::IntoParams; +use utoipa::ToSchema; + +use crate::error::Result; +use crate::models::stdcm_log::StdcmLog; + +use super::pagination::PaginatedList; +use super::pagination::PaginationQueryParams; +use super::pagination::PaginationStats; +use super::AuthenticationExt; +use super::AuthorizationError; + +crate::routes! { + "/stdcm_logs" => { + list_stdcm_logs, + "/{trace_id}" => { + stdcm_log_by_trace_id, + }, + }, +} + +#[derive(Debug, Error, EditoastError)] +#[editoast_error(base_id = "stdcm_log")] +pub enum StdcmLogError { + #[error("STDCM log entry '{trace_id}' could not be found")] + #[editoast_error(status = 404)] + NotFound { trace_id: String }, +} + +#[derive(Serialize, ToSchema)] +#[cfg_attr(test, derive(Deserialize))] +struct StdcmLogListResponse { + results: Vec, + #[serde(flatten)] + stats: PaginationStats, +} + +#[utoipa::path( + get, path = "", + tag = "stdcm_log", + params(PaginationQueryParams), + responses( + (status = 200, body = inline(StdcmLogListResponse), description = "The list of Stdcm Logs"), + ) +)] +async fn list_stdcm_logs( + State(db_pool): State, + Extension(auth): AuthenticationExt, + Query(pagination_params): Query, +) -> Result> { + let authorized = auth + .check_roles([BuiltinRole::Superuser].into()) + .await + .map_err(AuthorizationError::AuthError)?; + if !authorized { + return Err(AuthorizationError::Forbidden.into()); + } + + let conn = &mut db_pool.get().await?; + + let settings = pagination_params + .validate(1000)? + .warn_page_size(100) + .into_selection_settings(); + + let (stdcm_logs, stats) = StdcmLog::list_paginated(conn, settings).await?; + + Ok(Json(StdcmLogListResponse { + results: stdcm_logs, + stats, + })) +} + +#[derive(IntoParams, Deserialize)] +pub struct StdcmLogTraceIdParam { + trace_id: String, +} + +#[utoipa::path( + get, path = "", + params(StdcmLogTraceIdParam), + tag = "stdcm_log", + responses( + (status = 200, body = StdcmLog, description = "The stdcm log"), + ) +)] +async fn stdcm_log_by_trace_id( + State(db_pool): State, + Extension(auth): AuthenticationExt, + Path(StdcmLogTraceIdParam { trace_id }): Path, +) -> Result> { + let authorized = auth + .check_roles([BuiltinRole::Superuser].into()) + .await + .map_err(AuthorizationError::AuthError)?; + if !authorized { + return Err(AuthorizationError::Forbidden.into()); + } + + let conn = &mut db_pool.get().await?; + + match StdcmLog::find_by_trace_id(conn, &trace_id).await { + Ok(Some(stdcm_log)) => Ok(Json(stdcm_log)), + Ok(None) => Err(StdcmLogError::NotFound { trace_id }.into()), + Err(e) => Err(e), + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use axum::http::StatusCode; + use editoast_authz::authorizer::UserInfo; + use editoast_authz::BuiltinRole; + use editoast_models::DbConnectionPoolV2; + use pretty_assertions::assert_eq; + use rstest::rstest; + use serde_json::json; + use uuid::Uuid; + + use crate::models::fixtures::create_fast_rolling_stock; + use crate::models::fixtures::create_small_infra; + use crate::models::fixtures::create_timetable; + use crate::models::stdcm_log::StdcmLog; + use crate::views::fixtures::core_mocking_client; + use crate::views::fixtures::pathfinding_result_success; + use crate::views::fixtures::simulation_response; + use crate::views::fixtures::stdcm_payload; + use crate::views::stdcm_logs::StdcmLogListResponse; + use crate::views::test_app::TestApp; + use crate::views::test_app::TestAppBuilder; + use crate::views::test_app::TestRequestExt; + + async fn prepare_test_context(disable_superuser: bool) -> (TestApp, UserInfo, String) { + let db_pool = DbConnectionPoolV2::for_tests(); + let mut core = core_mocking_client(); + core.stub("/v2/stdcm") + .method(reqwest::Method::POST) + .response(StatusCode::OK) + .json(json!({ + "status": "success", + "simulation": serde_json::to_value(simulation_response()).unwrap(), + "path": serde_json::to_value(pathfinding_result_success()).unwrap(), + "departure_time": "2024-01-02T00:00:00Z" + })) + .finish(); + + let user = UserInfo { + identity: "superuser_id".to_owned(), + name: "superuser_name".to_owned(), + }; + + let mut roles = HashSet::from([BuiltinRole::Stdcm, BuiltinRole::Superuser]); + + if disable_superuser { + roles.remove(&BuiltinRole::Superuser); + } + + let app = TestAppBuilder::new() + .db_pool(db_pool.clone()) + .core_client(core.into()) + .enable_authorization(true) + .user(user.clone()) + .roles(roles) + .build(); + + let small_infra = create_small_infra(&mut db_pool.get_ok()).await; + let timetable = create_timetable(&mut db_pool.get_ok()).await; + let rolling_stock = + create_fast_rolling_stock(&mut db_pool.get_ok(), &Uuid::new_v4().to_string()).await; + + let trace_id = "cd62312a1bd0df0a612ff9ade3d03635".to_string(); + let request = app + .post(format!("/timetable/{}/stdcm?infra={}", timetable.id, small_infra.id).as_str()) + .json(&stdcm_payload(rolling_stock.id)) + .add_header("traceparent", format!("00-{trace_id}-18ae0228cf2d63b0-01")) + .by_user(user.clone()); + app.fetch(request).assert_status(StatusCode::OK); + (app, user, trace_id) + } + + #[rstest] + async fn list_stdcm_logs_return_success() { + let (app, user, trace_id) = prepare_test_context(false).await; + let request = app.get("/stdcm_logs").by_user(user.clone()); + let stdcm_logs_response: StdcmLogListResponse = + app.fetch(request).assert_status(StatusCode::OK).json_into(); + + assert_eq!(stdcm_logs_response.results.len(), 1); + assert_eq!(stdcm_logs_response.results[0].trace_id, trace_id); + } + + #[rstest] + async fn get_stdcm_log_by_trace_id_return_success() { + let (app, user, trace_id) = prepare_test_context(false).await; + let request = app + .get(format!("/stdcm_logs/{trace_id}").as_str()) + .by_user(user); + let stdcm_log: StdcmLog = app.fetch(request).assert_status(StatusCode::OK).json_into(); + assert_eq!(stdcm_log.trace_id, trace_id); + } + + #[rstest] + async fn get_stdcm_log_by_trace_id_return_unauthorized() { + let (app, user, trace_id) = prepare_test_context(true).await; + let request = app + .get(format!("/stdcm_logs/{trace_id}").as_str()) + .by_user(user); + app.fetch(request).assert_status(StatusCode::FORBIDDEN); + } +} diff --git a/editoast/src/views/test_app.rs b/editoast/src/views/test_app.rs index eba92675b2c..20724758d35 100644 --- a/editoast/src/views/test_app.rs +++ b/editoast/src/views/test_app.rs @@ -159,7 +159,11 @@ impl TestAppBuilder { endpoint: Url::parse("http://localhost:4317").unwrap(), }), }; - let sub = create_tracing_subscriber(tracing_config, NoopSpanExporter); + let sub = create_tracing_subscriber( + tracing_config, + tracing_subscriber::filter::LevelFilter::DEBUG, + NoopSpanExporter, + ); let tracing_guard = tracing::subscriber::set_default(sub); // Config valkey diff --git a/editoast/src/views/timetable/stdcm.rs b/editoast/src/views/timetable/stdcm.rs index adcfef9de90..6da681211a5 100644 --- a/editoast/src/views/timetable/stdcm.rs +++ b/editoast/src/views/timetable/stdcm.rs @@ -6,8 +6,9 @@ use axum::extract::Path; use axum::extract::Query; use axum::extract::State; use axum::Extension; +use chrono::DateTime; +use chrono::Duration; use chrono::Utc; -use chrono::{DateTime, Duration}; use editoast_authz::BuiltinRole; use editoast_derive::EditoastError; use editoast_models::DbConnectionPoolV2; @@ -17,6 +18,7 @@ use editoast_schemas::train_schedule::Margins; use editoast_schemas::train_schedule::ReceptionSignal; use editoast_schemas::train_schedule::ScheduleItem; use failure_handler::SimulationFailureHandler; +use opentelemetry::trace::TraceContextExt; use request::convert_steps; use request::Request; use serde::Deserialize; @@ -25,6 +27,7 @@ use std::collections::HashMap; use std::sync::Arc; use thiserror::Error; use tracing::Instrument; +use tracing_opentelemetry::OpenTelemetrySpanExt; use utoipa::IntoParams; use utoipa::ToSchema; use validator::Validate; @@ -141,6 +144,12 @@ async fn stdcm( return Err(AuthorizationError::Forbidden.into()); } + let trace_id = tracing::Span::current() + .context() + .span() + .span_context() + .trace_id(); + stdcm_request.validate()?; let mut conn = db_pool.get().await?; @@ -273,7 +282,14 @@ async fn stdcm( // We just don't await the creation of the log entry since we want // the endpoint to return as soon as possible, and because failing // to persist a log entry is not a very important error here. - StdcmLog::log(conn, stdcm_request, stdcm_response.clone(), user_id).in_current_span(), + StdcmLog::log( + conn, + trace_id.to_string(), + stdcm_request, + stdcm_response.clone(), + user_id, + ) + .in_current_span(), ) .await; } @@ -487,19 +503,16 @@ mod tests { use crate::core::conflict_detection::Conflict; use crate::core::conflict_detection::ConflictType; - use crate::core::mocking::MockingClient; - use crate::core::pathfinding::PathfindingResultSuccess; - use crate::core::simulation::CompleteReportTrain; - use crate::core::simulation::ElectricalProfiles; use crate::core::simulation::PhysicsConsist; - use crate::core::simulation::ReportTrain; - use crate::core::simulation::SimulationResponse; - use crate::core::simulation::SpeedLimitProperties; use crate::models::fixtures::create_fast_rolling_stock; use crate::models::fixtures::create_simple_rolling_stock; use crate::models::fixtures::create_small_infra; use crate::models::fixtures::create_timetable; use crate::models::fixtures::create_towed_rolling_stock; + use crate::views::fixtures::core_mocking_client; + use crate::views::fixtures::pathfinding_result_success; + use crate::views::fixtures::simulation_response; + use crate::views::fixtures::stdcm_payload; use crate::views::test_app::TestAppBuilder; use crate::views::timetable::stdcm::PathfindingResult; @@ -650,94 +663,6 @@ mod tests { assert_eq!(physics_consist.max_speed, 20_f64); } - fn pathfinding_result_success() -> PathfindingResult { - PathfindingResult::Success(PathfindingResultSuccess { - blocks: vec![], - routes: vec![], - track_section_ranges: vec![], - length: 0, - path_item_positions: vec![0, 10], - }) - } - - fn simulation_response() -> SimulationResponse { - SimulationResponse::Success { - base: ReportTrain { - positions: vec![], - times: vec![], - speeds: vec![], - energy_consumption: 0.0, - path_item_times: vec![0, 10], - }, - provisional: ReportTrain { - positions: vec![], - times: vec![0, 10], - speeds: vec![], - energy_consumption: 0.0, - path_item_times: vec![0, 10], - }, - final_output: CompleteReportTrain { - report_train: ReportTrain { - positions: vec![], - times: vec![], - speeds: vec![], - energy_consumption: 0.0, - path_item_times: vec![0, 10], - }, - signal_critical_positions: vec![], - zone_updates: vec![], - spacing_requirements: vec![], - routing_requirements: vec![], - }, - mrsp: SpeedLimitProperties { - boundaries: vec![], - values: vec![], - }, - electrical_profiles: ElectricalProfiles { - boundaries: vec![], - values: vec![], - }, - } - } - - fn stdcm_payload(rolling_stock_id: i64) -> serde_json::Value { - json!({ - "comfort": "STANDARD", - "margin": "4.5min/100km", - "rolling_stock_id": rolling_stock_id, - "speed_limit_tags": "AR120", - "steps": [ - { - "duration": 0, - "location": { "trigram": "WS", "secondary_code": "BV" }, - "timing_data": { - "arrival_time": "2024-09-17T20:05:00+02:00", - "arrival_time_tolerance_before": 0, - "arrival_time_tolerance_after": 0 - } - }, - { "duration": 0, "location": { "trigram": "MWS", "secondary_code": "BV" } } - ], - "time_gap_after": 35000, - "time_gap_before": 35000 - }) - } - - fn core_mocking_client() -> MockingClient { - let mut core = MockingClient::new(); - core.stub("/v2/pathfinding/blocks") - .method(reqwest::Method::POST) - .response(StatusCode::OK) - .json(pathfinding_result_success()) - .finish(); - core.stub("/v2/standalone_simulation") - .method(reqwest::Method::POST) - .response(StatusCode::OK) - .json(serde_json::to_value(simulation_response()).unwrap()) - .finish(); - core - } - fn conflict_data() -> Conflict { Conflict { train_ids: vec![0, 1], diff --git a/front/public/locales/en/errors.json b/front/public/locales/en/errors.json index 8b6dd34e806..d7d28237860 100644 --- a/front/public/locales/en/errors.json +++ b/front/public/locales/en/errors.json @@ -186,6 +186,9 @@ "stdcm": { "InfraNotFound": "Infrastructure '{{infra_id}}' does not exist" }, + "stdcm_log": { + "NotFound": "" + }, "stdcm_v2": { "InfraNotFound": "Infrastructure '{{infra_id}}' does not exist", "InvalidPathItems": "Invalid waypoint(s) {{items}}", diff --git a/front/public/locales/fr/errors.json b/front/public/locales/fr/errors.json index 9f41174a6aa..c7ec765e034 100644 --- a/front/public/locales/fr/errors.json +++ b/front/public/locales/fr/errors.json @@ -188,6 +188,9 @@ "stdcm": { "InfraNotFound": "Infrastructure {{infra_id}} non trouvée" }, + "stdcm_log": { + "NotFound": "" + }, "stdcm_v2": { "InfraNotFound": "Infrastructure '{{infra_id}}' non trouvée", "InvalidPathItems": "Point(s) de passage {{items}} invalide(s)", diff --git a/front/src/common/api/generatedEditoastApi.ts b/front/src/common/api/generatedEditoastApi.ts index bbbaf449098..63c2cd3f846 100644 --- a/front/src/common/api/generatedEditoastApi.ts +++ b/front/src/common/api/generatedEditoastApi.ts @@ -18,6 +18,7 @@ export const addTagTypes = [ 'speed_limit_tags', 'sprites', 'stdcm_search_environment', + 'stdcm_log', 'temporary_speed_limits', 'timetable', 'stdcm', @@ -769,6 +770,20 @@ const injectedRtkApi = api }), invalidatesTags: ['stdcm_search_environment'], }), + getStdcmLogs: build.query({ + query: (queryArg) => ({ + url: `/stdcm_logs`, + params: { page: queryArg.page, page_size: queryArg.pageSize }, + }), + providesTags: ['stdcm_log'], + }), + getStdcmLogsByTraceId: build.query< + GetStdcmLogsByTraceIdApiResponse, + GetStdcmLogsByTraceIdApiArg + >({ + query: (queryArg) => ({ url: `/stdcm_logs/${queryArg.traceId}` }), + providesTags: ['stdcm_log'], + }), postTemporarySpeedLimitGroup: build.mutation< PostTemporarySpeedLimitGroupApiResponse, PostTemporarySpeedLimitGroupApiArg @@ -1636,6 +1651,17 @@ export type PostStdcmSearchEnvironmentApiResponse = /** status 201 */ StdcmSear export type PostStdcmSearchEnvironmentApiArg = { stdcmSearchEnvironmentCreateForm: StdcmSearchEnvironmentCreateForm; }; +export type GetStdcmLogsApiResponse = /** status 200 The list of Stdcm Logs */ PaginationStats & { + results: StdcmLog[]; +}; +export type GetStdcmLogsApiArg = { + page?: number; + pageSize?: number | null; +}; +export type GetStdcmLogsByTraceIdApiResponse = /** status 200 The stdcm log */ StdcmLog; +export type GetStdcmLogsByTraceIdApiArg = { + traceId: string; +}; export type PostTemporarySpeedLimitGroupApiResponse = /** status 201 The id of the created temporary speed limit group. */ { group_id: number; @@ -3276,30 +3302,36 @@ export type StdcmSearchEnvironmentCreateForm = { timetable_id: number; work_schedule_group_id?: number | null; }; -export type TimetableResult = { - timetable_id: number; +export type RoutingZoneRequirement = { + /** Time in ms */ + end_time: number; + entry_detector: string; + exit_detector: string; + switches: { + [key: string]: string; + }; + zone: string; }; -export type TimetableDetailedResult = { - timetable_id: number; - train_ids: number[]; +export type RoutingRequirement = { + /** Time in ms */ + begin_time: number; + route: string; + zones: RoutingZoneRequirement[]; }; -export type ConflictRequirement = { - end_time: string; - start_time: string; +export type SpacingRequirement = { + begin_time: number; + end_time: number; zone: string; }; -export type Conflict = { - conflict_type: 'Spacing' | 'Routing'; - /** Datetime of the end of the conflict */ - end_time: string; - /** List of requirements causing the conflict */ - requirements: ConflictRequirement[]; - /** Datetime of the start of the conflict */ - start_time: string; - /** List of train ids involved in the conflict */ - train_ids: number[]; - /** List of work schedule ids involved in the conflict */ - work_schedule_ids: number[]; +export type WorkScheduleType = 'CATENARY' | 'TRACK'; +export type WorkSchedule = { + end_date_time: string; + id: number; + obj_id: string; + start_date_time: string; + track_ranges: TrackRange[]; + work_schedule_group_id: number; + work_schedule_type: WorkScheduleType; }; export type ReportTrain = { /** Total energy consumption */ @@ -3314,22 +3346,6 @@ export type ReportTrain = { speeds: number[]; times: number[]; }; -export type RoutingZoneRequirement = { - /** Time in ms */ - end_time: number; - entry_detector: string; - exit_detector: string; - switches: { - [key: string]: string; - }; - zone: string; -}; -export type RoutingRequirement = { - /** Time in ms */ - begin_time: number; - route: string; - zones: RoutingZoneRequirement[]; -}; export type SignalCriticalPosition = { /** Position in mm */ position: number; @@ -3338,11 +3354,6 @@ export type SignalCriticalPosition = { /** Time in ms */ time: number; }; -export type SpacingRequirement = { - begin_time: number; - end_time: number; - zone: string; -}; export type ZoneUpdate = { is_entry: boolean; position: number; @@ -3411,6 +3422,130 @@ export type SimulationResponse = core_error: InternalError; status: 'simulation_failed'; }; +export type StdcmLog = { + created: string; + id: number; + request: { + comfort: Comfort; + /** Infrastructure expected version */ + expected_version: string; + /** Infrastructure id */ + infra: number; + margin?: + | ( + | { + Percentage: number; + } + | { + MinPer100Km: number; + } + ) + | null; + /** Maximum departure delay in milliseconds. */ + maximum_departure_delay: number; + /** Maximum run time of the simulation in milliseconds */ + maximum_run_time: number; + /** List of waypoints. Each waypoint is a list of track offset. */ + path_items: PathItem[]; + physics_consist: { + base_power_class?: string | null; + /** In m/s² */ + comfort_acceleration: number; + /** The constant gamma braking coefficient used when NOT circulating + under ETCS/ERTMS signaling system in m/s^2 */ + const_gamma: number; + effort_curves: EffortCurves; + /** The time the train takes before actually using electrical power (in milliseconds). + Is null if the train is not electric or the value not specified. */ + electrical_power_startup_time?: number | null; + inertia_coefficient: number; + /** Length of the rolling stock in mm */ + length: number; + /** Mass of the rolling stock in kg */ + mass: number; + /** Maximum speed of the rolling stock in m/s */ + max_speed: number; + /** Mapping of power restriction code to power class */ + power_restrictions?: { + [key: string]: string; + }; + /** The time it takes to raise this train's pantograph in milliseconds. + Is null if the train is not electric or the value not specified. */ + raise_pantograph_time?: number | null; + rolling_resistance: RollingResistance; + /** In m/s² */ + startup_acceleration: number; + startup_time: number; + }; + rolling_stock_loading_gauge: LoadingGaugeType; + rolling_stock_supported_signaling_systems: RollingStockSupportedSignalingSystems; + speed_limit_tag?: string | null; + start_time: string; + /** List of applicable temporary speed limits between the train departure and arrival */ + temporary_speed_limits: { + /** Speed limitation in m/s */ + speed_limit: number; + /** Track ranges on which the speed limitation applies */ + track_ranges: TrackRange[]; + }[]; + /** Gap between the created train and following trains in milliseconds */ + time_gap_after: number; + /** Gap between the created train and previous trains in milliseconds */ + time_gap_before: number; + /** Numerical integration time step in milliseconds. Use default value if not specified. */ + time_step?: number | null; + trains_requirements: { + [key: string]: { + routing_requirements: RoutingRequirement[]; + spacing_requirements: SpacingRequirement[]; + start_time: string; + }; + }; + /** List of planned work schedules */ + work_schedules: WorkSchedule[]; + }; + response: + | { + departure_time: string; + path: PathfindingResultSuccess; + simulation: SimulationResponse; + status: 'success'; + } + | { + status: 'path_not_found'; + } + | { + error: SimulationResponse; + status: 'preprocessing_simulation_error'; + }; + trace_id: string; + user_id?: number | null; +}; +export type TimetableResult = { + timetable_id: number; +}; +export type TimetableDetailedResult = { + timetable_id: number; + train_ids: number[]; +}; +export type ConflictRequirement = { + end_time: string; + start_time: string; + zone: string; +}; +export type Conflict = { + conflict_type: 'Spacing' | 'Routing'; + /** Datetime of the end of the conflict */ + end_time: string; + /** List of requirements causing the conflict */ + requirements: ConflictRequirement[]; + /** Datetime of the start of the conflict */ + start_time: string; + /** List of train ids involved in the conflict */ + train_ids: number[]; + /** List of work schedule ids involved in the conflict */ + work_schedule_ids: number[]; +}; export type StepTimingData = { /** Time at which the train should arrive at the location */ arrival_time: string; @@ -3611,16 +3746,6 @@ export type WorkScheduleItemForm = { track_ranges: TrackRange[]; work_schedule_type: 'CATENARY' | 'TRACK'; }; -export type WorkScheduleType = 'CATENARY' | 'TRACK'; -export type WorkSchedule = { - end_date_time: string; - id: number; - obj_id: string; - start_date_time: string; - track_ranges: TrackRange[]; - work_schedule_group_id: number; - work_schedule_type: WorkScheduleType; -}; export type Intersection = { /** Distance of the end of the intersection relative to the beginning of the path */ end: number;