Add export to csv for books
This commit is contained in:
parent
a4c5e8185b
commit
60a5bf5583
22
Cargo.lock
generated
22
Cargo.lock
generated
@ -360,6 +360,7 @@ dependencies = [
|
|||||||
"askama_web",
|
"askama_web",
|
||||||
"axum",
|
"axum",
|
||||||
"camino",
|
"camino",
|
||||||
|
"csv",
|
||||||
"dirs",
|
"dirs",
|
||||||
"log",
|
"log",
|
||||||
"pretty_env_logger",
|
"pretty_env_logger",
|
||||||
@ -607,6 +608,27 @@ dependencies = [
|
|||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "csv"
|
||||||
|
version = "1.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938"
|
||||||
|
dependencies = [
|
||||||
|
"csv-core",
|
||||||
|
"itoa",
|
||||||
|
"ryu",
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "csv-core"
|
||||||
|
version = "0.1.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darling"
|
name = "darling"
|
||||||
version = "0.20.11"
|
version = "0.20.11"
|
||||||
|
|||||||
@ -31,3 +31,4 @@ pretty_env_logger = "0.5.0"
|
|||||||
# custom logger
|
# custom logger
|
||||||
log = "0.4.29"
|
log = "0.4.29"
|
||||||
serde_with = "3.16.1"
|
serde_with = "3.16.1"
|
||||||
|
csv = "1.4.0"
|
||||||
|
|||||||
12
assets/css/fork-awesome.min.css
vendored
Executable file
12
assets/css/fork-awesome.min.css
vendored
Executable file
File diff suppressed because one or more lines are too long
BIN
assets/fonts/forkawesome-webfont.woff2
Executable file
BIN
assets/fonts/forkawesome-webfont.woff2
Executable file
Binary file not shown.
@ -18,12 +18,13 @@ pub fn build_app(state: AppState) -> Router {
|
|||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", get(routes::book::index))
|
.route("/", get(routes::book::index))
|
||||||
|
.route("/books/new", get(routes::book::new))
|
||||||
.route("/books", post(routes::book::create))
|
.route("/books", post(routes::book::create))
|
||||||
.route("/books/{id}", get(routes::book::show))
|
.route("/books/{id}", get(routes::book::show))
|
||||||
.route("/books/{id}", post(routes::book::update))
|
.route("/books/{id}", post(routes::book::update))
|
||||||
.route("/books/{id}/delete", post(routes::book::delete))
|
.route("/books/{id}/delete", post(routes::book::delete))
|
||||||
.route("/books/{id}/edit", get(routes::book::edit))
|
.route("/books/{id}/edit", get(routes::book::edit))
|
||||||
.route("/books/new", get(routes::book::new))
|
.route("/books/download_csv", get(routes::book::download_csv))
|
||||||
.route("/users", get(routes::user::index))
|
.route("/users", get(routes::user::index))
|
||||||
.route("/users/new", get(routes::user::new))
|
.route("/users/new", get(routes::user::new))
|
||||||
.route("/users/{id}/edit", get(routes::user::edit))
|
.route("/users/{id}/edit", get(routes::user::edit))
|
||||||
|
|||||||
@ -78,7 +78,18 @@ impl BookOperator {
|
|||||||
.context(DBSnafu)
|
.context(DBSnafu)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_paginate(
|
pub async fn all_filtered(&self, query: Option<IndexQuery>) -> Result<Vec<Model>, BookError> {
|
||||||
|
let conditions = Self::filter_conditions(query);
|
||||||
|
|
||||||
|
Entity::find()
|
||||||
|
.filter(conditions)
|
||||||
|
.order_by_desc(Column::Id)
|
||||||
|
.all(&self.state.db)
|
||||||
|
.await
|
||||||
|
.context(DBSnafu)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn all_paginate(
|
||||||
&self,
|
&self,
|
||||||
page: u64,
|
page: u64,
|
||||||
query: Option<IndexQuery>,
|
query: Option<IndexQuery>,
|
||||||
@ -86,24 +97,7 @@ impl BookOperator {
|
|||||||
let page = if page > 0 { page } else { 1 }; // keep 1-indexed
|
let page = if page > 0 { page } else { 1 }; // keep 1-indexed
|
||||||
let page_0indexed = page - 1; // convert for SeaORM (0-based index)
|
let page_0indexed = page - 1; // convert for SeaORM (0-based index)
|
||||||
|
|
||||||
let mut conditions = Condition::all();
|
let conditions = Self::filter_conditions(query);
|
||||||
if let Some(book_query) = query {
|
|
||||||
if let Some(title) = book_query.title {
|
|
||||||
conditions = conditions.add(Column::Title.contains(&title));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(authors) = book_query.authors {
|
|
||||||
conditions = conditions.add(Column::Authors.contains(&authors));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(owner_id) = book_query.owner_id {
|
|
||||||
conditions = conditions.add(Column::OwnerId.eq(owner_id));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(current_holder_id) = book_query.current_holder_id {
|
|
||||||
conditions = conditions.add(Column::CurrentHolderId.eq(current_holder_id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let book_pages = Entity::find()
|
let book_pages = Entity::find()
|
||||||
.filter(conditions)
|
.filter(conditions)
|
||||||
@ -209,4 +203,28 @@ impl BookOperator {
|
|||||||
|
|
||||||
book.delete(&self.state.db).await.context(DBSnafu)
|
book.delete(&self.state.db).await.context(DBSnafu)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// private
|
||||||
|
|
||||||
|
fn filter_conditions(query: Option<IndexQuery>) -> Condition {
|
||||||
|
let mut conditions = Condition::all();
|
||||||
|
if let Some(book_query) = query {
|
||||||
|
if let Some(title) = book_query.title {
|
||||||
|
conditions = conditions.add(Column::Title.contains(&title));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(authors) = book_query.authors {
|
||||||
|
conditions = conditions.add(Column::Authors.contains(&authors));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(owner_id) = book_query.owner_id {
|
||||||
|
conditions = conditions.add(Column::OwnerId.eq(owner_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(current_holder_id) = book_query.current_holder_id {
|
||||||
|
conditions = conditions.add(Column::CurrentHolderId.eq(current_holder_id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return conditions;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,15 +4,17 @@ use askama::Template;
|
|||||||
use askama_web::WebTemplate;
|
use askama_web::WebTemplate;
|
||||||
use axum::{
|
use axum::{
|
||||||
Form,
|
Form,
|
||||||
|
body::Body,
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
response::{IntoResponse, Redirect},
|
response::{IntoResponse, Redirect, Response},
|
||||||
};
|
};
|
||||||
|
use csv::Writer;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_with::{NoneAsEmptyString, serde_as};
|
use serde_with::{NoneAsEmptyString, serde_as};
|
||||||
use snafu::prelude::*;
|
use snafu::prelude::*;
|
||||||
|
|
||||||
use crate::models::book::Model as BookModel;
|
use crate::{models::book::Model as BookModel, state::error::CSVSnafu};
|
||||||
use crate::models::user::Model as UserModel;
|
use crate::{models::user::Model as UserModel, state::error::IOSnafu};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
models::{book::BookOperator, user::UserOperator},
|
models::{book::BookOperator, user::UserOperator},
|
||||||
@ -72,7 +74,7 @@ pub async fn index(
|
|||||||
|
|
||||||
// Get all Book filtered with query
|
// Get all Book filtered with query
|
||||||
let books_paginate = BookOperator::new(state)
|
let books_paginate = BookOperator::new(state)
|
||||||
.list_paginate(page, Some(query.clone()))
|
.all_paginate(page, Some(query.clone()))
|
||||||
.await
|
.await
|
||||||
.context(BookSnafu)?;
|
.context(BookSnafu)?;
|
||||||
|
|
||||||
@ -254,3 +256,75 @@ pub async fn delete(
|
|||||||
|
|
||||||
Ok(Redirect::to("/").into_response())
|
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_id",
|
||||||
|
"current_holder_id",
|
||||||
|
"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()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -33,6 +33,14 @@ pub enum AppStateError {
|
|||||||
Book {
|
Book {
|
||||||
source: BookError,
|
source: BookError,
|
||||||
},
|
},
|
||||||
|
#[snafu(display("CSV Error"))]
|
||||||
|
CSV {
|
||||||
|
source: csv::Error,
|
||||||
|
},
|
||||||
|
#[snafu(display("IO Error"))]
|
||||||
|
IO {
|
||||||
|
source: std::io::Error,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Template, WebTemplate)]
|
#[derive(Template, WebTemplate)]
|
||||||
|
|||||||
@ -4,7 +4,11 @@
|
|||||||
{% import "components/cards.html" as cards %}
|
{% import "components/cards.html" as cards %}
|
||||||
|
|
||||||
{% block main %}
|
{% block main %}
|
||||||
{{ typography::heading("All books") }}
|
{% call typography::heading("All books") %}
|
||||||
|
<a href="/books/download_csv?{{ base_query }}" class="btn btn-info">
|
||||||
|
<i class="fa fa-download me-2" aria-hidden="true"></i> Download (csv)
|
||||||
|
</a>
|
||||||
|
{% endcall %}
|
||||||
|
|
||||||
{% call cards::card() %}
|
{% call cards::card() %}
|
||||||
<form method="get">
|
<form method="get">
|
||||||
|
|||||||
@ -25,7 +25,6 @@
|
|||||||
</select>
|
</select>
|
||||||
|
|
||||||
<a class="btn btn-success text-white" href="/books/new">
|
<a class="btn btn-success text-white" href="/books/new">
|
||||||
<i class="fa fa-download me-2"></i>
|
|
||||||
Add book
|
Add book
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user