User verification through db and easier test data

demo-mode
Wes Holland 1 year ago
parent 949b6b0238
commit cf65324827

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="inventory-app.db" uuid="a0dc3bc5-119e-4708-b51f-2c3820c30deb">
<driver-ref>sqlite.xerial</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
<jdbc-url>jdbc:sqlite:inventory-app.db</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SqlDialectMappings">
<file url="file://$PROJECT_DIR$/migrations/20241107225934_initial.sql" dialect="SQLite" />
<file url="PROJECT" dialect="SQLite" />
</component>
</project>

53
Cargo.lock generated

@ -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"

@ -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"

@ -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
1 name qty unit fractional reorder price
2 Amoxicillin/Clavulanate 62.5mg/ml 15ml 1 ct false 10 25
3 Animax Ointment 15ml 1 ct false 10 25
4 Buprenex 0.3mg/ml - Injectable 1 ct false 10 25
5 Buprenex 0.3mg/ml Oral Solution 28 ct false 10 25
6 Carprofen 100mg 168 ct false 10 25
7 Carprofen 25mg 232 ct false 10 25
8 Carprofen 75mg 80 ct false 10 25
9 Cephalexin 250mg 605 ct false 10 25
10 Cephalexin 500mg 513 ct false 10 25
11 Cerenia 10mg/ml (Per ml) 12 ml true 10 25
12 Cough Tabs 325 ct false 10 25
13 Cytopoint 10mg 2 ct false 10 25
14 Cytopoint 20mg 0 ct false 10 25
15 Cytopoint 30mg 4 ct false 10 25
16 Cytopoint 40mg 6 ct false 10 25
17 Dexamethasone 2mg/ml Injectable 99 ct false 10 25
18 Dog Ends Starter Kit 1 ct false 10 25
19 Doxycycline 100mg Tablet 104 ct false 10 25
20 Elura 20mg/ml - 15ml 1 ct false 10 25
21 Entyce 30mg/ml - 10ml bottle 1 ct false 10 25
22 Fenbendazole 100mg/ml 900 ct false 10 25
23 Florfenicol, Terbinafine, Mometasone furoate Ear Treament 1ml Tube 68 ct false 10 25
24 FortiFlora 30ct - Cat 2 ct false 10 25
25 FortiFlora SA K9 3 ct false 10 25
26 FVRCP TruFel Ultra Injectable 9 ct false 10 25
27 Gabapentin 100mg 479 ct false 10 25
28 Gabapentin 600mg Tablets 503 ct false 10 25
29 Gentamicin Sulfate/Betamethasone Spray 60ml 0 ct false 10 25
30 Kenalog 10mg/ml (Per ml) 8.52 ml true 10 25
31 Librela 10mg/ml 1 ct false 10 25
32 Librela 20mg/ml 20 ct false 10 25
33 Librela 30mg/ml 25 ct false 10 25
34 Librela 5mg/ml 3 ct false 10 25
35 Maropitant 16mg 10 ct false 10 25
36 Maropitant 24mg 8 ct false 10 25
37 Maropitant 60mg 16 ct false 10 25
38 Meloxicam 1.5mg/ml - 10ml 2 ct false 10 25
39 Metronidazole 125mg/ml - 30ML 0 ct false 10 25
40 Metronidazole 250mg 15 ct false 10 25
41 Mirataz 5g 3 ct false 10 25
42 NeoPolyBac Ointment 1 ct false 10 25
43 Nobivac DAPP 64 ct false 10 25
44 Nobivac Intra-Trac Bordetella 68 ct false 10 25
45 Nobivac Lepto 4 42 ct false 10 25
46 Nobivac Rabies 96 ct false 10 25
47 Ondansetron 8mg 16 ct false 10 25
48 Praziquantel 56.8 mg/ml Injection 8 ct false 10 25
49 Prednisolone 5mg 913 ct false 10 25
50 Prednisone 20mg 259 ct false 10 25
51 Pro-Pectalin 15ml 2 ct false 10 25
52 Probiotic Powder Canine - 30ct 1 ct false 10 25
53 PureVax Feline Rabies 25 ct false 10 25
54 PureVax Recombinant FeLV 13 ct false 10 25
55 Rilexine 150mg 12 ct false 10 25
56 Rilexine 300mg 34 ct false 10 25
57 Tobramycin Ophthalmic Sol 5ml 3 ct false 10 25
58 Trazodone 100mg 257 ct false 10 25
59 Varenzin-CA1 25mg/ml 2 ct false 10 25

