5 Commits

Author SHA1 Message Date
gabatxo1312 60a5bf5583 Add export to csv for books 2026-01-30 17:06:59 +01:00
gabatxo1312 a4c5e8185b User filtered 2026-01-30 14:24:00 +01:00
loub 3b9a5800a5 Merge pull request 'Delete user script' (#13) from delete-user into main
Reviewed-on: #13
2026-01-30 11:23:45 +01:00
gabatxo1312 1f5bc0c14a Delete user script 2026-01-30 11:22:32 +01:00
loub 81a9e11774 Merge pull request 'Add 404 and 500 page' (#12) from add-error-page into main
Reviewed-on: #12
2026-01-30 10:35:02 +01:00
15 changed files with 303 additions and 44 deletions
Generated
+22
View File
@@ -360,6 +360,7 @@ dependencies = [
"askama_web",
"axum",
"camino",
"csv",
"dirs",
"log",
"pretty_env_logger",
@@ -607,6 +608,27 @@ dependencies = [
"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]]
name = "darling"
version = "0.20.11"
+1
View File
@@ -31,3 +31,4 @@ pretty_env_logger = "0.5.0"
# custom logger
log = "0.4.29"
serde_with = "3.16.1"
csv = "1.4.0"
+12
View File
File diff suppressed because one or more lines are too long
Binary file not shown.
+2 -1
View File
@@ -18,12 +18,13 @@ pub fn build_app(state: AppState) -> Router {
Router::new()
.route("/", get(routes::book::index))
.route("/books/new", get(routes::book::new))
.route("/books", post(routes::book::create))
.route("/books/{id}", get(routes::book::show))
.route("/books/{id}", post(routes::book::update))
.route("/books/{id}/delete", post(routes::book::delete))
.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/new", get(routes::user::new))
.route("/users/{id}/edit", get(routes::user::edit))
+50 -20
View File
@@ -70,7 +70,7 @@ impl BookOperator {
/// Lists all books matching the optional query filters.
///
/// Results are ordered by ID in descending order (newest first).
pub async fn list(&self) -> Result<Vec<Model>, BookError> {
pub async fn all(&self) -> Result<Vec<Model>, BookError> {
Entity::find()
.order_by_desc(Column::Id)
.all(&self.state.db)
@@ -78,7 +78,18 @@ impl BookOperator {
.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,
page: u64,
query: Option<IndexQuery>,
@@ -86,24 +97,7 @@ impl BookOperator {
let page = if page > 0 { page } else { 1 }; // keep 1-indexed
let page_0indexed = page - 1; // convert for SeaORM (0-based index)
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));
}
}
let conditions = Self::filter_conditions(query);
let book_pages = Entity::find()
.filter(conditions)
@@ -149,6 +143,18 @@ impl BookOperator {
.context(DBSnafu)
}
/// Finds vec of book by its Owner
pub async fn find_all_by_current_holder(
&self,
current_holder_id: i32,
) -> Result<Vec<Model>, BookError> {
Entity::find()
.filter(Column::CurrentHolderId.eq(current_holder_id))
.all(&self.state.db)
.await
.context(DBSnafu)
}
/// Creates a new book from the given form data.
pub async fn create(&self, form: BookForm) -> Result<Model, BookError> {
let book = ActiveModel {
@@ -197,4 +203,28 @@ impl BookOperator {
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;
}
}
+59 -2
View File
@@ -1,8 +1,11 @@
use crate::models::book::BookOperator;
use crate::models::book;
use crate::routes::book::BookForm;
use crate::routes::user::IndexQuery;
use crate::routes::user::UserForm;
use crate::state::AppState;
use crate::state::error::UserSnafu;
use sea_orm::ActiveValue::Set;
use sea_orm::Condition;
use sea_orm::DeleteResult;
use sea_orm::entity::prelude::*;
use snafu::ResultExt;
@@ -38,6 +41,8 @@ pub enum UserError {
DB { source: sea_orm::DbErr },
#[snafu(display("User with id {id} not found"))]
NotFound { id: i32 },
#[snafu(display("Book error"))]
Book { source: super::book::BookError },
}
#[derive(Debug)]
@@ -50,10 +55,23 @@ impl UserOperator {
Self { state }
}
pub async fn list(&self) -> Result<Vec<Model>, UserError> {
pub async fn all(&self) -> Result<Vec<Model>, UserError> {
Entity::find().all(&self.state.db).await.context(DBSnafu)
}
pub async fn all_filtered(&self, query: IndexQuery) -> Result<Vec<Model>, UserError> {
let mut conditions = Condition::all();
if let Some(name) = query.name {
conditions = conditions.add(Column::Name.contains(name))
}
Entity::find()
.filter(conditions)
.all(&self.state.db)
.await
.context(DBSnafu)
}
pub async fn find_by_id(&self, id: i32) -> Result<Model, UserError> {
let user: Option<Model> = Entity::find_by_id(id)
.one(&self.state.db)
@@ -90,7 +108,46 @@ impl UserOperator {
}
}
/// Delete user by ID.
/// Before deleting the user, you must search for all the books they own in order to delete them beforehand,
/// then search for all the books they have borrowed in order to update the current holder to None.
pub async fn delete(&self, user_id: i32) -> Result<DeleteResult, UserError> {
// get all
let owner_books = book::BookOperator::new(self.state.clone())
.find_all_by_owner(user_id)
.await
.context(BookSnafu)?;
// Delete all book with owner_id = current_user
for owner_book in owner_books {
book::BookOperator::new(self.state.clone())
.delete(owner_book.id)
.await
.context(BookSnafu)?;
}
let current_holder_books = book::BookOperator::new(self.state.clone())
.find_all_by_current_holder(user_id)
.await
.context(BookSnafu)?;
// Update all book with current Holder = current user
for current_holder_book in current_holder_books {
let form = BookForm {
title: current_holder_book.title,
authors: current_holder_book.authors,
owner_id: current_holder_book.owner_id,
description: current_holder_book.description,
comment: current_holder_book.comment,
current_holder_id: None,
};
book::BookOperator::new(self.state.clone())
.update(current_holder_book.id, form)
.await
.context(BookSnafu)?;
}
let user: Option<Model> = Entity::find_by_id(user_id)
.one(&self.state.db)
.await
+81 -7
View File
@@ -4,15 +4,17 @@ use askama::Template;
use askama_web::WebTemplate;
use axum::{
Form,
body::Body,
extract::{Path, Query, State},
response::{IntoResponse, Redirect},
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;
use crate::models::user::Model as UserModel;
use crate::{models::book::Model as BookModel, state::error::CSVSnafu};
use crate::{models::user::Model as UserModel, state::error::IOSnafu};
use crate::{
models::{book::BookOperator, user::UserOperator},
@@ -66,13 +68,13 @@ pub async fn index(
// Get all Users
let users = UserOperator::new(state.clone())
.list()
.all()
.await
.context(UserSnafu)?;
// Get all Book filtered with query
let books_paginate = BookOperator::new(state)
.list_paginate(page, Some(query.clone()))
.all_paginate(page, Some(query.clone()))
.await
.context(BookSnafu)?;
@@ -203,7 +205,7 @@ struct NewBookTemplate {
pub async fn new(
State(state): State<AppState>,
) -> Result<impl axum::response::IntoResponse, AppStateError> {
let users = UserOperator::new(state).list().await.context(UserSnafu)?;
let users = UserOperator::new(state).all().await.context(UserSnafu)?;
Ok(NewBookTemplate { users })
}
@@ -220,7 +222,7 @@ pub async fn edit(
Path(id): Path<i32>,
) -> Result<impl axum::response::IntoResponse, AppStateError> {
let users = UserOperator::new(state.clone())
.list()
.all()
.await
.context(UserSnafu)?;
let book = BookOperator::new(state)
@@ -254,3 +256,75 @@ pub async fn delete(
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()),
}
}
+15 -3
View File
@@ -4,10 +4,11 @@ use askama::Template;
use askama_web::WebTemplate;
use axum::{
Form,
extract::{Path, State},
extract::{Path, Query, State},
response::Redirect,
};
use serde::Deserialize;
use serde_with::{NoneAsEmptyString, serde_as};
use snafu::prelude::*;
use crate::{
@@ -25,6 +26,7 @@ use crate::{
#[template(path = "users/index.html")]
struct UsersIndexTemplate {
user_with_books_number: Vec<UserWithBookNumber>,
query: IndexQuery,
}
pub struct UserWithBookNumber {
@@ -36,16 +38,25 @@ pub struct UserWithBookNumber {
pub borrowed_book_number: usize,
}
#[serde_as]
#[derive(Deserialize, Clone)]
pub struct IndexQuery {
#[serde(default)]
#[serde_as(as = "NoneAsEmptyString")]
pub name: Option<String>,
}
pub async fn index(
State(state): State<AppState>,
Query(query): Query<IndexQuery>,
) -> Result<impl axum::response::IntoResponse, AppStateError> {
let users = UserOperator::new(state.clone())
.list()
.all_filtered(query.clone())
.await
.context(UserSnafu)?;
let books = BookOperator::new(state.clone())
.list()
.all()
.await
.context(BookSnafu)?;
@@ -74,6 +85,7 @@ pub async fn index(
Ok(UsersIndexTemplate {
user_with_books_number: result,
query,
})
}
+9 -4
View File
@@ -5,10 +5,7 @@ use log::error;
use snafu::prelude::*;
use crate::{
models::{
book::{BookError, NotFoundSnafu},
user::UserError,
},
models::{book::BookError, user::UserError},
state::config::ConfigError,
};
@@ -36,6 +33,14 @@ pub enum AppStateError {
Book {
source: BookError,
},
#[snafu(display("CSV Error"))]
CSV {
source: csv::Error,
},
#[snafu(display("IO Error"))]
IO {
source: std::io::Error,
},
}
#[derive(Template, WebTemplate)]
-1
View File
@@ -28,5 +28,4 @@
<script src="/assets/js/bootstrap.min.js"></script>
<script src="/assets/js/script.js"></script>
</body>
</html>
+22 -3
View File
@@ -22,10 +22,29 @@
{% endif %}
<li><a class="dropdown-item" href="/{{ sub_path }}/{{ book.id }}/edit">Edit</a></li>
<li>
<form method="post" action="/{{ sub_path }}/{{ book.id }}/delete" class="mb-0">
<input class="dropdown-item" type="submit" value="Delete">
</form>
<a class="dropdown-item" href="#" data-bs-toggle="modal" data-bs-target="#deleteUserModal{{ book.id }}">Delete</a>
</li>
</ul>
</div>
<!-- Modal -->
<div class="modal fade" id="deleteUserModal{{ book.id }}" tabindex="-1" aria-labelledby="deleteUserModal{{ book.id }}Label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="exampleModalLabel">Confirmation</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Are you sure ?</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<form method="post" action="/{{ sub_path }}/{{ book.id }}/delete" class="m-0">
<input class="btn btn-danger" type="submit" value="Delete">
</form>
</div>
</div>
</div>
</div>
{% endmacro %}
+5 -1
View File
@@ -4,7 +4,11 @@
{% import "components/cards.html" as cards %}
{% 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() %}
<form method="get">
+1 -2
View File
@@ -25,10 +25,9 @@
</select>
<a class="btn btn-success text-white" href="/books/new">
<i class="fa fa-download me-2"></i>
Add&nbsp;book
</a>
</div>
</div>
</div>
</nav>
</nav>
+24
View File
@@ -9,6 +9,30 @@
<a class="btn-success btn" href="/users/new">Add User</a>
{% endcall %}
{% call cards::card() %}
<form method="get">
<div class="row">
<div class="col-md-3">
<label for="name" class="form-label">Name</label>
{% match query.name %}
{% when Some with (value) %}
<input type="text" name="name" value="{{ value }}" class="form-control" placeholder="Ex: Koprotkine">
{% when None %}
<input type="text" name="name" class="form-control" placeholder="Ex: Koprotkine">
{% endmatch %}
</div>
<div class="col-md-1 d-flex align-items-end">
<input type="submit" value="Search" class="btn btn-info w-100">
</div>
<div class="col-md-1 d-flex align-items-end">
<a href="/users" class="btn btn-light">Reset</a>
</div>
</div>
</form>
{% endcall %}
{% call cards::card() %}
<table class="table table-hover">
<thead>