update
This commit is contained in:
@@ -43,6 +43,7 @@ version = "0.3.28"
|
|||||||
|
|
||||||
[dependencies.time]
|
[dependencies.time]
|
||||||
version = "0.3.21"
|
version = "0.3.21"
|
||||||
|
features = ["serde"]
|
||||||
|
|
||||||
[dependencies.serde]
|
[dependencies.serde]
|
||||||
version = "1.0.162"
|
version = "1.0.162"
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ impl InventoryCategoryList {
|
|||||||
id="select-category"
|
id="select-category"
|
||||||
href=(
|
href=(
|
||||||
format!(
|
format!(
|
||||||
"/inventory/category/{id}",
|
"/inventory/category/{id}/",
|
||||||
id=category.id
|
id=category.id
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,13 +2,11 @@ use maud::{html, Markup, DOCTYPE};
|
|||||||
|
|
||||||
pub mod home;
|
pub mod home;
|
||||||
pub mod inventory;
|
pub mod inventory;
|
||||||
pub mod triplist;
|
pub mod trip;
|
||||||
|
|
||||||
mod theme;
|
|
||||||
|
|
||||||
pub use home::*;
|
pub use home::*;
|
||||||
pub use inventory::*;
|
pub use inventory::*;
|
||||||
pub use triplist::*;
|
pub use trip::*;
|
||||||
|
|
||||||
pub struct Root {
|
pub struct Root {
|
||||||
doc: Markup,
|
doc: Markup,
|
||||||
|
|||||||
349
rust/src/components/trip.rs
Normal file
349
rust/src/components/trip.rs
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
use crate::models;
|
||||||
|
use crate::models::*;
|
||||||
|
|
||||||
|
use maud::{html, Markup};
|
||||||
|
|
||||||
|
pub struct TripManager {
|
||||||
|
doc: Markup,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TripManager {
|
||||||
|
pub fn build(trips: Vec<models::Trip>) -> Self {
|
||||||
|
let doc = html!(
|
||||||
|
div ."p-8" {
|
||||||
|
(TripTable::build(trips).into_markup())
|
||||||
|
(NewTrip::build().into_markup())
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Self { doc }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TripTable {
|
||||||
|
doc: Markup,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<TripManager> for Markup {
|
||||||
|
fn from(val: TripManager) -> Self {
|
||||||
|
val.doc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TripTable {
|
||||||
|
pub fn build(trips: Vec<models::Trip>) -> Self {
|
||||||
|
let doc = html!(
|
||||||
|
h1 ."text-2xl" ."mb-5" {"Trips"}
|
||||||
|
table
|
||||||
|
."table"
|
||||||
|
."table-auto"
|
||||||
|
."border-collapse"
|
||||||
|
."border-spacing-0"
|
||||||
|
."border"
|
||||||
|
."w-full"
|
||||||
|
{
|
||||||
|
thead ."bg-gray-200" {
|
||||||
|
tr ."h-10" {
|
||||||
|
th ."border" ."p-2" { "Name" }
|
||||||
|
th ."border" ."p-2" { "From" }
|
||||||
|
th ."border" ."p-2" { "To" }
|
||||||
|
th ."border" ."p-2" { "Nights" }
|
||||||
|
th ."border" ."p-2" { "State" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tbody {
|
||||||
|
@for trip in trips {
|
||||||
|
tr ."h-10" ."even:bg-gray-100" ."hover:bg-purple-100" ."h-full" {
|
||||||
|
td ."border" ."p-0" ."m-0" {
|
||||||
|
a ."inline-block" ."p-2" ."m-0" ."w-full"
|
||||||
|
href=(format!("/trip/{id}/", id=trip.id))
|
||||||
|
{ (trip.name) }
|
||||||
|
}
|
||||||
|
td ."border" ."p-0" ."m-0" {
|
||||||
|
a ."inline-block" ."p-2" ."m-0" ."w-full"
|
||||||
|
href=(format!("/trip/{id}/", id=trip.id))
|
||||||
|
{ (trip.start_date) }
|
||||||
|
}
|
||||||
|
td ."border" ."p-0" ."m-0" {
|
||||||
|
a ."inline-block" ."p-2" ."m-0" ."w-full"
|
||||||
|
href=(format!("/trip/{id}/", id=trip.id))
|
||||||
|
{ (trip.end_date) }
|
||||||
|
}
|
||||||
|
td ."border" ."p-0" ."m-0" {
|
||||||
|
a ."inline-block" ."p-2" ."m-0" ."w-full"
|
||||||
|
href=(format!("/trip/{id}/", id=trip.id))
|
||||||
|
{ ((trip.end_date - trip.start_date).whole_days()) }
|
||||||
|
}
|
||||||
|
td ."border" ."p-0" ."m-0" {
|
||||||
|
a ."inline-block" ."p-2" ."m-0" ."w-full"
|
||||||
|
href=(format!("/trip/{id}/", id=trip.id))
|
||||||
|
{ (trip.state.to_string()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Self { doc }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_markup(self) -> Markup {
|
||||||
|
self.doc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct NewTrip {
|
||||||
|
doc: Markup,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NewTrip {
|
||||||
|
pub fn build() -> Self {
|
||||||
|
let doc = html!(
|
||||||
|
form
|
||||||
|
name="new_trip"
|
||||||
|
action="/trip/"
|
||||||
|
target="_self"
|
||||||
|
method="post"
|
||||||
|
."mt-8" ."p-5" ."border-2" ."border-gray-200"
|
||||||
|
{
|
||||||
|
div ."mb-5" ."flex" ."flex-row" ."trips-center" {
|
||||||
|
span ."mdi" ."mdi-playlist-plus" ."text-2xl" ."mr-4" {}
|
||||||
|
p ."inline" ."text-xl" { "Add new trip" }
|
||||||
|
}
|
||||||
|
div ."w-11/12" ."m-auto" {
|
||||||
|
div ."mx-auto" ."pb-8" {
|
||||||
|
div ."flex" ."flex-row" ."justify-center" {
|
||||||
|
label for="trip-name" ."font-bold" ."w-1/2" ."p-2" ."text-center" { "Name" }
|
||||||
|
span ."w-1/2" {
|
||||||
|
input
|
||||||
|
type="text"
|
||||||
|
id="trip-name"
|
||||||
|
name="new-trip-name"
|
||||||
|
."block"
|
||||||
|
."w-full"
|
||||||
|
."p-2"
|
||||||
|
."bg-gray-50"
|
||||||
|
."border-2"
|
||||||
|
."rounded"
|
||||||
|
."focus:outline-none"
|
||||||
|
."focus:bg-white"
|
||||||
|
{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div ."mx-auto" ."pb-8" {
|
||||||
|
div ."flex" ."flex-row" ."justify-center" {
|
||||||
|
label for="trip-name" ."font-bold" ."w-1/2" ."p-2" ."text-center" { "Start date" }
|
||||||
|
span ."w-1/2" {
|
||||||
|
input
|
||||||
|
type="date"
|
||||||
|
id="start-date"
|
||||||
|
name="new-trip-start-date"
|
||||||
|
."block"
|
||||||
|
."w-full"
|
||||||
|
."p-2"
|
||||||
|
."bg-gray-50"
|
||||||
|
."appearance-none"
|
||||||
|
."border-2"
|
||||||
|
."border-gray-300"
|
||||||
|
."rounded"
|
||||||
|
."focus:outline-none"
|
||||||
|
."focus:bg-white"
|
||||||
|
."focus:border-purple-500"
|
||||||
|
{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div ."mx-auto" ."pb-8" {
|
||||||
|
div ."flex" ."flex-row" ."justify-center" {
|
||||||
|
label for="trip-name" ."font-bold" ."w-1/2" ."p-2" ."text-center" { "Start date" }
|
||||||
|
span ."w-1/2" {
|
||||||
|
input
|
||||||
|
type="date"
|
||||||
|
id="end-date"
|
||||||
|
name="new-trip-end-date"
|
||||||
|
."block"
|
||||||
|
."w-full"
|
||||||
|
."p-2"
|
||||||
|
."bg-gray-50"
|
||||||
|
."appearance-none"
|
||||||
|
."border-2"
|
||||||
|
."border-gray-300"
|
||||||
|
."rounded"
|
||||||
|
."focus:outline-none"
|
||||||
|
."focus:bg-white"
|
||||||
|
."focus:border-purple-500"
|
||||||
|
{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
input
|
||||||
|
type="submit"
|
||||||
|
value="Add"
|
||||||
|
."py-2"
|
||||||
|
."border-2"
|
||||||
|
."rounded"
|
||||||
|
."border-gray-300"
|
||||||
|
."mx-auto"
|
||||||
|
."w-full"
|
||||||
|
{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Self { doc }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_markup(self) -> Markup {
|
||||||
|
self.doc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Trip {
|
||||||
|
doc: Markup,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Trip {
|
||||||
|
pub fn build(trip: &models::Trip) -> Self {
|
||||||
|
let doc = html!(
|
||||||
|
div ."p-8" {
|
||||||
|
div ."flex" ."flex-row" ."items-center" ."gap-x-3" {
|
||||||
|
h1 ."text-2xl" ."font-semibold"{ (trip.name) }
|
||||||
|
}
|
||||||
|
div ."my-6" {
|
||||||
|
(TripInfo::build(&trip).into_markup())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Self { doc }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_markup(self) -> Markup {
|
||||||
|
self.doc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TripInfo {
|
||||||
|
doc: Markup,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TripInfo {
|
||||||
|
pub fn build(trip: &models::Trip) -> Self {
|
||||||
|
let doc = html!(
|
||||||
|
table
|
||||||
|
."table"
|
||||||
|
."table-auto"
|
||||||
|
."border-collapse"
|
||||||
|
."border-spacing-0"
|
||||||
|
."border"
|
||||||
|
."w-full"
|
||||||
|
{
|
||||||
|
tbody {
|
||||||
|
tr {
|
||||||
|
td ."border" ."p-2" { "State" }
|
||||||
|
td ."border" ."p-2" { (trip.state.to_string()) }
|
||||||
|
}
|
||||||
|
tr {
|
||||||
|
td ."border" ."p-2" { "Location" }
|
||||||
|
td ."border" ."p-2" { (trip.location) }
|
||||||
|
}
|
||||||
|
tr {
|
||||||
|
td ."border" ."p-2" { "Start date" }
|
||||||
|
td ."border" ."p-2" { (trip.start_date) }
|
||||||
|
}
|
||||||
|
tr {
|
||||||
|
td ."border" ."p-2" { "End date" }
|
||||||
|
td ."border" ."p-2" { (trip.end_date) }
|
||||||
|
}
|
||||||
|
tr {
|
||||||
|
td ."border" ."p-2" { "Temp (min)" }
|
||||||
|
td ."border" ."p-2" { (trip.temp_min) }
|
||||||
|
}
|
||||||
|
tr {
|
||||||
|
td ."border" ."p-2" { "Temp (max)" }
|
||||||
|
td ."border" ."p-2" { (trip.temp_max) }
|
||||||
|
}
|
||||||
|
tr {
|
||||||
|
td ."border" ."p-2" { "Types" }
|
||||||
|
td ."border" ."p-2" {
|
||||||
|
ul
|
||||||
|
."flex"
|
||||||
|
."flex-row"
|
||||||
|
."flex-wrap"
|
||||||
|
."gap-2"
|
||||||
|
."justify-between"
|
||||||
|
{
|
||||||
|
@let types = trip.types();
|
||||||
|
div
|
||||||
|
."flex"
|
||||||
|
."flex-row"
|
||||||
|
."flex-wrap"
|
||||||
|
."gap-2"
|
||||||
|
."justify-start"
|
||||||
|
{
|
||||||
|
@for triptype in types.iter().filter(|t| t.active) {
|
||||||
|
a href=(format!("type/{}/remove", triptype.id)) {
|
||||||
|
li
|
||||||
|
."border"
|
||||||
|
."rounded-2xl"
|
||||||
|
."py-0.5"
|
||||||
|
."px-2"
|
||||||
|
."bg-green-100"
|
||||||
|
."cursor-pointer"
|
||||||
|
."flex"
|
||||||
|
."flex-column"
|
||||||
|
."items-center"
|
||||||
|
."hover:bg-red-200"
|
||||||
|
."gap-1"
|
||||||
|
{
|
||||||
|
span { (triptype.name) }
|
||||||
|
span ."mdi" ."mdi-delete" ."text-sm" {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div
|
||||||
|
."flex"
|
||||||
|
."flex-row"
|
||||||
|
."flex-wrap"
|
||||||
|
."gap-2"
|
||||||
|
."justify-start"
|
||||||
|
{
|
||||||
|
@for triptype in types.iter().filter(|t| !t.active) {
|
||||||
|
a href=(format!("type/{}/add", triptype.id)) {
|
||||||
|
li
|
||||||
|
."border"
|
||||||
|
."rounded-2xl"
|
||||||
|
."py-0.5"
|
||||||
|
."px-2"
|
||||||
|
."bg-gray-100"
|
||||||
|
."cursor-pointer"
|
||||||
|
."flex"
|
||||||
|
."flex-column"
|
||||||
|
."items-center"
|
||||||
|
."hover:bg-green-200"
|
||||||
|
."gap-1"
|
||||||
|
."opacity-60"
|
||||||
|
{
|
||||||
|
span { (triptype.name) }
|
||||||
|
span ."mdi" ."mdi-plus" ."text-sm" {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Self { doc }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_markup(self) -> Markup {
|
||||||
|
self.doc
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
use crate::models::*;
|
|
||||||
|
|
||||||
use maud::{html, Markup};
|
|
||||||
|
|
||||||
pub struct TripList {
|
|
||||||
doc: Markup,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TripList {
|
|
||||||
pub fn build(package_lists: Vec<Trip>) -> Self {
|
|
||||||
let doc = html!(
|
|
||||||
table {
|
|
||||||
thead {
|
|
||||||
td {
|
|
||||||
td { "ID" }
|
|
||||||
td { "Name" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tbody {
|
|
||||||
@for list in package_lists {
|
|
||||||
tr {
|
|
||||||
td { (list.id.to_string()) }
|
|
||||||
td { (list.name) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
Self { doc }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<TripList> for Markup {
|
|
||||||
fn from(val: TripList) -> Self {
|
|
||||||
val.doc
|
|
||||||
}
|
|
||||||
}
|
|
||||||
258
rust/src/main.rs
258
rust/src/main.rs
@@ -82,9 +82,13 @@ async fn main() -> Result<(), sqlx::Error> {
|
|||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/", get(root))
|
.route("/", get(root))
|
||||||
.route("/trips/", get(trips))
|
.route("/trips/", get(trips))
|
||||||
|
.route("/trip/", post(trip_create))
|
||||||
|
.route("/trip/:id/", get(trip))
|
||||||
|
.route("/trip/:id/type/:id/add", get(trip_type_add))
|
||||||
|
.route("/trip/:id/type/:id/remove", get(trip_type_remove))
|
||||||
.route("/inventory/", get(inventory_inactive))
|
.route("/inventory/", get(inventory_inactive))
|
||||||
.route("/inventory/item/", post(inventory_item_create))
|
.route("/inventory/item/", post(inventory_item_create))
|
||||||
.route("/inventory/category/:id", get(inventory_active))
|
.route("/inventory/category/:id/", get(inventory_active))
|
||||||
.route("/inventory/item/:id/delete", get(inventory_item_delete))
|
.route("/inventory/item/:id/delete", get(inventory_item_delete))
|
||||||
.route("/inventory/item/:id/edit", post(inventory_item_edit))
|
.route("/inventory/item/:id/edit", post(inventory_item_edit))
|
||||||
.route("/inventory/item/:id/cancel", get(inventory_item_cancel))
|
.route("/inventory/item/:id/cancel", get(inventory_item_cancel))
|
||||||
@@ -123,7 +127,7 @@ impl Default for InventoryQuery {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn inventory_active(
|
async fn inventory_active(
|
||||||
Path(id): Path<String>,
|
Path(id): Path<Uuid>,
|
||||||
State(mut state): State<AppState>,
|
State(mut state): State<AppState>,
|
||||||
Query(inventory_query): Query<InventoryQuery>,
|
Query(inventory_query): Query<InventoryQuery>,
|
||||||
) -> Result<(StatusCode, Html<String>), (StatusCode, Html<String>)> {
|
) -> Result<(StatusCode, Html<String>), (StatusCode, Html<String>)> {
|
||||||
@@ -141,13 +145,8 @@ async fn inventory_inactive(
|
|||||||
|
|
||||||
async fn inventory(
|
async fn inventory(
|
||||||
mut state: AppState,
|
mut state: AppState,
|
||||||
active_id: Option<String>,
|
active_id: Option<Uuid>,
|
||||||
) -> Result<(StatusCode, Html<String>), (StatusCode, Html<String>)> {
|
) -> Result<(StatusCode, Html<String>), (StatusCode, Html<String>)> {
|
||||||
let active_id = active_id
|
|
||||||
.map(|id| Uuid::try_parse(&id))
|
|
||||||
.transpose()
|
|
||||||
.map_err(|e| (StatusCode::BAD_REQUEST, Html::from(e.to_string())))?;
|
|
||||||
|
|
||||||
state.client_state.active_category_id = active_id;
|
state.client_state.active_category_id = active_id;
|
||||||
|
|
||||||
let mut categories = query("SELECT id,name,description FROM inventoryitemcategories")
|
let mut categories = query("SELECT id,name,description FROM inventoryitemcategories")
|
||||||
@@ -191,29 +190,6 @@ async fn inventory(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn trips(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
) -> Result<(StatusCode, Html<String>), (StatusCode, Html<String>)> {
|
|
||||||
let trips = query("SELECT * FROM trips")
|
|
||||||
.fetch(&state.database_pool)
|
|
||||||
.map_ok(std::convert::TryInto::try_into)
|
|
||||||
.try_collect::<Vec<Result<Trip, models::Error>>>()
|
|
||||||
.await
|
|
||||||
// we have two error handling lines here. these are distinct errors
|
|
||||||
// this one is the SQL error that may arise during the query
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Html::from(e.to_string())))?
|
|
||||||
.into_iter()
|
|
||||||
.collect::<Result<Vec<Trip>, models::Error>>()
|
|
||||||
// and this one is the model mapping error that may arise e.g. during
|
|
||||||
// reading of the rows
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Html::from(e.to_string())))?;
|
|
||||||
|
|
||||||
Ok((
|
|
||||||
StatusCode::OK,
|
|
||||||
Html::from(Root::build(TripList::build(trips).into(), &TopLevelPage::Trips).into_string()),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct NewItem {
|
struct NewItem {
|
||||||
#[serde(rename = "new-item-name")]
|
#[serde(rename = "new-item-name")]
|
||||||
@@ -285,7 +261,7 @@ async fn inventory_item_create(
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(Redirect::to(&format!(
|
Ok(Redirect::to(&format!(
|
||||||
"/inventory/category/{id}",
|
"/inventory/category/{id}/",
|
||||||
id = new_item.category_id
|
id = new_item.category_id
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
@@ -342,7 +318,7 @@ async fn inventory_item_delete(
|
|||||||
// "SELECT
|
// "SELECT
|
||||||
// i.id, i.name, i.description, i.weight, i.category_id
|
// i.id, i.name, i.description, i.weight, i.category_id
|
||||||
// FROM inventoryitemcategories AS c
|
// FROM inventoryitemcategories AS c
|
||||||
// LEFT JOIN inventoryitems AS i
|
// INNER JOIN inventoryitems AS i
|
||||||
// ON i.category_id = c.id WHERE c.id = '{id}';",
|
// ON i.category_id = c.id WHERE c.id = '{id}';",
|
||||||
// id = id,
|
// id = id,
|
||||||
// ))
|
// ))
|
||||||
@@ -390,7 +366,7 @@ async fn inventory_item_edit(
|
|||||||
format!("item with id {id} not found", id = id),
|
format!("item with id {id} not found", id = id),
|
||||||
))?;
|
))?;
|
||||||
|
|
||||||
Ok(Redirect::to(&format!("/inventory/category/{id}", id = id)))
|
Ok(Redirect::to(&format!("/inventory/category/{id}/", id = id)))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn inventory_item_cancel(
|
async fn inventory_item_cancel(
|
||||||
@@ -406,7 +382,219 @@ async fn inventory_item_cancel(
|
|||||||
))?;
|
))?;
|
||||||
|
|
||||||
Ok(Redirect::to(&format!(
|
Ok(Redirect::to(&format!(
|
||||||
"/inventory/category/{id}",
|
"/inventory/category/{id}/",
|
||||||
id = id.category_id
|
id = id.category_id
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct NewTrip {
|
||||||
|
#[serde(rename = "new-trip-name")]
|
||||||
|
name: String,
|
||||||
|
#[serde(rename = "new-trip-start-date")]
|
||||||
|
start_date: time::Date,
|
||||||
|
#[serde(rename = "new-trip-end-date")]
|
||||||
|
end_date: time::Date,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn trip_create(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Form(new_trip): Form<NewTrip>,
|
||||||
|
) -> Result<Redirect, (StatusCode, String)> {
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
query(
|
||||||
|
"INSERT INTO trips
|
||||||
|
(id, name, start_date, end_date)
|
||||||
|
VALUES
|
||||||
|
(?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(id.to_string())
|
||||||
|
.bind(&new_trip.name)
|
||||||
|
.bind(new_trip.start_date)
|
||||||
|
.bind(new_trip.end_date)
|
||||||
|
.execute(&state.database_pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| match e {
|
||||||
|
sqlx::Error::Database(ref error) => {
|
||||||
|
let sqlite_error = error.downcast_ref::<SqliteError>();
|
||||||
|
if let Some(code) = sqlite_error.code() {
|
||||||
|
match &*code {
|
||||||
|
"2067" => {
|
||||||
|
// SQLITE_CONSTRAINT_UNIQUE
|
||||||
|
(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
format!(
|
||||||
|
"trip with name \"{name}\" already exists",
|
||||||
|
name = new_trip.name,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_ => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("got error with unknown code: {}", sqlite_error.to_string()),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("got error without code: {}", sqlite_error.to_string()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("got unknown error: {}", e.to_string()),
|
||||||
|
),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Redirect::to(&format!("/trips/{id}/", id = id.to_string())))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn trips(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<(StatusCode, Html<String>), (StatusCode, Html<String>)> {
|
||||||
|
let trips: Vec<models::Trip> = query("SELECT * FROM trips")
|
||||||
|
.fetch(&state.database_pool)
|
||||||
|
.map_ok(std::convert::TryInto::try_into)
|
||||||
|
.try_collect::<Vec<Result<models::Trip, models::Error>>>()
|
||||||
|
.await
|
||||||
|
// we have two error handling lines here. these are distinct errors
|
||||||
|
// this one is the SQL error that may arise during the query
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Html::from(e.to_string())))?
|
||||||
|
.into_iter()
|
||||||
|
.collect::<Result<Vec<models::Trip>, models::Error>>()
|
||||||
|
// and this one is the model mapping error that may arise e.g. during
|
||||||
|
// reading of the rows
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Html::from(e.to_string())))?;
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
StatusCode::OK,
|
||||||
|
Html::from(
|
||||||
|
Root::build(TripManager::build(trips).into(), &TopLevelPage::Trips).into_string(),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn trip(
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<(StatusCode, Html<String>), (StatusCode, Html<String>)> {
|
||||||
|
let mut trip: models::Trip =
|
||||||
|
query("SELECT id,name,start_date,end_date,state,location,temp_min,temp_max FROM trips WHERE id = ?")
|
||||||
|
.bind(id.to_string())
|
||||||
|
.fetch_one(&state.database_pool)
|
||||||
|
.map_ok(std::convert::TryInto::try_into)
|
||||||
|
.await
|
||||||
|
.map_err(|e: sqlx::Error| match e {
|
||||||
|
sqlx::Error::RowNotFound => (
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Html::from(format!("trip with id {} not found", id)),
|
||||||
|
),
|
||||||
|
_ => (StatusCode::INTERNAL_SERVER_ERROR, Html::from(e.to_string())),
|
||||||
|
})?
|
||||||
|
.map_err(|e: Error| (StatusCode::INTERNAL_SERVER_ERROR, Html::from(e.to_string())))?;
|
||||||
|
|
||||||
|
trip.load_triptypes(&state.database_pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Html::from(e.to_string())))?;
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
StatusCode::OK,
|
||||||
|
Html::from(
|
||||||
|
Root::build(
|
||||||
|
components::Trip::build(&trip).into_markup(),
|
||||||
|
&TopLevelPage::Trips,
|
||||||
|
)
|
||||||
|
.into_string(),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn trip_type_remove(
|
||||||
|
Path((trip_id, type_id)): Path<(Uuid, Uuid)>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Redirect, (StatusCode, Html<String>)> {
|
||||||
|
let results = query(
|
||||||
|
"DELETE FROM trips_to_triptypes AS ttt
|
||||||
|
WHERE ttt.trip_id = ?
|
||||||
|
AND ttt.trip_type_id = ?
|
||||||
|
",
|
||||||
|
)
|
||||||
|
.bind(trip_id.to_string())
|
||||||
|
.bind(type_id.to_string())
|
||||||
|
.execute(&state.database_pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::BAD_REQUEST, Html::from(e.to_string())))?;
|
||||||
|
|
||||||
|
if results.rows_affected() == 0 {
|
||||||
|
Err((
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Html::from(format!("type {type_id} is not active for trip {trip_id}")),
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Ok(Redirect::to(&format!("/trip/{trip_id}/")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn trip_type_add(
|
||||||
|
Path((trip_id, type_id)): Path<(Uuid, Uuid)>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Redirect, (StatusCode, Html<String>)> {
|
||||||
|
let results = query(
|
||||||
|
"INSERT INTO trips_to_triptypes
|
||||||
|
(trip_id, trip_type_id) VALUES (?, ?)",
|
||||||
|
)
|
||||||
|
.bind(trip_id.to_string())
|
||||||
|
.bind(type_id.to_string())
|
||||||
|
.execute(&state.database_pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| match e {
|
||||||
|
sqlx::Error::Database(ref error) => {
|
||||||
|
let sqlite_error = error.downcast_ref::<SqliteError>();
|
||||||
|
if let Some(code) = sqlite_error.code() {
|
||||||
|
match &*code {
|
||||||
|
"787" => {
|
||||||
|
// SQLITE_CONSTRAINT_FOREIGNKEY
|
||||||
|
(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
// TODO: this is not perfect, as both foreign keys
|
||||||
|
// may be responsible for the error. how can we tell
|
||||||
|
// which one?
|
||||||
|
Html::from(format!("invalid id: {}", code.to_string())),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
"2067" => {
|
||||||
|
// SQLITE_CONSTRAINT_UNIQUE
|
||||||
|
(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Html::from(format!(
|
||||||
|
"type {type_id} is already active for trip {trip_id}"
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_ => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Html::from(format!(
|
||||||
|
"got error with unknown code: {}",
|
||||||
|
sqlite_error.to_string()
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Html::from(format!(
|
||||||
|
"got error without code: {}",
|
||||||
|
sqlite_error.to_string()
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Html::from(format!("got unknown error: {}", e.to_string())),
|
||||||
|
),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Redirect::to(&format!("/trip/{trip_id}/")))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
use sqlx::{sqlite::SqliteRow, Row};
|
use sqlx::{
|
||||||
|
database::Database,
|
||||||
|
database::HasValueRef,
|
||||||
|
sqlite::{Sqlite, SqliteRow},
|
||||||
|
Decode, Row,
|
||||||
|
};
|
||||||
use std::convert;
|
use std::convert;
|
||||||
use std::error;
|
use std::error;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
use std::str::FromStr;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use sqlx::sqlite::SqlitePoolOptions;
|
use sqlx::sqlite::SqlitePoolOptions;
|
||||||
@@ -13,6 +19,7 @@ pub enum Error {
|
|||||||
SqlError { description: String },
|
SqlError { description: String },
|
||||||
UuidError { description: String },
|
UuidError { description: String },
|
||||||
NotFoundError { description: String },
|
NotFoundError { description: String },
|
||||||
|
InvalidEnumError { description: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for Error {
|
impl fmt::Display for Error {
|
||||||
@@ -27,6 +34,9 @@ impl fmt::Display for Error {
|
|||||||
Self::NotFoundError { description } => {
|
Self::NotFoundError { description } => {
|
||||||
write!(f, "Not found: {description}")
|
write!(f, "Not found: {description}")
|
||||||
}
|
}
|
||||||
|
Self::InvalidEnumError { description } => {
|
||||||
|
write!(f, "Enum error: {description}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,11 +66,41 @@ impl convert::From<sqlx::Error> for Error {
|
|||||||
|
|
||||||
impl error::Error for Error {}
|
impl error::Error for Error {}
|
||||||
|
|
||||||
|
#[derive(sqlx::Type)]
|
||||||
|
pub enum TripState {
|
||||||
|
Planning,
|
||||||
|
Planned,
|
||||||
|
Active,
|
||||||
|
Review,
|
||||||
|
Done,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for TripState {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"{}",
|
||||||
|
match self {
|
||||||
|
Self::Planning => "Planning",
|
||||||
|
Self::Planned => "Planned",
|
||||||
|
Self::Active => "Active",
|
||||||
|
Self::Review => "Review",
|
||||||
|
Self::Done => "Done",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Trip {
|
pub struct Trip {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub start_date: time::Date,
|
pub start_date: time::Date,
|
||||||
pub end_date: time::Date,
|
pub end_date: time::Date,
|
||||||
|
pub state: TripState,
|
||||||
|
pub location: String,
|
||||||
|
pub temp_min: i32,
|
||||||
|
pub temp_max: i32,
|
||||||
|
types: Option<Vec<TripType>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TryFrom<SqliteRow> for Trip {
|
impl TryFrom<SqliteRow> for Trip {
|
||||||
@@ -70,18 +110,91 @@ impl TryFrom<SqliteRow> for Trip {
|
|||||||
let name: &str = row.try_get("name")?;
|
let name: &str = row.try_get("name")?;
|
||||||
let id: &str = row.try_get("id")?;
|
let id: &str = row.try_get("id")?;
|
||||||
let start_date: time::Date = row.try_get("start_date")?;
|
let start_date: time::Date = row.try_get("start_date")?;
|
||||||
let end_date: time::Date = row.try_get("start_date")?;
|
let end_date: time::Date = row.try_get("end_date")?;
|
||||||
|
let state: TripState = row.try_get("state")?;
|
||||||
|
let location = row.try_get("location")?;
|
||||||
|
let temp_min = row.try_get("temp_min")?;
|
||||||
|
let temp_max = row.try_get("temp_max")?;
|
||||||
|
|
||||||
let id: Uuid = Uuid::try_parse(id)?;
|
let id: Uuid = Uuid::try_parse(id)?;
|
||||||
|
|
||||||
Ok(Trip {
|
Ok(Trip {
|
||||||
name: name.to_string(),
|
|
||||||
id,
|
id,
|
||||||
|
name: name.to_string(),
|
||||||
start_date,
|
start_date,
|
||||||
end_date,
|
end_date,
|
||||||
|
state,
|
||||||
|
location,
|
||||||
|
temp_min,
|
||||||
|
temp_max,
|
||||||
|
types: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<'a> Trip {
|
||||||
|
pub fn types(&'a self) -> &Vec<TripType> {
|
||||||
|
self.types
|
||||||
|
.as_ref()
|
||||||
|
.expect("you need to call load_triptypes()")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Trip {
|
||||||
|
pub async fn load_triptypes(
|
||||||
|
&'a mut self,
|
||||||
|
pool: &sqlx::Pool<sqlx::Sqlite>,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let types = sqlx::query(
|
||||||
|
"
|
||||||
|
SELECT
|
||||||
|
type.id as id,
|
||||||
|
type.name as name,
|
||||||
|
CASE WHEN inner.id IS NOT NULL THEN true ELSE false END AS active
|
||||||
|
FROM triptypes AS type
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT type.id as id, type.name as name
|
||||||
|
FROM trips as trip
|
||||||
|
INNER JOIN trips_to_triptypes as ttt
|
||||||
|
ON ttt.trip_id = trip.id
|
||||||
|
INNER JOIN triptypes AS type
|
||||||
|
ON type.id == ttt.trip_type_id
|
||||||
|
WHERE trip.id = ?
|
||||||
|
) AS inner
|
||||||
|
ON inner.id = type.id
|
||||||
|
",
|
||||||
|
)
|
||||||
|
.bind(self.id.to_string())
|
||||||
|
.fetch(pool)
|
||||||
|
.map_ok(std::convert::TryInto::try_into)
|
||||||
|
.try_collect::<Vec<Result<TripType, Error>>>()
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.collect::<Result<Vec<TripType>, Error>>()?;
|
||||||
|
|
||||||
|
self.types = Some(types);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TripType {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<SqliteRow> for TripType {
|
||||||
|
type Error = Error;
|
||||||
|
|
||||||
|
fn try_from(row: SqliteRow) -> Result<Self, Self::Error> {
|
||||||
|
let id: Uuid = Uuid::try_parse(row.try_get("id")?)?;
|
||||||
|
let name: String = row.try_get::<&str, _>("name")?.to_string();
|
||||||
|
let active: bool = row.try_get("active")?;
|
||||||
|
|
||||||
|
Ok(Self { id, name, active })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Category {
|
pub struct Category {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
|
|||||||
Reference in New Issue
Block a user