feat: vehicle management

This commit is contained in:
Max Hohlfeld 2024-11-20 12:22:13 +01:00
parent 5740248b96
commit 1c6abc5882
16 changed files with 490 additions and 23 deletions

View File

@ -6,7 +6,7 @@ use chrono::{NaiveDate, Utc};
use serde::Deserialize;
use sqlx::PgPool;
use crate::models::{Area, Availabillity, Event, Function, Role, User};
use crate::models::{Area, Availabillity, Event, Role, User};
#[derive(Deserialize)]
pub struct CalendarQuery {

View File

@ -10,6 +10,7 @@ mod export;
mod location;
mod user;
mod imprint;
mod vehicle;
#[derive(Deserialize)]
pub struct IdPath {
@ -70,4 +71,11 @@ pub fn init(cfg: &mut ServiceConfig) {
cfg.service(export::get_availability_data::get);
cfg.service(imprint::get_imprint);
cfg.service(vehicle::delete::delete);
cfg.service(vehicle::get_new::get);
cfg.service(vehicle::get_edit::get);
cfg.service(vehicle::get_overview::get);
cfg.service(vehicle::post_new::post);
cfg.service(vehicle::post_edit::post);
}

View File

@ -0,0 +1,27 @@
use actix_web::{web, HttpResponse, Responder};
use sqlx::PgPool;
use crate::{
endpoints::IdPath,
models::{Role, User, Vehicle},
utils::ApplicationError,
};
#[actix_web::delete("/vehicles/{id}")]
pub async fn delete(
user: web::ReqData<User>,
pool: web::Data<PgPool>,
path: web::Path<IdPath>,
) -> Result<impl Responder, ApplicationError> {
if user.role != Role::Admin {
return Err(ApplicationError::Unauthorized);
}
let Some(_) = Vehicle::read(pool.get_ref(), path.id).await? else {
return Ok(HttpResponse::NotFound().finish());
};
Vehicle::delete(pool.get_ref(), path.id).await?;
Ok(HttpResponse::Ok().finish())
}

View File

@ -0,0 +1,31 @@
use actix_web::{web, HttpResponse, Responder};
use askama_actix::TemplateToResponse;
use sqlx::PgPool;
use crate::{
endpoints::{vehicle::VehicleNewOrEditTemplate, IdPath},
models::{Role, User, Vehicle},
utils::ApplicationError,
};
#[actix_web::get("/vehicles/{id}")]
pub async fn get(
user: web::ReqData<User>,
pool: web::Data<PgPool>,
path: web::Path<IdPath>,
) -> Result<impl Responder, ApplicationError> {
if user.role != Role::Admin {
return Err(ApplicationError::Unauthorized);
}
let Some(vehicle) = Vehicle::read(pool.get_ref(), path.id).await? else {
return Ok(HttpResponse::NotFound().finish());
};
let template = VehicleNewOrEditTemplate {
user: user.into_inner(),
vehicle: Some(vehicle),
};
Ok(template.to_response())
}

View File

@ -0,0 +1,22 @@
use actix_web::{web, Responder};
use askama_actix::TemplateToResponse;
use crate::{
endpoints::vehicle::VehicleNewOrEditTemplate,
models::{Role, User},
utils::ApplicationError,
};
#[actix_web::get("/vehicles/new")]
pub async fn get(user: web::ReqData<User>) -> Result<impl Responder, ApplicationError> {
if user.role != Role::Admin {
return Err(ApplicationError::Unauthorized);
}
let template = VehicleNewOrEditTemplate {
user: user.into_inner(),
vehicle: None,
};
Ok(template.to_response())
}

View File

@ -0,0 +1,29 @@
use actix_web::{web, Responder};
use askama::Template;
use askama_actix::TemplateToResponse;
use sqlx::PgPool;
use crate::{models::{User, Vehicle, Role}, utils::ApplicationError};
#[derive(Template)]
#[template(path = "vehicles/overview.html")]
pub struct VehiclesOverviewTemplate {
user: User,
vehicles: Vec<Vehicle>
}
#[actix_web::get("/vehicles")]
pub async fn get(user: web::ReqData<User>, pool: web::Data<PgPool>) -> Result<impl Responder, ApplicationError> {
if user.role != Role::Admin {
return Err(ApplicationError::Unauthorized);
}
let vehicles = Vehicle::read_all(pool.get_ref()).await?;
let template = VehiclesOverviewTemplate {
user: user.into_inner(),
vehicles
};
Ok(template.to_response())
}

View File

@ -0,0 +1,24 @@
use askama::Template;
use serde::Deserialize;
use crate::models::{Role, User, Vehicle};
pub mod delete;
pub mod get_edit;
pub mod get_new;
pub mod get_overview;
pub mod post_edit;
pub mod post_new;
#[derive(Template)]
#[template(path = "vehicles/new_or_edit.html")]
pub struct VehicleNewOrEditTemplate {
user: User,
vehicle: Option<Vehicle>,
}
#[derive(Deserialize)]
pub struct VehicleForm {
radio_call_name: String,
station: String,
}

View File

@ -0,0 +1,39 @@
use actix_web::{http::header::LOCATION, web, HttpResponse, Responder};
use sqlx::PgPool;
use crate::{
endpoints::{vehicle::VehicleForm, IdPath},
models::{Role, User, Vehicle},
utils::ApplicationError,
};
#[actix_web::post("/vehicles/{id}")]
pub async fn post(
user: web::ReqData<User>,
pool: web::Data<PgPool>,
path: web::Path<IdPath>,
form: web::Form<VehicleForm>,
) -> Result<impl Responder, ApplicationError> {
if user.role != Role::Admin {
return Err(ApplicationError::Unauthorized);
}
let Some(vehicle) = Vehicle::read(pool.get_ref(), path.id).await? else {
return Ok(HttpResponse::NotFound().finish());
};
if vehicle.radio_call_name != form.radio_call_name || vehicle.station != form.station {
Vehicle::update(
pool.get_ref(),
path.id,
&form.radio_call_name,
&form.station,
)
.await?;
}
Ok(HttpResponse::Found()
.insert_header((LOCATION, "/vehicles"))
.insert_header(("HX-LOCATION", "/vehicles"))
.finish())
}

View File

@ -0,0 +1,26 @@
use actix_web::{http::header::LOCATION, web, HttpResponse, Responder};
use sqlx::PgPool;
use crate::{
endpoints::vehicle::VehicleForm,
models::{Role, User, Vehicle},
utils::ApplicationError,
};
#[actix_web::post("/vehicles/new")]
pub async fn post(
user: web::ReqData<User>,
pool: web::Data<PgPool>,
form: web::Form<VehicleForm>,
) -> Result<impl Responder, ApplicationError> {
if user.role != Role::Admin {
return Err(ApplicationError::Unauthorized);
}
Vehicle::create(pool.get_ref(), &form.radio_call_name, &form.station).await?;
Ok(HttpResponse::Found()
.insert_header((LOCATION, "/vehicles"))
.insert_header(("HX-LOCATION", "/vehicles"))
.finish())
}

View File

@ -108,7 +108,7 @@ impl Location {
}
pub async fn delete(pool: &PgPool, id: i32) -> super::Result<()> {
sqlx::query!("DELETE FROM location WHERE id = $1;", id)
query!("DELETE FROM location WHERE id = $1;", id)
.execute(pool)
.await?;

View File

@ -9,6 +9,7 @@ mod user;
mod vehicle;
mod password_reset;
mod registration;
mod vehicle_assignement;
pub use area::Area;
pub use availabillity::Availabillity;
@ -20,5 +21,7 @@ pub use user::User;
pub use assignement::Assignment;
pub use password_reset::{PasswordReset, Token, NoneToken};
pub use registration::Registration;
pub use vehicle::Vehicle;
pub use vehicle_assignement::VehicleAssignement;
type Result<T> = std::result::Result<T, sqlx::Error>;

View File

@ -0,0 +1,80 @@
use sqlx::{query, PgPool};
use super::Result;
pub struct Vehicle {
pub id: i32,
pub radio_call_name: String,
pub station: String,
}
impl Vehicle {
pub async fn create(pool: &PgPool, radio_call_name: &str, station: &str) -> Result<()> {
query!(
"INSERT INTO vehicle (radioCallName, station) VALUES ($1, $2);",
radio_call_name,
station
)
.execute(pool)
.await?;
Ok(())
}
pub async fn read_all(pool: &PgPool) -> Result<Vec<Vehicle>> {
let records = query!("SELECT * FROM vehicle;").fetch_all(pool).await?;
let vehicles = records
.into_iter()
.map(|v| Vehicle {
id: v.id,
radio_call_name: v.radiocallname,
station: v.station,
})
.collect();
Ok(vehicles)
}
pub async fn read(pool: &PgPool, id: i32) -> Result<Option<Vehicle>> {
let record = query!("SELECT * FROM vehicle WHERE id = $1;", id)
.fetch_optional(pool)
.await?;
let vehicle = record.and_then(|v| {
Some(Vehicle {
id: v.id,
radio_call_name: v.radiocallname,
station: v.station,
})
});
Ok(vehicle)
}
pub async fn update(
pool: &PgPool,
id: i32,
radio_call_name: &str,
station: &str,
) -> Result<()> {
query!(
"UPDATE vehicle SET radiocallname = $1, station = $2 WHERE id = $3;",
radio_call_name,
station,
id
)
.execute(pool)
.await?;
Ok(())
}
pub async fn delete(pool: &PgPool, id: i32) -> Result<()> {
query!("DELETE FROM vehicle WHERE id = $1;", id)
.execute(pool)
.await?;
Ok(())
}
}

View File

@ -0,0 +1,34 @@
use sqlx::{query, PgPool};
use super::Result;
pub struct VehicleAssignement {
event_id: i32,
vehicle_id: i32,
}
impl VehicleAssignement {
pub async fn create(pool: &PgPool, event_id: i32, vehicle_id: i32) -> Result<()> {
query!(
"INSERT INTO vehicleassignement (eventId, vehicleId) VALUES ($1, $2);",
event_id,
vehicle_id
)
.execute(pool)
.await?;
Ok(())
}
pub async fn delete(pool: &PgPool, event_id: i32, vehicle_id: i32) -> Result<()> {
query!(
"DELETE FROM vehicleassignement WHERE eventId = $1 AND vehicleId = $2;",
event_id,
vehicle_id
)
.execute(pool)
.await?;
Ok(())
}
}

View File

@ -21,9 +21,7 @@
Kalender
</a>
{% match user.role %}
{% when Role::Staff %}
{% when Role::AreaManager %}
{% if user.role == Role::AreaManager || user.role == Role::Admin %}
<div class="navbar-item has-dropdown is-hoverable">
<div class="navbar-item">
Export
@ -35,32 +33,29 @@
</a>
</div>
</div>
<a href="/locations" class="navbar-item">
Veranstaltungsorte
</a>
<a href="/users" class="navbar-item">
Nutzerverwaltung
</a>
{% when Role::Admin %}
<div class="navbar-item has-dropdown is-hoverable">
<div class="navbar-item">
Export
Verwaltung
</div>
<div class="navbar-dropdown">
<a href="/export/availability" class="navbar-item">
Verfügbarkeiten
<a href="/users" class="navbar-item">
Nutzer
</a>
<a href="/locations" class="navbar-item">
Veranstaltungsorte
</a>
{% if user.role == Role::Admin %}
<a href="/vehicles" class="navbar-item">
Fahrzeuge
</a>
{% endif %}
</div>
</div>
<a href="/locations" class="navbar-item">
Veranstaltungsorte
</a>
<a href="/users" class="navbar-item">
Nutzerverwaltung
</a>
{% endmatch %}
{% endif %}
</div>
<div class="navbar-end">

View File

@ -0,0 +1,78 @@
{% extends "nav.html" %}
{% block content %}
<section class="section">
<div class="container">
{% if vehicle.is_some() %}
<form method="post" action="/vehicles/{{ vehicle.as_ref().unwrap().id }}">
<h1 class="title">Fahrzeug '{{ vehicle.as_ref().unwrap().radio_call_name }}' bearbeiten</h1>
{% else %}
<form method="post" action="/vehicles/new">
<h1 class="title">Neues Fahrzeug anlegen</h1>
{% endif %}
<div class="field is-horizontal">
<div class="field-label">
<label class="label">Funkkenner</label>
</div>
<div class="field-body">
<div class="field">
<div class="control">
<input class="input" type="text" name="radio_call_name" required placeholder="11.49.1" {% if
vehicle.is_some() -%} value="{{ vehicle.as_ref().unwrap().radio_call_name }}" {% endif -%} />
</div>
</div>
</div>
</div>
<div class="field is-horizontal">
<div class="field-label">
<label class="label">Wache</label>
</div>
<div class="field-body">
<div class="field">
<div class="control">
<input class="input" type="text" name="station" required placeholder="FF Leipzig Ost" {% if
vehicle.is_some() -%} value="{{ vehicle.as_ref().unwrap().station }}" {% endif -%} />
</div>
</div>
</div>
</div>
<div class="field is-horizontal">
<div class="field-label"></div>
<div class="field-body">
<div class="field is-grouped">
<div class="control">
<button class="button is-success">
<svg class="icon">
<use href="/static/feather-sprite.svg#check-circle" />
</svg>
<span>
{% if vehicle.is_some() %}
Speichern
{% else %}
Erstellen
{% endif %}
</span>
</button>
</div>
<div class="control">
<a class="button is-link is-light" hx-boost="true" href="/vehicles">
<svg class="icon">
<use href="/static/feather-sprite.svg#arrow-left" />
</svg>
<span>Zurück</span>
</a>
</div>
</div>
</div>
</div>
</form>
</div>
</section>
<script>
</script>
{% endblock %}

View File

@ -0,0 +1,71 @@
{% extends "nav.html" %}
{% block content %}
<section class="section">
<div class="container">
<div class="level">
<div class="level-left">
<h3 class="title is-3">
Fahrzeuge
</h3>
</div>
<div class="level-right">
<a class="button is-link is-light" hx-boost="true" href="/vehicles/new">
<svg class="icon">
<use href="/static/feather-sprite.svg#plus-circle" />
</svg>
<span>Neues Fahrzeug anlegen</span>
</a>
</div>
</div>
{% if vehicles.len() == 0 %}
<div class="box">
<h5 class="title is-5">keine Fahrzeuge vorhanden</h5>
</div>
{% else %}
<div class="box">
<table class="table is-fullwidth">
<thead>
<tr>
<th>Funkkenner</th>
<th>Wache</th>
<th></th>
</tr>
</thead>
<tbody>
{% for v in vehicles %}
<tr>
<td>
{{ v.radio_call_name }}
</td>
<td>
{{ v.station }}
</td>
<td>
<div class="buttons is-right">
<a class="button is-primary is-light" hx-boost="true" href="/vehicles/{{ v.id }}">
<svg class="icon">
<use href="/static/feather-sprite.svg#edit" />
</svg>
<span>Bearbeiten</span>
</a>
<button class="button is-danger is-light" hx-delete="/vehicles/{{ v.id }}" hx-target="closest tr"
hx-swap="delete" hx-trigger="confirmed">
<svg class="icon">
<use href="/static/feather-sprite.svg#x-circle" />
</svg>
<span>Löschen</span>
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
</section>
{% endblock %}