properly end station run; every team gets a station
All checks were successful
CI/CD Pipeline / test (push) Successful in 13m45s
CI/CD Pipeline / deploy (push) Successful in 6m4s

This commit is contained in:
2025-04-22 12:21:01 +02:00
parent 2f6130dd5d
commit 5cbdedc37c
9 changed files with 390 additions and 40 deletions

View File

@@ -15,6 +15,13 @@ login_succ: "Erfolgreich eingeloggt als %{name}"
user_id_nonexisting: "User mit ID %{id} gibts ned"
person: "Person"
people: "Personen"
end_run: "Stationslauf beenden"
restart_run: "Stationslauf wieder aufnehmen"
confirm_end_run: "Willst du den Stationslauf wirklich beenden?"
confirm_restart_run: "Willst du den Stationslauf wirklich wieder aufnehmen?"
run_ended: "Stationslauf erfolgreich beendet"
run_restarted: "Stationslauf erfolgreich wieder aufgenommen"
come_home_with_these_groups: "Gruppen mitnehmen"
#
# ######
@@ -105,8 +112,10 @@ confirm_station_cancel_team_finished: "Bist du sicher, dass das Team noch nicht
#
station: "Station"
stations: "Stationen"
go_to_stations: "Zu den Stationen"
crewless_station: "Station ohne Stationsbetreuer"
station_create: "Station erstellen"
no_stations_yet: "Es gibt noch keine Stationen."
stations_expl_without_first_word: "sind festgelegte Orte mit spezifischen Aufgaben."
station_warning_not_assigned_route: "Noch keiner Route zugeordnet" # should be short -> tooltip
station_confirm_deletion: "Bist du sicher, dass die Station gelöscht werden soll? Das kann _NICHT_ mehr rückgängig gemacht werden."

View File

