diff --git a/src/entities/bal.rs b/src/entities/bal.rs index 47bbcfd..dd5f8e5 100644 --- a/src/entities/bal.rs +++ b/src/entities/bal.rs @@ -34,6 +34,8 @@ where C: ConnectionTrait { pub enum Relation { #[sea_orm(has_many = "super::book_instance::Entity")] BookInstance, + #[sea_orm(has_one = "super::bal_stats::Entity")] + BalStats, } impl Related for Entity { diff --git a/src/entities/bal_stats.rs b/src/entities/bal_stats.rs new file mode 100644 index 0000000..dfdf601 --- /dev/null +++ b/src/entities/bal_stats.rs @@ -0,0 +1,38 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize, utoipa::ToSchema)] +#[sea_orm(table_name = "BALStats")] +#[schema(title="BalStats", as=entities::BALStats)] +pub struct Model { + #[sea_orm(primary_key, auto_increment = true)] + #[serde(skip_serializing)] + id: u32, + pub bal_id: u32, + + pub total_owned_sold_books: u32, + pub total_sold_books: u32, + + pub total_owned_collected_money: f32, + pub total_collected_money: f32, + + pub total_different_owners: u32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::bal::Entity", + from = "Column::BalId", + to = "super::bal::Column::Id" + )] + Bal, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Bal.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/entities/mod.rs b/src/entities/mod.rs index fd63ca8..57dcee5 100644 --- a/src/entities/mod.rs +++ b/src/entities/mod.rs @@ -1,6 +1,7 @@ pub mod prelude; pub mod bal; +pub mod bal_stats; pub mod book; pub mod book_instance; pub mod owner; diff --git a/src/entities/prelude.rs b/src/entities/prelude.rs index a4e87e2..303c6b4 100644 --- a/src/entities/prelude.rs +++ b/src/entities/prelude.rs @@ -1,5 +1,6 @@ pub use super::bal::Entity as Bal; +pub use super::bal_stats::Entity as BalStats; pub use super::book::Entity as Book; pub use super::book_instance::Entity as BookInstance; pub use super::owner::Entity as Owner; diff --git a/src/lib.rs b/src/lib.rs index 39e905d..8a0d8b8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -175,6 +175,8 @@ pub async fn run_server(db: Arc, port: u16, serve_docs: bool .routes(routes!(routes::bal::stop_bal)) .routes(routes!(routes::bal::get_bals)) .routes(routes!(routes::bal::get_bal_accounting)) + .routes(routes!(routes::bal::return_to_owner)) + .routes(routes!(routes::bal::get_stats_by_bal_id)) // Authentication .route_layer(middleware::from_fn_with_state(shared_state.clone(), routes::auth::auth_middleware)) .routes(routes!(routes::auth::auth)) diff --git a/src/routes/bal.rs b/src/routes/bal.rs index 0fdce03..cc2547f 100644 --- a/src/routes/bal.rs +++ b/src/routes/bal.rs @@ -1,4 +1,4 @@ -use std::{collections::HashMap, sync::Arc}; +use std::{collections::{HashMap, HashSet}, sync::Arc}; use axum::{extract::{Path, State}, Json}; use reqwest::{StatusCode}; @@ -6,7 +6,7 @@ use sea_orm::{ActiveModelTrait, ActiveValue::{NotSet, Set}, ColumnTrait, EntityT use serde::{Deserialize, Serialize}; use utoipa::IntoParams; -use crate::{entities::{bal::{self, BalState}, book_instance, owner, prelude::*}, routes::auth::Claims, AppState}; +use crate::{entities::{bal::{self, BalState}, bal_stats, book_instance, owner, prelude::*}, routes::auth::Claims, AppState}; #[derive(IntoParams)] @@ -242,16 +242,49 @@ pub async fn stop_bal( let mut bal: bal::ActiveModel = bal.into(); bal.state = Set(BalState::Ended); - match bal.update(state.db_conn.as_ref()).await { - Err(e) => { - log::error!(target: "api", "Error while updating bal from api: {:#?}", e); - (StatusCode::INTERNAL_SERVER_ERROR, Json(None)) - }, - Ok(res) => { - let model = res.try_into_model().expect("All fields should be set once the bal is saved"); - (StatusCode::OK, Json(Some(model))) + let model = bal.update(state.db_conn.as_ref()).await.unwrap().try_into_model().expect("All fields should be set once the bal is saved"); + + let self_owner = Owner::find() + .filter(owner::Column::User.eq(true)) + .filter(owner::Column::UserId.eq(claims.user_id)) + .one(state.db_conn.as_ref()).await.unwrap().unwrap(); + + let mut total_sold_books = 0; + let mut total_owned_sold_books = 0; + let mut total_collected_money = 0.; + let mut total_owned_collected_money = 0.; + + let mut owner_set = HashSet::new(); + for book in BookInstance::find() + .filter(book_instance::Column::BalId.eq(id)) + .all(state.db_conn.as_ref()).await.unwrap() { + if book.sold_price.is_some() { + owner_set.insert(book.owner_id); + + total_sold_books += 1; + total_collected_money += book.sold_price.unwrap(); + if book.owner_id == self_owner.id { + total_owned_sold_books += 1; + total_owned_collected_money += book.sold_price.unwrap(); + } } } + + let bal_stats = bal_stats::ActiveModel { + id: NotSet, + bal_id: Set(id), + + total_sold_books: Set(total_sold_books), + total_owned_sold_books: Set(total_owned_sold_books), + + total_collected_money: Set(total_collected_money), + total_owned_collected_money: Set(total_owned_collected_money), + + total_different_owners: Set(owner_set.len().try_into().unwrap()) + }; + + bal_stats.save(state.db_conn.as_ref()).await.unwrap(); + (StatusCode::OK, Json(Some(model))) } else { (StatusCode::NOT_FOUND, Json(None)) } @@ -349,3 +382,109 @@ pub async fn get_bal_accounting( } } +#[derive(Deserialize, Serialize, utoipa::ToSchema)] +pub struct BalAccountingReturnParams { + return_type: ReturnType, +} + +#[derive(Deserialize, Serialize, utoipa::ToSchema)] +pub enum ReturnType { + Books, + Money, + All +} + +#[derive(IntoParams)] +#[into_params(names("id", "owner_id"), parameter_in = Path)] +#[allow(dead_code)] +pub struct BalAndOwnerByIdParams(u32, u32); + +#[axum::debug_handler] +#[utoipa::path( + get, + path = "/bal/{id}/accounting/return/{owner_id}", + params(BalAndOwnerByIdParams), + request_body = BalAccountingReturnParams, + security(("jwt" = [])), + responses( + (status = OK, description = "Successfully returned requested type to owner"), + (status = CONFLICT, description = "Books and money have already been returned to the owner, or no money nor books were owed"), + (status = NOT_FOUND, description = "No bal or owner with this id exists in the database"), + (status = FORBIDDEN, description = "You don't own the specified bal or owner"), + ), + summary = "Return books and/or money to an owner", + description = "Return books and/or money to an owner", + tag = "bal-api", +)] +pub async fn return_to_owner( + State(state): State>, + claims: Claims, + Path((id, owner_id)): Path<(u32, u32)>, + Json(payload): Json +) -> StatusCode { + if let Ok(Some(bal)) = Bal::find_by_id(id).one(state.db_conn.as_ref()).await && let Ok(Some(owner)) = Owner::find_by_id(owner_id).one(state.db_conn.as_ref()).await { + if bal.user_id != claims.user_id || owner.user_id != claims.user_id { + StatusCode::FORBIDDEN + } else { + let owner_books = BookInstance::find() + .filter(book_instance::Column::OwnerId.eq(owner.id)) + .filter(book_instance::Column::BalId.eq(bal.id)) + .all(state.db_conn.as_ref()).await.unwrap(); + if owner_books.is_empty() { + return StatusCode::CONFLICT; + } + + let mut book_delete_query = BookInstance::delete_many() + .filter(book_instance::Column::OwnerId.eq(owner.id)) + .filter(book_instance::Column::BalId.eq(bal.id)); + book_delete_query = match payload.return_type { + ReturnType::Books => book_delete_query.filter(book_instance::Column::SoldPrice.is_null()), + ReturnType::Money => book_delete_query.filter(book_instance::Column::SoldPrice.is_not_null()), + ReturnType::All => book_delete_query + }; + + let _ = book_delete_query.exec(state.db_conn.as_ref()).await; + StatusCode::OK + } + } else { + StatusCode::NOT_FOUND + } +} + +#[axum::debug_handler] +#[utoipa::path( + get, + path = "/bal/{id}/stats", + params(BalByIdParams), + security(("jwt" = [])), + responses( + (status = OK, body = bal_stats::Model, description = "Found bal stats with corresponding ID in the database"), + (status = CONFLICT, description = "The specified BAL is not ended yet, can't get stats"), + (status = NOT_FOUND, description = "No bal with this id exists in the database"), + (status = FORBIDDEN, description = "You don't own the specified bal"), + ), + summary = "Get a bal's stats by its ID", + description = "Get a bal's stats from its ID", + tag = "bal-api", +)] +pub async fn get_stats_by_bal_id( + State(state): State>, + claims: Claims, + Path(id): Path, +) -> (StatusCode, Json>) { + if let Ok(Some(bal)) = Bal::find_by_id(id).one(state.db_conn.as_ref()).await { + if !bal.user_id == claims.user_id { + (StatusCode::FORBIDDEN, Json(None)) + } else if bal.state != BalState::Ended { + (StatusCode::CONFLICT, Json(None)) + } else { + if let Ok(Some(stats)) = BalStats::find().filter(bal_stats::Column::BalId.eq(bal.id)).one(state.db_conn.as_ref()).await { + (StatusCode::OK, Json(Some(stats))) + } else { + (StatusCode::INTERNAL_SERVER_ERROR, Json(None)) + } + } + } else { + (StatusCode::NOT_FOUND, Json(None)) + } +}