bookforge/src/routes/book.rs
2026-01-31 18:21:28 +01:00

355 lines
9.3 KiB
Rust

use std::collections::HashMap;
use askama::Template;
use askama_web::WebTemplate;
use axum::{
Form,
body::Body,
extract::{Path, Query, State},
response::{IntoResponse, Redirect, Response},
};
use csv::Writer;
use serde::Deserialize;
use serde_with::{NoneAsEmptyString, serde_as};
use snafu::prelude::*;
use crate::{models::book::Model as BookModel, routes::router::Router, state::error::CSVSnafu};
use crate::{models::user::Model as UserModel, state::error::IOSnafu};
use crate::{
models::{book::BookOperator, user::UserOperator},
state::{
AppState,
error::{AppStateError, BookSnafu, UserSnafu},
},
};
// Book list with the owner and the current holder inside
struct BookWithUser {
pub book: BookModel,
pub owner: UserModel,
pub current_holder: Option<UserModel>,
}
/// Query for filter search query
#[serde_as]
#[derive(Deserialize, Clone, Debug)]
pub struct IndexQuery {
pub title: Option<String>,
pub page: Option<usize>,
pub authors: Option<String>,
#[serde(default)]
#[serde_as(as = "NoneAsEmptyString")]
pub owner_id: Option<i32>,
#[serde(default)]
#[serde_as(as = "NoneAsEmptyString")]
pub current_holder_id: Option<i32>,
}
#[derive(Template, WebTemplate)]
#[template(path = "index.html")]
struct BookIndexTemplate {
books_with_user: Vec<BookWithUser>,
query: IndexQuery,
users: Vec<UserModel>,
current_page: u64,
total_page: u64,
base_query: String,
router: Router,
}
pub async fn index(
State(state): State<AppState>,
Query(query): Query<IndexQuery>,
) -> Result<impl axum::response::IntoResponse, AppStateError> {
let page: u64 = query
.page
.map(|p| p.max(1) as u64) // Minimum 1
.unwrap_or(1);
// Get all Users
let users = UserOperator::new(state.clone())
.all()
.await
.context(UserSnafu)?;
// Get all Book filtered with query
let books_paginate = BookOperator::new(state.clone())
.all_paginate(page, Some(query.clone()))
.await
.context(BookSnafu)?;
// Mapping between an user_id and user used in result to
// get easily user with his id
let user_by_id: HashMap<i32, UserModel> = users
.clone()
.into_iter()
.map(|user| (user.id, user))
.collect();
// Build object of Book with his relation Owner (User) and current_holder (User)
let result: Vec<BookWithUser> = books_paginate
.books
.into_iter()
.filter_map(|book| {
let owner = user_by_id.get(&book.owner_id).cloned()?;
let current_holder = book
.current_holder_id
.and_then(|id| user_by_id.get(&id).cloned());
Some(BookWithUser {
book,
owner,
current_holder,
})
})
.collect();
// build original search to be sure to keep
// search when we change page
let mut base_query = String::new();
if let Some(title) = &query.title {
base_query.push_str(&format!("title={}&", title));
}
if let Some(authors) = &query.authors {
base_query.push_str(&format!("authors={}&", authors));
}
if let Some(owner_id) = &query.owner_id {
base_query.push_str(&format!("owner_id={}&", owner_id));
}
if let Some(current_holder_id) = &query.current_holder_id {
base_query.push_str(&format!("current_holder_id={}&", current_holder_id));
}
Ok(BookIndexTemplate {
books_with_user: result,
query,
users,
current_page: books_paginate.current_page,
total_page: books_paginate.total_page,
base_query,
router: Router {
base_path: state.config.base_path,
},
})
}
#[derive(Template, WebTemplate)]
#[template(path = "books/show.html")]
struct ShowBookTemplate {
book: BookModel,
owner: UserModel,
current_holder: Option<UserModel>,
router: Router,
}
pub async fn show(
State(state): State<AppState>,
Path(id): Path<i32>,
) -> Result<impl axum::response::IntoResponse, AppStateError> {
let book = BookOperator::new(state.clone())
.find_by_id(id)
.await
.context(BookSnafu)?;
let owner = UserOperator::new(state.clone())
.find_by_id(book.owner_id)
.await
.context(UserSnafu)?;
let current_holder: Option<UserModel> = if let Some(current_holder_id) = book.current_holder_id
{
Some(
UserOperator::new(state.clone())
.find_by_id(current_holder_id)
.await
.context(UserSnafu)?,
)
} else {
None
};
Ok(ShowBookTemplate {
book,
owner,
current_holder,
router: Router {
base_path: state.config.base_path,
},
})
}
/// Form to build a new book or an update
#[serde_as]
#[derive(Deserialize)]
pub struct BookForm {
pub title: String,
pub authors: String,
pub owner_id: i32,
pub description: Option<String>,
pub comment: Option<String>,
#[serde_as(as = "NoneAsEmptyString")]
pub current_holder_id: Option<i32>,
}
pub async fn create(
State(state): State<AppState>,
Form(form): Form<BookForm>,
) -> Result<impl axum::response::IntoResponse, AppStateError> {
let _ = BookOperator::new(state)
.create(form)
.await
.context(BookSnafu)?;
Ok(Redirect::to("/").into_response())
}
#[derive(Template, WebTemplate)]
#[template(path = "books/new.html")]
struct NewBookTemplate {
users: Vec<UserModel>,
router: Router,
}
pub async fn new(
State(state): State<AppState>,
) -> Result<impl axum::response::IntoResponse, AppStateError> {
let users = UserOperator::new(state.clone())
.all()
.await
.context(UserSnafu)?;
Ok(NewBookTemplate {
users,
router: Router {
base_path: state.config.base_path,
},
})
}
#[derive(Template, WebTemplate)]
#[template(path = "books/edit.html")]
struct EditBookTemplate {
users: Vec<UserModel>,
book: BookModel,
router: Router,
}
pub async fn edit(
State(state): State<AppState>,
Path(id): Path<i32>,
) -> Result<impl axum::response::IntoResponse, AppStateError> {
let users = UserOperator::new(state.clone())
.all()
.await
.context(UserSnafu)?;
let book = BookOperator::new(state.clone())
.find_by_id(id)
.await
.context(BookSnafu)?;
Ok(EditBookTemplate {
users,
book,
router: Router {
base_path: state.config.base_path,
},
})
}
pub async fn update(
State(state): State<AppState>,
Path(id): Path<i32>,
Form(form): Form<BookForm>,
) -> Result<impl axum::response::IntoResponse, AppStateError> {
let _ = BookOperator::new(state)
.update(id, form)
.await
.context(BookSnafu)?;
Ok(Redirect::to(&format!("/books/{}", id)).into_response())
}
pub async fn delete(
State(state): State<AppState>,
Path(id): Path<i32>,
) -> Result<impl axum::response::IntoResponse, AppStateError> {
let _ = BookOperator::new(state)
.delete(id)
.await
.context(BookSnafu)?;
Ok(Redirect::to("/").into_response())
}
/// Download CSV filter (no paginate) of all books
pub async fn download_csv(
State(state): State<AppState>,
Query(query): Query<IndexQuery>,
) -> Result<impl axum::response::IntoResponse, AppStateError> {
let books = BookOperator::new(state.clone())
.all_filtered(Some(query))
.await
.context(BookSnafu)?;
let users = UserOperator::new(state).all().await.context(UserSnafu)?;
let users_by_id: HashMap<i32, UserModel> = users.into_iter().map(|u| (u.id, u)).collect();
let mut wtr = Writer::from_writer(vec![]);
wtr.write_record(&[
"ID",
"Title",
"Author(s)",
"Description",
"Owner",
"Current Holder",
"Comment",
])
.context(CSVSnafu)?;
for book in books {
let owner_format = match users_by_id.get(&book.owner_id).cloned().ok_or(UserSnafu) {
Ok(owner) => format!("{} (id: {})", owner.name.to_string(), owner.id),
Err(_) => "-".to_string(),
};
let current_holder = match users_by_id
// if current_holder_id is None, take 0.
// So get returns errors because user with id 0 can't exist
.get(&book.current_holder_id.unwrap_or(0))
.cloned()
.ok_or(UserSnafu)
{
Ok(current_holder) => format!(
"{} (id: {})",
current_holder.name.to_string(),
current_holder.id
),
Err(_) => "-".to_string(),
};
wtr.write_record(&[
book.id.to_string(),
book.title,
book.authors,
book.description.unwrap_or_default(),
owner_format,
current_holder,
book.comment.unwrap_or_default(),
])
.context(CSVSnafu)?;
}
wtr.flush().context(IOSnafu)?;
let csv_bytes = wtr.into_inner();
match csv_bytes {
Ok(csv_bytes) => Ok(Response::builder()
.header("Content-Type", "text/csv")
.body(Body::from(csv_bytes))
.unwrap()),
Err(_) => Ok(Redirect::to("/").into_response()),
}
}