Philipp Hofer 6b07772a18
All checks were successful
CI/CD Pipeline / test (push) Successful in 5m20s
CI/CD Pipeline / deploy (push) Successful in 3m56s
format
2025-04-21 15:14:52 +02:00

493 lines
14 KiB
Rust

use super::{generate_random_alphanumeric, team::Team};
use crate::{
AppState,
admin::route::Route,
models::rating::{Rating, TeamsAtStationLocation},
};
use axum::Router;
use chrono::{DateTime, Local, NaiveDateTime, Utc};
use futures::{StreamExt, stream};
use maud::{Markup, Render, html};
use serde::{Deserialize, Serialize};
use sqlx::{FromRow, SqlitePool};
mod web;
#[derive(FromRow, Debug, Serialize, Deserialize)]
pub(crate) struct Station {
pub(crate) id: i64,
pub(crate) name: String,
notes: Option<String>,
pub(crate) amount_people: Option<i64>,
last_login: Option<NaiveDateTime>, // TODO use proper timestamp (NaiveDateTime?)
pub(crate) pw: String,
pub(crate) ready: bool,
pub(crate) lat: Option<f64>,
pub(crate) lng: Option<f64>,
}
impl Render for Station {
fn render(&self) -> Markup {
html! {
a href=(format!("/admin/station/{}", self.id)){
(self.name)
}
@if self.crewless() {
em data-tooltip="Station ohne Stationsbetreuer" data-placement="bottom" {
small { "🤖" }
}
}
}
}
}
impl Station {
pub(crate) async fn all(db: &SqlitePool) -> Vec<Self> {
sqlx::query_as::<_, Self>(
"SELECT id, name, notes, amount_people, last_login, ready, pw, lat, lng FROM station;",
)
.fetch_all(db)
.await
.unwrap()
}
pub async fn find_by_id(db: &SqlitePool, id: i64) -> Option<Self> {
sqlx::query_as!(
Self,
"SELECT id, name, notes, amount_people, last_login, ready, pw, lat, lng FROM station WHERE id = ?",
id
)
.fetch_one(db)
.await
.ok()
}
pub fn crewless(&self) -> bool {
if let Some(amount_people) = self.amount_people {
return amount_people == 0;
}
false
}
pub async fn login(db: &SqlitePool, id: i64, code: &str) -> Option<Self> {
let station = sqlx::query_as!(
Self,
"SELECT id, name, notes, amount_people, last_login, ready, pw, lat, lng FROM station WHERE id = ? AND pw = ?",
id, code
)
.fetch_one(db)
.await
.ok()?;
sqlx::query!(
"UPDATE station SET last_login = CURRENT_TIMESTAMP WHERE id = ?",
station.id
)
.execute(db)
.await
.unwrap();
Some(station)
}
pub async fn switch_ready(&self, db: &SqlitePool) {
let new_ready_status = !self.ready;
sqlx::query!(
"UPDATE station SET ready = ? WHERE id = ?",
new_ready_status,
self.id
)
.execute(db)
.await
.unwrap();
}
pub(crate) async fn create(db: &SqlitePool, name: &str) -> Result<(), String> {
let code = generate_random_alphanumeric(8);
let station_id = sqlx::query!(
"INSERT INTO station(name, pw) VALUES (?, ?) RETURNING id",
name,
code
)
.fetch_one(db)
.await
.map_err(|e| e.to_string())?;
let mut routes = Route::all(db).await.into_iter();
if let Some(route) = routes.next() {
if routes.next().is_none() {
// Just one route exists -> use it for new station
let station = Station::find_by_id(db, station_id.id)
.await
.expect("just created");
route.add_station(db, &station).await?;
}
}
Ok(())
}
pub(crate) async fn new_team_waiting(
&self,
db: &SqlitePool,
team: &Team,
) -> Result<(), String> {
let teams = TeamsAtStationLocation::for_station(db, self).await;
if !teams.not_yet_here.contains(team) {
return Err(format!(
"Kann Team nicht der Warteschlange hinzufügen, weil das Team {} nicht zu deiner Station kommen soll.",
team.name
));
}
Rating::create(db, self, team).await?;
Ok(())
}
pub(crate) async fn team_update(
&self,
db: &SqlitePool,
team: &Team,
points: Option<i64>,
notes: Option<String>,
) -> Result<(), String> {
let notes = match notes {
Some(n) if n.is_empty() => None,
Some(n) => Some(n),
None => None,
};
let teams = TeamsAtStationLocation::for_station(db, self).await;
let waiting_teams: Vec<&Team> = teams.waiting.iter().map(|(team, _)| team).collect();
let doing_teams: Vec<&Team> = teams.doing.iter().map(|(team, _)| team).collect();
let finished_teams: Vec<&Team> = teams
.left_not_yet_rated
.iter()
.map(|(team, _)| team)
.collect();
let finished_and_rated_teams: Vec<&Team> =
teams.left_and_rated.iter().map(|(team, _)| team).collect();
if !waiting_teams.contains(&team)
&& !doing_teams.contains(&team)
&& !finished_teams.contains(&team)
&& !finished_and_rated_teams.contains(&team)
{
return Err(
"Es können nur Teams bewertet werden, die zumindest schon bei der Station sind."
.to_string(),
);
}
Rating::update(db, self, team, points, notes).await?;
Ok(())
}
pub(crate) async fn remove_team_waiting(
&self,
db: &SqlitePool,
team: &Team,
) -> Result<(), String> {
let teams = TeamsAtStationLocation::for_station(db, self).await;
let waiting_teams: Vec<&Team> = teams.waiting.iter().map(|(team, _)| team).collect();
if !waiting_teams.contains(&team) {
return Err(format!(
"Kann Team nicht von der Warteschlange gelöscht werden, weil das Team {} nicht in der Warteschlange ist.",
team.name
));
}
Rating::delete(db, self, team).await?;
Ok(())
}
pub(crate) async fn team_starting(&self, db: &SqlitePool, team: &Team) -> Result<(), String> {
let teams = TeamsAtStationLocation::for_station(db, self).await;
let waiting_teams: Vec<&Team> = teams.waiting.iter().map(|(team, _)| team).collect();
if !waiting_teams.contains(&team) {
return Err(format!(
"Team kann nicht starten, weil das Team {} nicht in der Warteschlange ist.",
team.name
));
}
sqlx::query!(
"UPDATE rating SET started_at = CURRENT_TIMESTAMP WHERE team_id = ? AND station_id = ?",
team.id,
self.id
)
.execute(db)
.await
.unwrap();
Ok(())
}
pub(crate) async fn remove_team_doing(
&self,
db: &SqlitePool,
team: &Team,
) -> Result<(), String> {
let teams = TeamsAtStationLocation::for_station(db, self).await;
let doing_teams: Vec<&Team> = teams.doing.iter().map(|(team, _)| team).collect();
if !doing_teams.contains(&team) {
return Err(format!(
"Team kann nicht zur Warteschlange hinzugefügt werden, weil das Team {} aktuell nicht an deiner Station arbeitet.",
team.name
));
}
sqlx::query!(
"UPDATE rating SET started_at = NULL WHERE team_id = ? AND station_id = ?",
team.id,
self.id
)
.execute(db)
.await
.unwrap();
Ok(())
}
pub(crate) async fn team_finished(&self, db: &SqlitePool, team: &Team) -> Result<(), String> {
let teams = TeamsAtStationLocation::for_station(db, self).await;
let doing_teams: Vec<&Team> = teams.doing.iter().map(|(team, _)| team).collect();
if !doing_teams.contains(&team) {
return Err(format!(
"Team kann nicht beenden, weil das Team {} nicht an der Station arbeitet.",
team.name
));
}
sqlx::query!(
"UPDATE rating SET left_at = CURRENT_TIMESTAMP WHERE team_id = ? AND station_id = ?",
team.id,
self.id
)
.execute(db)
.await
.unwrap();
Ok(())
}
pub(crate) async fn remove_team_left(
&self,
db: &SqlitePool,
team: &Team,
) -> Result<(), String> {
let teams = TeamsAtStationLocation::for_station(db, self).await;
let left_and_rated_teams: Vec<&Team> =
teams.left_and_rated.iter().map(|(team, _)| team).collect();
let left_not_yet_rated_teams: Vec<&Team> = teams
.left_not_yet_rated
.iter()
.map(|(team, _)| team)
.collect();
if !left_and_rated_teams.contains(&team) && !left_not_yet_rated_teams.contains(&team) {
return Err(format!(
"Team kann nicht zur Arbeitsposition hinzugefügt werden, weil das Team {} aktuell nicht feritg ist",
team.name
));
}
sqlx::query!(
"UPDATE rating SET left_at = NULL, points=NULL WHERE team_id = ? AND station_id = ?",
team.id,
self.id
)
.execute(db)
.await
.unwrap();
Ok(())
}
async fn update_name(&self, db: &SqlitePool, name: &str) {
sqlx::query!("UPDATE station SET name = ? WHERE id = ?", name, self.id)
.execute(db)
.await
.unwrap();
}
async fn update_notes(&self, db: &SqlitePool, notes: &str) {
sqlx::query!("UPDATE station SET notes = ? WHERE id = ?", notes, self.id)
.execute(db)
.await
.unwrap();
}
async fn update_amount_people(&self, db: &SqlitePool, amount_people: i64) {
sqlx::query!(
"UPDATE station SET amount_people = ? WHERE id = ?",
amount_people,
self.id
)
.execute(db)
.await
.unwrap();
}
async fn update_amount_people_reset(&self, db: &SqlitePool) {
sqlx::query!(
"UPDATE station SET amount_people = NULL WHERE id = ?",
self.id
)
.execute(db)
.await
.unwrap();
}
async fn update_location(&self, db: &SqlitePool, lat: f64, lng: f64) {
sqlx::query!(
"UPDATE station SET lat = ?, lng = ? WHERE id = ?",
lat,
lng,
self.id
)
.execute(db)
.await
.unwrap();
}
async fn update_location_clear(&self, db: &SqlitePool) {
sqlx::query!(
"UPDATE station SET lat = NULL, lng=NULL WHERE id = ?",
self.id
)
.execute(db)
.await
.unwrap();
}
async fn delete(&self, db: &SqlitePool) -> Result<(), String> {
sqlx::query!("DELETE FROM station WHERE id = ?", self.id)
.execute(db)
.await
.map_err(|e| e.to_string())?;
Ok(())
}
async fn routes(&self, db: &SqlitePool) -> Vec<Route> {
sqlx::query_as::<_, Route>(
"
SELECT r.id, r.name
FROM route r
JOIN route_station rs ON r.id = rs.route_id
WHERE rs.station_id = ?
ORDER BY rs.pos
",
)
.bind(self.id)
.fetch_all(db)
.await
.unwrap()
}
pub async fn is_in_route(&self, db: &SqlitePool, route: &Route) -> bool {
for r in self.routes(db).await {
if r.id == route.id {
return true;
}
}
false
}
pub(crate) fn local_last_login(&self) -> Option<DateTime<Local>> {
let Some(last_login) = &self.last_login else {
return None;
};
let datetime_utc = DateTime::<Utc>::from_naive_utc_and_offset(*last_login, Utc);
Some(datetime_utc.with_timezone(&Local))
}
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
FROM team t
JOIN route_station rs ON t.route_id = rs.route_id
WHERE rs.station_id = ?
ORDER BY LOWER(t.name);",
)
.bind(self.id)
.fetch_all(db)
.await
.unwrap()
}
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
FROM team t
JOIN rating r ON t.id = r.team_id
WHERE r.station_id = ?
AND r.left_at IS NOT NULL;",
)
.bind(self.id)
.fetch_all(db)
.await
.unwrap()
}
pub async fn teams_on_the_way(&self, db: &SqlitePool) -> Vec<TeamOnTheWay> {
let mut ret = Vec::new();
let teams = self.teams(db).await;
let missing_teams: Vec<Team> = stream::iter(teams)
.filter_map(|entry| async move {
if entry.been_at_station(db, self).await {
None
} else {
Some(entry)
}
})
.collect()
.await;
for team in missing_teams {
let route = team.route(db).await;
let Some(prev_station) = route.prev_station(db, self).await else {
continue;
};
let left_teams_of_prev_station = prev_station.left_teams(db).await;
if left_teams_of_prev_station.contains(&team) {
// team not yet at `self`, but already left `prev_station`
let rating = Rating::find_by_team_and_station(db, &team, &prev_station)
.await
.unwrap();
ret.push(TeamOnTheWay {
left: rating.local_time_left(),
team: team.clone(),
});
}
}
ret
}
}
pub struct TeamOnTheWay {
pub(crate) team: Team,
pub(crate) left: String,
//avg_time_in_secs: i64,
}
pub(super) fn routes() -> Router<AppState> {
web::routes()
}