Philipp Hofer 7f354879fe
Some checks failed
CI/CD Pipeline / deploy (push) Has been cancelled
CI/CD Pipeline / test (push) Has been cancelled
more external string, continue #12
2025-04-23 14:36:22 +02:00

824 lines
28 KiB
Rust

use super::{CreateError, LastContactTeam, Team};
use crate::{
admin::{route::Route, station::Station},
er,
models::rating::Rating,
partials::page,
suc, AppState,
};
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::{collections::HashMap, sync::Arc};
use tower_sessions::Session;
#[derive(Deserialize)]
struct CreateForm {
name: String,
route_id: i64,
}
async fn create(
State(db): State<Arc<SqlitePool>>,
session: Session,
Form(form): Form<CreateForm>,
) -> impl IntoResponse {
let Some(route) = Route::find_by_id(&db, form.route_id).await else {
er!(session, t!("nonexisting_route", id = form.route_id));
return Redirect::to("/admin/team");
};
let id = match Team::create(&db, &form.name, &route).await {
Ok(id) => {
suc!(session, t!("team_created", team = form.name));
id
}
Err(CreateError::DuplicateName(e)) => {
er!(
session,
t!("team_not_created_duplicate_name", team = form.name, err = e)
);
return Redirect::to("/admin/team");
}
Err(CreateError::NoStationForRoute) => {
er!(
session,
t!(
"team_not_created_no_station_in_route",
team = form.name,
route = route.name
)
);
return Redirect::to("/admin/team");
}
};
Redirect::to(&format!("/admin/team/{id}"))
}
async fn delete(
State(db): State<Arc<SqlitePool>>,
session: Session,
axum::extract::Path(id): axum::extract::Path<i64>,
) -> impl IntoResponse {
let Some(team) = Team::find_by_id(&db, id).await else {
er!(session, t!("nonexisting_team", id = id));
return Redirect::to("/admin/team");
};
match team.delete(&db).await {
Ok(()) => suc!(session, t!("team_deleted", team = team.name)),
Err(e) => er!(
session,
t!("team_not_deleted_already_in_use", team = team.name, err = e)
),
}
Redirect::to("/admin/team")
}
async fn quick(db: Arc<SqlitePool>, team: &Team, stations: Vec<Station>, redirect: &str) -> Markup {
html! {
h1 {
a href=(format!("/admin/team/{}", team.id)) { "↩️" }
(t!("ratings"))
" "
(t!("team"))
" "
(team.name)
}
form action=(format!("/admin/team/{}/quick", team.id)) method="post" {
input type="hidden" name="redirect" value=(redirect);
table {
thead {
tr {
th { (t!("station")) }
th { (t!("points")) }
}
}
tbody {
@for station in &stations {
tr {
td {
a href=(format!("/admin/station/{}", station.id)) {
(station.name)
}
}
td {
@if let Some(rating) = Rating::find_by_team_and_station(&db, team, station).await {
a href=(format!("/s/{}/{}", station.id, station.pw)){
@if let Some(points) = rating.points {
em data-tooltip=(t!("already_entered")) {
(points)
}
} @else {
em data-tooltip=(t!("team_currently_at_station")) { "?" }
}
}
} @else {
input type="number" min="0" max="10" name=(station.id);
}
}
}
}
}
}
input type="submit" value=(t!("save"));
}
}
}
async fn quick_crewless(
State(db): State<Arc<SqlitePool>>,
session: Session,
axum::extract::Path(id): axum::extract::Path<i64>,
) -> Result<Markup, impl IntoResponse> {
let Some(team) = Team::find_by_id(&db, id).await else {
er!(session, t!("nonexisting_team", id = id));
return Err(Redirect::to("/admin/team"));
};
let stations: Vec<Station> = team.route(&db).await.crewless_stations(&db).await;
let content = quick(db, &team, stations, "/crewless").await;
Ok(page(content, session, true).await)
}
async fn quick_all(
State(db): State<Arc<SqlitePool>>,
session: Session,
axum::extract::Path(id): axum::extract::Path<i64>,
) -> Result<Markup, impl IntoResponse> {
let Some(team) = Team::find_by_id(&db, id).await else {
er!(session, t!("nonexisting_team", id = id));
return Err(Redirect::to("/admin/team"));
};
let stations = team.route(&db).await.stations(&db).await;
let content = quick(db, &team, stations, "").await;
Ok(page(content, session, true).await)
}
#[derive(Deserialize, Debug)]
struct QuickUpdate {
redirect: String,
#[serde(flatten)]
fields: HashMap<String, String>,
}
async fn quick_post(
State(db): State<Arc<SqlitePool>>,
session: Session,
axum::extract::Path(id): axum::extract::Path<i64>,
Form(form): Form<QuickUpdate>,
) -> impl IntoResponse {
let Some(team) = Team::find_by_id(&db, id).await else {
er!(session, t!("nonexisting_team", id = id));
return Redirect::to("/admin/team");
};
let mut ret = String::new();
let mut amount_succ = 0;
for (station_id, points) in &form.fields {
let Ok(station_id) = station_id.parse::<i64>() else {
ret.push_str(&format!(
"Skipped stationid={station_id} because this id can't be parsed as i64"
));
continue;
};
let Ok(points) = points.parse::<i64>() else {
ret.push_str(&format!(
"Skipped stationid={station_id} because points {points} can't be parsed as i64",
));
continue;
};
let Some(station) = Station::find_by_id(&db, station_id).await else {
ret.push_str(&format!(
"Skipped stationid={station_id} because this station does not exist"
));
continue;
};
if Rating::find_by_team_and_station(&db, &team, &station)
.await
.is_some()
{
let msg: String = t!(
"error_rating_team_already_rated",
team = team.name,
station = station.name
)
.into();
ret.push_str(&msg);
continue;
}
Rating::create_quick(&db, &team, &station, points).await;
amount_succ += 1;
}
if !ret.is_empty() {
// TODO: properly log warnings
println!("{ret}");
}
if amount_succ == 0 {
suc!(session, t!("funny_you_entered_no_rating"));
} else {
suc!(session, t!("entered_n_ratings", amount = amount_succ));
}
Redirect::to(&format!("/admin/team/{id}/quick{}", form.redirect))
}
async fn view(
State(db): State<Arc<SqlitePool>>,
session: Session,
axum::extract::Path(id): axum::extract::Path<i64>,
) -> Result<Markup, impl IntoResponse> {
let Some(team) = Team::find_by_id(&db, id).await else {
er!(session, t!("nonexisting_team", id = id));
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;
// maybe switch to maud-display impl of team
let content = html! {
h1 {
a href="/admin/team" { "↩️" }
(t!("team"))
" "
(team.name)
}
article {
details {
summary {
(t!("edit_teamname"))
" ✏️"
}
form action=(format!("/admin/team/{}/name", team.id)) method="post" {
input type="text" name="name" value=(team.name) required;
input type="submit" value=(t!("save"));
}
}
}
table {
tbody {
tr {
th scope="row" { (t!("notes")) };
td {
@match &team.notes {
Some(notes) => {
(notes)
details {
summary { "✏️" }
form action=(format!("/admin/team/{}/notes", team.id)) method="post" {
textarea name="notes" required rows="10" { (notes) };
input type="submit" value=(t!("save"));
}
}
},
None => details {
summary { (t!("add_new_note")) }
form action=(format!("/admin/team/{}/notes", team.id)) method="post" {
textarea name="notes" required rows="10" {};
input type="submit" value=(t!("save"));
}
}
}
}
}
tr {
th scope="row" { (t!("amount_teammembers")) };
td {
@match team.amount_people {
Some(amount) => (amount),
None => "?",
}
details {
summary { "✏️" }
form action=(format!("/admin/team/{}/amount-people", team.id)) method="post" {
input type="number" name="amount_people" min="0" max="10";
input type="submit" value=(t!("save"));
}
a href=(format!("/admin/team/{}/amount-people-reset", team.id)) {
button class="error" {
em data-tooltip=(t!("not_sure_about_amount_team")) {
"?"
}
}
}
}
}
}
tr {
th scope="row" { (t!("route")) };
td {
a href=(format!("/admin/route/{}", &team.route(&db).await.id)) {
(&team.route(&db).await.name)
}
@if routes.len() > 1 {
details {
summary { "✏️" }
form action=(format!("/admin/team/{}/update-route", team.id)) method="post" {
select name="route_id" aria-label=(t!("select_route")) required {
@for route in &routes {
@if route.id != team.route(&db).await.id {
option value=(route.id) {
(route.name)
}
}
}
}
input type="submit" value=(t!("save"));
}
}
}
}
}
tr {
th scope="row" {
(t!("first_station"))
article {
(t!("first_station_expl"))
}
};
td {
a href=(format!("/admin/station/{}", first_station.id)) {
(first_station.name)
}
@if stations.len() > 1 {
details {
summary { "✏️" }
form action=(format!("/admin/team/{}/update-first-station", team.id)) method="post" {
select name="first_station_id" aria-label=(t!("select_station")) required {
@for station in &stations {
@if station.id != first_station.id {
option value=(station.id) {
(station.name)
@let amount_start_teams = Team::all_with_first_station(&db, station).await.len();
@if amount_start_teams > 0 {
@if amount_start_teams == 1 {
(t!("already_has_1_start_team"))
}@else{
(t!("already_has_n_start_team", amount=amount_start_teams))
}
}
}
}
}
}
input type="submit" value=(t!("save"));
}
}
}
}
}
@if let Some(last_station) = last_station {
tr {
th scope="row" {
(t!("last_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=(t!("select_station")) required {
@for station in &stations {
@if station.id != last_station.id {
option value=(station.id) {
(station.name)
}
}
}
}
input type="submit" value=(t!("save"));
}
}
}
}
}
}
}
}
a href=(format!("/admin/team/{}/quick", team.id)){
button {
(t!("ratings"))
}
}
hr;
a href=(format!("/admin/team/{}/quick/crewless", team.id)){
button {
(t!("rate_crewless_stations"))
}
}
};
Ok(page(content, session, true).await)
}
#[derive(Deserialize)]
struct UpdateNameForm {
name: String,
}
async fn update_name(
State(db): State<Arc<SqlitePool>>,
session: Session,
axum::extract::Path(id): axum::extract::Path<i64>,
Form(form): Form<UpdateNameForm>,
) -> impl IntoResponse {
let Some(team) = Team::find_by_id(&db, id).await else {
er!(session, t!("nonexisting_team", id = id));
return Redirect::to("/admin/team");
};
team.update_name(&db, &form.name).await;
suc!(
session,
t!("new_team_name", old = team.name, new = form.name)
);
Redirect::to(&format!("/admin/team/{id}"))
}
#[derive(Deserialize)]
struct UpdateNotesForm {
notes: String,
}
async fn update_notes(
State(db): State<Arc<SqlitePool>>,
session: Session,
axum::extract::Path(id): axum::extract::Path<i64>,
Form(form): Form<UpdateNotesForm>,
) -> impl IntoResponse {
let Some(team) = Team::find_by_id(&db, id).await else {
er!(session, t!("nonexisting_team", id = id));
return Redirect::to("/admin/team");
};
team.update_notes(&db, &form.notes).await;
suc!(session, t!("notes_edited", team = team.name));
Redirect::to(&format!("/admin/team/{id}"))
}
#[derive(Deserialize)]
struct UpdateAmountPeopleForm {
amount_people: i64,
}
async fn update_amount_people(
State(db): State<Arc<SqlitePool>>,
session: Session,
axum::extract::Path(id): axum::extract::Path<i64>,
Form(form): Form<UpdateAmountPeopleForm>,
) -> impl IntoResponse {
let Some(team) = Team::find_by_id(&db, id).await else {
er!(session, t!("nonexisting_team", id = id));
return Redirect::to("/admin/team");
};
team.update_amount_people(&db, form.amount_people).await;
suc!(session, t!("amount_teammembers_edited", team = team.name));
Redirect::to(&format!("/admin/team/{id}"))
}
#[derive(Deserialize)]
struct UpdateRouteForm {
route_id: i64,
}
async fn update_route(
State(db): State<Arc<SqlitePool>>,
session: Session,
axum::extract::Path(id): axum::extract::Path<i64>,
Form(form): Form<UpdateRouteForm>,
) -> impl IntoResponse {
// TODO: move sanity checks into mod.rs
let Some(team) = Team::find_by_id(&db, id).await else {
er!(session, t!("nonexisting_team", id = id));
return Redirect::to("/admin/team");
};
let Some(route) = Route::find_by_id(&db, form.route_id).await else {
er!(session, t!("nonexisting_route", id = form.route_id));
return Redirect::to(&format!("/admin/team/{id}"));
};
match team.update_route(&db, &route).await {
Ok(new_first_station_name) => suc!(
session,
t!(
"route_edited",
team = team.name,
route = route.name,
first_station = new_first_station_name
)
),
Err(()) => er!(
session,
t!(
"team_not_edited_route_has_no_stations",
team = team.name,
route = route.name
)
),
}
Redirect::to(&format!("/admin/team/{id}"))
}
#[derive(Deserialize)]
struct UpdateFirstStationForm {
first_station_id: i64,
}
async fn update_first_station(
State(db): State<Arc<SqlitePool>>,
session: Session,
axum::extract::Path(id): axum::extract::Path<i64>,
Form(form): Form<UpdateFirstStationForm>,
) -> impl IntoResponse {
let Some(team) = Team::find_by_id(&db, id).await else {
er!(session, t!("nonexisting_team", id = id));
return Redirect::to("/admin/team");
};
let Some(station) = Station::find_by_id(&db, form.first_station_id).await else {
er!(
session,
t!("nonexisting_station", id = form.first_station_id)
);
return Redirect::to(&format!("/admin/team/{id}"));
};
if !station.is_in_route(&db, &team.route(&db).await).await {
er!(
session,
t!(
"first_station_not_edited_not_on_route",
station = station.name,
team = team.name,
route = team.route(&db).await.name
)
);
return Redirect::to(&format!("/admin/team/{id}"));
}
team.update_first_station(&db, &station).await;
suc!(
session,
t!(
"changed_first_station",
team = team.name,
station = station.name
)
);
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 {
er!(session, t!("nonexisting_team", id = id));
return Redirect::to("/admin/team");
};
let Some(station) = Station::find_by_id(&db, form.last_station_id).await else {
er!(
session,
t!("nonexisting_station", id = form.last_station_id)
);
return Redirect::to(&format!("/admin/team/{id}"));
};
if !station.is_in_route(&db, &team.route(&db).await).await {
er!(
session,
t!(
"last_station_not_edited_not_on_route",
station = station.name,
team = team.name,
route = team.route(&db).await.name
)
);
return Redirect::to(&format!("/admin/team/{id}"));
}
team.update_last_station(&db, &station).await;
suc!(
session,
t!(
"changed_last_station",
team = team.name,
station = station.name
)
);
Redirect::to(&format!("/admin/team/{id}"))
}
async fn update_amount_people_reset(
State(db): State<Arc<SqlitePool>>,
session: Session,
axum::extract::Path(id): axum::extract::Path<i64>,
) -> impl IntoResponse {
let Some(team) = Team::find_by_id(&db, id).await else {
er!(session, t!("nonexisting_team", id = id));
return Redirect::to("/admin/team");
};
team.update_amount_people_reset(&db).await;
suc!(session, t!("amount_teammembers_edited", team = team.name));
Redirect::to(&format!("/admin/team/{id}"))
}
async fn lost(State(db): State<Arc<SqlitePool>>, session: Session) -> Markup {
let losts = LastContactTeam::all_sort_missing(&db).await;
let content = html! {
h1 {
a href="/admin/team" { "↩️" }
(t!("last_contact_team"))
}
div class="overflow-auto" {
table {
thead {
tr {
td { (t!("team")) }
td { (t!("time")) }
td { (t!("station")) }
}
}
tbody {
@for lost in &losts {
tr {
td {
a href=(format!("/admin/team/{}", lost.team.id)) {
(lost.team.name)
}
}
td {
@if let Some(time) = lost.local_last_contact() {
(time)
}@else{
(t!("not_yet_seen"))
}
}
td {
@if let Some(station) = &lost.station {
a href=(format!("/admin/station/{}", station.id)) {
(station.name)
}
}@else{
(t!("not_yet_seen"))
}
}
}
}
}
}
}
};
page(content, session, false).await
}
async fn index(State(db): State<Arc<SqlitePool>>, session: Session) -> Markup {
let teams = Team::all(&db).await;
let routes = Route::all(&db).await;
let content = html! {
h1 {
a href="/admin" { "↩️" }
(t!("teams"))
}
article {
em { (t!("teams")) }
"sind eine Menge an Personen, die verschiedene "
a href="/admin/station" { "Stationen" }
" ablaufen. Welche Stationen, entscheidet sich je nachdem, welcher "
a href="/admin/route" { "Route" }
" sie zugewiesen sind."
}
@if teams.is_empty() {
article class="warning" {
(t!("no_teams"))
@if !routes.is_empty() {
(t!("change_that_below"))
}
}
}
article {
h2 { (t!("new_team")) }
@if routes.is_empty() {
article class="error" {
(t!("route_needed_before_creating_teams"))
(PreEscaped(" &rarr; "))
a role="button" href="/admin/route" {
(t!("create_route"))
}
}
} @else {
form action="/admin/team" method="post" {
@if routes.len() == 1 {
fieldset role="group" {
input type="text" name="name" placeholder=(t!("teamname")) required;
input type="hidden" name="route_id" value=(routes[0].id) ;
input type="submit" value="Neues Team";
}
} @else {
input type="text" name="name" placeholder=(t!("teamname")) required;
select name="route_id" aria-label=(t!("select_route")) required {
@for route in &routes {
option value=(route.id) {
(route.name)
}
}
}
input type="submit" value=(t!("new_team"));
}
}
}
}
a href="/admin/team/lost" {
button class="outline" {
(t!("have_i_lost_groups"))
}
}
@for route in &routes {
h2 { (route.name) }
ol {
@for team in &route.teams(&db).await{
li {
a href=(format!("/admin/team/{}", team.id)){
(team.name)
}
a href=(format!("/admin/team/{}/delete", team.id))
onclick=(format!("return confirm('{}');", t!("confirm_delete_team"))) {
"🗑️"
}
}
}
}
}
};
page(content, session, false).await
}
pub(super) fn routes() -> Router<AppState> {
Router::new()
.route("/", get(index))
.route("/", post(create))
.route("/lost", get(lost))
.route("/{id}", get(view))
.route("/{id}/delete", get(delete))
.route("/{id}/quick", get(quick_all))
.route("/{id}/quick/crewless", get(quick_crewless))
.route("/{id}/quick", post(quick_post))
.route("/{id}/name", post(update_name))
.route("/{id}/notes", post(update_notes))
.route("/{id}/amount-people", post(update_amount_people))
.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))
}