@@ -30,8 +30,10 @@ CREATE TABLE IF NOT EXISTS team (
notes TEXT,
amount_people INTEGER,
first_station_id INTEGER NOT NULL,
last_station_id INTEGER,
route_id INTEGER NOT NULL,
FOREIGN KEY (first_station_id) REFERENCES station(id),
FOREIGN KEY (last_station_id) REFERENCES station(id),
FOREIGN KEY (route_id) REFERENCES route(id)
);
@@ -49,15 +51,14 @@ CREATE TABLE IF NOT EXISTS rating (
);
CREATE TABLE IF NOT EXISTS user (
id INTEGER PRIMARY KEY NOT NULL,
name TEXT NOT NULL UNIQUE,
pw TEXT NOT NULL,
require_new_password_code TEXT
id INTEGER PRIMARY KEY NOT NULL,
name TEXT NOT NULL UNIQUE,
pw TEXT NOT NULL,
require_new_password_code TEXT
);
create table if not exists "tower_sessions" (
id text primary key not null,
data blob not null,
expiry_date integer not null
CREATE TABLE IF NOT EXISTS tower_sessions (
id TEXT PRIMARY KEY NOT NULL,
data BLOB NOT NULL,
expiry_date INTEGER NOT NULL
);

View File

@@ -1,7 +1,12 @@
use crate::{AppState, auth::Backend, models::rating::Rating, page};
use axum::{Router, extract::State, routing::get};
use crate::{auth::Backend, models::rating::Rating, page, suc, AppState, Station};
use axum::{
extract::State,
response::{IntoResponse, Redirect},
routing::get,
Router,
};
use axum_login::login_required;
use maud::{Markup, html};
use maud::{html, Markup};
use rand::{
distr::{Distribution, Uniform},
rng,
@@ -9,6 +14,7 @@ use rand::{
use route::Route;
use sqlx::SqlitePool;
use std::sync::Arc;
use team::Team;
use tower_sessions::Session;
pub(crate) mod route;
@@ -107,7 +113,29 @@ async fn highscore(State(db): State<Arc<SqlitePool>>, session: Session) -> Marku
page(content, session, false).await
}
async fn index(session: Session) -> Markup {
#[derive(PartialEq)]
pub enum RunStatus {
NoStationsYet,
Active,
HasEnded,
}
impl RunStatus {
pub async fn curr(db: &SqlitePool) -> Self {
let stations = Station::all(db).await;
if stations.is_empty() {
return RunStatus::NoStationsYet;
}
if station::some_team_has_last_station_id(db).await {
return RunStatus::HasEnded;
}
RunStatus::Active
}
}
async fn index(State(db): State<Arc<SqlitePool>>, session: Session) -> Markup {
let status = RunStatus::curr(&db).await;
let content = html! {
nav {
ul {
@@ -147,16 +175,75 @@ async fn index(session: Session) -> Markup {
(t!("admins"))
}
}
}
}
@match status {
RunStatus::NoStationsYet => {
(t!("no_stations_yet"))
(t!("change_that_below"))
a role="button" href="/admin/station" {
(t!("go_to_stations"))
}
},
RunStatus::Active => {
a href="/admin/end-run" onclick=(format!("return confirm('{}');", t!("confirm_end_run"))) {
button style="background-color: red;" {
(t!("end_run"))
}
}
},
RunStatus::HasEnded => {
@let stations = Station::all(&db).await;
a href="/admin/restart-run" onclick=(format!("return confirm('{}');", t!("confirm_restart_run"))) {
button style="background-color: red;" {
(t!("restart_run"))
}
}
table {
thead {
tr {
th { (t!("stations")) }
th { (t!("come_home_with_these_groups")) }
}
}
tbody {
@for station in stations {
tr {
td { (station) }
td {
ol {
@for team in Team::all_with_last_station(&db, &station).await {
li { (team) }
}
}
}
}
}
}
}
}
}
};
page(content, session, false).await
}
async fn end_run(State(db): State<Arc<SqlitePool>>, session: Session) -> impl IntoResponse {
Team::end_run(&db).await;
suc!(session, t!("run_ended"));
Redirect::to("/admin")
}
async fn restart_run(State(db): State<Arc<SqlitePool>>, session: Session) -> impl IntoResponse {
Team::restart_run(&db).await;
suc!(session, t!("run_restarted"));
Redirect::to("/admin")
}
pub(super) fn routes() -> Router<AppState> {
Router::new()
.route("/", get(index))
.route("/highscore", get(highscore))
.route("/end-run", get(end_run))
.route("/restart-run", get(restart_run))
.nest("/station", station::routes())
.nest("/route", route::routes())
.nest("/team", team::routes())

View File

@@ -1,6 +1,6 @@
use crate::{
AppState,
admin::{station::Station, team::Team},
AppState,
};
use axum::Router;
use futures::future::join_all;
@@ -236,6 +236,18 @@ DROP TABLE temp_pos;",
)
}
pub async fn next_station(&self, db: &SqlitePool, target_station: &Station) -> Option<Station> {
let stations = Station::all(db).await;
for station in stations {
if let Some(prev_station) = self.prev_station(db, &station).await {
if &prev_station == target_station {
return Some(station);
}
}
}
None
}
pub async fn prev_station(&self, db: &SqlitePool, station: &Station) -> Option<Station> {
if station.crewless() {
return None;

View File

@@ -28,6 +28,12 @@ pub(crate) struct Station {
pub(crate) lng: Option<f64>,
}
impl PartialEq for Station {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
impl Render for Station {
fn render(&self) -> Markup {
html! {
@@ -406,7 +412,7 @@ impl Station {
pub(crate) async fn teams(&self, db: &SqlitePool) -> Vec<Team> {
sqlx::query_as::<_, Team>(
"SELECT DISTINCT t.id, t.name, t.notes, t.amount_people, t.first_station_id, t.route_id
"SELECT DISTINCT t.id, t.name, t.notes, t.amount_people, t.first_station_id, t.last_station_id, t.route_id
FROM team t
JOIN route_station rs ON t.route_id = rs.route_id
WHERE rs.station_id = ?
@@ -420,7 +426,7 @@ ORDER BY LOWER(t.name);",
pub(crate) async fn left_teams(&self, db: &SqlitePool) -> Vec<Team> {
sqlx::query_as::<_, Team>(
"SELECT t.id, t.name, t.notes, t.amount_people, t.first_station_id, t.route_id
"SELECT t.id, t.name, t.notes, t.amount_people, t.first_station_id, t.last_station_id, t.route_id
FROM team t
JOIN rating r ON t.id = r.team_id
WHERE r.station_id = ?
@@ -470,6 +476,14 @@ AND r.left_at IS NOT NULL;",
}
}
pub async fn some_team_has_last_station_id(db: &SqlitePool) -> bool {
sqlx::query_scalar!("SELECT 1 FROM team WHERE last_station_id IS NOT NULL")
.fetch_optional(db)
.await
.unwrap()
.is_some()
}
pub struct TeamOnTheWay {
pub(crate) team: Team,
pub(crate) left: String,

View File

@@ -1,10 +1,11 @@
use crate::{
AppState,
admin::{route::Route, station::Station},
models::rating::Rating,
AppState,
};
use axum::Router;
use chrono::{DateTime, Local, NaiveDateTime, Utc};
use maud::{html, Markup, Render};
use serde::{Deserialize, Serialize};
use sqlx::{FromRow, SqlitePool};
@@ -17,26 +18,33 @@ pub(crate) struct Team {
pub(crate) notes: Option<String>,
pub(crate) amount_people: Option<i64>,
first_station_id: i64,
last_station_id: Option<i64>,
route_id: i64,
}
impl Render for Team {
fn render(&self) -> Markup {
html! {
a href=(format!("/admin/team/{}", self.id)){
(self.name)
}
}
}
}
#[derive(FromRow, Debug, Serialize, Deserialize, PartialEq)]
pub(crate) struct LastContactTeam {
team_id: i64,
team_name: String,
station_id: i64,
station_name: String,
team: Team,
station: Option<Station>,
last_contact_time: Option<NaiveDateTime>,
}
impl LastContactTeam {
pub(crate) async fn all_sort_missing(db: &SqlitePool) -> Vec<Self> {
sqlx::query_as::<_, Self>(
let rows = sqlx::query_as::<_, (i64, i64, Option<NaiveDateTime>)>(
"SELECT
t.id AS team_id,
t.name AS team_name,
last_contact.station_id AS station_id,
s.name AS station_name,
last_contact.last_contact_time AS last_contact_time
FROM
team t
@@ -58,7 +66,16 @@ ORDER BY
)
.fetch_all(db)
.await
.unwrap()
.unwrap();
let mut ret = Vec::new();
for (team_id, station_id, last_contact_time) in rows {
ret.push(LastContactTeam {
team: Team::find_by_id(db, team_id).await.expect("db constraints"),
station: Station::find_by_id(db, station_id).await,
last_contact_time,
});
}
ret
}
pub(crate) fn local_last_contact(&self) -> Option<DateTime<Local>> {
@@ -78,7 +95,7 @@ enum CreateError {
impl Team {
pub(crate) async fn all(db: &SqlitePool) -> Vec<Self> {
sqlx::query_as::<_, Self>(
"SELECT id, name, notes, amount_people, first_station_id, route_id FROM team ORDER BY name;",
"SELECT id, name, notes, amount_people, first_station_id, last_station_id, route_id FROM team ORDER BY name;",
)
.fetch_all(db)
.await
@@ -88,7 +105,7 @@ impl Team {
pub(crate) async fn all_with_route(db: &SqlitePool, route: &Route) -> Vec<Self> {
sqlx::query_as!(
Team,
"SELECT id, name, notes, amount_people, first_station_id, route_id FROM team WHERE route_id = ?;",
"SELECT id, name, notes, amount_people, first_station_id, last_station_id, route_id FROM team WHERE route_id = ?;",
route.id
)
.fetch_all(db)
@@ -99,7 +116,18 @@ impl Team {
pub(crate) async fn all_with_first_station(db: &SqlitePool, station: &Station) -> Vec<Self> {
sqlx::query_as!(
Team,
"select id, name, notes, amount_people, first_station_id, route_id from team where first_station_id = ?;",
"select id, name, notes, amount_people, first_station_id, last_station_id, route_id from team where first_station_id = ?;",
station.id
)
.fetch_all(db)
.await
.unwrap()
}
pub(crate) async fn all_with_last_station(db: &SqlitePool, station: &Station) -> Vec<Self> {
sqlx::query_as!(
Team,
"select id, name, notes, amount_people, first_station_id, last_station_id, route_id from team where last_station_id = ?;",
station.id
)
.fetch_all(db)
@@ -110,7 +138,7 @@ impl Team {
pub async fn find_by_id(db: &SqlitePool, id: i64) -> Option<Self> {
sqlx::query_as!(
Self,
"SELECT id, name, notes, amount_people, first_station_id, route_id FROM team WHERE id = ?",
"SELECT id, name, notes, amount_people, first_station_id, last_station_id, route_id FROM team WHERE id = ?",
id
)
.fetch_one(db)
@@ -144,6 +172,17 @@ impl Team {
.unwrap();
}
async fn update_end_station(&self, db: &SqlitePool, station: &Station) {
sqlx::query!(
"UPDATE team SET last_station_id = ? WHERE id = ?",
station.id,
self.id
)
.execute(db)
.await
.unwrap();
}
async fn update_notes(&self, db: &SqlitePool, notes: &str) {
sqlx::query!("UPDATE team SET notes = ? WHERE id = ?", notes, self.id)
.execute(db)
@@ -191,6 +230,17 @@ impl Team {
.unwrap();
}
async fn update_last_station(&self, db: &SqlitePool, station: &Station) {
sqlx::query!(
"UPDATE team SET last_station_id = ? WHERE id = ?",
station.id,
self.id
)
.execute(db)
.await
.unwrap();
}
async fn update_amount_people_reset(&self, db: &SqlitePool) {
sqlx::query!("UPDATE team SET amount_people = NULL WHERE id = ?", self.id)
.execute(db)
@@ -212,6 +262,14 @@ impl Team {
.expect("db constraints")
}
pub async fn last_station(&self, db: &SqlitePool) -> Option<Station> {
if let Some(last_station_id) = self.last_station_id {
Station::find_by_id(db, last_station_id).await
} else {
None
}
}
pub async fn route(&self, db: &SqlitePool) -> Route {
Route::find_by_id(db, self.route_id)
.await
@@ -234,6 +292,63 @@ impl Team {
.await
.is_some()
}
pub(crate) async fn end_station(&self, db: &SqlitePool) -> Station {
match LastContactTeam::all_sort_missing(db)
.await
.into_iter()
.find(|last_contact_team| &last_contact_team.team == self)
{
Some(last_contact_team) => {
if let Some(station) = last_contact_team.station {
// Team already made some contact with a station
match Rating::find_by_team_and_station(db, self, &station).await {
Some(rating) => {
if rating.left_at.is_none() {
rating.station(db).await
} else {
let next_station = self
.route(db)
.await
.next_station(db, &station)
.await
.unwrap();
if Rating::find_by_team_and_station(db, self, &next_station)
.await
.is_some()
{
station // last station for team
} else {
next_station
}
}
}
None => self.first_station(db).await,
}
} else {
// Team has made no contact yet -> next station should be the first one
self.first_station(db).await
}
}
None => unreachable!(),
}
}
pub async fn end_run(db: &SqlitePool) {
// set `last_station_id` to the next station where `left_at` is not null
let teams = Team::all(db).await;
for team in teams {
let end_station = team.end_station(db).await;
team.update_end_station(db, &end_station).await;
}
}
pub async fn restart_run(db: &SqlitePool) {
sqlx::query!("UPDATE team SET last_station_id = null")
.execute(db)
.await
.unwrap();
}
}
pub(super) fn routes() -> Router<AppState> {

View File

@@ -1,19 +1,18 @@
use super::{CreateError, LastContactTeam, Team};
use crate::{
AppState,
admin::{route::Route, station::Station},
err,
models::rating::Rating,
partials::page,
pl, succ,
pl, succ, AppState,
};
use axum::{
Form, Router,
extract::State,
response::{IntoResponse, Redirect},
routing::{get, post},
Form, Router,
};
use maud::{Markup, PreEscaped, html};
use maud::{html, Markup, PreEscaped};
use serde::Deserialize;
use sqlx::SqlitePool;
use std::{collections::HashMap, sync::Arc};
@@ -271,6 +270,7 @@ async fn view(
return Err(Redirect::to("/admin/team"));
};
let first_station = team.first_station(&db).await;
let last_station = team.last_station(&db).await;
let routes = Route::all(&db).await;
let stations = team.route(&db).await.crewful_stations(&db).await;
@@ -399,6 +399,35 @@ async fn view(
}
}
}
@if let Some(last_station) = last_station {
tr {
th scope="row" {
"Letzte Station"
};
td {
a href=(format!("/admin/station/{}", last_station.id)) {
(last_station.name)
}
@if stations.len() > 1 {
details {
summary { "✏️" }
form action=(format!("/admin/team/{}/update-last-station", team.id)) method="post" {
select name="last_station_id" aria-label="Station auswählen" required {
@for station in &stations {
@if station.id != last_station.id {
option value=(station.id) {
(station.name)
}
}
}
}
input type="submit" value="Station speichern";
}
}
}
}
}
}
}
}
@@ -613,6 +642,61 @@ async fn update_first_station(
Redirect::to(&format!("/admin/team/{id}"))
}
#[derive(Deserialize)]
struct UpdateLastStationForm {
last_station_id: i64,
}
async fn update_last_station(
State(db): State<Arc<SqlitePool>>,
session: Session,
axum::extract::Path(id): axum::extract::Path<i64>,
Form(form): Form<UpdateLastStationForm>,
) -> impl IntoResponse {
let Some(team) = Team::find_by_id(&db, id).await else {
err!(
session,
"Team mit ID {id} konnte nicht bearbeitet werden, da sie nicht existiert"
);
return Redirect::to("/admin/team");
};
let Some(station) = Station::find_by_id(&db, form.last_station_id).await else {
err!(
session,
"Konnte die letzte Station (ID={}) des Teams mit ID {} nicht bearbeiten, da diese Station nicht existiert.",
form.last_station_id,
team.id
);
return Redirect::to(&format!("/admin/team/{id}"));
};
if !station.is_in_route(&db, &team.route(&db).await).await {
err!(
session,
"Konnte Station {} nicht dem Team {} hinzufügen, weil dieses Team bei Route {} und nicht bei Route {} mitläuft.",
station.name,
team.name,
team.route(&db).await.name,
team.name
);
return Redirect::to(&format!("/admin/team/{id}"));
}
team.update_last_station(&db, &station).await;
succ!(
session,
"Letzte Station des Teams {} ist ab sofort {}",
team.name,
station.name
);
Redirect::to(&format!("/admin/team/{id}"))
}
async fn update_amount_people_reset(
State(db): State<Arc<SqlitePool>>,
session: Session,
@@ -659,8 +743,8 @@ async fn lost(State(db): State<Arc<SqlitePool>>, session: Session) -> Markup {
@for lost in &losts {
tr {
td {
a href=(format!("/admin/team/{}", lost.team_id)) {
(lost.team_name)
a href=(format!("/admin/team/{}", lost.team.id)) {
(lost.team.name)
}
}
td {
@@ -671,8 +755,12 @@ async fn lost(State(db): State<Arc<SqlitePool>>, session: Session) -> Markup {
}
}
td {
a href=(format!("/admin/station/{}", lost.station_id)) {
(lost.station_name)
@if let Some(station) = &lost.station {
a href=(format!("/admin/station/{}", station.id)) {
(station.name)
}
}@else{
"Noch nicht gesehen"
}
}
}
@@ -782,4 +870,5 @@ pub(super) fn routes() -> Router<AppState> {
.route("/{id}/amount-people-reset", get(update_amount_people_reset))
.route("/{id}/update-route", post(update_route))
.route("/{id}/update-first-station", post(update_first_station))
.route("/{id}/update-last-station", post(update_last_station))
}

View File

@@ -1,4 +1,4 @@
use crate::{Station, admin::team::Team};
use crate::{admin::team::Team, Station};
use chrono::{DateTime, Local, NaiveDateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::{FromRow, SqlitePool};
@@ -49,6 +49,12 @@ impl Rating {
.expect("db constraints")
}
pub(crate) async fn station(&self, db: &SqlitePool) -> Station {
Station::find_by_id(db, self.station_id)
.await
.expect("db constraints")
}
pub(crate) async fn for_station(db: &SqlitePool, station: &Station) -> Vec<Self> {
sqlx::query_as::<_, Self>("SELECT team_id, station_id, points, notes, arrived_at, started_at, left_at FROM rating WHERE station_id = ?;")
.bind(station.id)

View File

@@ -1,6 +1,8 @@
use crate::{
admin::team::Team, er, err, models::rating::TeamsAtStationLocation, partials, suc, AppState,
Station,
admin::{team::Team, RunStatus},
er, err,
models::rating::TeamsAtStationLocation,
partials, suc, AppState, Station,
};
use axum::{
extract::State,
@@ -30,6 +32,7 @@ async fn view(
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 {
@@ -126,6 +129,20 @@ async fn view(
(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 {