use crate::{ admin::{team::Team, RunStatus}, er, err, models::rating::TeamsAtStationLocation, partials, suc, AppState, Station, }; use axum::{ extract::State, response::{IntoResponse, Redirect}, routing::{get, post}, Form, Router, }; use maud::{html, Markup, PreEscaped}; use serde::Deserialize; use sqlx::SqlitePool; use std::sync::Arc; use tower_sessions::Session; async fn view( State(db): State>, session: Session, axum::extract::Path((id, code)): axum::extract::Path<(i64, String)>, ) -> Markup { let Some(station) = Station::login(&db, id, &code).await else { let content = html! { article class="error" { (t!("invalid_rating_code")) } }; return partials::page(content, session, false).await; }; let teams = TeamsAtStationLocation::for_station(&db, &station).await; let teams_on_the_way = station.teams_on_the_way(&db).await; let status = RunStatus::curr(&db).await; let content = html! { h1 { (t!("station")) " " (station.name) } @if let (Some(lat), Some(lng)) = (station.lat, station.lng) { article { details open[(!station.ready)]{ summary { (t!("infos")) } "👋" (t!("station_info")) " " @let first_teams = Team::all_with_first_station(&db, &station).await; @if first_teams.is_empty() { (t!("station_has_no_teams_to_take_to_start")) } @else{ @if first_teams.len() == 1 { (t!("station_should_take_one_teams_to_start")) } @else { (t!("station_should_take_n_teams_to_start", amount=first_teams.len())) } ol { @for team in first_teams { li { (team.name) ul { @if let Some(amount_people) = team.amount_people { li { (amount_people) " " @if amount_people == 1 { (t!("person")) } @else { (t!("people")) } } } @if let Some(notes) = team.notes { li { (notes) } } } } } } } (t!("your_station_is_here")) div id="map" style="height: 500px" {} script { (format!(" const map = L.map('map').setView([{lat}, {lng}], 14); L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png', {{ attribution: '© OpenStreetMap contributors' }}).addTo(map); const myIcon = L.icon({{ iconUrl: '/marker.png', iconAnchor: [12, 41] }}); currentMarker = L.marker([{lat}, {lng}], {{icon: myIcon}}).addTo(map); map.setView([{lat}, {lng}], 14); ")) } div { sub { a href=(format!("https://www.google.com/maps?q={lat},{lng}")) target="_blank" { "Google Maps Navigation" } } } hr; "In diesem Tool solltest du diese 3 Dinge vermerken:" ol { li { b { "Ein Team kommt zu deiner Station:" } " Du wählst das entsprechende Team aus und klickst auf " em { (t!("team_is_here")) } ". Das Team ist nun im Wartemodus (⏳)." } li { b { "Das Team beginnt mit der Aufgabe bei deiner Station:" } " Du klickst beim entsprechenden Team auf " em { (t!("team_starting")) } ". Das Team ist nun im aktiven Modus (🎬)." } li { b { "Das Team hat deine Station beendet und ist gegangen:" } " Du klickst beim entsprechenden Team auf " em { (t!("team_finished")) } ". Bitte schau, dass du das immer zeitnah erledigst, damit die nächste Station informiert werden kann, dass ein Team auf dem Weg ist." } } "Zu jedem Zeitpunkt kannst du mit Klick auf ✏️ Notizen zu den Teams machen. In aller Ruhe kannst du unter dem Punkt " em { (t!("to_rate")) } " die Teams, die schon bei dir waren, bewerten." hr; a href=(format!("/s/{id}/{code}/ready")){ @if station.ready { (t!("station_not_yet_ready")) } @else { button { (t!("station_ready")) } } } } } } article { @if teams.total_teams == 1 { (t!("one_team_should_come_to_station")) } @else{ (t!("n_teams_should_come_to_station", amount=teams.total_teams)) } progress value=(teams.total_teams-teams.not_yet_here.len() as i64) max=(teams.total_teams) {} @if status == RunStatus::HasEnded { @let teams_to_take_home = Team::all_with_last_station(&db, &station).await; @if !teams_to_take_home.is_empty() { "Bitte nimm folgende Teams mit heim:" ol { @for team in teams_to_take_home { li { (team.name) } } } }@else { "Du musst keine Teams mit heim nehmen" } } } @for team in teams_on_the_way { article { (t!("team_on_the_way_to_your_station", team=team.team.name, time=team.left)) form action=(format!("/s/{id}/{code}/new-waiting")) method="post" { input type="hidden" name="team_id" value=(team.team.id); input type="submit" value=(t!("team_is_here")); } } } @if !teams.not_yet_here.is_empty() { form action=(format!("/s/{id}/{code}/new-waiting")) method="post" { fieldset role="group" { select name="team_id" aria-label=(t!("select_team")) required { @for team in &teams.not_yet_here { option value=(team.id) { (team.name) } } } input type="submit" value=(t!("team_is_here")); } } } h2 { (t!("teams_at_your_station")) } @if !teams.doing.is_empty() { @for (team, rating) in teams.doing { article { details { summary { em data-tooltip=(t!("state_active")) { (t!("state_active_icon")) " " } (team.name) small { " (" (t!("since_time", time=rating.local_time_doing())) ")" } "✏️" a href=(format!("/s/{id}/{code}/team-finished/{}", team.id)) { button { (t!("team_finished")) } } } form action=(format!("/s/{id}/{code}/team-update/{}", team.id)) method="post" { label { "Notizen" @if let Some(notes) = &rating.notes { input type="text" name="notes" value=(notes); } @else { input type="text" name="notes"; } } input type="submit" value=(t!("save_notes")); } a href=(format!("/s/{id}/{code}/remove-doing/{}", team.id)) onclick=(format!("return confirm('{}');", t!("confirm_station_cancel_team_active", team=team.name))) { "🗑️" } } } } } @if !teams.waiting.is_empty() { @for (team, rating) in teams.waiting { article { details { summary { em data-tooltip=(t!("state_waiting")) { (t!("state_waiting_icon")) " "} (team.name) small { " (" (t!("since_time", time=rating.local_time_arrived_at())) ")" } "✏️" a href=(format!("/s/{id}/{code}/team-starting/{}", team.id)) { button { (t!("team_starting")) } } } form action=(format!("/s/{id}/{code}/team-update/{}", team.id)) method="post" { label { "Notizen" @if let Some(notes) = &rating.notes { input type="text" name="notes" value=(notes); } @else { input type="text" name="notes"; } } input type="submit" value=(t!("save_notes")); } a href=(format!("/s/{id}/{code}/remove-waiting/{}", team.id)) onclick=(format!("return confirm('{}');", t!("confirm_station_cancel_team_waiting", team=team.name))) { "🗑️" } } } } } @if !teams.left_not_yet_rated.is_empty() { h2 { (t!("to_rate")) } article class="warning" { @if teams.left_not_yet_rated.len() == 1 { (t!("info_single_team_not_yet_rated")) } @else { (t!("info_multiple_teams_not_yet_rated")) } } @for (team, rating) in teams.left_not_yet_rated { article { em data-tooltip=(t!("state_to_rate")) { (t!("state_to_rate_icon")) " " } (team.name) small { " (" (t!("left_at", time=rating.local_time_left())) ")" } form action=(format!("/s/{id}/{code}/team-update/{}", team.id)) method="post" { label { @if let Some(points) = rating.points { span { (points) " " (t!("points")) } input type="range" name="points" min="0" max="10" value=(points) onchange=(format!("if(!confirm('{}')) {{ this.value = this.defaultValue; this.previousElementSibling.textContent = this.defaultValue + ' Punkte'; }}", t!("confirm_rating_change"))) oninput=(format!("this.previousElementSibling.textContent = this.value + ' {}'", t!("points"))) {} } @else { span { "0 " (t!("points")) } input type="range" name="points" min="0" max="10" value="0" oninput="this.previousElementSibling.textContent = this.value + ' Punkte'" {} } } label { (t!("notes")) @if let Some(notes) = &rating.notes { input type="text" name="notes" value=(notes); } @else { input type="text" name="notes"; } } input type="submit" value=(t!("save_notes")); } a href=(format!("/s/{id}/{code}/remove-left/{}", team.id)) onclick=(format!("return confirm('{}');", t!("confirm_station_cancel_team_finished"))) { "🗑️" } } } } h2 { (t!("history")) } @if !teams.left_and_rated.is_empty() { @for (team, rating) in teams.left_and_rated { article { details { summary { em data-tooltip=(t!("state_rated")) { (t!("state_rated_icon")) " " } (team.name) (PreEscaped(" → ")) (rating.points.unwrap()) " " (t!("points")) } small { " (" (t!("arrived_at_started_at_left_at", arrived=rating.local_time_arrived_at(), active=rating.local_time_doing(), left=rating.local_time_left())) ")" } form action=(format!("/s/{id}/{code}/team-update/{}", team.id)) method="post" { label { @if let Some(points) = rating.points { span { (points) " Punkte" } input type="range" name="points" min="0" max="10" value=(points) onchange=(format!("if(!confirm('{}')) {{ this.value = this.defaultValue; this.previousElementSibling.textContent = this.defaultValue + ' Punkte'; }}", t!("confirm_rating_change"))) oninput=(format!("this.previousElementSibling.textContent = this.value + ' {}'", t!("points"))) {} } } label { (t!("notes")) @if let Some(notes) = &rating.notes { input type="text" name="notes" value=(notes); } @else { input type="text" name="notes"; } } input type="submit" value=(t!("save_notes")); } a href=(format!("/s/{id}/{code}/remove-left/{}", team.id)) onclick=(format!("return confirm('{}');", t!("confirm_station_cancel_team_finished"))) { "🗑️" } } } } } @else { (t!("no_teams_rated_yet")) } }; let use_map = station.lat.is_some() && station.lng.is_some(); partials::page(content, session, use_map).await } async fn ready( State(db): State>, session: Session, axum::extract::Path((id, code)): axum::extract::Path<(i64, String)>, ) -> impl IntoResponse { let Some(station) = Station::login(&db, id, &code).await else { er!(session, t!("invalid_rating_code")); return Redirect::to("/s/{id}/{code}"); }; station.switch_ready(&db).await; suc!(session, t!("succ_change")); Redirect::to(&format!("/s/{id}/{code}")) } #[derive(Deserialize)] struct NewWaitingForm { team_id: i64, } async fn new_waiting( State(db): State>, session: Session, axum::extract::Path((id, code)): axum::extract::Path<(i64, String)>, Form(form): Form, ) -> impl IntoResponse { let Some(station) = Station::login(&db, id, &code).await else { er!(session, t!("invalid_rating_code")); return Redirect::to("/s/{id}/{code}"); }; let Some(team) = Team::find_by_id(&db, form.team_id).await else { er!( session, t!("cant_add_nonexisting_team_to_waiting", id = form.team_id) ); return Redirect::to("/s/{id}/{code}"); }; match station.new_team_waiting(&db, &team).await { Ok(()) => suc!(session, t!("team_added_to_waiting", team = team.name)), Err(e) => err!(session, "{e}"), } Redirect::to(&format!("/s/{id}/{code}")) } async fn remove_waiting( State(db): State>, session: Session, axum::extract::Path((id, code, team_id)): axum::extract::Path<(i64, String, i64)>, ) -> impl IntoResponse { let Some(station) = Station::login(&db, id, &code).await else { er!(session, t!("invalid_rating_code")); return Redirect::to("/s/{id}/{code}"); }; let Some(team) = Team::find_by_id(&db, team_id).await else { er!( session, t!("cant_remove_nonexisting_team_from_waiting", id = team_id) ); return Redirect::to("/s/{id}/{code}"); }; match station.remove_team_waiting(&db, &team).await { Ok(()) => suc!(session, t!("team_removed_from_waiting", team = team.name)), Err(e) => err!(session, "{e}"), } Redirect::to(&format!("/s/{id}/{code}")) } async fn team_starting( State(db): State>, session: Session, axum::extract::Path((id, code, team_id)): axum::extract::Path<(i64, String, i64)>, ) -> impl IntoResponse { let Some(station) = Station::login(&db, id, &code).await else { er!(session, t!("invalid_rating_code")); return Redirect::to("/s/{id}/{code}"); }; let Some(team) = Team::find_by_id(&db, team_id).await else { er!( session, t!("cant_add_nonexisting_team_to_active", id = team_id) ); return Redirect::to("/s/{id}/{code}"); }; match station.team_starting(&db, &team).await { Ok(()) => suc!(session, t!("team_added_to_active", team = team.name)), Err(e) => err!(session, "{e}"), } Redirect::to(&format!("/s/{id}/{code}")) } async fn remove_doing( State(db): State>, session: Session, axum::extract::Path((id, code, team_id)): axum::extract::Path<(i64, String, i64)>, ) -> impl IntoResponse { let Some(station) = Station::login(&db, id, &code).await else { er!(session, t!("invalid_rating_code")); return Redirect::to("/s/{id}/{code}"); }; let Some(team) = Team::find_by_id(&db, team_id).await else { er!( session, t!("cant_remove_nonexisting_team_from_active", id = team_id) ); return Redirect::to("/s/{id}/{code}"); }; match station.remove_team_doing(&db, &team).await { Ok(()) => suc!(session, t!("team_removed_from_active", team = team.name)), Err(e) => err!(session, "{e}"), } Redirect::to(&format!("/s/{id}/{code}")) } async fn team_finished( State(db): State>, session: Session, axum::extract::Path((id, code, team_id)): axum::extract::Path<(i64, String, i64)>, ) -> impl IntoResponse { let Some(station) = Station::login(&db, id, &code).await else { er!(session, t!("invalid_rating_code")); return Redirect::to("/s/{id}/{code}"); }; let Some(team) = Team::find_by_id(&db, team_id).await else { er!( session, t!("cant_add_nonexisting_team_to_finished", id = team_id) ); return Redirect::to("/s/{id}/{code}"); }; match station.team_finished(&db, &team).await { Ok(()) => suc!(session, t!("team_added_to_finished")), Err(e) => err!(session, "{e}"), } Redirect::to(&format!("/s/{id}/{code}")) } async fn remove_left( State(db): State>, session: Session, axum::extract::Path((id, code, team_id)): axum::extract::Path<(i64, String, i64)>, ) -> impl IntoResponse { let Some(station) = Station::login(&db, id, &code).await else { er!(session, t!("invalid_rating_code")); return Redirect::to("/s/{id}/{code}"); }; let Some(team) = Team::find_by_id(&db, team_id).await else { er!( session, t!("cant_remove_nonexisting_team_from_finished", id = team_id) ); return Redirect::to("/s/{id}/{code}"); }; match station.remove_team_left(&db, &team).await { Ok(()) => suc!(session, t!("team_removed_from_finished")), Err(e) => err!(session, "{e}"), } Redirect::to(&format!("/s/{id}/{code}")) } #[derive(Deserialize)] struct TeamUpdateForm { points: Option, notes: Option, } async fn team_update( State(db): State>, session: Session, axum::extract::Path((id, code, team_id)): axum::extract::Path<(i64, String, i64)>, Form(form): Form, ) -> impl IntoResponse { let Some(station) = Station::login(&db, id, &code).await else { er!(session, t!("invalid_rating_code")); return Redirect::to("/s/{id}/{code}"); }; let Some(team) = Team::find_by_id(&db, team_id).await else { er!(session, t!("cant_update_nonexisting_team", id = team_id)); return Redirect::to("/s/{id}/{code}"); }; match station .team_update(&db, &team, form.points, form.notes) .await { Ok(()) => suc!(session, t!("rating_updated", team = team.name)), Err(e) => err!(session, "{e}"), } Redirect::to(&format!("/s/{id}/{code}")) } pub(super) fn routes() -> Router { Router::new() .route("/", get(view)) .route("/ready", get(ready)) .route("/new-waiting", post(new_waiting)) .route("/remove-waiting/{team_id}", get(remove_waiting)) .route("/team-starting/{team_id}", get(team_starting)) .route("/remove-doing/{team_id}", get(remove_doing)) .route("/team-finished/{team_id}", get(team_finished)) .route("/remove-left/{team_id}", get(remove_left)) .route("/team-update/{team_id}", post(team_update)) } #[cfg(test)] mod test { use crate::{router, testdb, Station}; use sqlx::SqlitePool; use axum_test::TestServer; #[sqlx::test] async fn test_wrong_station() { let pool = testdb!(); Station::create(&pool, "Teststation").await.unwrap(); let server = TestServer::new(router(pool)).unwrap(); let response = server.get("/s/1/wrong-pw").await; response.assert_text_contains(t!("invalid_rating_code")); } #[sqlx::test] async fn test_correct_station() { let pool = testdb!(); Station::create(&pool, "42-Station").await.unwrap(); let stations = Station::all(&pool).await; let station = stations.last().unwrap(); let server = TestServer::new(router(pool)).unwrap(); let response = server.get(&format!("/s/1/{}", station.pw)).await; response.assert_text_contains("42-Station"); } }