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

View File

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

View File

@ -1,13 +1,80 @@
use crate::{auth::Backend, page, AppState};
use axum::{routing::get, Router};
use crate::{auth::Backend, models::rating::Rating, page, AppState};
use axum::{extract::State, routing::get, Router};
use axum_login::login_required;
use maud::{html, Markup};
use route::Route;
use sqlx::SqlitePool;
use std::sync::Arc;
use tower_sessions::Session;
pub(crate) mod route;
pub(crate) mod station;
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 {
let content = html! {
h1 { (t!("app_name")) }
@ -31,6 +98,11 @@ async fn index(session: Session) -> Markup {
(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> {
Router::new()
.route("/", get(index))
.route("/highscore", get(highscore))
.nest("/station", station::routes())
.nest("/route", route::routes())
.nest("/team", team::routes())

View File

@ -1,8 +1,9 @@
use crate::{
AppState,
admin::{station::Station, team::Team},
AppState,
};
use axum::Router;
use futures::future::join_all;
use serde::{Deserialize, Serialize};
use sqlx::{FromRow, Row, SqlitePool};
@ -167,6 +168,22 @@ DROP TABLE temp_pos;",
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> {
let Ok(row) = sqlx::query(&format!(
"

View File

@ -1,8 +1,8 @@
use super::team::Team;
use crate::{
AppState,
admin::route::Route,
models::rating::{Rating, TeamsAtStationLocation},
AppState,
};
use axum::Router;
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
FROM team t
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)
.fetch_all(db)

View File

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

View File

@ -1,6 +1,6 @@
use crate::{
AppState,
admin::{route::Route, station::Station},
AppState,
};
use axum::Router;
use chrono::{DateTime, Local, NaiveDateTime, Utc};
@ -77,7 +77,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;",
"SELECT id, name, notes, amount_people, first_station_id, route_id FROM team ORDER BY name;",
)
.fetch_all(db)
.await
@ -216,6 +216,17 @@ impl Team {
.await
.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> {

View File

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