From 0b542ff5428fb6226aaf80ffa8b36cef72c31fe0 Mon Sep 17 00:00:00 2001 From: Florian Amsallem Date: Fri, 23 Feb 2024 16:36:40 +0100 Subject: [PATCH] editoast: add views for timetable and train schedule v2 Co-authored-by: Youness Chrifi Alaoui --- editoast/openapi.yaml | 504 +++++++++++++++++++++++- editoast/src/fixtures.rs | 41 ++ editoast/src/views/mod.rs | 6 +- editoast/src/views/v2/mod.rs | 12 + editoast/src/views/v2/timetable.rs | 285 ++++++++++++++ editoast/src/views/v2/train_schedule.rs | 345 ++++++++++++++++ 6 files changed, 1187 insertions(+), 6 deletions(-) create mode 100644 editoast/src/views/v2/mod.rs create mode 100644 editoast/src/views/v2/timetable.rs create mode 100644 editoast/src/views/v2/train_schedule.rs diff --git a/editoast/openapi.yaml b/editoast/openapi.yaml index dbaae0c251c..438931d16ee 100644 --- a/editoast/openapi.yaml +++ b/editoast/openapi.yaml @@ -65,6 +65,17 @@ components: - percentage - value_type type: object + BatchDeletionRequest: + properties: + ids: + items: + format: int64 + type: integer + type: array + uniqueItems: true + required: + - ids + type: object BoundingBox: description: A bounding box items: @@ -180,6 +191,11 @@ components: - end - direction type: object + Distribution: + enum: + - STANDARD + - MARECO + type: string EditoastAttachedErrorTrackNotFound: properties: context: @@ -672,6 +688,7 @@ components: - $ref: '#/components/schemas/EditoastStudyErrorStartDateAfterEndDate' - $ref: '#/components/schemas/EditoastTimetableErrorInfraNotLoaded' - $ref: '#/components/schemas/EditoastTimetableErrorNotFound' + - $ref: '#/components/schemas/EditoastTimetableErrorNotFound' - $ref: '#/components/schemas/EditoastTrainScheduleErrorBatchShouldHaveSameTimetable' - $ref: '#/components/schemas/EditoastTrainScheduleErrorBatchTrainScheduleNotFound' - $ref: '#/components/schemas/EditoastTrainScheduleErrorNoSimulation' @@ -681,6 +698,8 @@ components: - $ref: '#/components/schemas/EditoastTrainScheduleErrorRollingStockNotFound' - $ref: '#/components/schemas/EditoastTrainScheduleErrorTimetableNotFound' - $ref: '#/components/schemas/EditoastTrainScheduleErrorUnsimulatedTrainSchedule' + - $ref: '#/components/schemas/EditoastTrainScheduleErrorBatchTrainScheduleNotFound' + - $ref: '#/components/schemas/EditoastTrainScheduleErrorNotFound' - $ref: '#/components/schemas/EditoastTypeCheckErrorArgMissing' - $ref: '#/components/schemas/EditoastTypeCheckErrorArgTypeMismatch' - $ref: '#/components/schemas/EditoastTypeCheckErrorUnexpectedArg' @@ -2274,16 +2293,21 @@ components: EditoastTrainScheduleErrorBatchTrainScheduleNotFound: properties: context: + properties: + number: + type: integer + required: + - number type: object message: type: string status: enum: - - 400 + - 404 type: integer type: enum: - - editoast:train_schedule:BatchTrainScheduleNotFound + - editoast:train_schedule_v2:BatchTrainScheduleNotFound type: string required: - type @@ -2341,11 +2365,11 @@ components: type: string status: enum: - - 400 + - 404 type: integer type: enum: - - editoast:train_schedule:NotFound + - editoast:train_schedule_v2:NotFound type: string required: - type @@ -3616,6 +3640,34 @@ components: - next - results type: object + PaginatedResponseOfTimetable: + description: A paginated response + properties: + count: + description: The total number of items + format: int64 + type: integer + next: + description: The next page number + format: int64 + nullable: true + type: integer + previous: + description: The previous page number + format: int64 + nullable: true + type: integer + results: + description: The list of results + items: + $ref: '#/components/schemas/TimetableResult' + type: array + required: + - count + - previous + - next + - results + type: object Patch: description: A JSONPatch document as defined by RFC 6902 properties: @@ -5547,6 +5599,38 @@ components: - id - name type: object + TimetableDetailedResult: + allOf: + - description: Creation form for a Timetable + properties: + electrical_profile_set_id: + format: int64 + nullable: true + type: integer + id: + format: int64 + type: integer + required: + - id + type: object + - properties: + train_ids: + items: + format: int64 + type: integer + type: array + required: + - train_ids + type: object + description: Creation form for a Timetable + TimetableForm: + description: Creation form for a Timetable + properties: + electrical_profile_set_id: + format: int64 + nullable: true + type: integer + type: object TimetableImportError: oneOf: - properties: @@ -5703,6 +5787,19 @@ components: - name - departure_time type: object + TimetableResult: + description: Creation form for a Timetable + properties: + electrical_profile_set_id: + format: int64 + nullable: true + type: integer + id: + format: int64 + type: integer + required: + - id + type: object TimetableWithSchedulesDetails: allOf: - $ref: '#/components/schemas/Timetable' @@ -5819,6 +5916,181 @@ components: - timetable_id - scheduled_points type: object + TrainScheduleBase: + properties: + comfort: + allOf: + - $ref: '#/components/schemas/Comfort' + constraint_distribution: + $ref: '#/components/schemas/Distribution' + initial_speed: + format: double + type: number + labels: + items: + type: string + type: array + margins: + allOf: + - additionalProperties: false + properties: + boundaries: + items: + minLength: 1 + type: string + type: array + values: + description: |- + The values of the margins. Must contains one more element than the boundaries + Can be a percentage `X%`, a time in minutes per kilometer `Xmin/km` or `0` + example: + - '0' + - 5% + - 2min/km + items: + type: string + type: array + required: + - boundaries + - values + type: object + options: + allOf: + - additionalProperties: false + properties: + use_electrical_profiles: + default: 'true' + type: boolean + required: + - use_electrical_profiles + type: object + path: + items: + allOf: + - description: The location of a path waypoint + oneOf: + - properties: + offset: + description: The offset in millimeters from the start of the track + format: int64 + minimum: 0 + type: integer + track: + minLength: 1 + type: string + required: + - track + - offset + type: object + - properties: + operational_point: + minLength: 1 + type: string + required: + - operational_point + type: object + - properties: + secondary_code: + description: An optional secondary code to identify a more specific location + nullable: true + type: string + trigram: + minLength: 1 + type: string + required: + - trigram + type: object + - properties: + secondary_code: + description: An optional secondary code to identify a more specific location + nullable: true + type: string + uic: + description: The [UIC](https://en.wikipedia.org/wiki/Railway_vehicle_owner%27s_code) code of an operational point + format: int32 + minimum: 0 + type: integer + required: + - uic + type: object + - properties: + deleted: + description: |- + Metadata given to mark a point as wishing to be deleted by the user. + It's useful for soft deleting the point (waiting to fix / remove all references) + If true, the train schedule is consider as invalid and must be edited + type: boolean + id: + minLength: 1 + type: string + required: + - id + type: object + description: A location on the path of a train + type: array + power_restrictions: + items: + additionalProperties: false + properties: + from: + minLength: 1 + type: string + to: + minLength: 1 + type: string + value: + type: string + required: + - from + - to + - value + type: object + type: array + rolling_stock_name: + type: string + schedule: + items: + additionalProperties: false + properties: + arrival: + nullable: true + type: string + at: + minLength: 1 + type: string + locked: + type: boolean + stop_for: + nullable: true + type: string + required: + - at + type: object + type: array + speed_limit_tag: + allOf: + - minLength: 1 + type: string + nullable: true + start_time: + format: date-time + type: string + train_name: + type: string + required: + - train_name + - labels + - rolling_stock_name + - start_time + - path + - schedule + - margins + - initial_speed + - comfort + - constraint_distribution + - power_restrictions + - options + type: object TrainScheduleBatchItem: properties: allowances: @@ -5864,6 +6136,16 @@ components: - initial_speed - rolling_stock_id type: object + TrainScheduleForm: + allOf: + - $ref: '#/components/schemas/TrainScheduleBase' + - properties: + timetable_id: + format: int64 + type: integer + required: + - timetable_id + type: object TrainScheduleOptions: description: Options for the standalone simulation properties: @@ -5931,6 +6213,20 @@ components: required: - id type: object + TrainScheduleResult: + allOf: + - $ref: '#/components/schemas/TrainScheduleBase' + - properties: + id: + format: int64 + type: integer + timetable_id: + format: int64 + type: integer + required: + - id + - timetable_id + type: object TrainScheduleScenarioStudyProject: properties: project_id: @@ -8608,6 +8904,206 @@ paths: summary: Retrieve a simulation result tags: - train_schedule + /v2/timetable/: + get: + description: Return all timetables + parameters: + - in: query + name: page + required: false + schema: + default: 1 + format: int64 + minimum: 1 + type: integer + - in: query + name: page_size + required: false + schema: + default: 25 + format: int64 + minimum: 1 + nullable: true + type: integer + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedResponseOfTimetable' + description: List timetables + summary: Return all timetables + tags: + - timetablev2 + post: + description: Return a specific timetable with its associated schedules + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/TimetableForm' + required: true + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/TimetableResult' + description: Timetable with train schedules ids + '404': + description: Timetable not found + summary: Return a specific timetable with its associated schedules + tags: + - timetablev2 + /v2/timetable/{id}/: + delete: + description: Return a specific timetable with its associated schedules + responses: + '204': + description: No content + '404': + description: Timetable not found + summary: Return a specific timetable with its associated schedules + tags: + - timetablev2 + get: + description: Return a specific timetable with its associated schedules + parameters: + - description: Timetable id + in: path + name: id + required: true + schema: + format: int64 + minimum: 0 + type: integer + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/TimetableDetailedResult' + description: Timetable with train schedules ids + '404': + description: Timetable not found + summary: Return a specific timetable with its associated schedules + tags: + - timetablev2 + put: + description: Update a specific timetable with its associated schedules + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/TimetableForm' + description: '' + required: true + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/TimetableDetailedResult' + description: Timetable with train schedules ids + '404': + description: Timetable not found + summary: Update a specific timetable with its associated schedules + tags: + - timetablev2 + /v2/train_schedule/: + delete: + description: Delete a train schedule and its result + requestBody: + content: + application/json: + schema: + properties: + ids: + items: + format: int64 + type: integer + type: array + uniqueItems: true + required: + - ids + type: object + required: true + responses: + '204': + description: All train schedules have been deleted + summary: Delete a train schedule and its result + tags: + - train_schedulev2 + post: + description: Create train schedule by batch + requestBody: + content: + application/json: + schema: + items: + $ref: '#/components/schemas/TrainScheduleForm' + type: array + required: true + responses: + '200': + content: + application/json: + schema: + items: + $ref: '#/components/schemas/TrainScheduleResult' + type: array + description: The train schedule + summary: Create train schedule by batch + tags: + - train_schedulev2 + /v2/train_schedule/{id}/: + get: + description: Return a specific timetable with its associated schedules + parameters: + - description: A train schedule ID + in: path + name: id + required: true + schema: + format: int64 + type: integer + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/TrainScheduleResult' + description: The train schedule + summary: Return a specific timetable with its associated schedules + tags: + - train_schedulev2 + put: + description: Update train schedule at once + parameters: + - description: A train schedule ID + in: path + name: id + required: true + schema: + format: int64 + type: integer + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/TrainScheduleForm' + required: true + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/TrainScheduleResult' + description: The train schedule have been updated + summary: Update train schedule at once + tags: + - train_schedulev2 + - timetable /version/: get: responses: diff --git a/editoast/src/fixtures.rs b/editoast/src/fixtures.rs index bd22a72ab38..3cbf513514d 100644 --- a/editoast/src/fixtures.rs +++ b/editoast/src/fixtures.rs @@ -10,10 +10,14 @@ pub mod tests { Scenario, SimulationOutput, SimulationOutputChangeset, Study, Timetable, TrainSchedule, }; use crate::modelsv2::projects::Tags; + use crate::modelsv2::timetable::Timetable as TimetableV2; + use crate::modelsv2::train_schedule::TrainSchedule as TrainScheduleV2; use crate::modelsv2::{self, Document, Model, Project}; use crate::schema::electrical_profiles::{ElectricalProfile, ElectricalProfileSetData}; + use crate::schema::v2::trainschedule::TrainScheduleBase; use crate::schema::{RailJson, TrackRange}; use crate::views::infra::InfraForm; + use crate::views::v2::train_schedule::TrainScheduleForm; use crate::DbPool; use actix_web::web::Data; @@ -163,6 +167,34 @@ pub mod tests { train_schedule.create(db_pool).await.unwrap() } + #[derive(Debug)] + pub struct TrainScheduleV2FixtureSet { + pub train_schedule: TestFixture, + pub timetable: TestFixture, + } + + #[fixture] + pub async fn train_schedule_v2( + #[future] timetable_v2: TestFixture, + db_pool: Data, + ) -> TrainScheduleV2FixtureSet { + let timetable = timetable_v2.await; + let train_schedule_base: TrainScheduleBase = + serde_json::from_str(include_str!("./tests/train_schedules/simple.json")) + .expect("Unable to parse"); + let train_schedule_form = TrainScheduleForm { + timetable_id: timetable.id(), + train_schedule: train_schedule_base, + }; + + let train_schedule = TestFixture::create(train_schedule_form.into(), db_pool).await; + + TrainScheduleV2FixtureSet { + train_schedule, + timetable, + } + } + #[derive(Debug)] pub struct TrainScheduleFixtureSet { pub train_schedule: TestFixture, @@ -293,6 +325,15 @@ pub mod tests { TestFixture::create_legacy(timetable_model, db_pool).await } + #[fixture] + pub async fn timetable_v2(db_pool: Data) -> TestFixture { + TestFixture::create( + TimetableV2::changeset().electrical_profile_set_id(None), + db_pool, + ) + .await + } + #[fixture] pub async fn document_example(db_pool: Data) -> TestFixture { let img = image::open("src/tests/example_rolling_stock_image_1.gif").unwrap(); diff --git a/editoast/src/views/mod.rs b/editoast/src/views/mod.rs index c4adc1fdf28..7af12b18697 100644 --- a/editoast/src/views/mod.rs +++ b/editoast/src/views/mod.rs @@ -17,6 +17,7 @@ pub mod stdcm; pub mod study; pub mod timetable; pub mod train_schedule; +pub mod v2; use self::openapi::{merge_path_items, remove_discriminator, OpenApiMerger, Routes}; use crate::client::get_app_version; @@ -48,15 +49,15 @@ fn routes_v2() -> Routes { (health, version, core_version), (rolling_stocks::routes(), light_rolling_stocks::routes()), (pathfinding::routes(), stdcm::routes(), train_schedule::routes()), - timetable::routes(), + (projects::routes(),timetable::routes()), documents::routes(), sprites::routes(), - projects::routes(), search::routes(), electrical_profiles::routes(), layers::routes(), infra::routes(), single_simulation::routes(), + v2::routes() } routes() } @@ -83,6 +84,7 @@ schemas! { electrical_profiles::schemas(), infra::schemas(), single_simulation::schemas(), + v2::schemas(), } #[derive(OpenApi)] diff --git a/editoast/src/views/v2/mod.rs b/editoast/src/views/v2/mod.rs new file mode 100644 index 00000000000..32396a1701d --- /dev/null +++ b/editoast/src/views/v2/mod.rs @@ -0,0 +1,12 @@ +pub mod timetable; +pub mod train_schedule; + +crate::routes! { + train_schedule::routes(), + timetable::routes(), +} + +crate::schemas! { + train_schedule::schemas(), + timetable::schemas(), +} diff --git a/editoast/src/views/v2/timetable.rs b/editoast/src/views/v2/timetable.rs new file mode 100644 index 00000000000..57eaf8d4ba0 --- /dev/null +++ b/editoast/src/views/v2/timetable.rs @@ -0,0 +1,285 @@ +use crate::decl_paginated_response; +use crate::error::Result; +use crate::models::List; +use crate::models::NoParams; +use crate::modelsv2::timetable::{Timetable, TimetableWithTrains}; +use crate::modelsv2::{Create, DeleteStatic, Model, Retrieve, Update}; +use crate::views::pagination::PaginatedResponse; +use crate::views::pagination::PaginationQueryParam; +use crate::DbPool; + +use actix_web::web::{Data, Json, Path, Query}; +use actix_web::{delete, get, post, put, HttpResponse}; +use derivative::Derivative; +use editoast_derive::EditoastError; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use utoipa::ToSchema; + +crate::routes! { + "/v2/timetable" => { + post, + list, + "/{id}" => { + delete, + get, + put, + } + }, +} + +crate::schemas! { + PaginatedResponseOfTimetable, + TimetableForm, + TimetableResult, + TimetableDetailedResult, +} + +#[derive(Debug, Error, EditoastError)] +#[editoast_error(base_id = "timetable")] +enum TimetableError { + #[error("Timetable '{timetable_id}', could not be found")] + #[editoast_error(status = 404)] + NotFound { timetable_id: i64 }, +} + +/// Creation form for a Timetable +#[derive(Serialize, Deserialize, Derivative, ToSchema)] +#[derivative(Default)] +struct TimetableForm { + #[serde(default)] + pub electrical_profile_set_id: Option, +} + +/// Creation form for a Timetable +#[derive(Debug, Default, Serialize, Deserialize, Derivative, ToSchema)] +struct TimetableResult { + pub id: i64, + pub electrical_profile_set_id: Option, +} + +impl From for TimetableResult { + fn from(timetable: Timetable) -> Self { + Self { + id: timetable.id, + electrical_profile_set_id: timetable.electrical_profile_set_id, + } + } +} + +/// Creation form for a Timetable +#[derive(Debug, Default, Serialize, Deserialize, Derivative, ToSchema)] +struct TimetableDetailedResult { + #[serde(flatten)] + #[schema(inline)] + pub timetable: TimetableResult, + pub train_ids: Vec, +} + +impl From for TimetableDetailedResult { + fn from(val: TimetableWithTrains) -> Self { + Self { + timetable: TimetableResult { + id: val.id, + electrical_profile_set_id: val.electrical_profile_set_id, + }, + train_ids: val.train_ids, + } + } +} + +/// Return a specific timetable with its associated schedules +#[utoipa::path( + tag = "timetablev2", + params( + ("id" = u64, Path, description = "Timetable id"), + ), + responses( + (status = 200, description = "Timetable with train schedules ids", body = TimetableDetailedResult), + (status = 404, description = "Timetable not found"), + ), +)] +#[get("")] +async fn get( + db_pool: Data, + timetable_id: Path, +) -> Result> { + let timetable_id = timetable_id.into_inner(); + // Return the timetable + + let conn = &mut db_pool.get().await?; + let timetable = TimetableWithTrains::retrieve_or_fail(conn, timetable_id, || { + TimetableError::NotFound { timetable_id } + }) + .await?; + + Ok(Json(timetable.into())) +} + +decl_paginated_response!(PaginatedResponseOfTimetable, TimetableResult); +/// Return all timetables +#[utoipa::path( + tag = "timetablev2", + params(PaginationQueryParam), + responses( + (status = 200, description = "List timetables", body = PaginatedResponseOfTimetable), + ), +)] +#[get("")] +async fn list( + db_pool: Data, + pagination_params: Query, +) -> Result>> { + let (page, per_page) = pagination_params + .validate(1000)? + .warn_page_size(100) + .unpack(); + let conn = &mut db_pool.get().await?; + let timetable = Timetable::list_conn(conn, page, per_page, NoParams).await?; + Ok(Json(timetable.into())) +} + +/// Return a specific timetable with its associated schedules +#[utoipa::path( + tag = "timetablev2", + request_body = TimetableForm, + responses( + (status = 200, description = "Timetable with train schedules ids", body = TimetableResult), + (status = 404, description = "Timetable not found"), + ), +)] +#[post("")] +async fn post(db_pool: Data, data: Json) -> Result> { + let conn = &mut db_pool.get().await?; + + let elec_profile_set = data.into_inner().electrical_profile_set_id; + let changeset = Timetable::changeset().electrical_profile_set_id(elec_profile_set); + let timetable = changeset.create(conn).await?; + + Ok(Json(timetable.into())) +} + +/// Update a specific timetable with its associated schedules +#[utoipa::path( + tag = "timetablev2", + responses( + (status = 200, description = "Timetable with train schedules ids", body = TimetableDetailedResult), + (status = 404, description = "Timetable not found"), + ), +)] +#[put("")] +async fn put( + db_pool: Data, + timetable_id: Path, + data: Json, +) -> Result> { + let timetable_id = timetable_id.into_inner(); + let conn = &mut db_pool.get().await?; + + let elec_profile_set = data.into_inner().electrical_profile_set_id; + let changeset = Timetable::changeset().electrical_profile_set_id(elec_profile_set); + changeset + .update_or_fail(conn, timetable_id, || TimetableError::NotFound { + timetable_id, + }) + .await?; + + let timetable = TimetableWithTrains::retrieve_or_fail(conn, timetable_id, || { + TimetableError::NotFound { timetable_id } + }) + .await?; + Ok(Json(timetable.into())) +} + +/// Return a specific timetable with its associated schedules +#[utoipa::path( + tag = "timetablev2", + responses( + (status = 204, description = "No content"), + (status = 404, description = "Timetable not found"), + ), +)] +#[delete("")] +async fn delete(db_pool: Data, timetable_id: Path) -> Result { + let timetable_id = timetable_id.into_inner(); + let conn = &mut db_pool.get().await?; + Timetable::delete_static_or_fail(conn, timetable_id, || TimetableError::NotFound { + timetable_id, + }) + .await?; + Ok(HttpResponse::NoContent().finish()) +} + +#[cfg(test)] +mod tests { + + use super::*; + use crate::fixtures::tests::{db_pool, timetable_v2, TestFixture}; + use crate::modelsv2::Delete; + use crate::views::tests::create_test_service; + use actix_web::test::{call_and_read_body_json, call_service, TestRequest}; + use rstest::rstest; + use serde_json::json; + + #[rstest] + async fn get_timetable(#[future] timetable_v2: TestFixture, db_pool: Data) { + let service = create_test_service().await; + let timetable = timetable_v2.await; + + let url = format!("/v2/timetable/{}", timetable.id()); + + // Should succeed + let request = TestRequest::get().uri(&url).to_request(); + let response = call_service(&service, request).await; + assert!(response.status().is_success()); + + // Delete the timetable + assert!(timetable + .model + .delete(&mut db_pool.get().await.unwrap()) + .await + .unwrap()); + + // Should fail + let request = TestRequest::get().uri(&url).to_request(); + let response = call_service(&service, request).await; + assert!(response.status().is_client_error()); + } + + #[rstest] + async fn timetable_post(db_pool: Data) { + let service = create_test_service().await; + + // Insert timetable + let request = TestRequest::post() + .uri("/v2/timetable") + .set_json(json!({ "electrical_profil_set_id": None::})) + .to_request(); + let response: TimetableResult = call_and_read_body_json(&service, request).await; + + // Delete the timetable + assert!( + Timetable::delete_static(&mut db_pool.get().await.unwrap(), response.id) + .await + .unwrap() + ); + } + + #[rstest] + async fn timetable_delete(#[future] timetable_v2: TestFixture) { + let timetable = timetable_v2.await; + let service = create_test_service().await; + let request = TestRequest::delete() + .uri(format!("/v2/timetable/{}", timetable.id()).as_str()) + .to_request(); + assert!(call_service(&service, request).await.status().is_success()); + } + + #[rstest] + async fn timetable_list(#[future] timetable_v2: TestFixture) { + timetable_v2.await; + let service = create_test_service().await; + let request = TestRequest::get().uri("/v2/timetable/").to_request(); + assert!(call_service(&service, request).await.status().is_success()); + } +} diff --git a/editoast/src/views/v2/train_schedule.rs b/editoast/src/views/v2/train_schedule.rs new file mode 100644 index 00000000000..0d43ac786ae --- /dev/null +++ b/editoast/src/views/v2/train_schedule.rs @@ -0,0 +1,345 @@ +use std::collections::HashSet; + +use crate::error::Result; +use crate::modelsv2::train_schedule::{TrainSchedule, TrainScheduleChangeset}; +use crate::modelsv2::Model; +use crate::schema::v2::trainschedule::{Distribution, TrainScheduleBase}; +use crate::DbPool; +use actix_web::web::{Data, Json, Path}; +use actix_web::{delete, get, post, put, HttpResponse}; +use diesel_json::Json as DieselJson; +use editoast_derive::EditoastError; +use itertools::Itertools; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use utoipa::{IntoParams, ToSchema}; + +crate::routes! { + "/v2/train_schedule" => { + post, + delete, + "/{id}" => { + get, + put, + } + }, +} + +crate::schemas! { + Distribution, + TrainScheduleBase, + TrainScheduleForm, + TrainScheduleResult, + BatchDeletionRequest, +} + +#[derive(Debug, Error, EditoastError)] +#[editoast_error(base_id = "train_schedule_v2")] +pub enum TrainScheduleError { + #[error("Train Schedule '{train_schedule_id}', could not be found")] + #[editoast_error(status = 404)] + NotFound { train_schedule_id: i64 }, + #[error("{number} train schedule(s) could not be found")] + #[editoast_error(status = 404)] + BatchTrainScheduleNotFound { number: usize }, +} + +#[derive(IntoParams)] +#[allow(unused)] +struct TrainScheduleIdParam { + /// A train schedule ID + id: i64, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, ToSchema)] +struct TrainScheduleResult { + id: i64, + timetable_id: i64, + #[serde(flatten)] + train_schedule: TrainScheduleBase, +} + +impl From for TrainScheduleResult { + fn from(value: TrainSchedule) -> Self { + Self { + id: value.id, + timetable_id: value.timetable_id, + train_schedule: TrainScheduleBase { + train_name: value.train_name, + labels: value.labels.into_iter().flatten().collect(), + rolling_stock_name: value.rolling_stock_name, + start_time: value.start_time, + schedule: value.schedule.0, + margins: value.margins.0, + initial_speed: value.initial_speed, + comfort: value.comfort, + path: value.path.0, + constraint_distribution: value.constraint_distribution, + speed_limit_tag: value.speed_limit_tag.map(Into::into), + power_restrictions: value.power_restrictions.0, + options: value.options.0, + }, + } + } +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, ToSchema)] +pub struct TrainScheduleForm { + pub timetable_id: i64, + #[serde(flatten)] + pub train_schedule: TrainScheduleBase, +} + +impl From for TrainScheduleChangeset { + fn from(value: TrainScheduleForm) -> Self { + let TrainScheduleForm { + timetable_id, + train_schedule: ts, + } = value; + + TrainSchedule::changeset() + .timetable_id(timetable_id) + .comfort(ts.comfort) + .constraint_distribution(ts.constraint_distribution) + .initial_speed(ts.initial_speed) + .labels(ts.labels.into_iter().map(Some).collect()) + .margins(DieselJson(ts.margins)) + .path(DieselJson(ts.path)) + .power_restrictions(DieselJson(ts.power_restrictions)) + .rolling_stock_name(ts.rolling_stock_name) + .schedule(DieselJson(ts.schedule)) + .speed_limit_tag(ts.speed_limit_tag.map(|s| s.0)) + .start_time(ts.start_time) + .train_name(ts.train_name) + .options(DieselJson(ts.options)) + } +} + +#[derive(Debug, Deserialize, ToSchema)] +struct BatchDeletionRequest { + ids: HashSet, +} + +/// Create train schedule by batch +#[utoipa::path( + tag = "train_schedulev2", + request_body = Vec, + responses( + (status = 200, description = "The train schedule", body = Vec) + ) +)] +#[post("")] +async fn post( + db_pool: Data, + data: Json>, +) -> Result>> { + use crate::modelsv2::CreateBatch; + + let changesets: Vec = + data.into_inner().into_iter().map_into().collect(); + let conn = &mut db_pool.get().await?; + + // Create a batch of train_schedule + let train_schedule: Vec<_> = TrainSchedule::create_batch(conn, changesets).await?; + Ok(Json(train_schedule.into_iter().map_into().collect())) +} + +/// Return a specific timetable with its associated schedules +#[utoipa::path( + tag = "train_schedulev2", + params(TrainScheduleIdParam), + responses( + (status = 200, description = "The train schedule", body = TrainScheduleResult) + ) +)] +#[get("")] +async fn get( + db_pool: Data, + train_schedule_id: Path, +) -> Result> { + use crate::modelsv2::Retrieve; + + let train_schedule_id = train_schedule_id.into_inner(); + let conn = &mut db_pool.get().await?; + + // Return the timetable + let train_schedule = TrainSchedule::retrieve_or_fail(conn, train_schedule_id, || { + TrainScheduleError::NotFound { train_schedule_id } + }) + .await?; + Ok(Json(train_schedule.into())) +} + +/// Delete a train schedule and its result +#[utoipa::path( + tag = "train_schedulev2", + request_body = inline(BatchDeletionRequest), + responses( + (status = 204, description = "All train schedules have been deleted") + ) +)] +#[delete("")] +async fn delete(db_pool: Data, data: Json) -> Result { + use crate::modelsv2::DeleteBatch; + + let conn = &mut db_pool.get().await?; + let train_ids = data.into_inner().ids; + dbg!(train_ids.clone()); + TrainSchedule::delete_batch_or_fail(conn, train_ids, |number| { + TrainScheduleError::BatchTrainScheduleNotFound { number } + }) + .await?; + + Ok(HttpResponse::NoContent().finish()) +} + +/// Update train schedule at once +#[utoipa::path( + tag = "train_schedulev2,timetable", + request_body = TrainScheduleForm, + params(TrainScheduleIdParam), + responses( + (status = 200, description = "The train schedule have been updated", body = TrainScheduleResult) + ) +)] +#[put("")] +async fn put( + db_pool: Data, + train_schedule_id: Path, + data: Json, +) -> Result> { + use crate::modelsv2::Update; + let conn = &mut db_pool.get().await?; + + let train_id = train_schedule_id.into_inner(); + let ts_changeset: TrainScheduleChangeset = data.into_inner().into(); + + let ts_result = ts_changeset + .update_or_fail(conn, train_id, || TrainScheduleError::NotFound { + train_schedule_id: train_id, + }) + .await?; + + Ok(Json(ts_result.into())) +} + +#[cfg(test)] +mod tests { + + use super::*; + use crate::fixtures::tests::{ + db_pool, timetable_v2, train_schedule_v2, TestFixture, TrainScheduleV2FixtureSet, + }; + use crate::modelsv2::timetable::Timetable; + use crate::modelsv2::{Delete, DeleteStatic}; + use crate::views::tests::create_test_service; + use actix_web::test::{call_and_read_body_json, call_service, TestRequest}; + use rstest::rstest; + use serde_json::json; + + #[rstest] + async fn get_trainschedule( + #[future] train_schedule_v2: TrainScheduleV2FixtureSet, + db_pool: Data, + ) { + let service = create_test_service().await; + let fixture = train_schedule_v2.await; + let url = format!("/v2/train_schedule/{}", fixture.train_schedule.id()); + + // Should succeed + let request = TestRequest::get().uri(&url).to_request(); + let response = call_service(&service, request).await; + assert!(response.status().is_success()); + + // Delete the train_schedule + assert!(fixture + .train_schedule + .model + .delete(&mut db_pool.get().await.unwrap()) + .await + .unwrap()); + + // Should fail + let request = TestRequest::get().uri(&url).to_request(); + let response = call_service(&service, request).await; + assert!(response.status().is_client_error()); + } + + #[rstest] + async fn train_schedule_post( + #[future] timetable_v2: TestFixture, + db_pool: Data, + ) { + let service = create_test_service().await; + + let timetable = timetable_v2.await; + // Insert train_schedule + let train_schedule_base: TrainScheduleBase = + serde_json::from_str(include_str!("../../tests/train_schedules/simple.json")) + .expect("Unable to parse"); + let train_schedule = TrainScheduleForm { + timetable_id: timetable.id(), + train_schedule: train_schedule_base, + }; + let request = TestRequest::post() + .uri("/v2/train_schedule") + .set_json(json!(vec![train_schedule])) + .to_request(); + let response: Vec = call_and_read_body_json(&service, request).await; + assert_eq!(response.len(), 1); + let train_id = response[0].id; + + // Delete the train_schedule + assert!( + TrainSchedule::delete_static(&mut db_pool.get().await.unwrap(), train_id) + .await + .is_ok() + ); + } + + #[rstest] + async fn train_schedule_delete(#[future] train_schedule_v2: TrainScheduleV2FixtureSet) { + let fixture = train_schedule_v2.await; + let service = create_test_service().await; + let request = TestRequest::delete() + .uri("/v2/train_schedule/") + .set_json(json!({"ids": vec![fixture.train_schedule.id()]})) + .to_request(); + let response = call_service(&service, request).await; + assert!(response.status().is_success()); + + // Delete should fail + + let request = TestRequest::delete() + .uri("/v2/train_schedule/") + .set_json(json!({"ids": vec![fixture.train_schedule.id()]})) + .to_request(); + assert_eq!(call_service(&service, request).await.status().as_u16(), 404); + } + + #[rstest] + async fn train_schedule_put(#[future] train_schedule_v2: TrainScheduleV2FixtureSet) { + let TrainScheduleV2FixtureSet { + timetable, + train_schedule, + } = train_schedule_v2.await; + let service = create_test_service().await; + let rs_name = String::from("NEW ROLLING_STOCK"); + let train_schedule_base: TrainScheduleBase = TrainScheduleBase { + rolling_stock_name: rs_name.clone(), + ..serde_json::from_str(include_str!("../../tests/train_schedules/simple.json")) + .expect("Unable to parse") + }; + let train_schedule_form = TrainScheduleForm { + timetable_id: timetable.id(), + train_schedule: train_schedule_base, + }; + let request = TestRequest::put() + .uri(format!("/v2/train_schedule/{}", train_schedule.id()).as_str()) + .set_json(json!(train_schedule_form)) + .to_request(); + + let response: TrainScheduleResult = call_and_read_body_json(&service, request).await; + assert_eq!(response.train_schedule.rolling_stock_name, rs_name) + } +}