@ -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)]"

Binary file not shown.

@ -3,34 +3,51 @@
CREATE TABLE IF NOT EXISTS User (
id INTEGER PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
role TEXT NOT NULL,
CHECK(role = 'admin' OR role = 'editor' OR role = 'viewer')
role INTEGER NOT NULL,
FOREIGN KEY(role) REFERENCES UserRole(id)
);
CREATE TABLE IF NOT EXISTS UserRole (
id INTEGER PRIMARY KEY NOT NULL,
name TEXT NOT NULL UNIQUE
);
INSERT INTO UserRole (id, name) VALUES
(1,'admin'),
(10,'edit'),
(99,'view');
CREATE TABLE IF NOT EXISTS InventoryItem (
id INTEGER PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
reorder_point INTEGER NOT NULL
display_unit INTEGER NOT NULL DEFAULT 1,
reorder_point REAL NOT NULL,
allow_fractional_units BOOLEAN NOT NULL,
FOREIGN KEY(display_unit) REFERENCES DisplayUnit(id)
);
CREATE TABLE IF NOT EXISTS PositiveAdjustment (
id INTEGER PRIMARY KEY NOT NULL,
item INTEGER NOT NULL,
user INTEGER NOT NULL,
create_date TIMESTAMP NOT NULL,
target_date TIMESTAMP NOT NULL,
amount INTEGER NOT NULL,
amount REAL NOT NULL,
unit_price INTEGER NOT NULL,
FOREIGN KEY(user) REFERENCES User(id)
FOREIGN KEY(user) REFERENCES User(id),
FOREIGN KEY(item) REFERENCES InventoryItem(id)
);
CREATE TABLE IF NOT EXISTS NegativeAdjustment (
id INTEGER PRIMARY KEY NOT NULL,
item INTEGER NOT NULL,
user INTEGER NOT NULL,
create_date TIMESTAMP NOT NULL,
target_date TIMESTAMP NOT NULL,
amount INTEGER NOT NULL,
amount REAL NOT NULL,
reason INTEGER NOT NULL,
FOREIGN KEY(user) REFERENCES User(id),
FOREIGN KEY(item) REFERENCES InventoryItem(id),
FOREIGN KEY(reason) REFERENCES NegativeAdjustmentReason(id)
);
@ -39,3 +56,18 @@ CREATE TABLE IF NOT EXISTS NegativeAdjustmentReason (
name TEXT NOT NULL
);
INSERT INTO NegativeAdjustmentReason (id, name) VALUES
(10,'Sale'),
(20,'Loss'),
(25,'Expired');
CREATE TABLE IF NOT EXISTS DisplayUnit (
id INTEGER PRIMARY KEY NOT NULL,
unit TEXT NOT NULL,
abbreviation TEXT NOT NULL
);
INSERT INTO DisplayUnit (id, unit, abbreviation) VALUES
(1,'count', 'ct'),
(2,'milliliter', 'ml'),
(3,'milligram', 'mg');

@ -5,7 +5,7 @@ use sqlx::SqlitePool;
use axum::response::Response;
use axum_htmx::HxRequest;
use serde::Deserialize;
use crate::db::inventory_item::{inventory_item_get_all, inventory_item_get_by_id, inventory_item_get_search, DbInventoryItem};
use crate::db::inventory_item::{inventory_item_get_all, inventory_item_get_by_id, inventory_item_get_search, sum_all_adjustments_for_item, DbInventoryItem};
use crate::error::{AppError, QueryExtractor};
#[derive(Template)]
@ -65,3 +65,10 @@ pub async fn item(State(db): State<SqlitePool>, Path(id): Path<i64>) -> Result<R
Ok(ItemTemplate { item }.into_response())
}
pub async fn item_count(State(db): State<SqlitePool>, Path(id): Path<i64>) -> Result<Response, AppError> {
let count = sum_all_adjustments_for_item(&db, id).await?;
Ok(count.to_string().into_response())
}

