Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 60a5bf5583 | |||
| a4c5e8185b | |||
| 3b9a5800a5 | |||
| 1f5bc0c14a | |||
| 81a9e11774 | |||
| 09a7bdfc76 | |||
| 386ef77e0f | |||
| cf9a7cf33f | |||
| 57af399ace |
Generated
+22
@@ -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
File diff suppressed because one or more lines are too long
Executable
BIN
Binary file not shown.
+17
-2
@@ -1,3 +1,5 @@
|
|||||||
|
use askama::Template;
|
||||||
|
use askama_web::WebTemplate;
|
||||||
use axum::{
|
use axum::{
|
||||||
Router,
|
Router,
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
@@ -16,15 +18,28 @@ 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/{id}/edit", get(routes::user::edit))
|
||||||
|
.route("/users/{id}", post(routes::user::update))
|
||||||
.route("/users", post(routes::user::create))
|
.route("/users", post(routes::user::create))
|
||||||
.route("/users/{id}", post(routes::user::delete))
|
.route("/users/{id}/delete", post(routes::user::delete))
|
||||||
.nest("/assets", static_router())
|
.nest("/assets", static_router())
|
||||||
|
.fallback(error_handler)
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Template, WebTemplate)]
|
||||||
|
#[template(path = "404.html")]
|
||||||
|
struct NotFoundTemplate {}
|
||||||
|
|
||||||
|
pub async fn error_handler() -> impl axum::response::IntoResponse {
|
||||||
|
NotFoundTemplate {}
|
||||||
|
}
|
||||||
|
|||||||
+60
-21
@@ -70,7 +70,7 @@ impl BookOperator {
|
|||||||
/// Lists all books matching the optional query filters.
|
/// Lists all books matching the optional query filters.
|
||||||
///
|
///
|
||||||
/// Results are ordered by ID in descending order (newest first).
|
/// 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()
|
Entity::find()
|
||||||
.order_by_desc(Column::Id)
|
.order_by_desc(Column::Id)
|
||||||
.all(&self.state.db)
|
.all(&self.state.db)
|
||||||
@@ -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,29 +97,12 @@ 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)
|
||||||
.order_by_desc(Column::Id)
|
.order_by_desc(Column::Id)
|
||||||
.paginate(&self.state.db, 1);
|
.paginate(&self.state.db, 100);
|
||||||
|
|
||||||
let books = book_pages
|
let books = book_pages
|
||||||
.fetch_page(page_0indexed)
|
.fetch_page(page_0indexed)
|
||||||
@@ -140,6 +134,27 @@ impl BookOperator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Finds vec of book by its Owner
|
||||||
|
pub async fn find_all_by_owner(&self, owner_id: i32) -> Result<Vec<Model>, BookError> {
|
||||||
|
Entity::find()
|
||||||
|
.filter(Column::OwnerId.eq(owner_id))
|
||||||
|
.all(&self.state.db)
|
||||||
|
.await
|
||||||
|
.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.
|
/// Creates a new book from the given form data.
|
||||||
pub async fn create(&self, form: BookForm) -> Result<Model, BookError> {
|
pub async fn create(&self, form: BookForm) -> Result<Model, BookError> {
|
||||||
let book = ActiveModel {
|
let book = ActiveModel {
|
||||||
@@ -188,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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+74
-1
@@ -1,6 +1,11 @@
|
|||||||
|
use crate::models::book;
|
||||||
|
use crate::routes::book::BookForm;
|
||||||
|
use crate::routes::user::IndexQuery;
|
||||||
use crate::routes::user::UserForm;
|
use crate::routes::user::UserForm;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
use crate::state::error::UserSnafu;
|
||||||
use sea_orm::ActiveValue::Set;
|
use sea_orm::ActiveValue::Set;
|
||||||
|
use sea_orm::Condition;
|
||||||
use sea_orm::DeleteResult;
|
use sea_orm::DeleteResult;
|
||||||
use sea_orm::entity::prelude::*;
|
use sea_orm::entity::prelude::*;
|
||||||
use snafu::ResultExt;
|
use snafu::ResultExt;
|
||||||
@@ -36,6 +41,8 @@ pub enum UserError {
|
|||||||
DB { source: sea_orm::DbErr },
|
DB { source: sea_orm::DbErr },
|
||||||
#[snafu(display("User with id {id} not found"))]
|
#[snafu(display("User with id {id} not found"))]
|
||||||
NotFound { id: i32 },
|
NotFound { id: i32 },
|
||||||
|
#[snafu(display("Book error"))]
|
||||||
|
Book { source: super::book::BookError },
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -48,10 +55,23 @@ impl UserOperator {
|
|||||||
Self { state }
|
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)
|
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> {
|
pub async fn find_by_id(&self, id: i32) -> Result<Model, UserError> {
|
||||||
let user: Option<Model> = Entity::find_by_id(id)
|
let user: Option<Model> = Entity::find_by_id(id)
|
||||||
.one(&self.state.db)
|
.one(&self.state.db)
|
||||||
@@ -74,7 +94,60 @@ impl UserOperator {
|
|||||||
user.insert(&self.state.db).await.context(DBSnafu)
|
user.insert(&self.state.db).await.context(DBSnafu)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn update(&self, id: i32, form: UserForm) -> Result<Model, UserError> {
|
||||||
|
let user_by_id = Self::find_by_id(&self, id).await.context(UserSnafu);
|
||||||
|
|
||||||
|
if let Ok(user) = user_by_id {
|
||||||
|
let mut user: ActiveModel = user.into();
|
||||||
|
|
||||||
|
user.name = Set(form.name);
|
||||||
|
|
||||||
|
user.update(&self.state.db).await.context(DBSnafu)
|
||||||
|
} else {
|
||||||
|
Err(UserError::NotFound { id })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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> {
|
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)
|
let user: Option<Model> = Entity::find_by_id(user_id)
|
||||||
.one(&self.state.db)
|
.one(&self.state.db)
|
||||||
.await
|
.await
|
||||||
|
|||||||
+81
-7
@@ -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},
|
||||||
@@ -66,13 +68,13 @@ pub async fn index(
|
|||||||
|
|
||||||
// Get all Users
|
// Get all Users
|
||||||
let users = UserOperator::new(state.clone())
|
let users = UserOperator::new(state.clone())
|
||||||
.list()
|
.all()
|
||||||
.await
|
.await
|
||||||
.context(UserSnafu)?;
|
.context(UserSnafu)?;
|
||||||
|
|
||||||
// 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)?;
|
||||||
|
|
||||||
@@ -203,7 +205,7 @@ struct NewBookTemplate {
|
|||||||
pub async fn new(
|
pub async fn new(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
) -> Result<impl axum::response::IntoResponse, AppStateError> {
|
) -> 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 })
|
Ok(NewBookTemplate { users })
|
||||||
}
|
}
|
||||||
@@ -220,7 +222,7 @@ pub async fn edit(
|
|||||||
Path(id): Path<i32>,
|
Path(id): Path<i32>,
|
||||||
) -> Result<impl axum::response::IntoResponse, AppStateError> {
|
) -> Result<impl axum::response::IntoResponse, AppStateError> {
|
||||||
let users = UserOperator::new(state.clone())
|
let users = UserOperator::new(state.clone())
|
||||||
.list()
|
.all()
|
||||||
.await
|
.await
|
||||||
.context(UserSnafu)?;
|
.context(UserSnafu)?;
|
||||||
let book = BookOperator::new(state)
|
let book = BookOperator::new(state)
|
||||||
@@ -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()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+71
-7
@@ -4,10 +4,11 @@ use askama::Template;
|
|||||||
use askama_web::WebTemplate;
|
use askama_web::WebTemplate;
|
||||||
use axum::{
|
use axum::{
|
||||||
Form,
|
Form,
|
||||||
extract::{Path, State},
|
extract::{Path, Query, State},
|
||||||
response::Redirect,
|
response::Redirect,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use serde_with::{NoneAsEmptyString, serde_as};
|
||||||
use snafu::prelude::*;
|
use snafu::prelude::*;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -24,23 +25,42 @@ use crate::{
|
|||||||
#[derive(Template, WebTemplate)]
|
#[derive(Template, WebTemplate)]
|
||||||
#[template(path = "users/index.html")]
|
#[template(path = "users/index.html")]
|
||||||
struct UsersIndexTemplate {
|
struct UsersIndexTemplate {
|
||||||
user_with_books_number: Vec<(user::Model, usize, usize)>,
|
user_with_books_number: Vec<UserWithBookNumber>,
|
||||||
|
query: IndexQuery,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct UserWithBookNumber {
|
||||||
|
/// the user model
|
||||||
|
pub user: user::Model,
|
||||||
|
/// the number of books owned by this user
|
||||||
|
pub owner_book_number: usize,
|
||||||
|
/// the number of books borrowed by this user
|
||||||
|
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(
|
pub async fn index(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
Query(query): Query<IndexQuery>,
|
||||||
) -> Result<impl axum::response::IntoResponse, AppStateError> {
|
) -> Result<impl axum::response::IntoResponse, AppStateError> {
|
||||||
let users = UserOperator::new(state.clone())
|
let users = UserOperator::new(state.clone())
|
||||||
.list()
|
.all_filtered(query.clone())
|
||||||
.await
|
.await
|
||||||
.context(UserSnafu)?;
|
.context(UserSnafu)?;
|
||||||
|
|
||||||
let books = BookOperator::new(state.clone())
|
let books = BookOperator::new(state.clone())
|
||||||
.list()
|
.all()
|
||||||
.await
|
.await
|
||||||
.context(BookSnafu)?;
|
.context(BookSnafu)?;
|
||||||
|
|
||||||
let mut result: Vec<(user::Model, usize, usize)> = vec![];
|
let mut result: Vec<UserWithBookNumber> = Vec::with_capacity(users.len());
|
||||||
|
|
||||||
let mut owner_books: HashMap<i32, usize> = HashMap::new();
|
let mut owner_books: HashMap<i32, usize> = HashMap::new();
|
||||||
let mut borrowed_books: HashMap<i32, usize> = HashMap::new();
|
let mut borrowed_books: HashMap<i32, usize> = HashMap::new();
|
||||||
@@ -56,11 +76,16 @@ pub async fn index(
|
|||||||
let owner_books_size = owner_books.get(&user.id).unwrap_or(&0);
|
let owner_books_size = owner_books.get(&user.id).unwrap_or(&0);
|
||||||
let borrowed_books_size = borrowed_books.get(&user.id).unwrap_or(&0);
|
let borrowed_books_size = borrowed_books.get(&user.id).unwrap_or(&0);
|
||||||
|
|
||||||
result.push((user, *owner_books_size, *borrowed_books_size));
|
result.push(UserWithBookNumber {
|
||||||
|
user,
|
||||||
|
owner_book_number: *owner_books_size,
|
||||||
|
borrowed_book_number: *borrowed_books_size,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(UsersIndexTemplate {
|
Ok(UsersIndexTemplate {
|
||||||
user_with_books_number: result,
|
user_with_books_number: result,
|
||||||
|
query,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,7 +98,7 @@ pub async fn create(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Form(form): Form<UserForm>,
|
Form(form): Form<UserForm>,
|
||||||
) -> Result<impl axum::response::IntoResponse, AppStateError> {
|
) -> Result<impl axum::response::IntoResponse, AppStateError> {
|
||||||
let _user = UserOperator::new(state)
|
let _ = UserOperator::new(state)
|
||||||
.create(form)
|
.create(form)
|
||||||
.await
|
.await
|
||||||
.context(UserSnafu)?;
|
.context(UserSnafu)?;
|
||||||
@@ -81,6 +106,19 @@ pub async fn create(
|
|||||||
Ok(Redirect::to("/users"))
|
Ok(Redirect::to("/users"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn update(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
Form(form): Form<UserForm>,
|
||||||
|
) -> Result<impl axum::response::IntoResponse, AppStateError> {
|
||||||
|
let _ = UserOperator::new(state)
|
||||||
|
.update(id, form)
|
||||||
|
.await
|
||||||
|
.context(UserSnafu)?;
|
||||||
|
|
||||||
|
Ok(Redirect::to("/users"))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn delete(
|
pub async fn delete(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<i32>,
|
Path(id): Path<i32>,
|
||||||
@@ -92,3 +130,29 @@ pub async fn delete(
|
|||||||
|
|
||||||
Ok(Redirect::to("/users"))
|
Ok(Redirect::to("/users"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Template, WebTemplate)]
|
||||||
|
#[template(path = "users/edit.html")]
|
||||||
|
struct EditTemplate {
|
||||||
|
user: user::Model,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn edit(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
) -> Result<impl axum::response::IntoResponse, AppStateError> {
|
||||||
|
let user = UserOperator::new(state)
|
||||||
|
.find_by_id(id)
|
||||||
|
.await
|
||||||
|
.context(UserSnafu)?;
|
||||||
|
|
||||||
|
Ok(EditTemplate { user })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template, WebTemplate)]
|
||||||
|
#[template(path = "users/new.html")]
|
||||||
|
struct NewTemplate {}
|
||||||
|
|
||||||
|
pub async fn new() -> impl axum::response::IntoResponse {
|
||||||
|
NewTemplate {}
|
||||||
|
}
|
||||||
|
|||||||
+32
-5
@@ -1,6 +1,7 @@
|
|||||||
use askama::Template;
|
use askama::Template;
|
||||||
use askama_web::WebTemplate;
|
use askama_web::WebTemplate;
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use log::error;
|
||||||
use snafu::prelude::*;
|
use snafu::prelude::*;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -8,10 +9,6 @@ use crate::{
|
|||||||
state::config::ConfigError,
|
state::config::ConfigError,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Template, WebTemplate)]
|
|
||||||
#[template(path = "error.html")]
|
|
||||||
struct ErrorTemplate {}
|
|
||||||
|
|
||||||
#[derive(Snafu, Debug)]
|
#[derive(Snafu, Debug)]
|
||||||
#[snafu(visibility(pub))]
|
#[snafu(visibility(pub))]
|
||||||
pub enum AppStateError {
|
pub enum AppStateError {
|
||||||
@@ -36,10 +33,40 @@ 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)]
|
||||||
|
#[template(path = "error.html")]
|
||||||
|
struct ErrorTemplate {
|
||||||
|
state: AppStateErrorContext,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AppStateErrorContext {
|
||||||
|
pub errors: Vec<AppStateError>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AppStateError> for AppStateErrorContext {
|
||||||
|
fn from(e: AppStateError) -> Self {
|
||||||
|
error!("{:?}", e);
|
||||||
|
|
||||||
|
Self { errors: vec![e] }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IntoResponse for AppStateError {
|
impl IntoResponse for AppStateError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
ErrorTemplate {}.into_response()
|
let error_context = AppStateErrorContext::from(self);
|
||||||
|
ErrorTemplate {
|
||||||
|
state: error_context,
|
||||||
|
}
|
||||||
|
.into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block main %}
|
||||||
|
<div class="mt-4 text-center">
|
||||||
|
<h1>Oops ! This page does not exist</h1>
|
||||||
|
<h2 class="fst-italic">404 NOT FOUND</h2>
|
||||||
|
<a href="/" class="mt-3 btn btn-info">Back to home</a>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -28,5 +28,4 @@
|
|||||||
<script src="/assets/js/bootstrap.min.js"></script>
|
<script src="/assets/js/bootstrap.min.js"></script>
|
||||||
<script src="/assets/js/script.js"></script>
|
<script src="/assets/js/script.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+11
-34
@@ -1,50 +1,27 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% import "components/typography.html" as typography %}
|
{% import "components/typography.html" as typography %}
|
||||||
{% import "components/cards.html" as cards %}
|
{% import "components/cards.html" as cards %}
|
||||||
|
{% import "components/inputs.html" as form_helpers %}
|
||||||
|
|
||||||
{% block main %}
|
{% block main %}
|
||||||
{{ typography::heading("New book") }}
|
{{ typography::heading("New book") }}
|
||||||
|
|
||||||
{% call cards::card() %}
|
{% call cards::card() %}
|
||||||
<form method="post" action="/books">
|
<form method="post" action="/books">
|
||||||
<div class="mb-3">
|
{{ form_helpers::input("title", "Name", is_required = true, placeholder = "Ex: La Petite Dernière") }}
|
||||||
<label for="title" class="form-label">Name</label>
|
{{ form_helpers::input("authors", "Author(s)", is_required = true, placeholder = "Ex: Fatima Daas") }}
|
||||||
<input type="text" name="title" class="form-control" required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
{% call(option) form_helpers::select("owner_id", "Owner", users, is_required = true) %}
|
||||||
<label for="authors" class="form-label">Author(s)</label>
|
<option value="{{ option.id }}">{{ option.name }}</option>
|
||||||
<input type="text" name="authors" class="form-control" required>
|
{% endcall %}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
{% call(option) form_helpers::select("current_holder_id", "Current Holder", users, is_required = false) %}
|
||||||
<label for="owner_id" class="form-label">Owner</label>
|
<option value="{{ option.id }}">{{ option.name }}</option>
|
||||||
<select name="owner_id" class="form-control" required>
|
{% endcall %}
|
||||||
{% for user in users %}
|
|
||||||
<option value="{{ user.id }}">{{ user.name }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
{{ form_helpers::textarea("description", "Description", rows = 5, is_required = false, placeholder = "Ex: Je m’appelle Fatima Daas. Je suis la mazoziya, la petite dernière. Celle à laquelle on ne s’est pas préparé. Française d’origine algérienne.") }}
|
||||||
<label for="current_holder_id" class="form-label">Current Holder</label>
|
|
||||||
<select name="current_holder_id" class="form-control">
|
|
||||||
<option></option>
|
|
||||||
{% for user in users %}
|
|
||||||
<option value="{{ user.id }}">{{ user.name }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
{{ form_helpers::textarea("comment", "Comment", rows = 3, is_required = false, placeholder = "Ex: I recommend it, it's great!") }}
|
||||||
<label for="description" class="form-label">Description</label>
|
|
||||||
<textarea name="description" class="form-control"></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="comment" class="form-label">Comment</label>
|
|
||||||
<textarea name="comment" class="form-control"></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4 text-center">
|
<div class="mt-4 text-center">
|
||||||
<input type="submit" value="Create book" class="btn btn-success">
|
<input type="submit" value="Create book" class="btn btn-success">
|
||||||
|
|||||||
@@ -11,21 +11,40 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro book_dropdown_button(book, show = true) %}
|
{% macro crud_dropdown_button(book, label, sub_path, show = true) %}
|
||||||
<div class="dropdown">
|
<div class="dropdown">
|
||||||
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
Actions
|
Actions
|
||||||
</button>
|
</button>
|
||||||
<ul class="dropdown-menu">
|
<ul class="dropdown-menu">
|
||||||
{% if show %}
|
{% if show %}
|
||||||
<li><a class="dropdown-item" href="/books/{{ book.id }}">Voir</a></li>
|
<li><a class="dropdown-item" href="/{{ sub_path }}/{{ book.id }}">Show</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<li><a class="dropdown-item" href="/books/{{ book.id }}/edit">Edit</a></li>
|
<li><a class="dropdown-item" href="/{{ sub_path }}/{{ book.id }}/edit">Edit</a></li>
|
||||||
<li>
|
<li>
|
||||||
<form method="post" action="/books/{{ book.id }}/delete">
|
<a class="dropdown-item" href="#" data-bs-toggle="modal" data-bs-target="#deleteUserModal{{ book.id }}">Delete</a>
|
||||||
<input class="dropdown-item" type="submit" value="Delete">
|
|
||||||
</form>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</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 %}
|
{% endmacro %}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{% macro input(name, label, value = "", type = "text", is_required = false, placeholder = "", margin_bottom = true) %}
|
||||||
|
<div {% if margin_bottom %}class="mb-3"{% endif %}>
|
||||||
|
<label for="{{ name }}" class="form-label">
|
||||||
|
{{ label }}
|
||||||
|
{% if is_required %}
|
||||||
|
<span class="text-danger">*</span>
|
||||||
|
{% endif %}
|
||||||
|
</label>
|
||||||
|
<input type="{{ type }}" value="{{ value }}" name="{{ name }}" class="form-control" placeholder="{{ placeholder }}" {% if is_required %}required{% endif %}>
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro select(name, label, options, selected_value = "", is_required = false, margin_bottom = true) -%}
|
||||||
|
<div {% if margin_bottom %}class="mb-3"{% endif %}>
|
||||||
|
<label for="{{ name }}" class="form-label">
|
||||||
|
{{ label }}
|
||||||
|
{% if is_required %}
|
||||||
|
<span class="text-danger">*</span>
|
||||||
|
{% endif %}
|
||||||
|
</label>
|
||||||
|
<select name="{{ name }}" class="form-select" {% if is_required %}required{% endif %}>
|
||||||
|
{% if !is_required %}
|
||||||
|
<option></option>
|
||||||
|
{% endif %}
|
||||||
|
{% for option in options %}
|
||||||
|
{{ caller(option) }}
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{% macro textarea(name, label, value = "", rows = 2, is_required = false, placeholder = "", margin_bottom = true) %}
|
||||||
|
<div {% if margin_bottom %}class="mb-3"{% endif %}>
|
||||||
|
<label for="{{ name }}" class="form-label">
|
||||||
|
{{ label }}
|
||||||
|
{% if is_required %}
|
||||||
|
<span class="text-danger">*</span>
|
||||||
|
{% endif %}
|
||||||
|
</label>
|
||||||
|
<textarea value="{{ value }}" name="{{ name }}" rows="{{ rows }}" class="form-control" placeholder="{{ placeholder }}" {% if is_required %}required{% endif %}></textarea>
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
{{ dropdown::book_dropdown_button(book, show) }}
|
{{ dropdown::crud_dropdown_button(book, "Actions", "books", show) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|||||||
+12
-1
@@ -1 +1,12 @@
|
|||||||
Error
|
{% extends "base.html" %}
|
||||||
|
{% block main %}
|
||||||
|
<div class="mt-4 text-center">
|
||||||
|
<h1>Oops ! An Error occured</h1>
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
{% for error in state.errors %}
|
||||||
|
<p>{{ error }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<a href="/" class="mt-3 btn btn-info">Back to home</a>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
+33
-23
@@ -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">
|
||||||
@@ -33,7 +37,7 @@
|
|||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
<label for="owner_id" class="form-label">Owner</label>
|
<label for="owner_id" class="form-label">Owner</label>
|
||||||
|
|
||||||
<select name="owner_id" class="form-control">
|
<select name="owner_id" class="form-select">
|
||||||
<option></option>
|
<option></option>
|
||||||
{% match query.owner_id %}
|
{% match query.owner_id %}
|
||||||
{% when Some with (owner_id) %}
|
{% when Some with (owner_id) %}
|
||||||
@@ -55,7 +59,7 @@
|
|||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
<label for="current_holder_id" class="form-label">Current Holder</label>
|
<label for="current_holder_id" class="form-label">Current Holder</label>
|
||||||
|
|
||||||
<select name="current_holder_id" class="form-control">
|
<select name="current_holder_id" class="form-select">
|
||||||
<option></option>
|
<option></option>
|
||||||
{% match query.current_holder_id %}
|
{% match query.current_holder_id %}
|
||||||
{% when Some with (current_holder_id) %}
|
{% when Some with (current_holder_id) %}
|
||||||
@@ -75,7 +79,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-1 d-flex align-items-end">
|
<div class="col-md-1 d-flex align-items-end">
|
||||||
<input type="submit" value="Search" class="btn btn-info btn">
|
<input type="submit" value="Search" class="btn btn-info w-100">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-1 d-flex align-items-end">
|
||||||
|
<a href="/" class="btn btn-light">Reset</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -95,7 +103,7 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for book_user in books_with_user %}
|
{% for book_user in books_with_user %}
|
||||||
<tr>
|
<tr class="align-middle">
|
||||||
<th scope="row">{{ book_user.book.id }}</th>
|
<th scope="row">{{ book_user.book.id }}</th>
|
||||||
<td>{{ book_user.book.title }}</td>
|
<td>{{ book_user.book.title }}</td>
|
||||||
<td>{{ book_user.book.authors }}</td>
|
<td>{{ book_user.book.authors }}</td>
|
||||||
@@ -109,7 +117,7 @@
|
|||||||
{% endmatch %}
|
{% endmatch %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{ dropdown::book_dropdown_button(book_user.book) }}
|
{{ dropdown::crud_dropdown_button(book_user.book, "Actions", "books") }}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -117,25 +125,27 @@
|
|||||||
</table>
|
</table>
|
||||||
|
|
||||||
{% if total_page > 1 %}
|
{% if total_page > 1 %}
|
||||||
<nav aria-label="Page navigation">
|
<div class="d-flex justify-content-center mt-1">
|
||||||
<ul class="pagination">
|
<nav aria-label="Page navigation">
|
||||||
<li class="page-item {% if current_page <= 1 %}disabled{% endif %}">
|
<ul class="pagination">
|
||||||
<a class="page-link" href="/?{{ base_query }}page={% if current_page > 1 %}{{ current_page - 1 }}{% else %}1{% endif %}">Prev</a>
|
<li class="page-item {% if current_page <= 1 %}disabled{% endif %}">
|
||||||
</li>
|
<a class="page-link" href="/?{{ base_query }}page={% if current_page > 1 %}{{ current_page - 1 }}{% else %}1{% endif %}">Prev</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
{% for page in 1..(total_page + 1) %}
|
{% for page in 1..(total_page + 1) %}
|
||||||
{% if page >= current_page - 1 && page <= current_page + 1 %}
|
{% if page >= current_page - 1 && page <= current_page + 1 %}
|
||||||
<li class="page-item {% if page == current_page %}active{% endif %}">
|
<li class="page-item {% if page == current_page %}active{% endif %}">
|
||||||
<a class="page-link" href="/?{{ base_query }}page={{ page }}">{{ page }}</a>
|
<a class="page-link" href="/?{{ base_query }}page={{ page }}">{{ page }}</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
<li class="page-item {% if current_page == total_page %}disabled{% endif %}">
|
<li class="page-item {% if current_page == total_page %}disabled{% endif %}">
|
||||||
<a class="page-link" href="/?{{ base_query }}page={{ current_page + 1 }}">Next</a>
|
<a class="page-link" href="/?{{ base_query }}page={{ current_page + 1 }}">Next</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% import "components/typography.html" as typography %}
|
||||||
|
{% import "components/cards.html" as cards %}
|
||||||
|
o{% import "components/inputs.html" as form_helpers %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
{{ typography::heading("New User") }}
|
||||||
|
|
||||||
|
{% call cards::card() %}
|
||||||
|
<form action="/users/{{ user.id }}" method="post">
|
||||||
|
<div class="row align-items-end">
|
||||||
|
<div class="col-md-10">
|
||||||
|
{{ form_helpers::input("name", "Name", value = user.name, is_required = true, placeholder = "Ex: Kropotkine", margin_bottom = false) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-2">
|
||||||
|
<input type="submit" value="Edit user" class="btn btn-success">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endcall %}
|
||||||
|
{% endblock %}
|
||||||
+36
-20
@@ -1,9 +1,37 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% import "components/typography.html" as typography %}
|
{% import "components/typography.html" as typography %}
|
||||||
|
{% import "components/dropdown.html" as dropdown %}
|
||||||
{% import "components/cards.html" as cards %}
|
{% import "components/cards.html" as cards %}
|
||||||
|
{% import "components/inputs.html" as form_helpers %}
|
||||||
|
|
||||||
{% block main %}
|
{% block main %}
|
||||||
{{ typography::heading("All users") }}
|
{% call typography::heading("All users") %}
|
||||||
|
<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() %}
|
{% call cards::card() %}
|
||||||
<table class="table table-hover">
|
<table class="table table-hover">
|
||||||
@@ -17,30 +45,18 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for (user, book_size, borrowed_book) in user_with_books_number %}
|
{% for user_information in user_with_books_number %}
|
||||||
<tr>
|
<tr class="align-middle">
|
||||||
<th scope="row">{{ user.id }}</th>
|
<th scope="row">{{ user_information.user.id }}</th>
|
||||||
<td>{{ user.name }}</td>
|
<td>{{ user_information.user.name }}</td>
|
||||||
<td>{{ book_size }}</td>
|
<td>{{ user_information.owner_book_number }}</td>
|
||||||
<td>{{ borrowed_book }}</td>
|
<td>{{ user_information.borrowed_book_number }}</td>
|
||||||
<td>
|
<td>
|
||||||
<form method="post" action="/users/{{ user.id }}">
|
{{ dropdown::crud_dropdown_button(user_information.user, "Actions", "users", show = false) }}
|
||||||
<input value="Delete" type="submit" class="btn btn-danger btn-sm">
|
|
||||||
</form>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
|
|
||||||
{% call cards::card() %}
|
|
||||||
<form action="/users" method="post">
|
|
||||||
<label class="form-label">Name</label>
|
|
||||||
<input type="text" name="name" class="form-control">
|
|
||||||
|
|
||||||
<input type="submit" value="Create user" class="btn btn-success mt-3">
|
|
||||||
</form>
|
|
||||||
{% endcall %}
|
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% import "components/typography.html" as typography %}
|
||||||
|
{% import "components/cards.html" as cards %}
|
||||||
|
o{% import "components/inputs.html" as form_helpers %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
{{ typography::heading("New User") }}
|
||||||
|
|
||||||
|
{% call cards::card() %}
|
||||||
|
<form action="/users" method="post">
|
||||||
|
<div class="row align-items-end">
|
||||||
|
<div class="col-md-10">
|
||||||
|
{{ form_helpers::input("name", "Name", is_required = true, placeholder = "Ex: Kropotkine", margin_bottom = false) }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<input type="submit" value="Create user" class="btn btn-success">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endcall %}
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user