From cf65324827cde159429c1345516559d24cee4e3a Mon Sep 17 00:00:00 2001 From: Wes Holland Date: Wed, 13 Nov 2024 16:47:50 -0600 Subject: [PATCH] User verification through db and easier test data --- .idea/dataSources.xml | 12 +++ .idea/sqldialects.xml | 7 ++ Cargo.lock | 53 ++++++++++++ Cargo.toml | 6 +- data/initial_catalog.csv | 59 +++++++++++++ example.env | 2 +- inventory-app.db-shm | Bin 0 -> 32768 bytes inventory-app.db-wal | 0 migrations/20241107225934_initial.sql | 44 ++++++++-- src/app/items.rs | 9 +- src/app/mod.rs | 9 +- src/app/upload.rs | 30 +++++++ src/auth.rs | 119 ++++++++++---------------- src/db/bootstrap_data.rs | 42 +++++++++ src/db/inventory_item.rs | 57 ++++++++++-- src/db/mod.rs | 6 ++ src/db/positive_adjustment.rs | 32 +++++++ src/db/user.rs | 87 +++++++++++++++++++ src/error.rs | 4 +- src/ingest.rs | 49 +++++++++++ src/main.rs | 1 + src/session.rs | 69 +++++++++++++-- templates/item_list.html | 9 ++ templates/item_list_fragment.html | 3 +- templates/main.html | 2 +- 25 files changed, 609 insertions(+), 102 deletions(-) create mode 100644 .idea/dataSources.xml create mode 100644 .idea/sqldialects.xml create mode 100644 data/initial_catalog.csv create mode 100644 inventory-app.db-shm create mode 100644 inventory-app.db-wal create mode 100644 src/app/upload.rs create mode 100644 src/db/bootstrap_data.rs create mode 100644 src/db/positive_adjustment.rs create mode 100644 src/db/user.rs create mode 100644 src/ingest.rs diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml new file mode 100644 index 0000000..2288b14 --- /dev/null +++ b/.idea/dataSources.xml @@ -0,0 +1,12 @@ + + + + + sqlite.xerial + true + org.sqlite.JDBC + jdbc:sqlite:inventory-app.db + $ProjectFileDir$ + + + \ No newline at end of file diff --git a/.idea/sqldialects.xml b/.idea/sqldialects.xml new file mode 100644 index 0000000..8748bad --- /dev/null +++ b/.idea/sqldialects.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 7fd5bd4..93e4e24 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -172,6 +172,7 @@ dependencies = [ "matchit", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", "rustversion", @@ -483,6 +484,27 @@ dependencies = [ "typenum", ] +[[package]] +name = "csv" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdc4883a9c96732e4733212c01447ebd805833b7275a73ca3ee080fd77afdaf" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "csv-core" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efa2b3d7902f4b634a20cae3c9c4e6209dc4779feb6863329607560143efa70" +dependencies = [ + "memchr", +] + [[package]] name = "der" version = "0.7.9" @@ -1132,10 +1154,12 @@ dependencies = [ "axum", "axum-extra", "axum-htmx", + "csv", "dotenvy", "httpc-test", "oauth2", "reqwest 0.12.9", + "ron", "serde", "sqlx", "time", @@ -1298,6 +1322,23 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http 1.1.0", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "native-tls" version = "0.2.12" @@ -1829,6 +1870,18 @@ dependencies = [ "serde", ] +[[package]] +name = "ron" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94" +dependencies = [ + "base64 0.21.7", + "bitflags 2.6.0", + "serde", + "serde_derive", +] + [[package]] name = "rsa" version = "0.9.6" diff --git a/Cargo.toml b/Cargo.toml index b393e5d..3a91d6f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,12 +6,12 @@ edition = "2021" [dependencies] anyhow = "1.0.91" askama = { version = "0.12.1", features = ["with-axum"] } -axum = { version = "0.7.7", features = ["macros"] } +axum = { version = "0.7.7", features = ["macros", "multipart"] } axum-htmx = { version = "0.6.0", features = ["auto-vary"] } dotenvy = "0.15.7" oauth2 = "4.4.2" sqlx = { version = "0.8.2", features = ["runtime-tokio", "sqlite"] } -time = "0.3.36" +time = { version = "0.3.36", features = ["parsing"] } tokio = { version = "1.41.0", features = ["full", "tracing"] } tower = { version = "0.5.1", features = ["util"] } tower-http = { version = "0.6.1", features = ["fs", "trace"] } @@ -23,6 +23,8 @@ serde = { version = "1.0.213", features = ["derive"] } reqwest = { version = "0.12.9", features = ["json"] } askama_axum = "0.4.0" axum-extra = "0.9.4" +csv = "1.3.1" +ron = "0.8.1" [dev-dependencies] httpc-test = "0.1.10" diff --git a/data/initial_catalog.csv b/data/initial_catalog.csv new file mode 100644 index 0000000..88f0862 --- /dev/null +++ b/data/initial_catalog.csv @@ -0,0 +1,59 @@ +name,qty,unit,fractional,reorder,price +Amoxicillin/Clavulanate 62.5mg/ml 15ml,1,ct,false,10,25 +Animax Ointment 15ml,1,ct,false,10,25 +Buprenex 0.3mg/ml - Injectable,1,ct,false,10,25 +Buprenex 0.3mg/ml Oral Solution,28,ct,false,10,25 +Carprofen 100mg,168,ct,false,10,25 +Carprofen 25mg,232,ct,false,10,25 +Carprofen 75mg,80,ct,false,10,25 +Cephalexin 250mg,605,ct,false,10,25 +Cephalexin 500mg,513,ct,false,10,25 +Cerenia 10mg/ml (Per ml),12,ml,true,10,25 +Cough Tabs,325,ct,false,10,25 +Cytopoint 10mg,2,ct,false,10,25 +Cytopoint 20mg,0,ct,false,10,25 +Cytopoint 30mg,4,ct,false,10,25 +Cytopoint 40mg,6,ct,false,10,25 +Dexamethasone 2mg/ml Injectable,99,ct,false,10,25 +Dog Ends Starter Kit,1,ct,false,10,25 +Doxycycline 100mg Tablet,104,ct,false,10,25 +Elura 20mg/ml - 15ml,1,ct,false,10,25 +Entyce 30mg/ml - 10ml bottle,1,ct,false,10,25 +Fenbendazole 100mg/ml,900,ct,false,10,25 +"Florfenicol, Terbinafine, Mometasone furoate Ear Treament 1ml Tube",68,ct,false,10,25 +FortiFlora 30ct - Cat,2,ct,false,10,25 +FortiFlora SA K9,3,ct,false,10,25 +FVRCP TruFel Ultra Injectable,9,ct,false,10,25 +Gabapentin 100mg,479,ct,false,10,25 +Gabapentin 600mg Tablets,503,ct,false,10,25 +Gentamicin Sulfate/Betamethasone Spray 60ml,0,ct,false,10,25 +Kenalog 10mg/ml (Per ml),8.52,ml,true,10,25 +Librela 10mg/ml,1,ct,false,10,25 +Librela 20mg/ml,20,ct,false,10,25 +Librela 30mg/ml,25,ct,false,10,25 +Librela 5mg/ml,3,ct,false,10,25 +Maropitant 16mg,10,ct,false,10,25 +Maropitant 24mg,8,ct,false,10,25 +Maropitant 60mg,16,ct,false,10,25 +Meloxicam 1.5mg/ml - 10ml,2,ct,false,10,25 +Metronidazole 125mg/ml - 30ML,0,ct,false,10,25 +Metronidazole 250mg,15,ct,false,10,25 +Mirataz 5g,3,ct,false,10,25 +NeoPolyBac Ointment,1,ct,false,10,25 +Nobivac DAPP,64,ct,false,10,25 +Nobivac Intra-Trac Bordetella,68,ct,false,10,25 +Nobivac Lepto 4,42,ct,false,10,25 +Nobivac Rabies,96,ct,false,10,25 +Ondansetron 8mg,16,ct,false,10,25 +Praziquantel 56.8 mg/ml Injection,8,ct,false,10,25 +Prednisolone 5mg,913,ct,false,10,25 +Prednisone 20mg,259,ct,false,10,25 +Pro-Pectalin 15ml,2,ct,false,10,25 +Probiotic Powder Canine - 30ct,1,ct,false,10,25 +PureVax Feline Rabies,25,ct,false,10,25 +PureVax Recombinant FeLV,13,ct,false,10,25 +Rilexine 150mg,12,ct,false,10,25 +Rilexine 300mg,34,ct,false,10,25 +Tobramycin Ophthalmic Sol 5ml,3,ct,false,10,25 +Trazodone 100mg,257,ct,false,10,25 +Varenzin-CA1 25mg/ml,2,ct,false,10,25 diff --git a/example.env b/example.env index 530e5ba..3b336d8 100644 --- a/example.env +++ b/example.env @@ -9,5 +9,5 @@ OAUTH_TOKEN_URL=https://accounts.google.com/o/oauth2/token OAUTH_REVOKE_URL=https://accounts.google.com/o/oauth2/revoke OAUTH_USER_INFO_URL=https://www.googleapis.com/oauth2/v1/userinfo OAUTH_REDIRECT_URL=http://localhost:4206/auth/authorized -AUTHORIZED_USERS=user1@somewhere.com;user2@somewhere.com ROUTES_INCLUDE_ERROR_TESTS=no +BOOTSTRAP_DATA="users=[(name:youruser@wherever.com, role:admin)]" diff --git a/inventory-app.db-shm b/inventory-app.db-shm new file mode 100644 index 0000000000000000000000000000000000000000..fe9ac2845eca6fe6da8a63cd096d9cf9e24ece10 GIT binary patch literal 32768 zcmeIuAr62r3, Path(id): Path) -> Result, Path(id): Path) -> Result { + let count = sum_all_adjustments_for_item(&db, id).await?; + + Ok(count.to_string().into_response()) +} diff --git a/src/app/mod.rs b/src/app/mod.rs index fdcfee2..5e608d2 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,12 +1,13 @@ use axum::middleware::from_extractor; use axum::Router; -use axum::routing::get; +use axum::routing::{get, post}; use axum_htmx::{AutoVaryLayer}; use crate::app::state::AppState; -use crate::auth::User; +use crate::session::SessionUser; pub mod state; pub mod items; +mod upload; pub fn routes() -> Router { Router::new() @@ -16,9 +17,11 @@ pub fn routes() -> Router { .route("/items/", get(items::item_list)) .route("/item/:item_id", get(items::item)) .route("/item/:item_id/", get(items::item)) + .route("/item/:item_id/count", get(items::item_count)) + .route("/catalog", post(upload::catalog)) // Ensure that all routes here require an authenticated user // whether explicitly asked or not - .route_layer(from_extractor::()) + .route_layer(from_extractor::()) .layer(AutoVaryLayer) } diff --git a/src/app/upload.rs b/src/app/upload.rs new file mode 100644 index 0000000..755b3bf --- /dev/null +++ b/src/app/upload.rs @@ -0,0 +1,30 @@ +use crate::error::{AppError}; +use crate::ingest::{ingest_catalog_bytes}; +use crate::session::SessionUser; +use anyhow::anyhow; +use askama_axum::Response; +use axum::extract::{Multipart, State}; +use axum::response::IntoResponse; +use sqlx::SqlitePool; +use std::format; +use tracing::info; + +pub async fn catalog( + State(db): State, + user: SessionUser, + mut multipart: Multipart, +) -> Result { + let mut filename = "".to_owned(); + while let Some(field) = multipart.next_field().await? { + filename = field.file_name().ok_or(anyhow!("field missing filename"))?.to_string(); + + let name = field.name().ok_or(anyhow!("field missing name"))?.to_string(); + let content_type = field.content_type().ok_or(anyhow!("field missing content type"))?.to_string(); + let data = field.bytes().await?; + + info!("Name: {}, file: {}, content: {}, data: {} bytes", name, filename, content_type, data.len()); + + ingest_catalog_bytes(data, db.clone(), user.id).await?; + } + Ok(format!("File {} uploaded successfully", filename).into_response()) +} diff --git a/src/auth.rs b/src/auth.rs index a9ef663..6dd218e 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,24 +1,24 @@ use anyhow::{anyhow, Context}; use askama::Template; -use axum::{async_trait, Router}; -use axum::extract::{FromRequestParts, State}; -use axum::http::request::Parts; -use axum::response::{IntoResponse, Redirect, Response}; +use axum::Router; +use axum::extract::State; +use axum::response::{IntoResponse, Redirect}; use axum::routing::get; use oauth2::basic::BasicClient; use oauth2::reqwest::async_http_client; use oauth2::{AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, RedirectUrl, RevocationUrl, Scope, TokenResponse, TokenUrl}; use serde::{Deserialize, Serialize}; +use sqlx::SqlitePool; use tower_sessions::Session; use crate::error::{AppError, AppForbiddenResponse}; use crate::error::QueryExtractor; use crate::app::state::AppState; - +use crate::session::{SessionUser, USER_SESSION}; +use crate::db; // This module is all the stuff related to authentication and authorization const CSRF_TOKEN: &str = "csrf_token"; -const USER_SESSION: &str = "user"; pub fn routes() -> Router { Router::new() @@ -56,10 +56,19 @@ pub fn init_client() -> anyhow::Result { Ok(client) } +#[derive(Debug, Serialize, Deserialize)] +struct OAuthUser { + pub id: String, + pub email: String, + pub name: String, + pub verified_email: bool, + pub picture: String, +} + /// Handler for when the user logs in pub async fn auth_login( session: Session, - user: Option, + user: Option, State(oauth_client): State, ) -> anyhow::Result { @@ -88,6 +97,7 @@ pub async fn auth_authorized( session: Session, QueryExtractor(query_auth): QueryExtractor, State(oauth_client): State, + State(db): State, ) -> anyhow::Result { let user_info_endpoint = std::env::var("OAUTH_USER_INFO_URL") .context("OAUTH_USER_INFO_URL not set")?; @@ -112,36 +122,41 @@ pub async fn auth_authorized( // STEP 6 - Use the Access Token to pull user data like name, email, etc let client = reqwest::Client::new(); - let user_data = client + let oauth_user_data = client .get(user_info_endpoint) .bearer_auth(token.access_token().secret()) .send() .await .context("failed in sending request to target Url")?; - let user_data = user_data - .json::() + let oauth_user_data = oauth_user_data + .json::() .await .context("failed to deserialize response as JSON")?; // STEP 7 - Authorize the user at the application level - //TODO Check against database instead of string - let valid_users = std::env::var("AUTHORIZED_USERS") - .context("Authorized users not set")?; - - let valid_users = valid_users.split(";") - .collect::>(); - - let is_authorized = valid_users.contains(&user_data.email.as_str()); - - if !is_authorized { - return Ok(AppForbiddenResponse::new(&user_data.email, "application").into_response()) - } - - // STEP 8 - Save user session data - session.insert(USER_SESSION, user_data).await?; - - // STEP 9 - Redirect back to the rest of the application + let db_user = match db::user::get_user_by_name(&db, &oauth_user_data.email).await? { + Some(user) => user, + None => { + return Ok(AppForbiddenResponse::new(&oauth_user_data.email, "application").into_response()) + } + }; + + // STEP 8 - Create session user that combines oauth and db info + let session_user = SessionUser { + id: db_user.id, + role: db_user.role, + oauth_id: oauth_user_data.id, + email: oauth_user_data.email, + name: oauth_user_data.name, + verified_email: oauth_user_data.verified_email, + picture: oauth_user_data.picture, + }; + + // STEP 10 - Save user session data + session.insert(USER_SESSION, session_user).await?; + + // STEP 11 - Redirect back to the rest of the application Ok(Redirect::to("/").into_response()) } @@ -152,12 +167,12 @@ struct LoggedOutTemplate; /// Handler for user log-out pub async fn auth_logout( session: Session, - user: Option, + user: Option, ) -> anyhow::Result { // Logging out is as simple as clearing the user session if user.is_some() { - session.remove::(USER_SESSION).await?; + session.remove::(USER_SESSION).await?; } Ok(LoggedOutTemplate.into_response()) @@ -170,49 +185,3 @@ pub struct AuthRequest { pub state: String, } -/// User information that will be return from the OAUTH authority -#[derive(Debug, Serialize, Deserialize)] -pub struct User { - pub id: String, - pub email: String, - pub name: String, - pub verified_email: bool, - pub picture: String, -} - -/// A custom error for the User extractor -pub enum UserExtractError { - InternalServerError(anyhow::Error), - Unauthorized, -} - -impl IntoResponse for UserExtractError { - fn into_response(self) -> Response { - match self { - UserExtractError::InternalServerError(err) => AppError::from(err).into_response(), - UserExtractError::Unauthorized => { Redirect::temporary("/auth/login").into_response() } - } - } -} - -/// The user extractor is used to pull out the user data from the session. This can be used -/// as a guard to ensure that a user session exists. Basically an authentication -/// (but not authorization) guard -#[async_trait] -impl FromRequestParts for User -where - S: Send + Sync, -{ - type Rejection = UserExtractError; - - async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { - let session = Session::from_request_parts(parts, state).await - .map_err(|_| UserExtractError::InternalServerError(anyhow!("session from parts failed")))?; - - let user = session.get(USER_SESSION).await - .map_err(|e| UserExtractError::InternalServerError(anyhow::Error::from(e)))? - .ok_or(UserExtractError::Unauthorized)?; - - Ok(user) - } -} diff --git a/src/db/bootstrap_data.rs b/src/db/bootstrap_data.rs new file mode 100644 index 0000000..da22f44 --- /dev/null +++ b/src/db/bootstrap_data.rs @@ -0,0 +1,42 @@ +use crate::db::user::{add_user, get_user_by_name, DbUserRole}; +use anyhow::{anyhow, Result}; +use serde::Deserialize; +use sqlx::SqlitePool; +use tracing::info; + +#[derive(Debug, Deserialize)] +struct BootstrapData { + users: Vec, +} + +#[derive(Debug, Deserialize)] +struct BootstrapUser { + name: String, + role: String +} + +pub async fn bootstrap_database(db: &SqlitePool) -> Result<()> { + + let bootstrap_str = match std::env::var("BOOTSTRAP_DATA") { + Ok(s) => { + info!("bootstrap data found, updating db: {}", s); + s + }, + Err(_) => { + info!("no Bootstrap data found"); + return Ok(()); + }, + }; + + let data = ron::from_str::(&bootstrap_str)?; + + for user in &data.users { + let role = DbUserRole::try_from_str(&user.role).ok_or(anyhow!("invalid role {}", user.role))?; + if get_user_by_name(db, &user.name).await?.is_none() { + let new_id = add_user(db, &user.name, role).await?; + info!("bootstrap new user {}:{} ({})", new_id, user.name, user.role); + } + } + + Ok(()) +} diff --git a/src/db/inventory_item.rs b/src/db/inventory_item.rs index 0aa5be4..af7c143 100644 --- a/src/db/inventory_item.rs +++ b/src/db/inventory_item.rs @@ -7,7 +7,9 @@ use sqlx::SqlitePool; pub struct DbInventoryItem { pub id: i64, pub name: String, - pub reorder_point: i64, + pub reorder_point: f64, + pub allow_fractional_units: bool, + pub display_unit: i64, } @@ -17,7 +19,11 @@ pub async fn inventory_item_get_all(db: &SqlitePool, page_size: i64, page_num: i DbInventoryItem, r#" SELECT - id, name, reorder_point + id, + name, + reorder_point, + allow_fractional_units, + display_unit FROM InventoryItem LIMIT ? OFFSET ? @@ -39,7 +45,11 @@ pub async fn inventory_item_get_search(db: &SqlitePool, DbInventoryItem, r#" SELECT - id, name, reorder_point + id, + name, + reorder_point, + allow_fractional_units, + display_unit FROM InventoryItem WHERE InventoryItem.name LIKE ? @@ -57,13 +67,50 @@ pub async fn inventory_item_get_by_id(db: &SqlitePool, id: i64) -> Result Result { + let res = sqlx::query!( + r#" + SELECT + (SELECT TOTAL(amount) FROM PositiveAdjustment WHERE item = ?) AS plus, + (SELECT TOTAL(amount) FROM NegativeAdjustment WHERE item = ?) AS minus + "#, + id, id + ) + .fetch_one(db).await?; + + let plus: f64 = res.plus.unwrap_or_default(); + let minus: f64 = res.minus.unwrap_or_default(); + + Ok(plus - minus) +} + +pub async fn add_inventory_item(db: &SqlitePool, name: &str, reorder_point: f64, + allow_fractional_units: bool, display_unit_abbreviation: &str +) -> Result { + let res = sqlx::query!( + r#" + INSERT INTO InventoryItem (name, reorder_point, allow_fractional_units, display_unit) + VALUES (?, ?, ?, (SELECT id from DisplayUnit WHERE abbreviation = ? )) + "#, + name, reorder_point, allow_fractional_units, display_unit_abbreviation + ).execute(db).await?; + + let new_id = res.last_insert_rowid(); + + Ok(new_id) +} diff --git a/src/db/mod.rs b/src/db/mod.rs index d97d80b..7f25780 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,9 +1,13 @@ pub mod inventory_item; +pub mod positive_adjustment; +mod bootstrap_data; +pub mod user; use std::str::FromStr; use anyhow::Context; use sqlx::SqlitePool; use sqlx::sqlite::SqliteConnectOptions; +use crate::db::bootstrap_data::bootstrap_database; pub async fn init() -> anyhow::Result { let db_url = std::env::var("DATABASE_URL") @@ -12,6 +16,8 @@ pub async fn init() -> anyhow::Result { let db = connect_db(&db_url).await?; sqlx::migrate!().run(&db).await?; + + bootstrap_database(&db).await?; Ok(db) } diff --git a/src/db/positive_adjustment.rs b/src/db/positive_adjustment.rs new file mode 100644 index 0000000..8e6af3e --- /dev/null +++ b/src/db/positive_adjustment.rs @@ -0,0 +1,32 @@ +use serde::Serialize; +use sqlx::SqlitePool; +use anyhow::Result; + +#[derive(Debug, Serialize)] +#[derive(sqlx::FromRow)] +pub struct DbPositiveAdjustment { + pub id: i64, + pub item: i64, + pub user: i64, + pub create_date: String, + pub target_date: String, + pub amount: f64, + pub unit_price: i64, +} + +pub async fn add_positive_adjustment(db: &SqlitePool, item: i64, user: i64, + create_date: &str, target_date: &str, + amount: f64, unit_price: i64) + -> Result { + let res = sqlx::query!( + r#" + INSERT INTO PositiveAdjustment (item, user, create_date, target_date, amount, unit_price) + VALUES (?, ?, ?, ?, ?, ?) + "#, + item, user, create_date, target_date, amount, unit_price + ).execute(db).await?; + + let new_id = res.last_insert_rowid(); + + Ok(new_id) +} diff --git a/src/db/user.rs b/src/db/user.rs new file mode 100644 index 0000000..d7841ad --- /dev/null +++ b/src/db/user.rs @@ -0,0 +1,87 @@ +use anyhow::Result; +use serde::Serialize; +use sqlx::SqlitePool; + +#[derive(Debug, Serialize)] +#[derive(sqlx::FromRow)] +pub struct DbUser { + pub id: i64, + pub name: String, + pub role: i64, +} + +pub async fn get_user_by_name(db: &SqlitePool, user: &str) -> Result> { + sqlx::query_as!( + DbUser, + r#" + SELECT + id, + name, + role + FROM + User + WHERE + name = ? + "#, + user + ).fetch_optional(db).await + .map_err(From::from) +} + +pub async fn add_user(db: &SqlitePool, name: &str, role: DbUserRole) -> Result { + let role = role.value(); + let res = sqlx::query!( + r#" + INSERT INTO User(name, role) + VALUES (?, ?) + "#, + name, role + ).execute(db).await?; + + let new_id = res.last_insert_rowid(); + + Ok(new_id) +} + + +#[derive(Debug, Clone, Copy)] +pub enum DbUserRole { + Admin, + Editor, + Viewer, +} + + +#[allow(dead_code)] +impl DbUserRole { + pub fn value(&self) -> i64 { + match self { + DbUserRole::Admin => {1} + DbUserRole::Editor => {10} + DbUserRole::Viewer => {99} + } + } + + pub fn from_str(s: &str) -> Self { + Self::try_from_str(s).unwrap_or_else(|| DbUserRole::Viewer) + } + + pub fn try_from_str(s: &str) -> Option { + match s { + "admin" => Some(DbUserRole::Admin), + "editor" => Some(DbUserRole::Editor), + "viewer" => Some(DbUserRole::Viewer), + _ => None + } + } + + pub fn to_string(&self) -> String { + match self { + DbUserRole::Admin => { String::from("admin") } + DbUserRole::Editor => { String::from("editor") } + DbUserRole::Viewer => { String::from("viewer") } + } + } +} + + diff --git a/src/error.rs b/src/error.rs index f87f0d3..87ee4e1 100644 --- a/src/error.rs +++ b/src/error.rs @@ -6,7 +6,7 @@ use axum::Router; use axum::routing::get; use axum::extract::FromRequestParts; use crate::app::state::AppState; -use crate::auth::User; +use crate::session::SessionUser; // This module is all the stuff related to handling error responses @@ -122,7 +122,7 @@ fn always_fails() -> anyhow::Result<()> { } /// Handler that always responds with 403 Forbidden -async fn forbidden(user: User) -> impl IntoResponse { +async fn forbidden(user: SessionUser) -> impl IntoResponse { AppForbiddenResponse::new(&user.email, "test endpoint") } diff --git a/src/ingest.rs b/src/ingest.rs new file mode 100644 index 0000000..3e4e5b0 --- /dev/null +++ b/src/ingest.rs @@ -0,0 +1,49 @@ + +use anyhow::Result; +use axum::body::Bytes; +use sqlx::SqlitePool; +use time::format_description::well_known::Iso8601; +use tracing::info; +use crate::db::inventory_item::add_inventory_item; +use crate::db::positive_adjustment::add_positive_adjustment; + +#[derive(Debug, serde::Deserialize)] +struct CatalogRecord { + name: String, + #[serde(alias = "qty")] + quantity: f64, + unit: String, + fractional: bool, + #[serde(alias = "reorder")] + reorder_point: f64, + #[serde(alias = "price")] + unit_price: i64, +} + +pub async fn ingest_catalog_bytes(bytes: Bytes, db: SqlitePool, user_id: i64) -> Result<()> { + let reader = csv::Reader::from_reader(bytes.as_ref()); + + ingest_catalog(reader, db, user_id).await +} + +pub async fn ingest_catalog(mut reader: csv::Reader, db: SqlitePool, user_id: i64) -> Result<()> +{ + //TODO Is this how we want to do dates? + let timestamp = time::OffsetDateTime::now_utc().format(&Iso8601::DEFAULT)?; + + for result in reader.deserialize() { + let record: CatalogRecord = result?; + + let new_entry_id = add_inventory_item(&db, + &record.name, + record.reorder_point, + record.fractional, + &record.unit).await?; + + let new_positive_adjustment = add_positive_adjustment(&db, new_entry_id, + user_id, ×tamp, ×tamp, record.quantity, record.unit_price).await?; + + info!("Added new item: {}/{} - {}", new_entry_id, new_positive_adjustment, record.name); + } + Ok(()) +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 8cd0a07..c855dc3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,7 @@ mod session; mod auth; mod static_routes; mod app; +mod ingest; //NOTE TO FUTURE ME: I'm leaving a bunch of notes about these things as part of the learning // process. There is a lot of implementation details that are obscured by all these pieces, and it // can be hard to tell how heavy a line is. Lots of comment in this file and some of the kind of diff --git a/src/session.rs b/src/session.rs index e60b0c1..7d692c3 100644 --- a/src/session.rs +++ b/src/session.rs @@ -1,10 +1,18 @@ -use tower_sessions_sqlx_store::SqliteStore; -use tower_sessions::{ExpiredDeletion, Expiry, SessionManagerLayer}; -use tower_sessions::cookie::SameSite; +use crate::db; +use crate::error::AppError; +use anyhow::{anyhow, Context, Result}; +use askama_axum::{IntoResponse, Response}; +use axum::async_trait; +use axum::extract::FromRequestParts; +use axum::http::request::Parts; +use axum::response::Redirect; +use serde::{Deserialize, Serialize}; +use std::result; use time::Duration; -use anyhow::{Context, Result}; use tokio::task::JoinHandle; -use crate::db; +use tower_sessions::cookie::SameSite; +use tower_sessions::{ExpiredDeletion, Expiry, Session, SessionManagerLayer}; +use tower_sessions_sqlx_store::SqliteStore; pub async fn init() -> Result<(SessionManagerLayer, JoinHandle>)> { @@ -42,3 +50,54 @@ async fn deletion_task(session_store: SqliteStore) -> Result<()> { .await .context("delete expired task failed") } + +pub const USER_SESSION: &str = "user"; + +/// User information that will be return from the OAUTH authority +#[derive(Debug, Serialize, Deserialize)] +pub struct SessionUser { + pub id: i64, + pub role: i64, + pub oauth_id: String, + pub email: String, + pub name: String, + pub verified_email: bool, + pub picture: String, +} + +/// A custom error for the User extractor +pub enum UserExtractError { + InternalServerError(anyhow::Error), + Unauthorized, +} + +impl IntoResponse for UserExtractError { + fn into_response(self) -> Response { + match self { + UserExtractError::InternalServerError(err) => AppError::from(err).into_response(), + UserExtractError::Unauthorized => { Redirect::temporary("/auth/login").into_response() } + } + } +} + +/// The user extractor is used to pull out the user data from the session. This can be used +/// as a guard to ensure that a user session exists. Basically an authentication +/// (but not authorization) guard +#[async_trait] +impl FromRequestParts for SessionUser +where + S: Send + Sync, +{ + type Rejection = UserExtractError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> result::Result { + let session = Session::from_request_parts(parts, state).await + .map_err(|_| UserExtractError::InternalServerError(anyhow!("session from parts failed")))?; + + let user = session.get(USER_SESSION).await + .map_err(|e| UserExtractError::InternalServerError(anyhow::Error::from(e)))? + .ok_or(UserExtractError::Unauthorized)?; + + Ok(user) + } +} \ No newline at end of file diff --git a/templates/item_list.html b/templates/item_list.html index c197a5b..c28b53f 100644 --- a/templates/item_list.html +++ b/templates/item_list.html @@ -19,4 +19,13 @@ {% include "item_list_fragment.html" %} +
+ + + +
+ {% endblock %} \ No newline at end of file diff --git a/templates/item_list_fragment.html b/templates/item_list_fragment.html index f853b72..5c55f9b 100644 --- a/templates/item_list_fragment.html +++ b/templates/item_list_fragment.html @@ -1,8 +1,9 @@ {% for item in items %} -
+ diff --git a/templates/main.html b/templates/main.html index ffd4be0..0aa9f8f 100644 --- a/templates/main.html +++ b/templates/main.html @@ -8,7 +8,7 @@ {% block title %}Title{% endblock %} - +