@ -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<AppState> {
Router::new()
@ -16,9 +17,11 @@ pub fn routes() -> Router<AppState> {
.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::<User>())
.route_layer(from_extractor::<SessionUser>())
.layer(AutoVaryLayer)
}

@ -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<SqlitePool>,
user: SessionUser,
mut multipart: Multipart,
) -> Result<Response, AppError> {
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())
}

@ -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<AppState> {
Router::new()
@ -56,10 +56,19 @@ pub fn init_client() -> anyhow::Result<BasicClient> {
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>,
user: Option<SessionUser>,
State(oauth_client): State<BasicClient>,
) -> anyhow::Result<impl IntoResponse, AppError> {
@ -88,6 +97,7 @@ pub async fn auth_authorized(
session: Session,
QueryExtractor(query_auth): QueryExtractor<AuthRequest>,
State(oauth_client): State<BasicClient>,
State(db): State<SqlitePool>,
) -> anyhow::Result<impl IntoResponse, AppError> {
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::<User>()
let oauth_user_data = oauth_user_data
.json::<OAuthUser>()
.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::<Vec<&str>>();
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>,
user: Option<SessionUser>,
) -> anyhow::Result<impl IntoResponse, AppError> {
// Logging out is as simple as clearing the user session
if user.is_some() {
session.remove::<User>(USER_SESSION).await?;
session.remove::<SessionUser>(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<S> FromRequestParts<S> for User
where
S: Send + Sync,
{
type Rejection = UserExtractError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
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)
}
}

@ -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<BootstrapUser>,
}
#[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::<BootstrapData>(&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(())
}

@ -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<DbInve
DbInventoryItem,
r#"
SELECT
id, name, reorder_point
id,
name,
reorder_point,
allow_fractional_units,
display_unit
FROM
InventoryItem
WHERE id = ?
WHERE InventoryItem.id = ?
"#,
id
)
.fetch_one(db).await
.map_err(From::from)
}
pub async fn sum_all_adjustments_for_item(db: &SqlitePool, id: i64) -> Result<f64> {
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<i64> {
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)
}

@ -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<SqlitePool> {
let db_url = std::env::var("DATABASE_URL")
@ -13,6 +17,8 @@ pub async fn init() -> anyhow::Result<SqlitePool> {
sqlx::migrate!().run(&db).await?;
bootstrap_database(&db).await?;
Ok(db)
}

@ -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<i64> {
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)
}

@ -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<Option<DbUser>> {
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<i64> {
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<Self> {
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") }
}
}
}

@ -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")
}

@ -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<T: std::io::Read>(mut reader: csv::Reader<T>, 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, &timestamp, &timestamp, record.quantity, record.unit_price).await?;
info!("Added new item: {}/{} - {}", new_entry_id, new_positive_adjustment, record.name);
}
Ok(())
}

@ -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

@ -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<SqliteStore>, JoinHandle<Result<()>>)> {
@ -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<S> FromRequestParts<S> for SessionUser
where
S: Send + Sync,
{
type Rejection = UserExtractError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> result::Result<Self, Self::Rejection> {
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)
}
}

@ -19,4 +19,13 @@
{% include "item_list_fragment.html" %}
</div>
<form action="/catalog" method="post" enctype="multipart/form-data">
<label>
Upload file:
<input type="file" name="file" multiple>
</label>
<input type="submit" value="Upload files">
</form>
{% endblock %}

@ -1,8 +1,9 @@
{% for item in items %}
<article>
<article id="item-{{item.id}}-card">
<div class="grid">
<div><a href="/item/{{item.id}}/">{{ item.name }}</a></div>
<div>Count: <span id="item-{{item.id}}-count" hx-get="/item/{{item.id}}/count" hx-trigger="load">0</span></div>
<div>Reorder Point: {{ item.reorder_point }}</div>
</div>
</article>

@ -8,7 +8,7 @@
<script src="/js/htmx.min.js"></script>
<title>{% block title %}Title{% endblock %}</title>
</head>
<body>
<body hx-boost="true">
<header class="container">
<nav class="container">
<ul>

Loading…
Cancel
Save

Powered by TurnKey Linux.