implement first version of ranking board; Fixes #20
All checks were successful
CI/CD Pipeline / test (push) Successful in 5m21s
CI/CD Pipeline / deploy (push) Successful in 3m37s

This commit is contained in:
Philipp Hofer 2025-04-12 21:03:59 +02:00
parent b7f5ce27db
commit 1cbd77ec95
8 changed files with 117 additions and 12 deletions

3
Cargo.lock generated
View File

@ -605,6 +605,7 @@ checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
dependencies = [ dependencies = [
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"futures-executor",
"futures-io", "futures-io",
"futures-sink", "futures-sink",
"futures-task", "futures-task",
@ -684,6 +685,7 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [ dependencies = [
"futures-channel",
"futures-core", "futures-core",
"futures-io", "futures-io",
"futures-macro", "futures-macro",
@ -2228,6 +2230,7 @@ dependencies = [
"axum-test", "axum-test",
"chrono", "chrono",
"dotenv", "dotenv",
"futures",
"maud", "maud",
"password-auth", "password-auth",
"rust-i18n", "rust-i18n",

View File

@ -20,6 +20,7 @@ async-trait = "0.1"
password-auth = "1.0" password-auth = "1.0"
tower-sessions-sqlx-store-chrono = { version = "0.14", features = ["sqlite"] } tower-sessions-sqlx-store-chrono = { version = "0.14", features = ["sqlite"] }
tracing-subscriber = "0.3" tracing-subscriber = "0.3"
futures = "0.3"
[dev-dependencies] [dev-dependencies]

View File

@ -1,13 +1,80 @@
use crate::{auth::Backend, page, AppState}; use crate::{auth::Backend, models::rating::Rating, page, AppState};
use axum::{routing::get, Router}; use axum::{extract::State, routing::get, Router};
use axum_login::login_required; use axum_login::login_required;
use maud::{html, Markup}; use maud::{html, Markup};
use route::Route;
use sqlx::SqlitePool;
use std::sync::Arc;
use tower_sessions::Session; use tower_sessions::Session;
pub(crate) mod route; pub(crate) mod route;
pub(crate) mod station; pub(crate) mod station;
pub(crate) mod team; pub(crate) mod team;
async fn highscore(State(db): State<Arc<SqlitePool>>, session: Session) -> Markup {
let routes = Route::all(&db).await;
let content = html! {
h1 {
a href="/admin" { "↩️" }
"Highscore"
}
@for (idx, route) in routes.into_iter().enumerate() {
details open[idx==0] {
summary { (route.name) }
table {
thead {
tr {
td { "Team" }
@for station in route.stations(&db).await {
td {
a href=(format!("/admin/station/{}", station.id)){
(station.name)
}
}
}
td { "Gesamtpunkte" }
}
}
tbody {
@for team in route.teams_ordered_by_points(&db).await {
@let mut total_points = 0;
tr {
td {
a href=(format!("/admin/team/{}", team.id)) {
(team.name)
}
}
@for station in route.stations(&db).await {
td {
@if let Some(rating) = Rating::find_by_team_and_station(&db, &team, &station).await {
@if let (Some(notes), Some(points)) = (rating.notes, rating.points) {
({total_points += points;""})
em data-tooltip=(notes) { (points) }
}@else if let Some(points) = rating.points {
({total_points += points;""})
(points)
}@else {
em data-tooltip="Station hat Team noch nicht bewertet" {
"?"
}
}
}
}
}
td { (total_points) }
}
}
}
}
}
}
};
page(content, session, false).await
}
async fn index(session: Session) -> Markup { async fn index(session: Session) -> Markup {
let content = html! { let content = html! {
h1 { (t!("app_name")) } h1 { (t!("app_name")) }
@ -31,6 +98,11 @@ async fn index(session: Session) -> Markup {
(t!("teams")) (t!("teams"))
} }
} }
li {
a role="button" href="/admin/highscore" {
"Highscore"
}
}
} }
} }
}; };
@ -40,6 +112,7 @@ async fn index(session: Session) -> Markup {
pub(super) fn routes() -> Router<AppState> { pub(super) fn routes() -> Router<AppState> {
Router::new() Router::new()
.route("/", get(index)) .route("/", get(index))
.route("/highscore", get(highscore))
.nest("/station", station::routes()) .nest("/station", station::routes())
.nest("/route", route::routes()) .nest("/route", route::routes())
.nest("/team", team::routes()) .nest("/team", team::routes())

View File

@ -1,8 +1,9 @@
use crate::{ use crate::{
AppState,
admin::{station::Station, team::Team}, admin::{station::Station, team::Team},
AppState,
}; };
use axum::Router; use axum::Router;
use futures::future::join_all;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::{FromRow, Row, SqlitePool}; use sqlx::{FromRow, Row, SqlitePool};
@ -167,6 +168,22 @@ DROP TABLE temp_pos;",
Team::all_with_route(db, self).await Team::all_with_route(db, self).await
} }
pub(crate) async fn teams_ordered_by_points(&self, db: &SqlitePool) -> Vec<Team> {
let teams = Team::all_with_route(db, self).await;
// First, collect all the points
let points_futures: Vec<_> = teams.iter().map(|team| team.get_curr_points(db)).collect();
let points = join_all(points_futures).await;
// Create pairs of (team, points)
let mut team_with_points: Vec<_> = teams.into_iter().zip(points).collect();
// Sort by points (descending)
team_with_points.sort_by(|a, b| b.1.cmp(&a.1));
// Extract just the teams in sorted order
team_with_points.into_iter().map(|(team, _)| team).collect()
}
pub(crate) async fn get_next_first_station(&self, db: &SqlitePool) -> Option<Station> { pub(crate) async fn get_next_first_station(&self, db: &SqlitePool) -> Option<Station> {
let Ok(row) = sqlx::query(&format!( let Ok(row) = sqlx::query(&format!(
" "

View File

@ -1,8 +1,8 @@
use super::team::Team; use super::team::Team;
use crate::{ use crate::{
AppState,
admin::route::Route, admin::route::Route,
models::rating::{Rating, TeamsAtStationLocation}, models::rating::{Rating, TeamsAtStationLocation},
AppState,
}; };
use axum::Router; use axum::Router;
use chrono::{DateTime, Local, NaiveDateTime, Utc}; use chrono::{DateTime, Local, NaiveDateTime, Utc};
@ -353,7 +353,8 @@ impl Station {
"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.route_id
FROM team t FROM team t
JOIN route_station rs ON t.route_id = rs.route_id JOIN route_station rs ON t.route_id = rs.route_id
WHERE rs.station_id = ?;", WHERE rs.station_id = ?
ORDER BY t.name;",
) )
.bind(self.id) .bind(self.id)
.fetch_all(db) .fetch_all(db)

View File

@ -1,18 +1,17 @@
use crate::{ use crate::{
AppState,
admin::station::Station, admin::station::Station,
er, err, er, err,
models::rating::{Rating, TeamsAtStationLocation}, models::rating::{Rating, TeamsAtStationLocation},
partials::page, partials::page,
suc, succ, suc, succ, AppState,
}; };
use axum::{ use axum::{
Form, Router,
extract::State, extract::State,
response::{IntoResponse, Redirect}, response::{IntoResponse, Redirect},
routing::{get, post}, routing::{get, post},
Form, Router,
}; };
use maud::{Markup, html}; use maud::{html, Markup};
use serde::Deserialize; use serde::Deserialize;
use sqlx::SqlitePool; use sqlx::SqlitePool;
use std::sync::Arc; use std::sync::Arc;

View File

@ -1,6 +1,6 @@
use crate::{ use crate::{
AppState,
admin::{route::Route, station::Station}, admin::{route::Route, station::Station},
AppState,
}; };
use axum::Router; use axum::Router;
use chrono::{DateTime, Local, NaiveDateTime, Utc}; use chrono::{DateTime, Local, NaiveDateTime, Utc};
@ -77,7 +77,7 @@ enum CreateError {
impl Team { impl Team {
pub(crate) async fn all(db: &SqlitePool) -> Vec<Self> { pub(crate) async fn all(db: &SqlitePool) -> Vec<Self> {
sqlx::query_as::<_, Self>( sqlx::query_as::<_, Self>(
"SELECT id, name, notes, amount_people, first_station_id, route_id FROM team;", "SELECT id, name, notes, amount_people, first_station_id, route_id FROM team ORDER BY name;",
) )
.fetch_all(db) .fetch_all(db)
.await .await
@ -216,6 +216,17 @@ impl Team {
.await .await
.expect("db constraints") .expect("db constraints")
} }
pub async fn get_curr_points(&self, db: &SqlitePool) -> i64 {
sqlx::query!(
"SELECT IFNULL(sum(points), 0) as points FROM rating WHERE team_id = ?",
self.id
)
.fetch_one(db)
.await
.unwrap()
.points
}
} }
pub(super) fn routes() -> Router<AppState> { pub(super) fn routes() -> Router<AppState> {

View File

@ -36,7 +36,7 @@ impl AuthUser for User {
} }
fn session_auth_hash(&self) -> &[u8] { fn session_auth_hash(&self) -> &[u8] {
&self.pw.as_bytes() self.pw.as_bytes()
} }
} }