init
This commit is contained in:
commit
70a93aeb0b
79 changed files with 9396 additions and 0 deletions
1
.env
Normal file
1
.env
Normal file
|
@ -0,0 +1 @@
|
|||
DATABASE_URL=sqlite://data/development.db
|
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
data
|
||||
target
|
||||
config-dev.yaml
|
||||
config-prod.yaml
|
2698
Cargo.lock
generated
Normal file
2698
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
52
Cargo.toml
Normal file
52
Cargo.toml
Normal file
|
@ -0,0 +1,52 @@
|
|||
[package]
|
||||
name = "yoake"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
authors = ["Yumechi <yume@yumechi.jp>"]
|
||||
description = "Yoake is Yumechi's Personal Information Management System."
|
||||
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
aes-gcm = "0.10.2"
|
||||
anyhow = "1.0.71"
|
||||
argon2 = "0.5.0"
|
||||
askama = { version = "0.12.0" }
|
||||
async-trait = "0.1.68"
|
||||
axum = { version = "0.6.18", features = ["http2"] }
|
||||
axum-server = { version = "0.5.1", features = ["rustls", "rustls-pemfile", "tls-rustls"] }
|
||||
base64 = "0.21.2"
|
||||
chrono = { version = "0.4.26", features = ["serde"] }
|
||||
clap = { version = "4.3.2", features = ["derive"] }
|
||||
diesel = { version = "2.1.0", features = ["sqlite", "chrono"] }
|
||||
diesel_migrations = { version = "2.1.0", features = ["sqlite"] }
|
||||
flate2 = "1.0.26"
|
||||
hyper = "0.14.26"
|
||||
lazy_static = "1.4.0"
|
||||
lettre = "0.10.4"
|
||||
log = "0.4.18"
|
||||
rand_core = { version = "0.6.4", features = ["getrandom"] }
|
||||
regex = "1.8.4"
|
||||
reqwest = { version = "0.11.18", features = ["json", "blocking"] }
|
||||
rust-embed = "6.7.0"
|
||||
rustls-pemfile = "1.0.2"
|
||||
serde = { version = "1.0.163", features = ["derive"] }
|
||||
serde_json = "1.0.96"
|
||||
serde_yaml = "0.9.21"
|
||||
simple_logger = "4.1.0"
|
||||
tempfile = "3.6.0"
|
||||
thirtyfour = "0.31.0"
|
||||
tokio = { version = "1.28.2", features = ["macros", "rt-multi-thread", "time"] }
|
||||
tokio-rustls = "0.24.0"
|
||||
tower = { version = "0.4.13", features = ["limit", "timeout", "buffer"] }
|
||||
tower-http = { version = "0.4.0", features = ["timeout", "limit", "cors"] }
|
||||
uuid = { version = "1.3.3", features = ["v4"] }
|
||||
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
|
||||
[[bin]]
|
||||
name = "yoake_server"
|
||||
path = "src/bin/server.rs"
|
45
config.yaml
Normal file
45
config.yaml
Normal file
|
@ -0,0 +1,45 @@
|
|||
|
||||
listen:
|
||||
addr: 127.0.0.1:3000
|
||||
|
||||
db:
|
||||
url: sqlite://data/development.db
|
||||
|
||||
canvas_lms:
|
||||
token: "1017~xxxxxxxxxxxx"
|
||||
endpoint: "https://xxx.instructure.com/api/graphql"
|
||||
refresh_interval: 300
|
||||
|
||||
comm:
|
||||
gotify:
|
||||
url: https://gotify.yumechi.jp
|
||||
token: Axxxxxx
|
||||
email:
|
||||
from: "Yoake <yoake@yumechi.jp>"
|
||||
to: "yoake@yumechi.jp"
|
||||
host: "smtp.example.com"
|
||||
port: 587
|
||||
username: "yume@yumechi.jp"
|
||||
password: "xxxx"
|
||||
default_subject: "[ohime]お姫様のご注意"
|
||||
|
||||
auth:
|
||||
users:
|
||||
yume:
|
||||
roles:
|
||||
- Admin
|
||||
- User
|
||||
password: $argon2id$v=19$m=19456,t=2,p=1$U7zg/pa1Wf9Hi9NM+ns9aA$tivXyIMw+wo9ZZoz0I+6yLm7+1SfkW9fF5hONy/qq1Y
|
||||
test:
|
||||
roles:
|
||||
- User
|
||||
password: $argon2id$v=19$m=19456,t=2,p=1$U7zg/pa1Wf9Hi9NM+ns9aA$tivXyIMw+wo9ZZoz0I+6yLm7+1SfkW9fF5hONy/qq1Y
|
||||
|
||||
session:
|
||||
secret: I3fEE8L65Ldk+BrjwPoQQu+skB7k3PlDg5uVx8EB
|
||||
|
||||
webcheck:
|
||||
utd_app:
|
||||
username: user
|
||||
password: xxxx
|
||||
interval: 600
|
9
diesel.toml
Normal file
9
diesel.toml
Normal file
|
@ -0,0 +1,9 @@
|
|||
# For documentation on how to configure this file,
|
||||
# see https://diesel.rs/guides/configuring-diesel-cli
|
||||
|
||||
[print_schema]
|
||||
file = "src/schema.rs"
|
||||
custom_type_derives = ["diesel::query_builder::QueryId"]
|
||||
|
||||
[migrations_directory]
|
||||
dir = "migrations"
|
0
migrations/.keep
Normal file
0
migrations/.keep
Normal file
2
migrations/2023-06-07-213214_session_store/down.sql
Normal file
2
migrations/2023-06-07-213214_session_store/down.sql
Normal file
|
@ -0,0 +1,2 @@
|
|||
-- This file should undo anything in `up.sql`
|
||||
drop table sessions;
|
6
migrations/2023-06-07-213214_session_store/up.sql
Normal file
6
migrations/2023-06-07-213214_session_store/up.sql
Normal file
|
@ -0,0 +1,6 @@
|
|||
-- Your SQL goes here
|
||||
create table sessions (
|
||||
uuid text primary key not null,
|
||||
expiry datetime not null,
|
||||
content text not null
|
||||
);
|
3
migrations/2023-06-13-192016_med_management/down.sql
Normal file
3
migrations/2023-06-13-192016_med_management/down.sql
Normal file
|
@ -0,0 +1,3 @@
|
|||
-- This file should undo anything in `up.sql`
|
||||
drop table medications;
|
||||
drop table medication_logs;
|
27
migrations/2023-06-13-192016_med_management/up.sql
Normal file
27
migrations/2023-06-13-192016_med_management/up.sql
Normal file
|
@ -0,0 +1,27 @@
|
|||
-- Your SQL goes here
|
||||
create table medications (
|
||||
uuid text primary key not null,
|
||||
name text not null,
|
||||
dosage integer not null,
|
||||
dosage_unit text not null,
|
||||
period_hours integer not null,
|
||||
flags text not null,
|
||||
options text not null,
|
||||
|
||||
created datetime not null,
|
||||
updated datetime not null
|
||||
);
|
||||
|
||||
create table medication_logs (
|
||||
uuid text primary key not null,
|
||||
med_uuid text not null,
|
||||
dosage integer not null,
|
||||
time_actual datetime not null,
|
||||
time_expected datetime not null,
|
||||
dose_offset real not null,
|
||||
|
||||
created datetime not null,
|
||||
updated datetime not null,
|
||||
|
||||
FOREIGN KEY(med_uuid) REFERENCES medications(uuid)
|
||||
)
|
25
scripts/deploy.fish
Executable file
25
scripts/deploy.fish
Executable file
|
@ -0,0 +1,25 @@
|
|||
#!/usr/bin/env fish
|
||||
|
||||
echo "Building UI..."
|
||||
cd ui && yarn build && cd .. \
|
||||
|| exit 1
|
||||
|
||||
echo "Building server..."
|
||||
cargo build --release \
|
||||
|| exit 1
|
||||
|
||||
echo "Copying files..."
|
||||
scp target/release/yoake_server config-prod.yaml yoake: \
|
||||
|| exit 1
|
||||
|
||||
echo "Deploying..."
|
||||
ssh yoake "
|
||||
echo 'Stopping server...'
|
||||
sudo systemctl stop yoake-server
|
||||
sudo mv yoake_server /var/lib/caddy/yoake/
|
||||
sudo mv config-prod.yaml /var/lib/caddy/yoake/config.yaml
|
||||
sudo chown -R caddy:caddy /var/lib/caddy/yoake/
|
||||
echo 'Starting server...'
|
||||
sudo systemctl start yoake-server
|
||||
" \
|
||||
|| exit 1
|
16
scripts/dev.fish
Executable file
16
scripts/dev.fish
Executable file
|
@ -0,0 +1,16 @@
|
|||
#!/usr/bin/env fish
|
||||
|
||||
echo "Starting Server..."
|
||||
|
||||
trap "echo 'Stopping Server...'; kill (cat .server_pid) && rm .server_pid; exit 0" SIGINT SIGTERM
|
||||
|
||||
while true
|
||||
|
||||
if [ -f .server_pid ]
|
||||
kill (cat .server_pid)
|
||||
end
|
||||
|
||||
cargo run --bin yoake_server -- -c config-dev.yaml & echo $last_pid > .server_pid
|
||||
inotifywait -e modify -e move -e create -e delete -r src \
|
||||
&& kill (cat .server_pid)
|
||||
end
|
115
src/apps/auth/middleware.rs
Normal file
115
src/apps/auth/middleware.rs
Normal file
|
@ -0,0 +1,115 @@
|
|||
use std::convert::Infallible;
|
||||
|
||||
use axum::{async_trait, extract::FromRequestParts};
|
||||
use hyper::http::request::Parts;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{http::ApiResponse, session::SessionStore};
|
||||
|
||||
use super::Role;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuthInfo {
|
||||
valid: bool,
|
||||
user: String,
|
||||
display_name: String,
|
||||
roles: Vec<Role>,
|
||||
}
|
||||
|
||||
impl AuthInfo {
|
||||
pub fn is_valid(&self) -> bool {
|
||||
self.valid
|
||||
}
|
||||
pub fn user(&self) -> &str {
|
||||
&self.user
|
||||
}
|
||||
pub fn display_name(&self) -> &str {
|
||||
&self.display_name
|
||||
}
|
||||
pub fn roles(&self) -> &[Role] {
|
||||
&self.roles
|
||||
}
|
||||
pub fn has_any_role(&self, roles: &[Role]) -> bool {
|
||||
for role in roles {
|
||||
if self.roles.contains(role) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
pub fn check_for_any_role(&self, roles: &[Role]) -> Result<(), ApiResponse<()>> {
|
||||
if self.has_any_role(roles) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ApiResponse::unauthorized("Unauthorized".to_string(), None))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AuthInfo {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
valid: false,
|
||||
user: String::new(),
|
||||
display_name: "anonymous".to_string(),
|
||||
roles: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl<S> FromRequestParts<S> for AuthInfo
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = Infallible;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||
let reject = AuthInfo::default();
|
||||
|
||||
let Some(session) = parts.extensions.get::<SessionStore>() else {
|
||||
return Ok(reject);
|
||||
};
|
||||
|
||||
if session.auth.user.is_empty() {
|
||||
return Ok(reject);
|
||||
}
|
||||
let now = chrono::Utc::now();
|
||||
if session.auth.expire < now {
|
||||
return Ok(reject);
|
||||
}
|
||||
|
||||
let mut res = AuthInfo::default();
|
||||
res.valid = true;
|
||||
res.user = session.auth.user.clone();
|
||||
res.display_name = session.auth.user.clone();
|
||||
res.roles = session.auth.roles.clone();
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
#[macro_export]
|
||||
macro_rules! require_role {
|
||||
($auth:ident, $roles:expr) => {
|
||||
if !$auth.has_any_role(&[$roles]) {
|
||||
return ApiResponse::<()>::error(
|
||||
format!("You do not have permission to access this resource. Acceptable role: {:?}, you have: {:?}", $roles, $auth.roles()),
|
||||
403,
|
||||
None,
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
($auth:ident, [$($roles:expr),*]) => {
|
||||
if !$auth.has_any_role(&[$($roles),*]) {
|
||||
return ApiResponse::<()>::error(
|
||||
format!("You do not have permission to access this resource. Acceptable roles: {:?}, you have: {:?}", [$($roles),*], $auth.roles()),
|
||||
403,
|
||||
None,
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
}
|
||||
*/
|
170
src/apps/auth/mod.rs
Normal file
170
src/apps/auth/mod.rs
Normal file
|
@ -0,0 +1,170 @@
|
|||
use std::{
|
||||
future::Future,
|
||||
pin::Pin,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use axum::{
|
||||
body::HttpBody,
|
||||
error_handling::HandleErrorLayer,
|
||||
http::Request,
|
||||
response::{IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
BoxError, Extension, Router,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tower::ServiceBuilder;
|
||||
|
||||
use crate::{
|
||||
apps::App,
|
||||
config::Config,
|
||||
http::{ApiResponse, JsonApiForm},
|
||||
session::SessionStore,
|
||||
AppState,
|
||||
};
|
||||
|
||||
use self::middleware::AuthInfo;
|
||||
|
||||
pub mod middleware;
|
||||
mod password;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct SessionAuth {
|
||||
pub user: String,
|
||||
pub expire: DateTime<Utc>,
|
||||
pub roles: Vec<Role>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub enum Role {
|
||||
Admin,
|
||||
User,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl From<&str> for Role {
|
||||
fn from(s: &str) -> Self {
|
||||
match s {
|
||||
"Admin" => Self::Admin,
|
||||
"User" => Self::User,
|
||||
_ => Self::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LoginForm {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
pub async fn route_login(
|
||||
app: Extension<Arc<AuthApp>>,
|
||||
Extension(mut session_store): Extension<SessionStore>,
|
||||
JsonApiForm(form): JsonApiForm<LoginForm>,
|
||||
) -> Result<(Extension<SessionStore>, ApiResponse<()>), ApiResponse<()>> {
|
||||
let failed_response = ApiResponse::<()>::error("Invalid credentials".to_string(), 401, None);
|
||||
|
||||
let state = app.state.lock().unwrap();
|
||||
let state = state.as_ref().unwrap();
|
||||
|
||||
let Some(user) = state.config.auth.users.get(&form.username) else {
|
||||
const DUMMY_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$U7zg/pa1Wf9Hi9NM+ns9aA$tivXyIMw+wo9ZZoz0I+6yLm7+1SfkW9fF5hONy/qq1Y";
|
||||
password::verify_password(DUMMY_HASH, form.password.as_str());
|
||||
return Err(failed_response);
|
||||
};
|
||||
|
||||
let hash = user.password.as_str();
|
||||
if !password::verify_password(hash, form.password.as_str()) {
|
||||
return Err(failed_response);
|
||||
}
|
||||
|
||||
session_store.auth.user = form.username.to_string();
|
||||
session_store.auth.expire = Utc::now() + chrono::Duration::days(7);
|
||||
session_store.auth.roles = user.roles.iter().map(|r| r.as_str().into()).collect();
|
||||
|
||||
Ok((
|
||||
Extension(session_store),
|
||||
ApiResponse::ok("Login successful".to_string(), None),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn route_logout(
|
||||
Extension(mut session_store): Extension<SessionStore>,
|
||||
) -> Result<(Extension<SessionStore>, ApiResponse<()>), ApiResponse<()>> {
|
||||
session_store.auth.user = String::new();
|
||||
|
||||
Ok((
|
||||
Extension(session_store),
|
||||
ApiResponse::ok("Logout successful".to_string(), None),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn route_self<B: HttpBody>(auth: AuthInfo, _req: Request<B>) -> Response
|
||||
where
|
||||
<B as HttpBody>::Error: std::fmt::Debug,
|
||||
{
|
||||
ApiResponse::ok("".to_string(), Some(auth)).into_response()
|
||||
}
|
||||
|
||||
pub struct AuthApp {
|
||||
state: Mutex<Option<AuthAppState>>,
|
||||
}
|
||||
|
||||
pub struct AuthAppState {
|
||||
config: &'static Config,
|
||||
_app_state: Arc<Mutex<AppState>>,
|
||||
}
|
||||
|
||||
impl AuthApp {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl App for AuthApp {
|
||||
fn initialize(
|
||||
self: Arc<Self>,
|
||||
config: &'static Config,
|
||||
app_state: Arc<Mutex<AppState>>,
|
||||
) -> Pin<Box<dyn Future<Output = ()>>> {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
*state = Some(AuthAppState {
|
||||
config,
|
||||
_app_state: app_state,
|
||||
});
|
||||
|
||||
Box::pin(async {})
|
||||
}
|
||||
|
||||
fn api_routes(self: Arc<Self>) -> Router {
|
||||
let rate_limiter = tower::limit::RateLimitLayer::new(1, std::time::Duration::from_secs(1));
|
||||
Router::new()
|
||||
.route(
|
||||
"/auth/hash_password",
|
||||
post(password::route_hash_password)
|
||||
// rate limit
|
||||
.layer(
|
||||
ServiceBuilder::new()
|
||||
.layer(HandleErrorLayer::new(|err: BoxError| async move {
|
||||
log::error!("Error: {:?}", err);
|
||||
(
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Internal Server Error".to_string(),
|
||||
)
|
||||
}))
|
||||
.buffer(64)
|
||||
.concurrency_limit(1)
|
||||
.rate_limit(1, std::time::Duration::from_secs(1))
|
||||
.layer(rate_limiter),
|
||||
),
|
||||
)
|
||||
.route("/auth/logout", post(route_logout))
|
||||
.route("/auth/login", post(route_login))
|
||||
.route("/auth/self", get(route_self))
|
||||
.layer(Extension(self.clone()))
|
||||
}
|
||||
}
|
54
src/apps/auth/password.rs
Normal file
54
src/apps/auth/password.rs
Normal file
|
@ -0,0 +1,54 @@
|
|||
use argon2::{
|
||||
password_hash::{rand_core::OsRng, SaltString},
|
||||
Argon2, PasswordHasher, PasswordVerifier,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::http::{ApiResponse, ApiResult, JsonApiForm};
|
||||
|
||||
pub fn hash_password(password: &str) -> String {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
|
||||
let argon2 = Argon2::default();
|
||||
argon2
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.unwrap()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub fn verify_password(hash: &str, password: &str) -> bool {
|
||||
let argon2 = Argon2::default();
|
||||
let hash = argon2::PasswordHash::new(hash).unwrap();
|
||||
argon2.verify_password(password.as_bytes(), &hash).is_ok()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HashPasswordForm {
|
||||
password: String,
|
||||
}
|
||||
|
||||
pub async fn route_hash_password(
|
||||
JsonApiForm(form): JsonApiForm<HashPasswordForm>,
|
||||
) -> ApiResult<String> {
|
||||
let hash = hash_password(&form.password);
|
||||
|
||||
Ok(ApiResponse::ok("".to_string(), Some(hash)))
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_hash_password() {
|
||||
let password = "password";
|
||||
let hash = hash_password(password);
|
||||
assert!(verify_password(&hash, password));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hash_invalid_password() {
|
||||
let password = "password";
|
||||
let hash = hash_password(password);
|
||||
assert!(!verify_password(&hash, "invalid"));
|
||||
}
|
||||
}
|
71
src/apps/canvas_lms/comm.rs
Normal file
71
src/apps/canvas_lms/comm.rs
Normal file
|
@ -0,0 +1,71 @@
|
|||
use askama::Template;
|
||||
use chrono::{Local, TimeZone};
|
||||
|
||||
use super::grading::Grading;
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "grades.html")]
|
||||
pub struct GradesTemplate<'a> {
|
||||
pub grades: &'a [TemplateGrade],
|
||||
}
|
||||
|
||||
pub struct TemplateGrade {
|
||||
pub submission_username: String,
|
||||
pub course_code: String,
|
||||
pub assignment_url: String,
|
||||
pub name: String,
|
||||
pub due: String,
|
||||
pub grade: String,
|
||||
pub graded_at: String,
|
||||
pub posted_at: String,
|
||||
}
|
||||
|
||||
impl From<&Grading> for TemplateGrade {
|
||||
fn from(grading: &Grading) -> Self {
|
||||
Self {
|
||||
submission_username: grading.submission_username.clone(),
|
||||
course_code: grading.course_code.clone(),
|
||||
assignment_url: grading.assignment_url.clone(),
|
||||
name: grading.name.clone(),
|
||||
due: grading
|
||||
.due_at
|
||||
.map(|d| {
|
||||
Local
|
||||
.from_utc_datetime(&d.naive_utc())
|
||||
.format("%Y-%m-%d %H:%M")
|
||||
.to_string()
|
||||
})
|
||||
.unwrap_or_else(|| "N/A".to_string()),
|
||||
grade: if grading.grade_hidden {
|
||||
"Hidden".to_string()
|
||||
} else if grading.score.is_none() {
|
||||
"Not Graded".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"{:.2} ({})/ {:.2}",
|
||||
grading.score.unwrap(),
|
||||
grading.grade,
|
||||
grading.possible_points
|
||||
)
|
||||
},
|
||||
graded_at: grading
|
||||
.graded_at
|
||||
.map(|d| {
|
||||
Local
|
||||
.from_utc_datetime(&d.naive_utc())
|
||||
.format("%Y-%m-%d %H:%M")
|
||||
.to_string()
|
||||
})
|
||||
.unwrap_or_else(|| "N/A".to_string()),
|
||||
posted_at: grading
|
||||
.posted_at
|
||||
.map(|d| {
|
||||
Local
|
||||
.from_utc_datetime(&d.naive_utc())
|
||||
.format("%Y-%m-%d %H:%M")
|
||||
.to_string()
|
||||
})
|
||||
.unwrap_or_else(|| "N/A".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
127
src/apps/canvas_lms/grading.rs
Normal file
127
src/apps/canvas_lms/grading.rs
Normal file
|
@ -0,0 +1,127 @@
|
|||
use chrono::{DateTime, Utc};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Grading {
|
||||
pub name: String,
|
||||
pub course_name: String,
|
||||
pub course_code: String,
|
||||
|
||||
pub submission_username: String,
|
||||
|
||||
pub assignment_id: String,
|
||||
pub assignment_legacy_id: String,
|
||||
pub assignment_url: String,
|
||||
pub submission_id: String,
|
||||
pub submission_legacy_id: String,
|
||||
pub course_id: String,
|
||||
pub course_legacy_id: String,
|
||||
|
||||
pub due_at: Option<DateTime<Utc>>,
|
||||
pub state: String,
|
||||
pub score: Option<f64>,
|
||||
pub entered_score: Option<f64>,
|
||||
pub possible_points: f64,
|
||||
pub grade_hidden: bool,
|
||||
pub grade: String,
|
||||
pub entered_grade: String,
|
||||
|
||||
pub graded_at: Option<DateTime<Utc>>,
|
||||
pub posted_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl Grading {
|
||||
pub fn last_updated(&self) -> Option<DateTime<Utc>> {
|
||||
[self.graded_at, self.posted_at]
|
||||
.iter()
|
||||
.filter_map(|d| *d)
|
||||
.max()
|
||||
}
|
||||
}
|
||||
|
||||
impl Grading {
|
||||
pub fn find_updates<'a>(before: &[Grading], after: &'a [Grading]) -> Vec<&'a Grading> {
|
||||
let mut updates = Vec::new();
|
||||
for new in after {
|
||||
if let Some(old) = before
|
||||
.iter()
|
||||
.find(|old| old.submission_id == new.submission_id)
|
||||
{
|
||||
if old.last_updated() < new.last_updated() {
|
||||
updates.push(new);
|
||||
}
|
||||
} else {
|
||||
updates.push(new);
|
||||
}
|
||||
}
|
||||
updates
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for Grading {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.submission_id == other.submission_id && self.last_updated() == other.last_updated()
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for Grading {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
if self == other {
|
||||
Some(std::cmp::Ordering::Equal)
|
||||
} else {
|
||||
self.last_updated().partial_cmp(&other.last_updated())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for Grading {
|
||||
fn assert_receiver_is_total_eq(&self) {}
|
||||
}
|
||||
|
||||
impl Ord for Grading {
|
||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||
self.partial_cmp(other).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CanvasGradingData(pub super::graph::AllCourseData);
|
||||
|
||||
impl IntoIterator for CanvasGradingData {
|
||||
type Item = Grading;
|
||||
type IntoIter = std::vec::IntoIter<Self::Item>;
|
||||
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
let mut vec = Vec::new();
|
||||
for course in self.0.all_courses {
|
||||
for submission in course.submissions_connection.nodes {
|
||||
vec.push(Grading {
|
||||
name: submission.assignment.name,
|
||||
course_name: course.name.clone(),
|
||||
course_code: course.course_code.clone(),
|
||||
due_at: submission.assignment.due_at.map(|d| d.into()),
|
||||
|
||||
submission_username: submission.user.name.clone(),
|
||||
|
||||
assignment_id: submission.assignment.id.clone(),
|
||||
assignment_legacy_id: submission.assignment.id_legacy.clone(),
|
||||
assignment_url: submission.assignment.html_url.clone(),
|
||||
submission_id: submission.id.clone(),
|
||||
submission_legacy_id: submission.id_legacy.clone(),
|
||||
course_id: course.id.clone(),
|
||||
course_legacy_id: course.id_legacy.clone(),
|
||||
|
||||
state: course.state.clone(),
|
||||
score: submission.score,
|
||||
entered_score: submission.entered_score,
|
||||
possible_points: submission.assignment.points_possible,
|
||||
grade_hidden: submission.grade_hidden,
|
||||
grade: submission.grade.unwrap_or_else(|| "".to_string()),
|
||||
entered_grade: submission.entered_grade.unwrap_or_else(|| "".to_string()),
|
||||
graded_at: submission.graded_at.map(|d| d.into()),
|
||||
posted_at: submission.posted_at.map(|d| d.into()),
|
||||
});
|
||||
}
|
||||
}
|
||||
vec.into_iter()
|
||||
}
|
||||
}
|
132
src/apps/canvas_lms/graph.rs
Normal file
132
src/apps/canvas_lms/graph.rs
Normal file
|
@ -0,0 +1,132 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct GraphQuery<T> {
|
||||
pub query: String,
|
||||
#[serde(rename = "operationName")]
|
||||
pub operation_name: String,
|
||||
pub variables: T,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct GraphNodes<T> {
|
||||
pub nodes: Vec<T>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct GraphResponse<T> {
|
||||
pub data: T,
|
||||
}
|
||||
|
||||
pub const ALL_COURSES_QUERY: &str = "query gradeQuery {
|
||||
allCourses {
|
||||
_id
|
||||
id
|
||||
name
|
||||
state
|
||||
courseCode
|
||||
submissionsConnection(first: $maxn$, orderBy: {field: gradedAt, direction: descending}) {
|
||||
nodes {
|
||||
_id
|
||||
id
|
||||
assignment {
|
||||
_id
|
||||
id
|
||||
name
|
||||
dueAt
|
||||
gradingType
|
||||
pointsPossible
|
||||
htmlUrl
|
||||
}
|
||||
score
|
||||
enteredScore
|
||||
grade
|
||||
enteredGrade
|
||||
gradingStatus
|
||||
gradeHidden
|
||||
gradedAt
|
||||
posted
|
||||
postedAt
|
||||
state
|
||||
user {
|
||||
_id
|
||||
id
|
||||
name
|
||||
sisId
|
||||
email
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}";
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct AllCourseData {
|
||||
#[serde(rename = "allCourses")]
|
||||
pub all_courses: Vec<GraphCourse>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct GraphCourse {
|
||||
#[serde(rename = "_id")]
|
||||
pub id_legacy: String,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub state: String,
|
||||
#[serde(rename = "courseCode")]
|
||||
pub course_code: String,
|
||||
#[serde(rename = "submissionsConnection")]
|
||||
pub submissions_connection: GraphNodes<GraphSubmission>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct GraphSubmission {
|
||||
#[serde(rename = "_id")]
|
||||
pub id_legacy: String,
|
||||
pub id: String,
|
||||
pub assignment: GraphAssignment,
|
||||
pub score: Option<f64>,
|
||||
#[serde(rename = "enteredScore")]
|
||||
pub entered_score: Option<f64>,
|
||||
pub grade: Option<String>,
|
||||
#[serde(rename = "enteredGrade")]
|
||||
pub entered_grade: Option<String>,
|
||||
#[serde(rename = "gradingStatus")]
|
||||
pub grading_status: Option<String>,
|
||||
#[serde(rename = "gradeHidden")]
|
||||
pub grade_hidden: bool,
|
||||
#[serde(rename = "gradedAt")]
|
||||
pub graded_at: Option<chrono::DateTime<chrono::FixedOffset>>,
|
||||
pub posted: bool,
|
||||
#[serde(rename = "postedAt")]
|
||||
pub posted_at: Option<chrono::DateTime<chrono::FixedOffset>>,
|
||||
pub state: String,
|
||||
pub user: GraphUser,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct GraphAssignment {
|
||||
#[serde(rename = "_id")]
|
||||
pub id_legacy: String,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(rename = "dueAt")]
|
||||
pub due_at: Option<chrono::DateTime<chrono::FixedOffset>>,
|
||||
#[serde(rename = "gradingType")]
|
||||
pub grading_type: String,
|
||||
#[serde(rename = "pointsPossible")]
|
||||
pub points_possible: f64,
|
||||
#[serde(rename = "htmlUrl")]
|
||||
pub html_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct GraphUser {
|
||||
#[serde(rename = "_id")]
|
||||
pub id_legacy: String,
|
||||
pub id: String,
|
||||
#[serde(rename = "sisId")]
|
||||
pub sis_id: Option<String>,
|
||||
pub name: String,
|
||||
pub email: Option<String>,
|
||||
}
|
220
src/apps/canvas_lms/mod.rs
Normal file
220
src/apps/canvas_lms/mod.rs
Normal file
|
@ -0,0 +1,220 @@
|
|||
use std::{
|
||||
future::Future,
|
||||
pin::Pin,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use askama::Template;
|
||||
use axum::{body::HttpBody, extract::Query, http::Request, routing::get, Extension, Router};
|
||||
use log::{debug, info};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::Mutex as AsyncMutex;
|
||||
|
||||
use crate::{
|
||||
apps::{
|
||||
auth::Role,
|
||||
canvas_lms::{
|
||||
comm::{GradesTemplate, TemplateGrade},
|
||||
grading::CanvasGradingData,
|
||||
},
|
||||
},
|
||||
comm::{Communicator, Message},
|
||||
config::Config,
|
||||
AppState,
|
||||
};
|
||||
|
||||
use self::{
|
||||
grading::Grading,
|
||||
graph::{AllCourseData, GraphQuery, GraphResponse, ALL_COURSES_QUERY},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
apps::{auth::middleware::AuthInfo, App},
|
||||
http::{ApiResponse, ApiResult},
|
||||
};
|
||||
|
||||
mod comm;
|
||||
pub mod grading;
|
||||
mod graph;
|
||||
|
||||
pub async fn query_grades(
|
||||
endpoint: &str,
|
||||
token: &str,
|
||||
maxn: i32,
|
||||
) -> Result<GraphResponse<AllCourseData>, reqwest::Error> {
|
||||
let client = reqwest::Client::new();
|
||||
let query = GraphQuery {
|
||||
query: ALL_COURSES_QUERY
|
||||
.to_string()
|
||||
.replace("$maxn$", &maxn.to_string()),
|
||||
operation_name: "gradeQuery".to_string(),
|
||||
variables: (),
|
||||
};
|
||||
let res = client
|
||||
.post(endpoint)
|
||||
.bearer_auth(token)
|
||||
.json(&query)
|
||||
.send()
|
||||
.await?
|
||||
.json::<GraphResponse<AllCourseData>>()
|
||||
.await?;
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
pub struct CanvasLMSApp {
|
||||
state: AsyncMutex<CanvasLMSAppState>,
|
||||
}
|
||||
|
||||
struct CanvasLMSAppState {
|
||||
config: Option<&'static Config>,
|
||||
grade_cache: GradeCache,
|
||||
global_app_state: Option<Arc<Mutex<AppState>>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct GradeCache {
|
||||
last_updated: chrono::DateTime<chrono::Local>,
|
||||
response: Option<Vec<Grading>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GetGradesOptions {
|
||||
force_refresh: Option<bool>,
|
||||
}
|
||||
|
||||
async fn route_get_grades<B: HttpBody>(
|
||||
auth: AuthInfo,
|
||||
app: Extension<Arc<CanvasLMSApp>>,
|
||||
Query(query): Query<GetGradesOptions>,
|
||||
_req: Request<B>,
|
||||
) -> ApiResult<GradeCache>
|
||||
where
|
||||
<B as HttpBody>::Error: std::fmt::Debug,
|
||||
{
|
||||
auth.check_for_any_role(&[Role::Admin])?;
|
||||
|
||||
if Some(true) == query.force_refresh || !app.grade_loaded().await {
|
||||
app.refresh_grades().await;
|
||||
}
|
||||
let state = app.state.lock().await;
|
||||
let grade_cache = state.grade_cache.to_owned();
|
||||
if grade_cache.response.is_none() {
|
||||
return Err(ApiResponse::<()>::error(
|
||||
"Grades not available yet".to_string(),
|
||||
503,
|
||||
None,
|
||||
));
|
||||
}
|
||||
Ok(ApiResponse::ok(
|
||||
"Grades retrieved successfully".to_string(),
|
||||
Some(grade_cache),
|
||||
))
|
||||
}
|
||||
|
||||
impl CanvasLMSApp {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: AsyncMutex::new(CanvasLMSAppState {
|
||||
config: None,
|
||||
grade_cache: GradeCache {
|
||||
last_updated: chrono::Local::now(),
|
||||
response: None,
|
||||
},
|
||||
global_app_state: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
pub(crate) async fn grade_loaded(&self) -> bool {
|
||||
let state: tokio::sync::MutexGuard<CanvasLMSAppState> = self.state.lock().await;
|
||||
state.grade_cache.response.is_some()
|
||||
}
|
||||
pub(crate) async fn refresh_grades(&self) {
|
||||
let mut state = self.state.lock().await;
|
||||
let config = state.config.unwrap();
|
||||
let res = query_grades(&config.canvas_lms.endpoint, &config.canvas_lms.token, 50).await;
|
||||
match res {
|
||||
Ok(res) => {
|
||||
let mut res_generalized =
|
||||
CanvasGradingData(res.data).into_iter().collect::<Vec<_>>();
|
||||
res_generalized.sort_unstable();
|
||||
res_generalized.reverse();
|
||||
debug!("Finished refreshing grades");
|
||||
if let Some(old_grades) = &state.grade_cache.response {
|
||||
let updates = Grading::find_updates(old_grades, &res_generalized);
|
||||
if !updates.is_empty() {
|
||||
let templated_grades: Vec<TemplateGrade> =
|
||||
res_generalized.iter().map(|g| g.into()).collect();
|
||||
let template_ctx = GradesTemplate {
|
||||
grades: templated_grades.as_ref(),
|
||||
};
|
||||
let template_rendered = template_ctx.render().unwrap();
|
||||
let global_app_state =
|
||||
state.global_app_state.as_ref().unwrap().lock().unwrap();
|
||||
let email_result = global_app_state.comm.send_message(&Message {
|
||||
subject: "New grades available".to_string(),
|
||||
body: template_rendered,
|
||||
mime: "text/html",
|
||||
..Default::default()
|
||||
});
|
||||
match email_result {
|
||||
Ok(_) => {
|
||||
info!("Sent email notification for new grades");
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Error sending email notification: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
state.grade_cache.last_updated = chrono::Local::now();
|
||||
state.grade_cache.response = Some(res_generalized);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Error querying Canvas LMS: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl App for CanvasLMSApp {
|
||||
fn initialize(
|
||||
self: Arc<Self>,
|
||||
config: &'static Config,
|
||||
app_state: Arc<Mutex<AppState>>,
|
||||
) -> Pin<Box<dyn Future<Output = ()>>> {
|
||||
let self_clone = self.clone();
|
||||
|
||||
let refresh_interval = config.canvas_lms.refresh_interval;
|
||||
if refresh_interval == 0 {
|
||||
panic!("Canvas LMS refresh interval cannot be 0");
|
||||
}
|
||||
|
||||
let init_async = Box::pin(async move {
|
||||
let mut state = self.state.lock().await;
|
||||
state.global_app_state = Some(app_state);
|
||||
state.config = Some(config);
|
||||
state.grade_cache = GradeCache {
|
||||
last_updated: chrono::Local::now(),
|
||||
response: None,
|
||||
};
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut ticker =
|
||||
tokio::time::interval(std::time::Duration::from_secs(refresh_interval));
|
||||
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
loop {
|
||||
self_clone.refresh_grades().await;
|
||||
ticker.tick().await;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
init_async
|
||||
}
|
||||
|
||||
fn api_routes(self: Arc<Self>) -> Router {
|
||||
Router::new()
|
||||
.route("/canvas_lms/grades", get(route_get_grades))
|
||||
.layer(Extension(self.clone()))
|
||||
}
|
||||
}
|
176
src/apps/med/directive.rs
Normal file
176
src/apps/med/directive.rs
Normal file
|
@ -0,0 +1,176 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
apps::auth::{middleware::AuthInfo, Role},
|
||||
http::{ApiResponse, ApiResult, JsonApiForm},
|
||||
models::med::Medication,
|
||||
};
|
||||
|
||||
use axum::{extract::Path, Extension};
|
||||
use log::error;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::MedManagementApp;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ParseShorthandForm {
|
||||
pub shorthand: String,
|
||||
}
|
||||
|
||||
pub async fn route_parse_shorthand(
|
||||
JsonApiForm(form): JsonApiForm<ParseShorthandForm>,
|
||||
) -> ApiResult<Medication> {
|
||||
let res = form.shorthand.parse::<Medication>().map_err(|e| {
|
||||
error!("Failed to parse shorthand: {}", e);
|
||||
ApiResponse::bad_request("Failed to parse".to_string(), None)
|
||||
})?;
|
||||
Ok(ApiResponse::ok(
|
||||
"Parsed successfully".to_string(),
|
||||
Some(res),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn route_format_shorthand(
|
||||
JsonApiForm(form): JsonApiForm<crate::models::med::Medication>,
|
||||
) -> ApiResult<String> {
|
||||
let shorthand: String = form.into();
|
||||
Ok(ApiResponse::ok(
|
||||
"Formatted successfully".to_string(),
|
||||
Some(shorthand),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn route_get_directive(
|
||||
auth: AuthInfo,
|
||||
app: Extension<Arc<MedManagementApp>>,
|
||||
) -> ApiResult<Vec<Medication>> {
|
||||
auth.check_for_any_role(&[Role::Admin])?;
|
||||
|
||||
let state = app.state.lock().await;
|
||||
let state = state.as_ref().unwrap();
|
||||
let mut global_app_state = state.global_app_state.lock().unwrap();
|
||||
|
||||
let meds = {
|
||||
use crate::schema::medications::dsl::*;
|
||||
use diesel::prelude::*;
|
||||
|
||||
medications
|
||||
.load::<Medication>(&mut global_app_state.db)
|
||||
.map_err(|e| {
|
||||
error!("Failed to load meds: {:?}", e);
|
||||
ApiResponse::error("Database error".to_string(), 500, None)
|
||||
})?
|
||||
};
|
||||
|
||||
Ok(ApiResponse::ok(
|
||||
"Directives retrieved".to_string(),
|
||||
Some(meds),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn route_post_directive(
|
||||
auth: AuthInfo,
|
||||
app: Extension<Arc<MedManagementApp>>,
|
||||
JsonApiForm(mut form): JsonApiForm<crate::models::med::Medication>,
|
||||
) -> ApiResult<Medication> {
|
||||
auth.check_for_any_role(&[Role::Admin])?;
|
||||
|
||||
let state = app.state.lock().await;
|
||||
let state = state.as_ref().unwrap();
|
||||
let mut global_app_state = state.global_app_state.lock().unwrap();
|
||||
|
||||
form.uuid = uuid::Uuid::new_v4().to_string();
|
||||
form.created = chrono::Utc::now().naive_local();
|
||||
form.updated = chrono::Utc::now().naive_local();
|
||||
|
||||
let res = {
|
||||
use crate::schema::medications;
|
||||
use crate::schema::medications::dsl::*;
|
||||
use diesel::prelude::*;
|
||||
|
||||
diesel::insert_into(medications::table)
|
||||
.values(&form)
|
||||
.execute(&mut global_app_state.db)
|
||||
.unwrap();
|
||||
|
||||
medications
|
||||
.filter(medications::uuid.eq(&form.uuid))
|
||||
.first::<crate::models::med::Medication>(&mut global_app_state.db)
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
Ok(ApiResponse::ok("Directives posted".to_string(), Some(res)))
|
||||
}
|
||||
|
||||
pub async fn route_patch_directive(
|
||||
auth: AuthInfo,
|
||||
app: Extension<Arc<MedManagementApp>>,
|
||||
JsonApiForm(form): JsonApiForm<crate::models::med::Medication>,
|
||||
) -> ApiResult<Medication> {
|
||||
auth.check_for_any_role(&[Role::Admin])?;
|
||||
|
||||
let state = app.state.lock().await;
|
||||
let state = state.as_ref().unwrap();
|
||||
let mut global_app_state = state.global_app_state.lock().unwrap();
|
||||
|
||||
let res = {
|
||||
use crate::schema::medications;
|
||||
use crate::schema::medications::dsl::*;
|
||||
use diesel::prelude::*;
|
||||
|
||||
diesel::update(medications.filter(medications::uuid.eq(&form.uuid)))
|
||||
.set((
|
||||
name.eq(&form.name),
|
||||
dosage.eq(&form.dosage),
|
||||
dosage_unit.eq(&form.dosage_unit),
|
||||
period_hours.eq(&form.period_hours),
|
||||
flags.eq(&form.flags),
|
||||
options.eq(&form.options),
|
||||
updated.eq(chrono::Utc::now().naive_local()),
|
||||
))
|
||||
.execute(&mut global_app_state.db)
|
||||
.map_err(|e| {
|
||||
error!("Failed to update med: {:?}", e);
|
||||
ApiResponse::error("Database error".to_string(), 500, None)
|
||||
})?;
|
||||
|
||||
medications
|
||||
.filter(medications::uuid.eq(&form.uuid))
|
||||
.first::<crate::models::med::Medication>(&mut global_app_state.db)
|
||||
.map_err(|e| {
|
||||
error!("Failed to load med: {:?}", e);
|
||||
ApiResponse::error("Database error".to_string(), 500, None)
|
||||
})?
|
||||
};
|
||||
|
||||
Ok(ApiResponse::ok("Directives updated".to_string(), Some(res)))
|
||||
}
|
||||
|
||||
pub async fn route_delete_directive(
|
||||
auth: AuthInfo,
|
||||
app: Extension<Arc<MedManagementApp>>,
|
||||
Path(med_uuid): Path<String>,
|
||||
) -> ApiResult<()> {
|
||||
auth.check_for_any_role(&[Role::Admin])?;
|
||||
|
||||
let state = app.state.lock().await;
|
||||
let state = state.as_ref().unwrap();
|
||||
let mut global_app_state = state.global_app_state.lock().unwrap();
|
||||
|
||||
{
|
||||
use crate::schema::medications::dsl::medications;
|
||||
use diesel::prelude::*;
|
||||
|
||||
diesel::delete(medications.filter(crate::schema::medications::uuid.eq(&med_uuid)))
|
||||
.execute(&mut global_app_state.db)
|
||||
.map_err(|e| {
|
||||
error!("Failed to delete med: {:?}", e);
|
||||
ApiResponse::error("Database error".to_string(), 500, None)
|
||||
})?;
|
||||
};
|
||||
|
||||
Ok(ApiResponse::<()>::ok(
|
||||
"Directives deleted".to_string(),
|
||||
None,
|
||||
))
|
||||
}
|
270
src/apps/med/log.rs
Normal file
270
src/apps/med/log.rs
Normal file
|
@ -0,0 +1,270 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
apps::auth::{middleware::AuthInfo, Role},
|
||||
http::{ApiResponse, ApiResult, JsonApiForm},
|
||||
models::med::{Medication, MedicationLog},
|
||||
};
|
||||
|
||||
use axum::{
|
||||
extract::{Path, Query},
|
||||
Extension,
|
||||
};
|
||||
use chrono::{Duration, TimeZone, Utc};
|
||||
use log::error;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::MedManagementApp;
|
||||
|
||||
pub fn effective_last_dose(
|
||||
med: &Medication,
|
||||
mut med_logs: Vec<MedicationLog>,
|
||||
) -> Option<MedicationLog> {
|
||||
let mut remaining_dose = med.dosage;
|
||||
med_logs.sort_by(|a, b| a.time_actual.cmp(&b.time_actual));
|
||||
med_logs.reverse();
|
||||
|
||||
for log in med_logs {
|
||||
if log.dosage >= remaining_dose {
|
||||
return Some(log);
|
||||
} else {
|
||||
remaining_dose -= log.dosage;
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn project_next_dose(med: &Medication, med_logs: Vec<MedicationLog>) -> MedicationLog {
|
||||
let effective_last = effective_last_dose(med, med_logs);
|
||||
|
||||
let now = Utc::now().naive_utc();
|
||||
|
||||
let next_time = match effective_last {
|
||||
Some(last) => last.time_actual + Duration::hours(med.period_hours as i64),
|
||||
None => now,
|
||||
};
|
||||
|
||||
let offset = (now.timestamp_millis() - next_time.timestamp_millis()) as f64
|
||||
/ (med.period_hours * 60 * 60 * 1000) as f64;
|
||||
|
||||
MedicationLog {
|
||||
uuid: uuid::Uuid::new_v4().to_string(),
|
||||
med_uuid: med.uuid.clone(),
|
||||
dosage: med.dosage,
|
||||
time_actual: now,
|
||||
time_expected: next_time,
|
||||
dose_offset: offset as f32,
|
||||
created: now,
|
||||
updated: now,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn route_project_next_dose(
|
||||
auth: AuthInfo,
|
||||
app: Extension<Arc<MedManagementApp>>,
|
||||
Path(med_uuid_form): Path<String>,
|
||||
) -> ApiResult<MedicationLog> {
|
||||
auth.check_for_any_role(&[Role::Admin])?;
|
||||
|
||||
let state = app.state.lock().await;
|
||||
let state = state.as_ref().unwrap();
|
||||
let mut global_app_state = state.global_app_state.lock().unwrap();
|
||||
|
||||
let med = {
|
||||
use crate::schema::medications::dsl::*;
|
||||
use diesel::prelude::*;
|
||||
|
||||
medications
|
||||
.filter(uuid.eq(&med_uuid_form))
|
||||
.first::<Medication>(&mut global_app_state.db)
|
||||
.map_err(|e| {
|
||||
error!("Failed to get med: {}", e);
|
||||
ApiResponse::error("Failed to get med".to_string(), 500, None)
|
||||
})?
|
||||
};
|
||||
|
||||
let med_logs = {
|
||||
use crate::schema::medication_logs::dsl::*;
|
||||
use diesel::prelude::*;
|
||||
|
||||
medication_logs
|
||||
.order_by(time_actual.desc())
|
||||
.limit(10)
|
||||
.filter(med_uuid.eq(&med_uuid_form))
|
||||
.load::<MedicationLog>(&mut global_app_state.db)
|
||||
.map_err(|e| {
|
||||
error!("Failed to get med logs: {}", e);
|
||||
ApiResponse::error("Failed to get med logs".to_string(), 500, None)
|
||||
})?
|
||||
};
|
||||
|
||||
let next_dose = project_next_dose(&med, med_logs);
|
||||
Ok(ApiResponse::ok(
|
||||
"Next dose projected successfully".to_string(),
|
||||
Some(next_dose),
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct GetLogOptions {
|
||||
until: Option<i64>,
|
||||
limit: i64,
|
||||
}
|
||||
|
||||
pub async fn route_get_log(
|
||||
auth: AuthInfo,
|
||||
app: Extension<Arc<MedManagementApp>>,
|
||||
Path(med_uuid_form): Path<String>,
|
||||
Query(GetLogOptions { until, limit }): Query<GetLogOptions>,
|
||||
) -> ApiResult<Vec<MedicationLog>> {
|
||||
auth.check_for_any_role(&[Role::Admin])?;
|
||||
|
||||
let state = app.state.lock().await;
|
||||
let state = state.as_ref().unwrap();
|
||||
let mut global_app_state = state.global_app_state.lock().unwrap();
|
||||
|
||||
let med_logs = {
|
||||
use crate::schema::medication_logs::dsl::*;
|
||||
use diesel::prelude::*;
|
||||
|
||||
match until {
|
||||
None => medication_logs
|
||||
.limit(limit)
|
||||
.order_by(time_actual.desc())
|
||||
.filter(med_uuid.eq(&med_uuid_form))
|
||||
.load::<MedicationLog>(&mut global_app_state.db)
|
||||
.map_err(|e| {
|
||||
error!("Failed to get med logs: {}", e);
|
||||
ApiResponse::error("Failed to get med logs".to_string(), 500, None)
|
||||
})?,
|
||||
Some(until) => {
|
||||
let until = Utc.timestamp_millis_opt(until).unwrap().naive_utc();
|
||||
medication_logs
|
||||
.limit(limit)
|
||||
.order_by(time_actual.desc())
|
||||
.filter(med_uuid.eq(&med_uuid_form).and(time_actual.lt(until)))
|
||||
.load::<MedicationLog>(&mut global_app_state.db)
|
||||
.map_err(|e| {
|
||||
error!("Failed to get med logs: {}", e);
|
||||
ApiResponse::error("Failed to get med logs".to_string(), 500, None)
|
||||
})?
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(ApiResponse::ok(
|
||||
"Med logs retrieved successfully".to_string(),
|
||||
Some(med_logs),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn route_post_log(
|
||||
auth: AuthInfo,
|
||||
app: Extension<Arc<MedManagementApp>>,
|
||||
Path(med_uuid_form): Path<String>,
|
||||
JsonApiForm(form): JsonApiForm<MedicationLog>,
|
||||
) -> ApiResult<MedicationLog> {
|
||||
auth.check_for_any_role(&[Role::Admin])?;
|
||||
|
||||
let state = app.state.lock().await;
|
||||
let state = state.as_ref().unwrap();
|
||||
let mut global_app_state = state.global_app_state.lock().unwrap();
|
||||
|
||||
let med = {
|
||||
use crate::schema::medications::dsl::*;
|
||||
use diesel::prelude::*;
|
||||
|
||||
medications
|
||||
.filter(uuid.eq(&med_uuid_form))
|
||||
.first::<Medication>(&mut global_app_state.db)
|
||||
.map_err(|e| {
|
||||
error!("Failed to get med: {}", e);
|
||||
ApiResponse::error("Failed to get med".to_string(), 500, None)
|
||||
})?
|
||||
};
|
||||
|
||||
let med_logs = {
|
||||
use crate::schema::medication_logs::dsl::*;
|
||||
use diesel::prelude::*;
|
||||
|
||||
medication_logs
|
||||
.order_by(time_actual.desc())
|
||||
.limit(10)
|
||||
.filter(
|
||||
med_uuid
|
||||
.eq(&med_uuid_form)
|
||||
.and(time_actual.lt(form.time_actual)),
|
||||
)
|
||||
.load::<MedicationLog>(&mut global_app_state.db)
|
||||
.map_err(|e| {
|
||||
error!("Failed to get med logs: {}", e);
|
||||
ApiResponse::error("Failed to get med logs".to_string(), 500, None)
|
||||
})?
|
||||
};
|
||||
|
||||
let projected_next_dose = project_next_dose(&med, med_logs);
|
||||
let mut form = form;
|
||||
|
||||
form.med_uuid = med_uuid_form;
|
||||
form.time_expected = projected_next_dose.time_expected;
|
||||
form.dose_offset = projected_next_dose.dose_offset;
|
||||
let now = Utc::now().naive_utc();
|
||||
form.created = now;
|
||||
form.updated = now;
|
||||
|
||||
let log = {
|
||||
use crate::schema::medication_logs::dsl::*;
|
||||
use diesel::prelude::*;
|
||||
|
||||
diesel::insert_into(medication_logs)
|
||||
.values(form.clone())
|
||||
.execute(&mut global_app_state.db)
|
||||
.map_err(|e| {
|
||||
error!("Failed to insert log: {}", e);
|
||||
ApiResponse::error("Failed to insert log".to_string(), 500, None)
|
||||
})?;
|
||||
|
||||
medication_logs
|
||||
.filter(uuid.eq(form.uuid))
|
||||
.first::<MedicationLog>(&mut global_app_state.db)
|
||||
.map_err(|e| {
|
||||
error!("Failed to get log: {}", e);
|
||||
ApiResponse::error("Failed to get log".to_string(), 500, None)
|
||||
})?
|
||||
};
|
||||
|
||||
Ok(ApiResponse::ok(
|
||||
"Log inserted successfully".to_string(),
|
||||
Some(log),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn route_delete_log(
|
||||
auth: AuthInfo,
|
||||
app: Extension<Arc<MedManagementApp>>,
|
||||
Path((_med_uuid, log_uuid)): Path<(String, String)>,
|
||||
) -> ApiResult<()> {
|
||||
auth.check_for_any_role(&[Role::Admin])?;
|
||||
|
||||
let state = app.state.lock().await;
|
||||
let state = state.as_ref().unwrap();
|
||||
let mut global_app_state = state.global_app_state.lock().unwrap();
|
||||
|
||||
{
|
||||
use crate::schema::medication_logs::dsl::*;
|
||||
use diesel::prelude::*;
|
||||
|
||||
diesel::delete(medication_logs.filter(uuid.eq(log_uuid)))
|
||||
.execute(&mut global_app_state.db)
|
||||
.map_err(|e| {
|
||||
error!("Failed to delete log: {}", e);
|
||||
ApiResponse::error("Failed to delete log".to_string(), 500, None)
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(ApiResponse::ok(
|
||||
"Log deleted successfully".to_string(),
|
||||
None,
|
||||
))
|
||||
}
|
84
src/apps/med/mod.rs
Normal file
84
src/apps/med/mod.rs
Normal file
|
@ -0,0 +1,84 @@
|
|||
use std::{
|
||||
future::Future,
|
||||
pin::Pin,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use crate::{config::Config, AppState};
|
||||
|
||||
use super::App;
|
||||
|
||||
use axum::{
|
||||
routing::{delete, get, post},
|
||||
Extension, Router,
|
||||
};
|
||||
use tokio::sync::Mutex as AsyncMutex;
|
||||
|
||||
mod directive;
|
||||
mod log;
|
||||
|
||||
pub struct MedManagementApp {
|
||||
state: AsyncMutex<Option<MedManagementAppState>>,
|
||||
}
|
||||
|
||||
struct MedManagementAppState {
|
||||
global_app_state: Arc<Mutex<AppState>>,
|
||||
}
|
||||
|
||||
impl MedManagementApp {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: AsyncMutex::new(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl App for MedManagementApp {
|
||||
fn initialize(
|
||||
self: Arc<Self>,
|
||||
_config: &'static Config,
|
||||
app_state: Arc<Mutex<AppState>>,
|
||||
) -> Pin<Box<dyn Future<Output = ()>>> {
|
||||
Box::pin(async move {
|
||||
let mut state = self.state.lock().await;
|
||||
*state = Some(MedManagementAppState {
|
||||
global_app_state: app_state,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
fn api_routes(self: Arc<Self>) -> Router {
|
||||
Router::new()
|
||||
.route(
|
||||
"/med/parse_shorthand",
|
||||
post(directive::route_parse_shorthand),
|
||||
)
|
||||
.route(
|
||||
"/med/format_shorthand",
|
||||
post(directive::route_format_shorthand),
|
||||
)
|
||||
.route(
|
||||
"/med/directive",
|
||||
get(directive::route_get_directive)
|
||||
.post(directive::route_post_directive)
|
||||
.patch(directive::route_patch_directive),
|
||||
)
|
||||
.route(
|
||||
"/med/directive/:med_uuid",
|
||||
delete(directive::route_delete_directive),
|
||||
)
|
||||
.route(
|
||||
"/med/directive/:med_uuid/project_next_dose",
|
||||
get(log::route_project_next_dose),
|
||||
)
|
||||
.route(
|
||||
"/med/directive/:med_uuid/log",
|
||||
get(log::route_get_log).post(log::route_post_log),
|
||||
)
|
||||
.route(
|
||||
"/med/directive/:med_uuid/log/:log_uuid",
|
||||
delete(log::route_delete_log),
|
||||
)
|
||||
.layer(Extension(self.clone()))
|
||||
}
|
||||
}
|
27
src/apps/mod.rs
Normal file
27
src/apps/mod.rs
Normal file
|
@ -0,0 +1,27 @@
|
|||
use crate::{config::Config, AppState};
|
||||
use axum::Router;
|
||||
|
||||
use std::{
|
||||
future::Future,
|
||||
pin::Pin,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
pub trait App {
|
||||
fn initialize(
|
||||
self: Arc<Self>,
|
||||
_config: &'static Config,
|
||||
_app_state: Arc<Mutex<AppState>>,
|
||||
) -> Pin<Box<dyn Future<Output = ()>>> {
|
||||
Box::pin(async {})
|
||||
}
|
||||
fn api_routes(self: Arc<Self>) -> Router {
|
||||
Router::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub mod auth;
|
||||
pub mod canvas_lms;
|
||||
pub mod med;
|
||||
pub mod server_info;
|
||||
pub mod webcheck;
|
51
src/apps/server_info/mod.rs
Normal file
51
src/apps/server_info/mod.rs
Normal file
|
@ -0,0 +1,51 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use crate::{apps::App, http::ApiResponse};
|
||||
use axum::{
|
||||
body::HttpBody,
|
||||
http::Request,
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub struct ServerInfoApp {}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
const PROFILE: &str = "debug";
|
||||
#[cfg(not(debug_assertions))]
|
||||
const PROFILE: &str = "release";
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ServerInfo {
|
||||
version: String,
|
||||
profile: String,
|
||||
}
|
||||
|
||||
async fn get_server_info<B: HttpBody>(_req: Request<B>) -> Response
|
||||
where
|
||||
<B as HttpBody>::Error: std::fmt::Debug,
|
||||
{
|
||||
let server_info = ServerInfo {
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
profile: PROFILE.to_string(),
|
||||
};
|
||||
ApiResponse::ok(
|
||||
"Server info retrieved successfully".to_string(),
|
||||
Some(server_info),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
impl ServerInfoApp {
|
||||
pub fn new() -> Self {
|
||||
Self {}
|
||||
}
|
||||
}
|
||||
|
||||
impl App for ServerInfoApp {
|
||||
fn api_routes(self: Arc<Self>) -> Router {
|
||||
Router::new().route("/server_info", get(get_server_info))
|
||||
}
|
||||
}
|
67
src/apps/webcheck/driver/chrome.rs
Normal file
67
src/apps/webcheck/driver/chrome.rs
Normal file
|
@ -0,0 +1,67 @@
|
|||
use rand_core::RngCore;
|
||||
use std::process::{Child, Command};
|
||||
use thirtyfour::{prelude::WebDriverResult, ChromeCapabilities, WebDriver};
|
||||
pub struct ChromeDriver {
|
||||
child: Option<Child>,
|
||||
port: u16,
|
||||
tmp_dir: Option<tempfile::TempDir>,
|
||||
}
|
||||
|
||||
impl ChromeDriver {
|
||||
pub fn new() -> Self {
|
||||
let port = rand_core::OsRng.next_u32() as u16 + 10000;
|
||||
Self::new_port(port)
|
||||
}
|
||||
pub fn new_port(port: u16) -> Self {
|
||||
Self {
|
||||
child: None,
|
||||
port,
|
||||
tmp_dir: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn spawn(&mut self, args: &[&str]) -> anyhow::Result<()> {
|
||||
let tmp_dir = tempfile::tempdir()?;
|
||||
std::fs::create_dir(tmp_dir.path().join("data"))?;
|
||||
std::fs::create_dir(tmp_dir.path().join("crashpad"))?;
|
||||
self.tmp_dir = Some(tmp_dir);
|
||||
|
||||
let mut cmd = Command::new("chromedriver");
|
||||
cmd.arg(format!("--port={}", self.port));
|
||||
cmd.args(args);
|
||||
|
||||
let child = cmd.spawn()?;
|
||||
|
||||
self.child = Some(child);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn connect(&mut self, mut caps: ChromeCapabilities) -> WebDriverResult<WebDriver> {
|
||||
let temp_dir = self.tmp_dir.as_ref().unwrap();
|
||||
|
||||
caps.add_chrome_arg(
|
||||
format!("--user-data-dir={}", temp_dir.path().join("data").display()).as_str(),
|
||||
)?;
|
||||
caps.add_chrome_arg(
|
||||
format!(
|
||||
"--crash-dumps-dir={}",
|
||||
temp_dir.path().join("crashpad").display()
|
||||
)
|
||||
.as_str(),
|
||||
)?;
|
||||
|
||||
let addr = format!("http://localhost:{}/", self.port);
|
||||
WebDriver::new(addr.as_str(), caps).await
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ChromeDriver {
|
||||
fn drop(&mut self) {
|
||||
if let Some(child) = &mut self.child {
|
||||
if let Err(e) = child.kill() {
|
||||
log::error!("Error killing chrome driver: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
1
src/apps/webcheck/driver/mod.rs
Normal file
1
src/apps/webcheck/driver/mod.rs
Normal file
|
@ -0,0 +1 @@
|
|||
pub mod chrome;
|
199
src/apps/webcheck/mod.rs
Normal file
199
src/apps/webcheck/mod.rs
Normal file
|
@ -0,0 +1,199 @@
|
|||
use std::{
|
||||
collections::HashMap,
|
||||
future::Future,
|
||||
pin::Pin,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use axum::{routing::get, Extension, Router};
|
||||
use chrono::DateTime;
|
||||
use log::info;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thirtyfour::{DesiredCapabilities, WebDriver};
|
||||
use tokio::sync::Mutex as AsyncMutex;
|
||||
|
||||
use crate::{
|
||||
comm::{Communicator, Message},
|
||||
config::Config,
|
||||
http::{ApiResponse, ApiResult},
|
||||
AppState,
|
||||
};
|
||||
|
||||
use super::{
|
||||
auth::{middleware::AuthInfo, Role},
|
||||
App,
|
||||
};
|
||||
|
||||
mod driver;
|
||||
mod utd_app;
|
||||
|
||||
pub struct WebcheckApp {
|
||||
state: AsyncMutex<WebcheckAppState>,
|
||||
}
|
||||
|
||||
struct WebcheckAppState {
|
||||
config: Option<&'static Config>,
|
||||
global_app_state: Option<Arc<Mutex<AppState>>>,
|
||||
last_response: HashMap<String, LastResponse>,
|
||||
checkers: HashMap<String, Box<dyn WebDriverChecker + Send + Sync>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
struct LastResponse {
|
||||
pub response: String,
|
||||
pub timestamp: DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
async fn route_get_results(
|
||||
auth: AuthInfo,
|
||||
app: Extension<Arc<WebcheckApp>>,
|
||||
) -> ApiResult<HashMap<String, LastResponse>> {
|
||||
auth.check_for_any_role(&[Role::Admin])?;
|
||||
|
||||
let state = app.state.lock().await;
|
||||
|
||||
Ok(ApiResponse::ok(
|
||||
"Results retrieved successfully".to_string(),
|
||||
Some(state.last_response.to_owned()),
|
||||
))
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait WebDriverChecker {
|
||||
fn init(&mut self, config: &HashMap<String, String>) -> anyhow::Result<()>;
|
||||
fn interval(&self) -> u64;
|
||||
async fn check(&self, driver: &WebDriver) -> anyhow::Result<String>;
|
||||
}
|
||||
|
||||
impl WebcheckApp {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: AsyncMutex::new(WebcheckAppState {
|
||||
config: None,
|
||||
global_app_state: None,
|
||||
last_response: HashMap::new(),
|
||||
checkers: HashMap::new(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run_single_check(self: &Self, key: &str) -> anyhow::Result<()> {
|
||||
let mut state = self.state.lock().await;
|
||||
|
||||
let checker = state.checkers.get_mut(key).unwrap();
|
||||
|
||||
let mut driver = driver::chrome::ChromeDriver::new();
|
||||
driver.spawn(&["--enable-chrome-logs"])?;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
|
||||
let mut caps = DesiredCapabilities::chrome();
|
||||
caps.set_headless().unwrap();
|
||||
caps.set_disable_gpu().unwrap();
|
||||
|
||||
let driver = driver.connect(caps).await?;
|
||||
let response = checker.check(&driver).await?;
|
||||
|
||||
let new_response = LastResponse {
|
||||
response: response.clone(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let last_response = state.last_response.get(key);
|
||||
|
||||
match last_response {
|
||||
Some(last_response) => {
|
||||
if last_response.response != response {
|
||||
state
|
||||
.global_app_state
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.comm
|
||||
.send_message(&Message {
|
||||
subject: format!("webcheck {} changed", key),
|
||||
body: format!("{} changed to {}", key, response),
|
||||
mime: "text/plain",
|
||||
priority: 0,
|
||||
})?;
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
|
||||
state.last_response.insert(key.to_string(), new_response);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn run_check_loops(self: Arc<Self>) {
|
||||
let self_clone = self.clone();
|
||||
|
||||
let state = self.state.lock().await;
|
||||
for key in state.checkers.keys() {
|
||||
let key = key.clone();
|
||||
let self_clone = self_clone.clone();
|
||||
tokio::spawn(async move {
|
||||
let interval = {
|
||||
let state = self_clone.state.lock().await;
|
||||
let checker = state.checkers.get(key.as_str()).unwrap();
|
||||
checker.interval()
|
||||
};
|
||||
|
||||
let mut ticker = tokio::time::interval(std::time::Duration::from_secs(interval));
|
||||
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
|
||||
info!("Running webcheck for {}", key);
|
||||
self_clone
|
||||
.run_single_check(key.as_str())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
log::error!("Error running webcheck for {}: {}", key, e);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl App for WebcheckApp {
|
||||
fn initialize(
|
||||
self: Arc<Self>,
|
||||
config: &'static Config,
|
||||
app_state: Arc<Mutex<AppState>>,
|
||||
) -> Pin<Box<dyn Future<Output = ()>>> {
|
||||
Box::pin(async move {
|
||||
let mut state = self.state.lock().await;
|
||||
state.config = Some(config);
|
||||
state.global_app_state = Some(app_state);
|
||||
|
||||
let Some(ref config) = config.webcheck else {
|
||||
return;
|
||||
};
|
||||
|
||||
config.keys().for_each(|key| match key.as_str() {
|
||||
"utd_app" => {
|
||||
let mut checker = utd_app::UTDAppChecker::new();
|
||||
checker
|
||||
.init(config.get(key).unwrap())
|
||||
.expect("Failed to initialize UTDAppChecker");
|
||||
state.checkers.insert(key.clone(), Box::new(checker));
|
||||
}
|
||||
_ => panic!("Invalid key in webcheck config: {}", key),
|
||||
});
|
||||
|
||||
let self_clone = self.clone();
|
||||
tokio::spawn(self_clone.run_check_loops());
|
||||
})
|
||||
}
|
||||
|
||||
fn api_routes(self: Arc<Self>) -> Router {
|
||||
Router::new()
|
||||
.route("/webcheck/results", get(route_get_results))
|
||||
.layer(Extension(self.clone()))
|
||||
}
|
||||
}
|
75
src/apps/webcheck/utd_app.rs
Normal file
75
src/apps/webcheck/utd_app.rs
Normal file
|
@ -0,0 +1,75 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use thirtyfour::prelude::*;
|
||||
use tokio::time;
|
||||
|
||||
use super::WebDriverChecker;
|
||||
|
||||
pub struct UTDAppChecker {
|
||||
config: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl UTDAppChecker {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
config: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WebDriverChecker for UTDAppChecker {
|
||||
fn init(&mut self, config: &HashMap<String, String>) -> Result<()> {
|
||||
if config.get("username").is_none() || config.get("password").is_none() {
|
||||
return Err(anyhow::anyhow!("username or password not set"));
|
||||
}
|
||||
|
||||
self.config = config.clone();
|
||||
Ok(())
|
||||
}
|
||||
fn interval(&self) -> u64 {
|
||||
let default_interval = "3600".to_string();
|
||||
let interval = self.config.get("interval").unwrap_or(&default_interval);
|
||||
interval.parse::<u64>().unwrap()
|
||||
}
|
||||
|
||||
async fn check(&self, driver: &WebDriver) -> Result<String> {
|
||||
let username = self.config.get("username").unwrap();
|
||||
let password = self.config.get("password").unwrap();
|
||||
|
||||
driver
|
||||
.goto("https://utdallas.my.site.com/TX_SiteLogin?startURL=%2FTargetX_Portal__PB")
|
||||
.await?;
|
||||
|
||||
let input_els = driver.find_all(By::Css("input[type='text']")).await?;
|
||||
|
||||
for input_el in input_els {
|
||||
let name_attr = input_el.attr("name").await?;
|
||||
if name_attr.is_some() && name_attr.unwrap().ends_with(":username") {
|
||||
input_el.send_keys(username).await?;
|
||||
}
|
||||
}
|
||||
|
||||
let password_el = driver.find(By::Css("input[type='password']")).await?;
|
||||
password_el.send_keys(password).await?;
|
||||
|
||||
let submit_el = driver.find(By::Css("a.targetx-button")).await?;
|
||||
submit_el.click().await?;
|
||||
|
||||
time::sleep(time::Duration::from_secs(10)).await;
|
||||
|
||||
let mut checklist_item_text = Vec::new();
|
||||
let checklist_item_text_els = driver.find_all(By::Css("p.checklist-item-text")).await?;
|
||||
|
||||
for checklist_item_text_el in checklist_item_text_els {
|
||||
let text = checklist_item_text_el.text().await?;
|
||||
checklist_item_text.push(text);
|
||||
}
|
||||
|
||||
checklist_item_text.sort();
|
||||
|
||||
Ok(checklist_item_text.join("\n"))
|
||||
}
|
||||
}
|
41
src/bin/server.rs
Normal file
41
src/bin/server.rs
Normal file
|
@ -0,0 +1,41 @@
|
|||
use clap::Parser;
|
||||
use simple_logger::SimpleLogger;
|
||||
use yoake::config;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[clap(name = "yoake_server", version, author, about)]
|
||||
struct Cli {
|
||||
#[arg(short, long, default_value = "config.yaml")]
|
||||
config: String,
|
||||
#[arg(short, long)]
|
||||
dev: bool,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let args = Cli::parse();
|
||||
|
||||
SimpleLogger::new()
|
||||
.with_module_level(
|
||||
"yoake",
|
||||
if args.dev {
|
||||
log::LevelFilter::Debug
|
||||
} else {
|
||||
log::LevelFilter::Info
|
||||
},
|
||||
)
|
||||
.with_level(if args.dev {
|
||||
log::LevelFilter::Info
|
||||
} else {
|
||||
log::LevelFilter::Warn
|
||||
})
|
||||
.init()
|
||||
.unwrap();
|
||||
|
||||
let config = config::Config::load_yaml_file(args.config);
|
||||
unsafe {
|
||||
config::set_config(config);
|
||||
}
|
||||
|
||||
yoake::main_server(args.dev).await;
|
||||
}
|
46
src/comm/email.rs
Normal file
46
src/comm/email.rs
Normal file
|
@ -0,0 +1,46 @@
|
|||
use lettre::{
|
||||
message::header::ContentType, transport::smtp::authentication::Credentials, Transport,
|
||||
};
|
||||
|
||||
use crate::config::{comm::EmailConfig, Config};
|
||||
|
||||
use super::Communicator;
|
||||
|
||||
pub struct EmailCommunicator {
|
||||
config: &'static EmailConfig,
|
||||
}
|
||||
impl EmailCommunicator {
|
||||
pub fn new(config: &'static Config) -> Self {
|
||||
Self {
|
||||
config: &config.comm.email,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Communicator for EmailCommunicator {
|
||||
fn name(&self) -> &'static str {
|
||||
"email"
|
||||
}
|
||||
fn supported_mimes(&self) -> Vec<&'static str> {
|
||||
vec!["text/plain", "text/html"]
|
||||
}
|
||||
fn send_message(&self, message: &super::Message) -> anyhow::Result<()> {
|
||||
let mailer = lettre::SmtpTransport::relay(&self.config.host)?
|
||||
.credentials(Credentials::new(
|
||||
self.config.username.clone(),
|
||||
self.config.password.clone(),
|
||||
))
|
||||
.build();
|
||||
|
||||
let email = lettre::Message::builder()
|
||||
.from(self.config.from.parse()?)
|
||||
.to(self.config.to.parse()?)
|
||||
.subject(message.subject.clone())
|
||||
.header(ContentType::parse(message.mime)?)
|
||||
.body(message.body.clone())?;
|
||||
|
||||
mailer.send(&email)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
73
src/comm/gotify.rs
Normal file
73
src/comm/gotify.rs
Normal file
|
@ -0,0 +1,73 @@
|
|||
use super::Communicator;
|
||||
use crate::config::{comm::GotifyConfig, Config};
|
||||
use serde::Serialize;
|
||||
|
||||
pub struct GotifyCommunicator {
|
||||
config: &'static GotifyConfig,
|
||||
}
|
||||
|
||||
impl GotifyCommunicator {
|
||||
pub fn new(config: &'static Config) -> Self {
|
||||
Self {
|
||||
config: &config.comm.gotify,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct GotifyMessage {
|
||||
title: String,
|
||||
message: String,
|
||||
priority: i8,
|
||||
extras: GotifyMessageExtras,
|
||||
}
|
||||
|
||||
impl Into<GotifyMessage> for &super::Message {
|
||||
fn into(self) -> GotifyMessage {
|
||||
GotifyMessage {
|
||||
title: self.subject.clone(),
|
||||
message: self.body.clone(),
|
||||
priority: self.priority,
|
||||
extras: GotifyMessageExtras {
|
||||
client_display: GotifyMessageExtrasClientDisplay {
|
||||
content_type: self.mime.clone().to_string(),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct GotifyMessageExtras {
|
||||
#[serde(rename = "client::display")]
|
||||
client_display: GotifyMessageExtrasClientDisplay,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct GotifyMessageExtrasClientDisplay {
|
||||
#[serde(rename = "contentType")]
|
||||
content_type: String,
|
||||
}
|
||||
|
||||
impl Communicator for GotifyCommunicator {
|
||||
fn name(&self) -> &'static str {
|
||||
"gotify"
|
||||
}
|
||||
fn supported_mimes(&self) -> Vec<&'static str> {
|
||||
vec!["text/plain", "text/markdown"]
|
||||
}
|
||||
fn send_message(&self, message: &super::Message) -> anyhow::Result<()> {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let response = client
|
||||
.post(&format!("{}/message", self.config.url))
|
||||
.header("X-Gotify-Key", &self.config.token)
|
||||
.json::<GotifyMessage>(&message.into())
|
||||
.send()?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
anyhow::bail!("Gotify returned an error: {:?}", response);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
99
src/comm/mod.rs
Normal file
99
src/comm/mod.rs
Normal file
|
@ -0,0 +1,99 @@
|
|||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use anyhow::Result;
|
||||
use log::{error, warn};
|
||||
|
||||
pub mod email;
|
||||
pub mod gotify;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Message {
|
||||
pub subject: String,
|
||||
pub body: String,
|
||||
pub mime: &'static str,
|
||||
pub priority: i8,
|
||||
}
|
||||
|
||||
impl Default for Message {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
subject: String::new(),
|
||||
body: String::new(),
|
||||
mime: MIME_PLAIN,
|
||||
priority: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub const MIME_PLAIN: &'static str = "text/plain";
|
||||
pub const MIME_HTML: &'static str = "text/html";
|
||||
|
||||
pub trait Communicator {
|
||||
fn name(&self) -> &'static str;
|
||||
fn supported_mimes(&self) -> Vec<&'static str>;
|
||||
fn send_message(&self, message: &Message) -> Result<()>;
|
||||
}
|
||||
|
||||
pub struct GlobalCommunicator {
|
||||
communicators: HashMap<&'static str, Vec<Arc<dyn Communicator + Sync + Send>>>,
|
||||
}
|
||||
|
||||
impl GlobalCommunicator {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
communicators: HashMap::new(),
|
||||
}
|
||||
}
|
||||
pub fn add_communicator(&mut self, communicator: Arc<dyn Communicator + Sync + Send>) {
|
||||
for mime in communicator.supported_mimes() {
|
||||
if !self.communicators.contains_key(mime) {
|
||||
self.communicators.insert(mime, Vec::new());
|
||||
}
|
||||
self.communicators
|
||||
.get_mut(mime)
|
||||
.unwrap()
|
||||
.push(communicator.clone());
|
||||
}
|
||||
}
|
||||
pub fn by_mime(&self, mime: &'static str) -> Option<&Vec<Arc<dyn Communicator + Sync + Send>>> {
|
||||
self.communicators.get(mime)
|
||||
}
|
||||
pub fn by_name(&self, name: &'static str) -> Option<&Arc<dyn Communicator + Sync + Send>> {
|
||||
for communicators in self.communicators.values() {
|
||||
for communicator in communicators {
|
||||
if communicator.name() == name {
|
||||
return Some(communicator);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl Communicator for GlobalCommunicator {
|
||||
fn name(&self) -> &'static str {
|
||||
"global"
|
||||
}
|
||||
|
||||
fn supported_mimes(&self) -> Vec<&'static str> {
|
||||
self.communicators.keys().map(|k| *k).collect()
|
||||
}
|
||||
|
||||
fn send_message(&self, message: &Message) -> Result<()> {
|
||||
let mime = message.mime;
|
||||
if let Some(communicators) = self.communicators.get(mime) {
|
||||
for communicator in communicators {
|
||||
if let Err(e) = communicator.send_message(message) {
|
||||
warn!("Failed to send message with {}: {}", communicator.name(), e);
|
||||
continue;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
error!("No communicators available for mime {}", mime);
|
||||
Err(anyhow::anyhow!(
|
||||
"No communicators available for mime {}",
|
||||
mime
|
||||
))
|
||||
}
|
||||
}
|
24
src/config/comm.rs
Normal file
24
src/config/comm.rs
Normal file
|
@ -0,0 +1,24 @@
|
|||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Config {
|
||||
pub email: EmailConfig,
|
||||
pub gotify: GotifyConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct EmailConfig {
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub default_subject: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GotifyConfig {
|
||||
pub url: String,
|
||||
pub token: String,
|
||||
}
|
79
src/config/mod.rs
Normal file
79
src/config/mod.rs
Normal file
|
@ -0,0 +1,79 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
pub mod comm;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Config {
|
||||
pub listen: ListenConfig,
|
||||
pub db: DbConfig,
|
||||
|
||||
pub canvas_lms: CanvasLmsConfig,
|
||||
|
||||
pub auth: AuthConfig,
|
||||
|
||||
pub session: SessionConfig,
|
||||
|
||||
pub webcheck: Option<HashMap<String, HashMap<String, String>>>,
|
||||
|
||||
pub comm: comm::Config,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load_yaml<R: std::io::Read>(reader: R) -> Self {
|
||||
let config = serde_yaml::from_reader(reader).expect("Failed to parse config");
|
||||
config
|
||||
}
|
||||
pub fn load_yaml_file<P: AsRef<std::path::Path>>(path: P) -> Self {
|
||||
let file = std::fs::File::open(path).expect("Failed to open config file");
|
||||
Self::load_yaml(file)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListenConfig {
|
||||
pub addr: String,
|
||||
pub cert: Option<String>,
|
||||
pub key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DbConfig {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CanvasLmsConfig {
|
||||
pub token: String,
|
||||
pub endpoint: String,
|
||||
pub refresh_interval: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AuthConfig {
|
||||
pub users: HashMap<String, AuthUser>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SessionConfig {
|
||||
pub secret: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AuthUser {
|
||||
pub password: String,
|
||||
pub roles: Vec<String>,
|
||||
}
|
||||
|
||||
static mut CURRENT_CONFIG: Option<Config> = None;
|
||||
|
||||
pub unsafe fn set_config(config: Config) {
|
||||
unsafe {
|
||||
CURRENT_CONFIG = Some(config);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_config() -> &'static Config {
|
||||
unsafe { CURRENT_CONFIG.as_ref().expect("Config not initialized") }
|
||||
}
|
108
src/http.rs
Normal file
108
src/http.rs
Normal file
|
@ -0,0 +1,108 @@
|
|||
use axum::{
|
||||
async_trait,
|
||||
body::{Bytes, HttpBody},
|
||||
extract::FromRequest,
|
||||
http::Request,
|
||||
response::{IntoResponse, Response},
|
||||
BoxError, Json,
|
||||
};
|
||||
use hyper::StatusCode;
|
||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||
|
||||
pub struct JsonApiForm<T>(pub T);
|
||||
|
||||
#[async_trait]
|
||||
impl<T, S, B> FromRequest<S, B> for JsonApiForm<T>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
S: Send + Sync,
|
||||
B: HttpBody + Send + 'static,
|
||||
B::Data: Send,
|
||||
B::Error: Into<BoxError>,
|
||||
{
|
||||
type Rejection = ApiResponse<()>;
|
||||
|
||||
async fn from_request(req: Request<B>, state: &S) -> Result<Self, Self::Rejection> {
|
||||
if req.headers().get("content-type") != Some(&"application/json".parse().unwrap()) {
|
||||
return Err(ApiResponse::error(
|
||||
"invalid content-type".to_string(),
|
||||
400,
|
||||
None,
|
||||
));
|
||||
}
|
||||
let bytes = Bytes::from_request(req, state)
|
||||
.await
|
||||
.map_err(|_| ApiResponse::error("failed reading request".to_string(), 400, None))?;
|
||||
|
||||
let des = &mut serde_json::Deserializer::from_slice(&bytes);
|
||||
|
||||
Ok(JsonApiForm(T::deserialize(des).map_err(|e| {
|
||||
ApiResponse::error(format!("failed parsing json: {}", e), 400, None)
|
||||
})?))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ApiResponse<T> {
|
||||
pub code: u16,
|
||||
pub status: ApiStatus,
|
||||
pub message: String,
|
||||
pub data: Option<T>,
|
||||
}
|
||||
|
||||
pub type ApiResult<T> = Result<ApiResponse<T>, ApiResponse<()>>;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub enum ApiStatus {
|
||||
Ok,
|
||||
Error,
|
||||
}
|
||||
|
||||
impl<T> ApiResponse<T> {
|
||||
pub const fn ok(message: String, data: Option<T>) -> Self {
|
||||
Self {
|
||||
code: 200,
|
||||
status: ApiStatus::Ok,
|
||||
message,
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn error(message: String, code: u16, data: Option<T>) -> Self {
|
||||
Self {
|
||||
code,
|
||||
status: ApiStatus::Error,
|
||||
message,
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn unauthorized(message: String, data: Option<T>) -> Self {
|
||||
Self {
|
||||
code: 401,
|
||||
status: ApiStatus::Error,
|
||||
message,
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn bad_request(message: String, data: Option<T>) -> Self {
|
||||
Self {
|
||||
code: 400,
|
||||
status: ApiStatus::Error,
|
||||
message,
|
||||
data,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> IntoResponse for ApiResponse<T>
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
fn into_response(self) -> Response {
|
||||
let resp = (StatusCode::from_u16(self.code).unwrap(), Json(self)).into_response();
|
||||
|
||||
resp
|
||||
}
|
||||
}
|
134
src/lib.rs
Normal file
134
src/lib.rs
Normal file
|
@ -0,0 +1,134 @@
|
|||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use apps::{med, webcheck};
|
||||
use axum::{http::Request, middleware::Next, response::Response, routing::get, Extension, Router};
|
||||
use axum_server::tls_rustls::RustlsConfig;
|
||||
use diesel::{sqlite, Connection};
|
||||
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
|
||||
use hyper::Method;
|
||||
use log::info;
|
||||
use tower_http::cors::{self, CorsLayer};
|
||||
|
||||
use crate::{
|
||||
apps::{auth, canvas_lms, server_info, App},
|
||||
comm::GlobalCommunicator,
|
||||
};
|
||||
|
||||
pub mod apps;
|
||||
pub mod config;
|
||||
|
||||
pub mod session;
|
||||
|
||||
pub mod models;
|
||||
pub mod schema;
|
||||
|
||||
pub mod http;
|
||||
|
||||
pub mod comm;
|
||||
|
||||
pub mod ui;
|
||||
|
||||
async fn log_middleware<B>(req: Request<B>, next: Next<B>) -> Response {
|
||||
info!("Request: {} {}", req.method(), req.uri());
|
||||
let res = next.run(req).await;
|
||||
info!("Response: {:?}", res);
|
||||
res
|
||||
}
|
||||
|
||||
const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
|
||||
|
||||
pub struct AppState {
|
||||
pub db: sqlite::SqliteConnection,
|
||||
pub comm: comm::GlobalCommunicator,
|
||||
}
|
||||
|
||||
pub fn establish_db_connection() -> sqlite::SqliteConnection {
|
||||
let db_url = &config::get_config().db.url;
|
||||
|
||||
let mut conn = sqlite::SqliteConnection::establish(db_url)
|
||||
.unwrap_or_else(|_| panic!("Error connecting to {}", db_url));
|
||||
|
||||
conn.run_pending_migrations(MIGRATIONS).unwrap();
|
||||
|
||||
conn
|
||||
}
|
||||
|
||||
pub async fn server_listen(router: Router) {
|
||||
let config = config::get_config();
|
||||
|
||||
let listen_addr = config
|
||||
.listen
|
||||
.addr
|
||||
.parse()
|
||||
.expect("Failed to parse listen address");
|
||||
if config.listen.cert.is_some() && config.listen.key.is_some() {
|
||||
let tls_config = RustlsConfig::from_pem_file(
|
||||
config.listen.cert.as_ref().unwrap(),
|
||||
config.listen.key.as_ref().unwrap(),
|
||||
)
|
||||
.await
|
||||
.expect("Failed to load TLS certificate and key");
|
||||
info!("Listening on https://{}", config.listen.addr);
|
||||
axum_server::bind_rustls(listen_addr, tls_config)
|
||||
.serve(router.into_make_service())
|
||||
.await
|
||||
.unwrap();
|
||||
} else {
|
||||
info!("Listening on http://{}", config.listen.addr);
|
||||
axum_server::bind(listen_addr)
|
||||
.serve(router.into_make_service())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn main_server(dev: bool) {
|
||||
let config = config::get_config();
|
||||
|
||||
let apps: &mut [Arc<dyn App>] = &mut [
|
||||
Arc::new(server_info::ServerInfoApp::new()),
|
||||
Arc::new(auth::AuthApp::new()),
|
||||
Arc::new(canvas_lms::CanvasLMSApp::new()),
|
||||
Arc::new(med::MedManagementApp::new()),
|
||||
Arc::new(webcheck::WebcheckApp::new()),
|
||||
];
|
||||
|
||||
let mut comm = GlobalCommunicator::new();
|
||||
comm.add_communicator(Arc::new(comm::gotify::GotifyCommunicator::new(config)));
|
||||
comm.add_communicator(Arc::new(comm::email::EmailCommunicator::new(config)));
|
||||
let app_state = Arc::new(Mutex::new(AppState {
|
||||
db: establish_db_connection(),
|
||||
comm: comm,
|
||||
}));
|
||||
|
||||
for app in &mut *apps {
|
||||
app.clone().initialize(config, app_state.clone()).await;
|
||||
}
|
||||
|
||||
let mut api_router = axum::Router::new();
|
||||
for app in apps {
|
||||
api_router = api_router.merge(app.clone().api_routes());
|
||||
}
|
||||
|
||||
let router = axum::Router::new()
|
||||
.nest("/api", api_router)
|
||||
.route("/", get(ui::redirect_to_ui))
|
||||
.route("/ui/", get(ui::ui_index_handler))
|
||||
.route("/ui/*path", get(ui::ui_path_handler))
|
||||
.layer(axum::middleware::from_fn(session::middleware))
|
||||
.layer(Extension(config))
|
||||
.layer(Extension(app_state))
|
||||
.layer(axum::middleware::from_fn(log_middleware));
|
||||
|
||||
let router = if dev {
|
||||
router.layer(
|
||||
CorsLayer::new()
|
||||
.allow_methods([Method::GET, Method::POST])
|
||||
.allow_origin(cors::Any),
|
||||
)
|
||||
} else {
|
||||
router
|
||||
};
|
||||
|
||||
server_listen(router).await;
|
||||
}
|
182
src/models/med.rs
Normal file
182
src/models/med.rs
Normal file
|
@ -0,0 +1,182 @@
|
|||
use std::str::FromStr;
|
||||
|
||||
use diesel::prelude::*;
|
||||
use lazy_static::lazy_static;
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Queryable, Selectable, Insertable, Serialize, Deserialize, Debug)]
|
||||
#[diesel(table_name = crate::schema::medications)]
|
||||
pub struct Medication {
|
||||
pub uuid: String,
|
||||
pub name: String,
|
||||
pub dosage: i32,
|
||||
pub dosage_unit: String,
|
||||
pub period_hours: i32,
|
||||
pub flags: String,
|
||||
pub options: String,
|
||||
pub created: chrono::NaiveDateTime,
|
||||
pub updated: chrono::NaiveDateTime,
|
||||
}
|
||||
|
||||
impl Medication {
|
||||
pub fn flags_split(&self) -> Vec<String> {
|
||||
self.flags.split(' ').map(|s| s.to_string()).collect()
|
||||
}
|
||||
pub fn options_split(&self) -> Vec<(String, String)> {
|
||||
lazy_static! {
|
||||
static ref REGEX_MED_OPTION: Regex = Regex::new(r"^([a-zA-Z]+)\((\w+)\)$").unwrap();
|
||||
};
|
||||
self.options
|
||||
.split(' ')
|
||||
.map(|s| {
|
||||
let caps = REGEX_MED_OPTION.captures(s).unwrap();
|
||||
(
|
||||
caps.get(1).unwrap().as_str().to_string(),
|
||||
caps.get(2).unwrap().as_str().to_string(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
pub const FLAGS_WITH_IMPLIED_FREQ: [&str; 2] = ["qhs", "qam"];
|
||||
|
||||
impl Into<String> for Medication {
|
||||
fn into(self) -> String {
|
||||
let mut output = String::new();
|
||||
|
||||
output.push_str(&self.name);
|
||||
output.push(' ');
|
||||
|
||||
output.push_str(&self.dosage.to_string());
|
||||
output.push_str(&self.dosage_unit);
|
||||
output.push(' ');
|
||||
|
||||
if !FLAGS_WITH_IMPLIED_FREQ.contains(&self.flags.as_str()) {
|
||||
match self.period_hours {
|
||||
6 => output.push_str("qid"),
|
||||
8 => output.push_str("tid"),
|
||||
12 => output.push_str("bid"),
|
||||
24 => output.push_str("qd"),
|
||||
_ => {
|
||||
if self.period_hours % 24 == 0 {
|
||||
output.push_str(&format!("q{}d", self.period_hours / 24));
|
||||
} else {
|
||||
output.push_str(&format!("q{}h", self.period_hours));
|
||||
}
|
||||
}
|
||||
}
|
||||
output.push(' ');
|
||||
}
|
||||
|
||||
output.push_str(&self.flags);
|
||||
output.push(' ');
|
||||
output.push_str(&self.options);
|
||||
|
||||
output.trim().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Medication {
|
||||
type Err = anyhow::Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
lazy_static! {
|
||||
static ref REGEX_NUMBER: Regex = Regex::new(r"^\d+$").unwrap();
|
||||
static ref REGEX_NUMBER_WITH_UNIT: Regex = Regex::new(r"^(\d+)(\w+)$").unwrap();
|
||||
static ref REGEX_MED_OPTION: Regex = Regex::new(r"^([a-zA-Z]+)\((\w+)\)$").unwrap();
|
||||
}
|
||||
let mut parts = s.split(' ');
|
||||
|
||||
let mut name = String::new();
|
||||
let mut flags = Vec::new();
|
||||
let mut options = Vec::new();
|
||||
let mut dosage = 0;
|
||||
let mut dosage_unit = None;
|
||||
for part in parts.by_ref() {
|
||||
if REGEX_NUMBER.is_match(part) {
|
||||
dosage = part.parse()?;
|
||||
break;
|
||||
} else if let Some(caps) = REGEX_NUMBER_WITH_UNIT.captures(part) {
|
||||
dosage = caps.get(1).unwrap().as_str().parse()?;
|
||||
dosage_unit = Some(caps.get(2).unwrap().as_str().to_string());
|
||||
break;
|
||||
} else {
|
||||
name.push_str(part);
|
||||
name.push(' ');
|
||||
}
|
||||
}
|
||||
if dosage_unit.is_none() {
|
||||
dosage_unit = parts.next().map(|s| s.to_string());
|
||||
}
|
||||
let period_spec = parts
|
||||
.next()
|
||||
.ok_or(anyhow::anyhow!("missing period spec"))?
|
||||
.to_lowercase();
|
||||
let period_hours = match period_spec.as_str() {
|
||||
"qd" => 24,
|
||||
"bid" => 12,
|
||||
"tid" => 8,
|
||||
"qid" => 6,
|
||||
"qhs" => {
|
||||
flags.push("qhs".to_string());
|
||||
24
|
||||
}
|
||||
"qam" => {
|
||||
flags.push("qam".to_string());
|
||||
24
|
||||
}
|
||||
_ => {
|
||||
if period_spec.starts_with("q") {
|
||||
let period_unit = period_spec.chars().last().unwrap();
|
||||
let period_duration = period_spec[1..period_spec.len() - 1].parse()?;
|
||||
match period_unit {
|
||||
'h' => period_duration,
|
||||
'd' => period_duration * 24,
|
||||
_ => return Err(anyhow::anyhow!("invalid period spec")),
|
||||
}
|
||||
} else {
|
||||
return Err(anyhow::anyhow!("invalid period spec"));
|
||||
}
|
||||
}
|
||||
};
|
||||
for part in parts {
|
||||
if let Some(caps) = REGEX_MED_OPTION.captures(part) {
|
||||
let opt_name = caps.get(1).unwrap().as_str();
|
||||
let opt_value = caps.get(2).unwrap().as_str();
|
||||
options.push((opt_name.to_string(), opt_value.to_string()));
|
||||
} else {
|
||||
flags.push(part.to_string());
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
uuid: uuid::Uuid::new_v4().to_string(),
|
||||
name: name.trim().to_string(),
|
||||
dosage,
|
||||
dosage_unit: dosage_unit.unwrap(),
|
||||
period_hours,
|
||||
flags: flags.join(" "),
|
||||
options: options
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}({})", k, v))
|
||||
.collect::<Vec<String>>()
|
||||
.join(" "),
|
||||
created: chrono::Utc::now().naive_utc(),
|
||||
updated: chrono::Utc::now().naive_utc(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Queryable, Selectable, Insertable, Serialize, Deserialize, Debug, Clone)]
|
||||
#[diesel(table_name = crate::schema::medication_logs)]
|
||||
pub struct MedicationLog {
|
||||
pub uuid: String,
|
||||
pub med_uuid: String,
|
||||
pub dosage: i32,
|
||||
pub time_actual: chrono::NaiveDateTime,
|
||||
pub time_expected: chrono::NaiveDateTime,
|
||||
pub dose_offset: f32,
|
||||
pub created: chrono::NaiveDateTime,
|
||||
pub updated: chrono::NaiveDateTime,
|
||||
}
|
74
src/models/med_test.rs
Normal file
74
src/models/med_test.rs
Normal file
|
@ -0,0 +1,74 @@
|
|||
use super::med::*;
|
||||
|
||||
#[test]
|
||||
pub fn test_parse_med() {
|
||||
let uuid_stub = "".to_string();
|
||||
let time_stub = chrono::Utc::now().naive_utc();
|
||||
let cases = vec![
|
||||
(
|
||||
"Atorvastatin 10mg QD",
|
||||
Medication {
|
||||
uuid: uuid_stub.clone(),
|
||||
name: "Atorvastatin".to_string(),
|
||||
dosage: 10,
|
||||
dosage_unit: "mg".to_string(),
|
||||
period_hours: 24,
|
||||
flags: "".to_string(),
|
||||
options: "".to_string(),
|
||||
created: time_stub.clone(),
|
||||
updated: time_stub.clone(),
|
||||
},
|
||||
),
|
||||
(
|
||||
"Something 10mg tid adlib",
|
||||
Medication {
|
||||
uuid: uuid_stub.clone(),
|
||||
name: "Something".to_string(),
|
||||
dosage: 10,
|
||||
dosage_unit: "mg".to_string(),
|
||||
period_hours: 8,
|
||||
flags: "adlib".to_string(),
|
||||
options: "".to_string(),
|
||||
created: time_stub.clone(),
|
||||
updated: time_stub.clone(),
|
||||
},
|
||||
),
|
||||
(
|
||||
"Metformin 500mg qHS",
|
||||
Medication {
|
||||
uuid: uuid_stub.clone(),
|
||||
name: "Metformin".to_string(),
|
||||
dosage: 500,
|
||||
dosage_unit: "mg".to_string(),
|
||||
period_hours: 24,
|
||||
flags: "qhs".to_string(),
|
||||
options: "".to_string(),
|
||||
created: time_stub.clone(),
|
||||
updated: time_stub.clone(),
|
||||
},
|
||||
),
|
||||
(
|
||||
"Hydroxyzine 50mg qid prn sched(whole)",
|
||||
Medication {
|
||||
uuid: uuid_stub.clone(),
|
||||
name: "Hydroxyzine".to_string(),
|
||||
dosage: 50,
|
||||
dosage_unit: "mg".to_string(),
|
||||
period_hours: 6,
|
||||
flags: "prn".to_string(),
|
||||
options: "sched(whole)".to_string(),
|
||||
created: time_stub.clone(),
|
||||
updated: time_stub.clone(),
|
||||
},
|
||||
),
|
||||
];
|
||||
for (input, expected) in cases {
|
||||
let actual = input.parse::<Medication>().unwrap();
|
||||
assert_eq!(actual.name, expected.name);
|
||||
assert_eq!(actual.dosage, expected.dosage);
|
||||
assert_eq!(actual.dosage_unit, expected.dosage_unit);
|
||||
assert_eq!(actual.period_hours, expected.period_hours);
|
||||
assert_eq!(actual.flags, expected.flags);
|
||||
assert_eq!(actual.options, expected.options);
|
||||
}
|
||||
}
|
4
src/models/mod.rs
Normal file
4
src/models/mod.rs
Normal file
|
@ -0,0 +1,4 @@
|
|||
pub mod med;
|
||||
#[cfg(test)]
|
||||
mod med_test;
|
||||
pub mod session;
|
9
src/models/session.rs
Normal file
9
src/models/session.rs
Normal file
|
@ -0,0 +1,9 @@
|
|||
use diesel::prelude::*;
|
||||
|
||||
#[derive(Queryable, Selectable, Insertable)]
|
||||
#[diesel(table_name = crate::schema::sessions)]
|
||||
pub struct Session {
|
||||
pub uuid: String,
|
||||
pub expiry: chrono::NaiveDateTime,
|
||||
pub content: String,
|
||||
}
|
44
src/schema.rs
Normal file
44
src/schema.rs
Normal file
|
@ -0,0 +1,44 @@
|
|||
// @generated automatically by Diesel CLI.
|
||||
|
||||
diesel::table! {
|
||||
medication_logs (uuid) {
|
||||
uuid -> Text,
|
||||
med_uuid -> Text,
|
||||
dosage -> Integer,
|
||||
time_actual -> Timestamp,
|
||||
time_expected -> Timestamp,
|
||||
dose_offset -> Float,
|
||||
created -> Timestamp,
|
||||
updated -> Timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
medications (uuid) {
|
||||
uuid -> Text,
|
||||
name -> Text,
|
||||
dosage -> Integer,
|
||||
dosage_unit -> Text,
|
||||
period_hours -> Integer,
|
||||
flags -> Text,
|
||||
options -> Text,
|
||||
created -> Timestamp,
|
||||
updated -> Timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
sessions (uuid) {
|
||||
uuid -> Text,
|
||||
expiry -> Timestamp,
|
||||
content -> Text,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::joinable!(medication_logs -> medications (med_uuid));
|
||||
|
||||
diesel::allow_tables_to_appear_in_same_query!(
|
||||
medication_logs,
|
||||
medications,
|
||||
sessions,
|
||||
);
|
71
src/session/mod.rs
Normal file
71
src/session/mod.rs
Normal file
|
@ -0,0 +1,71 @@
|
|||
use axum::{http::Request, middleware::Next, response::Response, Extension};
|
||||
use base64::{engine::general_purpose, Engine};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{apps::auth::SessionAuth, config::Config};
|
||||
|
||||
mod wrap;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct SessionStore {
|
||||
pub auth: SessionAuth,
|
||||
}
|
||||
|
||||
const SESSION_COOKIE_NAME: &str = "session_state";
|
||||
|
||||
pub async fn middleware<B>(
|
||||
Extension(config): Extension<&Config>,
|
||||
mut req: Request<B>,
|
||||
next: Next<B>,
|
||||
) -> Response {
|
||||
let mut session_state = SessionStore::default();
|
||||
|
||||
let mut key = [0u8; 32];
|
||||
general_purpose::STANDARD
|
||||
.decode_slice(config.session.secret.as_bytes(), &mut key)
|
||||
.expect("Failed to decode session secret");
|
||||
|
||||
{
|
||||
let cookies = req
|
||||
.headers()
|
||||
.get("Cookie")
|
||||
.map(|c| c.to_str().unwrap_or(""))
|
||||
.unwrap_or("")
|
||||
.split("; ")
|
||||
.map(|c| {
|
||||
let mut parts = c.splitn(2, '=');
|
||||
(
|
||||
parts.next().unwrap_or("").to_string(),
|
||||
parts.next().unwrap_or("").to_string(),
|
||||
)
|
||||
});
|
||||
|
||||
for (name, value) in cookies {
|
||||
if name == SESSION_COOKIE_NAME {
|
||||
if let Some(store) = wrap::unwrap_json::<SessionStore>(&value, key.as_ref()) {
|
||||
session_state = store;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
req.extensions_mut().insert(session_state);
|
||||
|
||||
let mut resp = next.run(req).await;
|
||||
|
||||
if let Some(new_store) = resp.extensions().get::<SessionStore>() {
|
||||
let wrapped = wrap::wrap_json(new_store, key.as_ref());
|
||||
|
||||
resp.headers_mut().insert(
|
||||
"Set-Cookie",
|
||||
format!(
|
||||
"{}={}; Path=/; HttpOnly; Max-Age=31536000",
|
||||
SESSION_COOKIE_NAME, wrapped
|
||||
)
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
|
||||
resp
|
||||
}
|
60
src/session/wrap.rs
Normal file
60
src/session/wrap.rs
Normal file
|
@ -0,0 +1,60 @@
|
|||
use aes_gcm::{
|
||||
aead::{Aead, OsRng},
|
||||
AeadCore, Aes256Gcm, Key, KeyInit, Nonce,
|
||||
};
|
||||
use base64::{engine::general_purpose, Engine};
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
|
||||
pub fn wrap_json<T: Serialize>(data: T, key: &[u8]) -> String {
|
||||
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
|
||||
let key = Key::<Aes256Gcm>::from_slice(key);
|
||||
let cipher = Aes256Gcm::new(&key);
|
||||
|
||||
let b64_engine = general_purpose::STANDARD;
|
||||
|
||||
let plaintext = serde_json::to_string(&data).unwrap();
|
||||
let ciphertext = cipher
|
||||
.encrypt(&nonce, plaintext.as_bytes())
|
||||
.expect("Failed to encrypt");
|
||||
|
||||
let mut output = String::new();
|
||||
|
||||
output.push_str(&b64_engine.encode(&nonce));
|
||||
output.push_str(":");
|
||||
b64_engine.encode_string(&ciphertext, &mut output);
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
pub fn unwrap_json<T: DeserializeOwned>(data: &str, key: &[u8]) -> Option<T> {
|
||||
let data = data.splitn(2, ':').collect::<Vec<_>>();
|
||||
let nonce_b64 = data.get(0)?;
|
||||
let ciphertext_b64 = data.get(1)?;
|
||||
|
||||
let nonce = general_purpose::STANDARD.decode(nonce_b64).ok()?;
|
||||
let ciphertext = general_purpose::STANDARD.decode(ciphertext_b64).ok()?;
|
||||
|
||||
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(key));
|
||||
let nonce = Nonce::from_slice(&nonce);
|
||||
let plaintext = cipher
|
||||
.decrypt(&nonce, ciphertext.as_slice())
|
||||
.expect("Failed to decrypt");
|
||||
|
||||
let plaintext_utf8 = String::from_utf8(plaintext).ok()?;
|
||||
|
||||
serde_json::from_str(&plaintext_utf8).ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn test_wrap_json() {
|
||||
let data = "test";
|
||||
let key: [u8; 32] = [0; 32];
|
||||
|
||||
let wrapped = super::wrap_json(data, key.as_ref());
|
||||
let unwrapped = super::unwrap_json::<String>(&wrapped, key.as_ref()).unwrap();
|
||||
|
||||
assert_eq!(data, unwrapped);
|
||||
}
|
||||
}
|
103
src/ui.rs
Normal file
103
src/ui.rs
Normal file
|
@ -0,0 +1,103 @@
|
|||
use std::io::Write;
|
||||
|
||||
use axum::{
|
||||
body::HttpBody, extract::Path, http::Request, response::IntoResponse, response::Response,
|
||||
routing::get, Router,
|
||||
};
|
||||
use flate2::write::GzEncoder;
|
||||
use hyper::{header, StatusCode};
|
||||
use rust_embed::RustEmbed;
|
||||
|
||||
#[derive(RustEmbed)]
|
||||
#[folder = "ui/dist"]
|
||||
struct UiAssets;
|
||||
|
||||
pub async fn serve_file(path: &str, accepted_encodings: Vec<&str>) -> Response {
|
||||
let ext = path.split('.').last().unwrap_or("");
|
||||
let content_type = match ext {
|
||||
"html" => "text/html",
|
||||
"css" => "text/css",
|
||||
"js" => "text/javascript",
|
||||
"png" => "image/png",
|
||||
"jpg" => "image/jpeg",
|
||||
"jpeg" => "image/jpeg",
|
||||
"gif" => "image/gif",
|
||||
"svg" => "image/svg+xml",
|
||||
_ => "text/plain",
|
||||
};
|
||||
|
||||
if let Some(asset) = UiAssets::get(path) {
|
||||
if accepted_encodings.contains(&"gzip") {
|
||||
let mut encoder = GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
encoder.write_all(asset.data.as_ref()).unwrap();
|
||||
let data = encoder.finish().unwrap();
|
||||
return (
|
||||
StatusCode::OK,
|
||||
[
|
||||
(header::CONTENT_TYPE, content_type),
|
||||
(header::CONTENT_ENCODING, "gzip"),
|
||||
],
|
||||
data,
|
||||
)
|
||||
.into_response();
|
||||
} else {
|
||||
(
|
||||
StatusCode::OK,
|
||||
[(header::CONTENT_TYPE, content_type)],
|
||||
asset.data.to_vec(),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
} else {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
[(header::CONTENT_TYPE, "text/plain")],
|
||||
format!("File not found: {}", path),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn ui_path_handler<B: HttpBody>(Path(path): Path<String>, req: Request<B>) -> Response {
|
||||
let mut path = if path.ends_with("/") {
|
||||
path + "index.html"
|
||||
} else {
|
||||
path
|
||||
};
|
||||
path = path.trim_start_matches('/').to_owned();
|
||||
|
||||
let accepted_encodings = req
|
||||
.headers()
|
||||
.get(header::ACCEPT_ENCODING)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.split(", ");
|
||||
|
||||
serve_file(&path, accepted_encodings.collect()).await
|
||||
}
|
||||
|
||||
pub async fn ui_index_handler<B: HttpBody>(req: Request<B>) -> Response {
|
||||
let accepted_encodings = req
|
||||
.headers()
|
||||
.get(header::ACCEPT_ENCODING)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.split(", ");
|
||||
|
||||
serve_file("index.html", accepted_encodings.collect()).await
|
||||
}
|
||||
|
||||
pub async fn redirect_to_ui() -> Response {
|
||||
(
|
||||
StatusCode::TEMPORARY_REDIRECT,
|
||||
[(header::LOCATION, "/ui/")],
|
||||
"",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub fn ui_router(_dev: bool) -> Router {
|
||||
Router::new()
|
||||
.route("/*path", get(ui_path_handler))
|
||||
.fallback(get(ui_index_handler))
|
||||
}
|
28
templates/grades.html
Normal file
28
templates/grades.html
Normal file
|
@ -0,0 +1,28 @@
|
|||
<h2>お姫様の成績がアップデートされました!</h2>
|
||||
<h2><a href="https://yoake.yumechi.jp/#/grades">最近の採点</a></h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>Name</td>
|
||||
<td>Course</td>
|
||||
<td>Assignment</td>
|
||||
<td>Due</td>
|
||||
<td>Grade</td>
|
||||
<td>Graded At</td>
|
||||
<td>Posted At</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for grade in grades %}
|
||||
<tr>
|
||||
<td>{{ grade.submission_username }}</td>
|
||||
<td>{{ grade.course_code }}</td>
|
||||
<td><a href="{{grade.assignment_url}}">{{ grade.name }}</a></td>
|
||||
<td>{{ grade.due }}
|
||||
<td>{{ grade.grade }}</td>
|
||||
<td>{{ grade.graded_at }} </td>
|
||||
<td>{{ grade.posted_at }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
14
ui/.eslintrc.cjs
Normal file
14
ui/.eslintrc.cjs
Normal file
|
@ -0,0 +1,14 @@
|
|||
module.exports = {
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': 'warn',
|
||||
},
|
||||
}
|
24
ui/.gitignore
vendored
Normal file
24
ui/.gitignore
vendored
Normal file
|
@ -0,0 +1,24 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
1
ui/.server_pid
Normal file
1
ui/.server_pid
Normal file
|
@ -0,0 +1 @@
|
|||
346497
|
16
ui/index.html
Normal file
16
ui/index.html
Normal file
|
@ -0,0 +1,16 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/ui/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>夜明け</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
35
ui/package.json
Normal file
35
ui/package.json
Normal file
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"name": "ui",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@mui/icons-material": "^5.11.16",
|
||||
"@mui/material": "^5.13.4",
|
||||
"@mui/system": "^5.13.2",
|
||||
"@mui/x-data-grid": "^6.7.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.12.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.37",
|
||||
"@types/react-dom": "^18.0.11",
|
||||
"@typescript-eslint/eslint-plugin": "^5.59.0",
|
||||
"@typescript-eslint/parser": "^5.59.0",
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"eslint": "^8.38.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.3.4",
|
||||
"typescript": "^5.0.2",
|
||||
"vite": "^4.3.9"
|
||||
}
|
||||
}
|
BIN
ui/public/favicon.ico
Normal file
BIN
ui/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.4 KiB |
42
ui/src/App.css
Normal file
42
ui/src/App.css
Normal file
|
@ -0,0 +1,42 @@
|
|||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
83
ui/src/App.tsx
Normal file
83
ui/src/App.tsx
Normal file
|
@ -0,0 +1,83 @@
|
|||
|
||||
import './App.css'
|
||||
import { useState } from 'react';
|
||||
import { RouterProvider, createHashRouter } from 'react-router-dom';
|
||||
import PageBase from './PageBase';
|
||||
import { ThemeProvider } from '@emotion/react';
|
||||
import theme from './theme';
|
||||
import { LoginContext } from './context/LoginContext';
|
||||
import { EmptyAuthInfo, getLoginState } from './api/auth';
|
||||
import HomePage from './pages/HomePage';
|
||||
import LoginDialog from './pages/LoginDialog';
|
||||
import GradesPage from './pages/GradesPage';
|
||||
import FramePage from './pages/FramePage';
|
||||
import { EnsureRole } from './components/EnsureRole';
|
||||
import MedsPage from './pages/MedsPage';
|
||||
|
||||
|
||||
const persistent_pages = [
|
||||
{
|
||||
path: "/gotify_ui",
|
||||
element: <FramePage url="https://gotify.yumechi.jp/" />
|
||||
}
|
||||
];
|
||||
|
||||
function App() {
|
||||
|
||||
const [auth, setAuth] = useState(EmptyAuthInfo);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
const refreshAuth = () => {
|
||||
getLoginState().then((auth) => {
|
||||
setAuth(auth);
|
||||
}
|
||||
).catch((error) => {
|
||||
console.error(error);
|
||||
setAuth(EmptyAuthInfo);
|
||||
});
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
setMounted(true);
|
||||
refreshAuth();
|
||||
}
|
||||
|
||||
const router = createHashRouter([
|
||||
{
|
||||
path: "/",
|
||||
element: <PageBase persistentPages={persistent_pages} ><HomePage /></PageBase>
|
||||
},
|
||||
{
|
||||
path: "/grades",
|
||||
element: <PageBase persistentPages={persistent_pages} ><EnsureRole role="Admin"><GradesPage /></EnsureRole></PageBase>
|
||||
},
|
||||
{
|
||||
path: "/meds",
|
||||
element: <PageBase persistentPages={persistent_pages} ><EnsureRole role="Admin"><MedsPage /></EnsureRole></PageBase>
|
||||
},
|
||||
{
|
||||
path: "/gotify_ui",
|
||||
element: <PageBase persistentPages={persistent_pages} ></PageBase>
|
||||
},
|
||||
{
|
||||
path: "/login",
|
||||
element: <PageBase persistentPages={persistent_pages} ><LoginDialog open={true}
|
||||
onClose={(_, navigate) => navigate("/")}
|
||||
/></PageBase>
|
||||
}
|
||||
]);
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<LoginContext.Provider value={{
|
||||
auth,
|
||||
setAuth: setAuth,
|
||||
refreshAuth: refreshAuth,
|
||||
}}>
|
||||
<RouterProvider router={router} />
|
||||
</LoginContext.Provider>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
175
ui/src/PageBase.tsx
Normal file
175
ui/src/PageBase.tsx
Normal file
|
@ -0,0 +1,175 @@
|
|||
import { ReactNode, useContext, useState } from 'react'
|
||||
import './App.css'
|
||||
import { AppBar, Button, Divider, IconButton, Toolbar, Drawer, List, ListItem, ListItemButton, ListItemText } from '@mui/material'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { Box, Container } from '@mui/system'
|
||||
import MenuIcon from '@mui/icons-material/Menu'
|
||||
import HomeIcon from '@mui/icons-material/Home'
|
||||
import GradeIcon from '@mui/icons-material/Grade'
|
||||
import CampaignIcon from '@mui/icons-material/Campaign'
|
||||
import MedicationIcon from '@mui/icons-material/Medication'
|
||||
import { useMatches, useNavigate } from 'react-router-dom'
|
||||
import { LoginContext } from './context/LoginContext'
|
||||
import { ServerInfo, getServerInfo } from './api/server_info'
|
||||
|
||||
interface PersistentPage {
|
||||
path: string;
|
||||
element: ReactNode;
|
||||
};
|
||||
|
||||
const drawerWidth = 240;
|
||||
|
||||
function routerMatch(matches: { pathname: string }[], path: string) {
|
||||
for (let i = 0; i < matches.length; i++) {
|
||||
if (matches[i].pathname === path) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
function PageBase({ children, persistentPages }: { children?: ReactNode, persistentPages: PersistentPage[] }) {
|
||||
const navigate = useNavigate();
|
||||
const matches = useMatches();
|
||||
|
||||
const { auth } = useContext(LoginContext);
|
||||
const [openMenu, setOpenMenu] = useState(false);
|
||||
|
||||
const handleMenuToggle = () => {
|
||||
setOpenMenu(!openMenu);
|
||||
};
|
||||
|
||||
const [server_info, setServerInfo] = useState<ServerInfo | null>(null);
|
||||
if (!server_info) {
|
||||
getServerInfo().then((serverInfo) => {
|
||||
setServerInfo(serverInfo);
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
|
||||
const drawer = (
|
||||
<div>
|
||||
<Toolbar />
|
||||
<Divider />
|
||||
<List>
|
||||
{[
|
||||
{ key: "home", name: "Home", icon: <HomeIcon />, path: "/" },
|
||||
{ key: "grades", name: "Grades", icon: <GradeIcon />, path: "/grades" },
|
||||
{ key: "meds", name: "Meds", icon: <MedicationIcon />, path: "/meds" },
|
||||
{ key: "gotify", name: "Gotify", icon: <CampaignIcon />, path: "/gotify_ui" },
|
||||
].map((item) => (
|
||||
<ListItem key={item.key}
|
||||
onClick={() => {
|
||||
navigate(item.path);
|
||||
}}
|
||||
sx={{ color: routerMatch(matches, item.path) ? "#f00" : "#000" }}
|
||||
>
|
||||
<ListItemButton>
|
||||
{item.icon}
|
||||
<ListItemText primary={item.name} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))
|
||||
}
|
||||
</List>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "flex" }}>
|
||||
|
||||
<AppBar
|
||||
position="fixed"
|
||||
sx={{
|
||||
width: { md: `calc(100% - ${drawerWidth}px)` },
|
||||
ml: { md: `${drawerWidth}px` },
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="lg">
|
||||
<Toolbar disableGutters>
|
||||
<IconButton edge="start" color="inherit" aria-label="menu"
|
||||
onClick={handleMenuToggle}
|
||||
sx={{ mr: 2, display: { md: 'none' } }}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
<Typography variant="h6" component="a"
|
||||
onClick={() => { navigate("/") }}
|
||||
sx={{ color: 'inherit', textDecoration: 'none', display: { md: 'flex' }, }} >
|
||||
夜明け
|
||||
</Typography>
|
||||
<Box sx={{ flexGrow: 0 }}>
|
||||
<Typography variant="body1" component="i"
|
||||
sx={{ color: '#808080', display: 'block', marginLeft: '0.6em' }} >
|
||||
{server_info?.version ? `${server_info.version} (${server_info.profile})` : "Unknown"}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
{
|
||||
!auth.valid ?
|
||||
<Button color="inherit" onClick={() => navigate("/login")}>Login</Button>
|
||||
:
|
||||
<Button disabled color="inherit">{auth.display_name}</Button>
|
||||
}
|
||||
</Toolbar>
|
||||
</Container>
|
||||
</AppBar >
|
||||
<Box
|
||||
component="nav"
|
||||
sx={{ width: { md: drawerWidth }, flexShrink: { md: 0 } }}
|
||||
>
|
||||
<Drawer
|
||||
container={window.document.body}
|
||||
variant="temporary"
|
||||
open={openMenu}
|
||||
onClose={handleMenuToggle}
|
||||
ModalProps={{
|
||||
keepMounted: true, // Better open performance on mobile.
|
||||
}}
|
||||
sx={{
|
||||
display: { xs: 'block', md: 'none' },
|
||||
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth },
|
||||
}}
|
||||
>
|
||||
{drawer}
|
||||
</Drawer>
|
||||
<Drawer
|
||||
variant="permanent"
|
||||
sx={{
|
||||
display: { xs: 'none', md: 'block' },
|
||||
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth },
|
||||
}}
|
||||
open
|
||||
>
|
||||
{drawer}
|
||||
</Drawer>
|
||||
</Box>
|
||||
|
||||
{
|
||||
(children ?
|
||||
<Box
|
||||
sx={{ width: "100%", display: "block", marginTop: "4em", padding: "1em" }}>
|
||||
{children}
|
||||
</Box> : null)
|
||||
}
|
||||
|
||||
{
|
||||
persistentPages.map((page) => {
|
||||
return (
|
||||
<Box key={page.path} sx={{
|
||||
width: "100%", display: routerMatch(matches, page.path)
|
||||
? "block" : "none"
|
||||
, marginTop: "4em", padding: "1em"
|
||||
}}>
|
||||
{page.element}
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
}
|
||||
</Box >
|
||||
)
|
||||
}
|
||||
|
||||
export default PageBase
|
42
ui/src/api/auth.ts
Normal file
42
ui/src/api/auth.ts
Normal file
|
@ -0,0 +1,42 @@
|
|||
import { makeJSONRequest } from "./request";
|
||||
|
||||
export type Role = "Admin" | "User";
|
||||
|
||||
export interface AuthInfo {
|
||||
valid: boolean,
|
||||
user: string,
|
||||
display_name: string,
|
||||
roles: Role[],
|
||||
}
|
||||
|
||||
export const EmptyAuthInfo: AuthInfo = {
|
||||
valid: false,
|
||||
user: "",
|
||||
display_name: "",
|
||||
roles: [],
|
||||
}
|
||||
|
||||
export async function getLoginState(): Promise<AuthInfo> {
|
||||
const response = await makeJSONRequest<AuthInfo>("/api/auth/self", "GET");
|
||||
return response.data;
|
||||
}
|
||||
|
||||
interface PostLoginParams {
|
||||
username: string,
|
||||
password: string,
|
||||
}
|
||||
|
||||
export async function postLogin(params: PostLoginParams): Promise<AuthInfo> {
|
||||
const response = await makeJSONRequest<AuthInfo>("/api/auth/login", "POST", {
|
||||
username: params.username,
|
||||
password: params.password,
|
||||
});
|
||||
if (response.status != "Ok") {
|
||||
throw new Error(response.message);
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function postLogout(): Promise<void> {
|
||||
await makeJSONRequest<void>("/api/auth/logout", "POST");
|
||||
}
|
41
ui/src/api/canvas_lms.ts
Normal file
41
ui/src/api/canvas_lms.ts
Normal file
|
@ -0,0 +1,41 @@
|
|||
import { makeJSONRequest } from "./request";
|
||||
|
||||
|
||||
export interface Grading {
|
||||
name: string,
|
||||
course_name: string,
|
||||
course_code: string,
|
||||
|
||||
assignment_id: string,
|
||||
assignment_legacy_id: string,
|
||||
assignment_url: string,
|
||||
submission_id: string,
|
||||
submission_legacy_id: string,
|
||||
course_id: string,
|
||||
course_legacy_id: string,
|
||||
|
||||
due_at: string | null,
|
||||
state: string,
|
||||
score: number | null,
|
||||
entered_score: number | null,
|
||||
possible_points: number,
|
||||
grade: string,
|
||||
grade_hidden: boolean,
|
||||
entered_grade: string,
|
||||
|
||||
graded_at: string | null,
|
||||
posted_at: string | null,
|
||||
}
|
||||
|
||||
export interface GetGradingsResponse {
|
||||
last_updated: string,
|
||||
response: Grading[],
|
||||
}
|
||||
|
||||
export async function getGradings(force: boolean): Promise<GetGradingsResponse> {
|
||||
let ret = await makeJSONRequest<GetGradingsResponse>("/api/canvas_lms/grades" + (force ? "?force_refresh=true" : ""), "GET");
|
||||
if (ret.status != "Ok") {
|
||||
throw new Error(ret.message);
|
||||
}
|
||||
return ret.data;
|
||||
}
|
55
ui/src/api/med_directive.ts
Normal file
55
ui/src/api/med_directive.ts
Normal file
|
@ -0,0 +1,55 @@
|
|||
import { makeJSONRequest } from "./request";
|
||||
|
||||
export interface Medication {
|
||||
uuid: string,
|
||||
name: string,
|
||||
dosage: number,
|
||||
dosage_unit: string,
|
||||
period_hours: number,
|
||||
flags: string,
|
||||
options: string,
|
||||
}
|
||||
|
||||
export async function parseShorthand(shorthand: string): Promise<Medication> {
|
||||
const url = "/api/med/parse_shorthand";
|
||||
const method = "POST";
|
||||
const body = {
|
||||
shorthand: shorthand,
|
||||
};
|
||||
return (await makeJSONRequest<Medication>(url, method, body)).data;
|
||||
}
|
||||
|
||||
export async function formatShorthand(med: Medication): Promise<string> {
|
||||
const url = "/api/med/format_shorthand";
|
||||
const method = "POST";
|
||||
const body = med;
|
||||
return (await makeJSONRequest<string>(url, method, body)).data;
|
||||
}
|
||||
|
||||
export async function getDirectives(): Promise<Medication[]> {
|
||||
const url = "/api/med/directive";
|
||||
const method = "GET";
|
||||
return (await makeJSONRequest<Medication[]>(url, method)).data;
|
||||
}
|
||||
|
||||
export async function postDirective(med: Medication): Promise<Medication> {
|
||||
const url = "/api/med/directive";
|
||||
const method = "POST";
|
||||
const body = med;
|
||||
return (await makeJSONRequest<Medication>(url, method, body)).data;
|
||||
}
|
||||
|
||||
export async function patchDirective(med: Medication): Promise<Medication> {
|
||||
const url = "/api/med/directive";
|
||||
const method = "PATCH";
|
||||
const body = med;
|
||||
|
||||
return (await makeJSONRequest<Medication>(url, method, body)).data;
|
||||
}
|
||||
|
||||
export async function deleteDirective(uuid: string): Promise<void> {
|
||||
const url = "/api/med/directive/" + uuid;
|
||||
const method = "DELETE";
|
||||
|
||||
await makeJSONRequest<void>(url, method);
|
||||
}
|
61
ui/src/api/med_log.ts
Normal file
61
ui/src/api/med_log.ts
Normal file
|
@ -0,0 +1,61 @@
|
|||
import { makeJSONRequest } from "./request";
|
||||
|
||||
/*
|
||||
pub struct MedicationLog {
|
||||
pub uuid: String,
|
||||
pub med_uuid: String,
|
||||
pub dosage: i32,
|
||||
pub time_actual: chrono::NaiveDateTime,
|
||||
pub time_expected: chrono::NaiveDateTime,
|
||||
pub dose_offset: f32,
|
||||
pub created: chrono::NaiveDateTime,
|
||||
pub updated: chrono::NaiveDateTime,
|
||||
}
|
||||
|
||||
*/
|
||||
|
||||
export interface MedicationLog {
|
||||
uuid: string,
|
||||
med_uuid: string,
|
||||
dosage: number,
|
||||
time_actual: string,
|
||||
time_expected: string,
|
||||
dose_offset: number,
|
||||
created: string,
|
||||
updated: string,
|
||||
}
|
||||
|
||||
export async function projectNextDose(med_uuid: string): Promise<MedicationLog> {
|
||||
const url = `/api/med/directive/${med_uuid}/project_next_dose`;
|
||||
const method = "GET";
|
||||
return (await makeJSONRequest<MedicationLog>(url, method)).data;
|
||||
}
|
||||
|
||||
export interface GetMedicationLogParams {
|
||||
until?: Date
|
||||
limit: number,
|
||||
}
|
||||
|
||||
export async function getMedicationLog(med_uuid: string, params: GetMedicationLogParams): Promise<MedicationLog[]> {
|
||||
let url = `/api/med/directive/${med_uuid}/log?limit=${params.limit}`;
|
||||
if (params.until) {
|
||||
url += `&until=${params.until.toISOString()}`;
|
||||
}
|
||||
const method = "GET";
|
||||
return (await makeJSONRequest<MedicationLog[]>(url, method)).data;
|
||||
}
|
||||
|
||||
export async function postMedicationLog(med: MedicationLog): Promise<MedicationLog> {
|
||||
const uri = `/api/med/directive/${med.med_uuid}/log`;
|
||||
const method = "POST";
|
||||
const body = med;
|
||||
return (await makeJSONRequest<MedicationLog>(uri, method, body)).data;
|
||||
}
|
||||
|
||||
|
||||
export async function deleteMedicationLog(med_uuid: string, uuid: string): Promise<void> {
|
||||
const url = `/api/med/directive/${med_uuid}/log/${uuid}`;
|
||||
const method = "DELETE";
|
||||
|
||||
await makeJSONRequest<void>(url, method);
|
||||
}
|
23
ui/src/api/request.ts
Normal file
23
ui/src/api/request.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
|
||||
|
||||
type APIStatus = "Ok" | "Error";
|
||||
|
||||
type APIResponse<T> = {
|
||||
code: number;
|
||||
status: APIStatus,
|
||||
message: string,
|
||||
data: T,
|
||||
|
||||
}
|
||||
|
||||
export async function makeJSONRequest<T>(url: string, method: string, body?: any): Promise<APIResponse<T>> {
|
||||
const response = await fetch(url, {
|
||||
method: method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const data = await response.json();
|
||||
return data;
|
||||
}
|
11
ui/src/api/server_info.ts
Normal file
11
ui/src/api/server_info.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
import { makeJSONRequest } from "./request";
|
||||
|
||||
export interface ServerInfo {
|
||||
version: string,
|
||||
profile: string,
|
||||
};
|
||||
|
||||
export async function getServerInfo(): Promise<ServerInfo> {
|
||||
const response = await makeJSONRequest<ServerInfo>("/api/server_info", "GET");
|
||||
return response.data;
|
||||
}
|
12
ui/src/api/time.ts
Normal file
12
ui/src/api/time.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
export function format_rust_naive_date(date: Date): string {
|
||||
const rfc3339 = date.toISOString();
|
||||
|
||||
// Rust's NaiveDateTime::parse_from_rfc3339() doesn't like the trailing 'Z'
|
||||
// that JS's toISOString() adds, so we strip it off.
|
||||
return rfc3339.slice(0, rfc3339.length - 1);
|
||||
|
||||
}
|
||||
|
||||
export function parse_rust_naive_date(date: string): Date {
|
||||
return new Date(Date.parse(date + "Z"));
|
||||
}
|
1
ui/src/assets/react.svg
Normal file
1
ui/src/assets/react.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
After Width: | Height: | Size: 4 KiB |
18
ui/src/components/EnsureRole.tsx
Normal file
18
ui/src/components/EnsureRole.tsx
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { useContext } from "react";
|
||||
import { LoginContext } from "../context/LoginContext";
|
||||
import { Role } from "../api/auth";
|
||||
import LoginDialog from "../pages/LoginDialog";
|
||||
|
||||
export function EnsureRole({ children, role }: { children: React.ReactNode, role: Role }) {
|
||||
const { auth } = useContext(LoginContext);
|
||||
|
||||
if (auth.roles.includes(role)) {
|
||||
return <>{children}</>;
|
||||
} else {
|
||||
return <><LoginDialog open={true} onClose={(success) => {
|
||||
if (!success) {
|
||||
window.history.back();
|
||||
}
|
||||
}} /></>;
|
||||
}
|
||||
}
|
65
ui/src/components/TimeBody.tsx
Normal file
65
ui/src/components/TimeBody.tsx
Normal file
|
@ -0,0 +1,65 @@
|
|||
import { Tooltip, Typography } from "@mui/material";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface TimeBodyProps {
|
||||
time: Date | null;
|
||||
}
|
||||
|
||||
|
||||
function formatRelativeTime(time: Date): [string, number] {
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - time.getTime();
|
||||
if (diff >= 0) {
|
||||
if (diff < 1000) {
|
||||
return ["just now", 1000];
|
||||
}
|
||||
if (diff < 60 * 1000) {
|
||||
return [`${Math.floor(diff / 1000)} seconds ago`, 1000];
|
||||
} else if (diff < 60 * 60 * 1000) {
|
||||
return [`${Math.floor(diff / 1000 / 60)} minutes ago`, 60 * 1000];
|
||||
} else if (diff < 24 * 60 * 60 * 1000) {
|
||||
return [`${Math.floor(diff / 1000 / 60 / 60)} hours ago`, 60 * 60 * 1000];
|
||||
} else {
|
||||
return [`${Math.floor(diff / 1000 / 60 / 60 / 24)} days ago`, 24 * 60 * 60 * 1000];
|
||||
}
|
||||
} else {
|
||||
if (diff > -1000) {
|
||||
return ["just now", 1000];
|
||||
}
|
||||
if (diff > -60 * 1000) {
|
||||
return [`${Math.floor(-diff / 1000)} seconds from now`, 1000];
|
||||
} else if (diff > -60 * 60 * 1000) {
|
||||
return [`${Math.floor(-diff / 1000 / 60)} minutes from now`, 60 * 1000];
|
||||
} else if (diff > -24 * 60 * 60 * 1000) {
|
||||
return [`${Math.floor(-diff / 1000 / 60 / 60)} hours from now`, 60 * 60 * 1000];
|
||||
} else {
|
||||
return [`${Math.floor(-diff / 1000 / 60 / 60 / 24)} days from now`, 24 * 60 * 60 * 1000];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function TimeBody(props: TimeBodyProps) {
|
||||
const { time } = props;
|
||||
if (time === null) {
|
||||
return <>N/A</>;
|
||||
}
|
||||
|
||||
const [relativeTime, setRelativeTime] = useState(formatRelativeTime(time)[0]);
|
||||
|
||||
useEffect(() => {
|
||||
let timer: number | null = null;
|
||||
const update = () => {
|
||||
const [relativeTime, interval] = formatRelativeTime(time);
|
||||
setRelativeTime(relativeTime);
|
||||
timer = setTimeout(update, interval);
|
||||
}
|
||||
update();
|
||||
return () => clearTimeout(timer!);
|
||||
}, [time]);
|
||||
|
||||
|
||||
return <Tooltip title={time.toLocaleString()} sx={{ display: "inline-block" }}>
|
||||
<Typography variant="body2" color="textSecondary"> {relativeTime}</Typography>
|
||||
</Tooltip>
|
||||
|
||||
}
|
8
ui/src/context/LoginContext.ts
Normal file
8
ui/src/context/LoginContext.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import { createContext } from "react";
|
||||
import { AuthInfo, EmptyAuthInfo } from "../api/auth";
|
||||
|
||||
export const LoginContext = createContext({
|
||||
auth: EmptyAuthInfo,
|
||||
setAuth: (_auth: AuthInfo) => { },
|
||||
refreshAuth: () => { },
|
||||
});
|
13
ui/src/index.css
Normal file
13
ui/src/index.css
Normal file
|
@ -0,0 +1,13 @@
|
|||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
10
ui/src/main.tsx
Normal file
10
ui/src/main.tsx
Normal file
|
@ -0,0 +1,10 @@
|
|||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
21
ui/src/pages/FramePage.tsx
Normal file
21
ui/src/pages/FramePage.tsx
Normal file
|
@ -0,0 +1,21 @@
|
|||
import { useEffect } from "react";
|
||||
|
||||
|
||||
type FramePageProps = {
|
||||
url: string;
|
||||
sx?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export default function FramePage(props: FramePageProps) {
|
||||
useEffect(() => {
|
||||
console.log("FramePage mounted, url: " + props.url);
|
||||
return () => {
|
||||
console.log("FramePage unmounted, url: " + props.url);
|
||||
}
|
||||
}, [props.url]);
|
||||
return (
|
||||
<iframe src={props.url} style={{
|
||||
width: "100%", height: "100%", border: "none", aspectRatio: "16/9", ...props.sx
|
||||
}}></iframe>
|
||||
)
|
||||
}
|
195
ui/src/pages/GradesPage.tsx
Normal file
195
ui/src/pages/GradesPage.tsx
Normal file
|
@ -0,0 +1,195 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { Grading, getGradings, GetGradingsResponse } from "../api/canvas_lms";
|
||||
import { Typography, Box, Button, Link } from "@mui/material";
|
||||
import { DataGrid, GridCellParams, GridColDef } from "@mui/x-data-grid";
|
||||
import TimeBody from "../components/TimeBody";
|
||||
|
||||
|
||||
const renderTimeCell = (params: GridCellParams) => {
|
||||
const value = params.value as Date | null;
|
||||
return <TimeBody time={value} />;
|
||||
}
|
||||
|
||||
|
||||
const grading_columns: GridColDef[] = [
|
||||
{
|
||||
field: "course_code",
|
||||
headerName: "Course",
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
field: "name",
|
||||
headerName: "Name",
|
||||
width: 200,
|
||||
renderCell: (params) => {
|
||||
const row = params.row as Grading;
|
||||
return <Link href={row.assignment_url}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
color="inherit"
|
||||
>{row.name}</Link>;
|
||||
}
|
||||
},
|
||||
{
|
||||
field: "grade",
|
||||
headerName: "Grade",
|
||||
minWidth: 250,
|
||||
valueGetter: (params) => {
|
||||
const row = params.row as Grading;
|
||||
if (row.grade_hidden) {
|
||||
return "Hidden";
|
||||
} else if (row.score === null) {
|
||||
return "Not Graded";
|
||||
}
|
||||
const percentage = row.score! / row.possible_points;
|
||||
return `${row.score!.toFixed(2)} (${row.grade}) / ${row.possible_points} (${(percentage * 100).toFixed(2)}%)`;
|
||||
},
|
||||
flex: 1,
|
||||
cellClassName: (params) => {
|
||||
const row = params.row as Grading;
|
||||
if (row.grade_hidden) {
|
||||
return "grade_hidden";
|
||||
} else if (row.score === null) {
|
||||
return "grade_not_graded";
|
||||
}
|
||||
const percentage = row.score! / row.possible_points;
|
||||
if (percentage < 0.6) {
|
||||
return "grade_bad";
|
||||
} else if (percentage < 0.8) {
|
||||
return "grade_ok";
|
||||
} else if (percentage < 1.0) {
|
||||
return "grade_good";
|
||||
} else {
|
||||
return "grade_perfect";
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
field: "graded_at",
|
||||
headerName: "Graded At",
|
||||
minWidth: 100,
|
||||
valueGetter: (params) => {
|
||||
const row = params.row as Grading;
|
||||
return row.graded_at ? new Date(row.graded_at) : null;
|
||||
},
|
||||
flex: 1,
|
||||
renderCell: renderTimeCell
|
||||
},
|
||||
{
|
||||
field: "posted_at",
|
||||
headerName: "Posted At",
|
||||
minWidth: 100,
|
||||
valueGetter: (params) => {
|
||||
const row = params.row as Grading;
|
||||
return row.posted_at ? new Date(row.posted_at) : null;
|
||||
},
|
||||
flex: 1,
|
||||
renderCell: renderTimeCell
|
||||
},
|
||||
{
|
||||
field: "updated_at",
|
||||
headerName: "Updated At",
|
||||
minWidth: 100,
|
||||
valueGetter: (params) => {
|
||||
const row = params.row as Grading;
|
||||
let ret = null;
|
||||
if (row.posted_at !== null)
|
||||
ret = new Date(row.posted_at);
|
||||
if (row.graded_at !== null && (ret == null || new Date(row.graded_at) > ret))
|
||||
ret = new Date(row.graded_at);
|
||||
return ret;
|
||||
},
|
||||
flex: 1,
|
||||
renderCell: renderTimeCell
|
||||
}
|
||||
];
|
||||
|
||||
function GradePage() {
|
||||
const [mounted, setMounted] = useState<boolean>(false);
|
||||
const [gradings, setGradings] = useState<GetGradingsResponse | null>(null);
|
||||
const [updating, setUpdating] = useState<boolean>(false);
|
||||
|
||||
|
||||
const updateGradings = (force: boolean) => {
|
||||
setUpdating(true);
|
||||
getGradings(force).then((gradings) => {
|
||||
setGradings(gradings);
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
}).finally(() => {
|
||||
setUpdating(false);
|
||||
});
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
setMounted(true);
|
||||
updateGradings(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => updateGradings(false), 5 * 60 * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
Grades
|
||||
{
|
||||
gradings ?
|
||||
<Box>
|
||||
<Typography variant="caption" display="block" gutterBottom>
|
||||
Last updated: <TimeBody time={new Date(gradings.last_updated)} />
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained" onClick={() => updateGradings(true)} disabled={updating}
|
||||
sx={{ marginBottom: 2 }}
|
||||
>Refresh</Button>
|
||||
<DataGrid
|
||||
rows={gradings.response}
|
||||
columns={grading_columns}
|
||||
density="compact"
|
||||
getRowId={(row) => row.submission_id}
|
||||
autoHeight={true}
|
||||
sortModel={[{
|
||||
field: "updated_at",
|
||||
sort: "desc",
|
||||
}]}
|
||||
initialState={{
|
||||
pagination: {
|
||||
paginationModel: {
|
||||
pageSize: 25,
|
||||
}
|
||||
},
|
||||
}}
|
||||
sx={{
|
||||
"& .grade_hidden": {
|
||||
color: "grey",
|
||||
},
|
||||
"& .grade_not_graded": {
|
||||
color: "grey",
|
||||
},
|
||||
"& .grade_bad": {
|
||||
color: "red",
|
||||
},
|
||||
"& .grade_ok": {
|
||||
color: "orange",
|
||||
},
|
||||
"& .grade_good": {
|
||||
color: "blue",
|
||||
},
|
||||
"& .grade_perfect": {
|
||||
color: "green",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
: "Loading..."
|
||||
}
|
||||
</Typography>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default GradePage
|
117
ui/src/pages/HomePage.tsx
Normal file
117
ui/src/pages/HomePage.tsx
Normal file
|
@ -0,0 +1,117 @@
|
|||
import { styled } from '@mui/material/styles';
|
||||
import { useState, useEffect, useContext } from 'react';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import Card from '@mui/material/Card';
|
||||
import { CardContent, CardHeader, Typography, Button, Box } from '@mui/material';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
|
||||
import { LoginContext } from '../context/LoginContext';
|
||||
import { postLogout } from '../api/auth';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const CardItem = styled(Card)(({ theme }) => ({
|
||||
...theme.typography.body2,
|
||||
padding: theme.spacing(1),
|
||||
textAlign: 'center',
|
||||
backgroundColor: "#fff4f5",
|
||||
}))
|
||||
|
||||
function dateInZone(zone?: string) {
|
||||
if (zone)
|
||||
return new Date(new Date().toLocaleString("en-US", { timeZone: zone }));
|
||||
return new Date()
|
||||
}
|
||||
|
||||
function ClockCard() {
|
||||
|
||||
const theme = useTheme();
|
||||
const [time, setTime] = useState(new Date());
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setTime(new Date());
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
return (<CardItem>
|
||||
<CardHeader title="Clock" sx={{ backgroundColor: theme.palette.primary.main }} />
|
||||
<CardContent>
|
||||
<Typography variant="h3" component="div">
|
||||
{time.toLocaleTimeString()}
|
||||
</Typography>
|
||||
<hr />
|
||||
{
|
||||
["America/Los_Angeles", "America/New_York", "America/Chicago",
|
||||
"Asia/Tokyo", "Asia/Shanghai", "UTC"].map((zone) => {
|
||||
return (
|
||||
<Box key={"clock-" + zone}>
|
||||
<Typography variant="body1" component="div" sx={{ textDecoration: "italic" }}>
|
||||
{zone}
|
||||
</Typography>
|
||||
<Typography variant="h5" component="div" sx={{ paddingBottom: "1em" }}>
|
||||
{dateInZone(zone).toLocaleTimeString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
}
|
||||
</CardContent>
|
||||
</CardItem>);
|
||||
}
|
||||
|
||||
function HomePage() {
|
||||
|
||||
const theme = useTheme();
|
||||
const { auth, refreshAuth } = useContext(LoginContext);
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
|
||||
<Grid container spacing={4} sx={{ minWidth: "80%", flexGrow: 2 }} columns={{ xs: 4, sm: 8, md: 12 }}>
|
||||
<Grid item xs={4} sm={8} md={8}>
|
||||
<CardItem>
|
||||
<CardHeader title="Welcome" sx={{ backgroundColor: theme.palette.primary.main }} />
|
||||
<CardContent>
|
||||
<Typography variant="h6" component="div">
|
||||
夜明け前が一番暗い。
|
||||
</Typography>
|
||||
<Typography variant="body2" component="div">
|
||||
The darkest hour is just before the dawn.
|
||||
</Typography>
|
||||
<hr />
|
||||
<Typography variant="body2" component="div">
|
||||
This is yoake.yumechi.jp, Yumechi's <abbr title="Personal Information Manager" className="initialism">PIM</abbr>. <br />
|
||||
Built with axum and React.
|
||||
</Typography>
|
||||
<hr />
|
||||
{
|
||||
auth.valid ?
|
||||
<>
|
||||
<Typography variant="body2" component="div">
|
||||
Welcome, {auth.display_name}
|
||||
</Typography>
|
||||
<Button variant="contained" onClick={() => {
|
||||
postLogout().then(refreshAuth).catch(console.error);
|
||||
}}>Logout</Button>
|
||||
</>
|
||||
:
|
||||
<>
|
||||
<Typography variant="body2" component="div">
|
||||
You are not logged in
|
||||
</Typography>
|
||||
<Button variant="contained" onClick={() => navigate("/login")}>Login</Button>
|
||||
</>
|
||||
}
|
||||
|
||||
</CardContent>
|
||||
</CardItem>
|
||||
</Grid>
|
||||
<Grid item xs={4} sm={8} md={4}>
|
||||
<ClockCard />
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
export default HomePage;
|
89
ui/src/pages/LoginDialog.tsx
Normal file
89
ui/src/pages/LoginDialog.tsx
Normal file
|
@ -0,0 +1,89 @@
|
|||
import {
|
||||
Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle,
|
||||
TextField, Button, Alert
|
||||
} from "@mui/material";
|
||||
import { Box } from "@mui/system";
|
||||
import { postLogin } from "../api/auth";
|
||||
import { LoginContext } from "../context/LoginContext";
|
||||
import { useContext, useRef, useState } from "react";
|
||||
import { Navigate, NavigateFunction, useNavigate } from "react-router-dom";
|
||||
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onClose?: (success: boolean, navigate: NavigateFunction) => void;
|
||||
}
|
||||
export default function LoginDialog({ open, onClose }: Props) {
|
||||
|
||||
const { auth, refreshAuth } = useContext(LoginContext);
|
||||
const usernameNode = useRef<HTMLInputElement>(null);
|
||||
const passwordNode = useRef<HTMLInputElement>(null);
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
return auth.valid ? <Navigate to="/" /> :
|
||||
|
||||
(
|
||||
<Dialog open={open} onClose={() => onClose?.(false, navigate)}>
|
||||
<Box component="form" onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (
|
||||
!usernameNode.current ||
|
||||
!passwordNode.current ||
|
||||
!usernameNode.current
|
||||
) {
|
||||
setLoginError("Internal error: missing form elements.");
|
||||
return;
|
||||
}
|
||||
const username = usernameNode.current?.value;
|
||||
const password = passwordNode.current?.value;
|
||||
postLogin({ username: username!, password: password! }).then((auth) => {
|
||||
console.log("Got new auth state: ", auth);
|
||||
refreshAuth();
|
||||
onClose?.(true, navigate);
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
setLoginError(error.message);
|
||||
refreshAuth();
|
||||
});
|
||||
}}>
|
||||
<DialogTitle>Login</DialogTitle>
|
||||
<DialogContent>
|
||||
{
|
||||
loginError ?
|
||||
<Alert severity="error">{loginError}</Alert>
|
||||
: <Alert severity="info">Present Credentials.</Alert>
|
||||
}
|
||||
<DialogContentText>
|
||||
Userpass login:
|
||||
</DialogContentText>
|
||||
|
||||
<TextField
|
||||
id="username"
|
||||
label="Username"
|
||||
variant="standard"
|
||||
fullWidth
|
||||
inputRef={usernameNode}
|
||||
/>
|
||||
<TextField
|
||||
id="password"
|
||||
label="Password"
|
||||
variant="standard"
|
||||
type="password"
|
||||
fullWidth
|
||||
inputRef={passwordNode}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
{
|
||||
onClose ?
|
||||
<Button onClick={() => onClose?.(false, navigate)}>Back</Button>
|
||||
:
|
||||
<Button onClick={() => navigate("/")}>Cancel</Button>
|
||||
}
|
||||
<Button type="submit">Login</Button>
|
||||
</DialogActions>
|
||||
</Box>
|
||||
</Dialog >
|
||||
)
|
||||
}
|
273
ui/src/pages/MedsPage.tsx
Normal file
273
ui/src/pages/MedsPage.tsx
Normal file
|
@ -0,0 +1,273 @@
|
|||
import { useState } from "react";
|
||||
import { Accordion, AccordionDetails, AccordionSummary, Typography, TextField, Box, Divider, Button } from "@mui/material"
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import { parseShorthand, Medication, postDirective, patchDirective, getDirectives, deleteDirective, formatShorthand } from "../api/med_directive";
|
||||
import { MedicationLog, deleteMedicationLog, getMedicationLog, postMedicationLog, projectNextDose } from "../api/med_log";
|
||||
import { DataGrid, GridColDef } from "@mui/x-data-grid";
|
||||
import TimeBody from "../components/TimeBody";
|
||||
import { format_rust_naive_date, parse_rust_naive_date } from "../api/time";
|
||||
|
||||
|
||||
function DirectionEditor({ onUpdate }: { onUpdate?: (medication: Medication) => void }) {
|
||||
const [direction, setDirection] = useState<Medication | null>(null);
|
||||
|
||||
return (
|
||||
<Accordion>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMoreIcon />}
|
||||
>
|
||||
<Typography>Edit Direction</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<TextField fullWidth
|
||||
label="Shorthand" variant="outlined"
|
||||
onChange={(event) => {
|
||||
parseShorthand(event.target.value)
|
||||
.then((medication) => {
|
||||
medication.uuid = direction?.uuid || "";
|
||||
setDirection(medication);
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<TextField fullWidth
|
||||
label="UUID" variant="outlined"
|
||||
value={direction?.uuid || ""} onChange={(event) => {
|
||||
setDirection({ ...direction!, uuid: event.target.value });
|
||||
}} />
|
||||
<TextField label="Name"
|
||||
variant="standard"
|
||||
value={direction?.name || ""} onChange={(event) => {
|
||||
setDirection({ ...direction!, name: event.target.value });
|
||||
}} />
|
||||
<TextField label="Dosage"
|
||||
type="number"
|
||||
variant="standard"
|
||||
value={direction?.dosage || ""} onChange={(event) => {
|
||||
setDirection({ ...direction!, dosage: parseInt(event.target.value) });
|
||||
}} />
|
||||
<TextField label="Dosage Unit"
|
||||
variant="standard"
|
||||
value={direction?.dosage_unit || ""} onChange={(event) => {
|
||||
setDirection({ ...direction!, dosage_unit: event.target.value });
|
||||
}} />
|
||||
<br />
|
||||
<TextField label="Period Hours"
|
||||
type="number"
|
||||
variant="standard"
|
||||
value={direction?.period_hours || ""} onChange={(event) => {
|
||||
setDirection({ ...direction!, period_hours: parseInt(event.target.value) });
|
||||
}} />
|
||||
<TextField label="Flags"
|
||||
variant="standard"
|
||||
value={direction?.flags || ""} onChange={(event) => {
|
||||
setDirection({ ...direction!, flags: event.target.value });
|
||||
}} />
|
||||
<TextField label="Options"
|
||||
variant="standard"
|
||||
value={direction?.options || ""} onChange={(event) => {
|
||||
setDirection({ ...direction!, options: event.target.value });
|
||||
}} />
|
||||
<Divider sx={{ margin: 1 }} />
|
||||
<Button variant="contained" onClick={() => {
|
||||
((direction?.uuid) ? patchDirective : postDirective)
|
||||
(direction!).then((response) => {
|
||||
setDirection(response);
|
||||
onUpdate?.(response);
|
||||
});
|
||||
}}>{direction?.uuid ? "Update" : "Create"}</Button>
|
||||
<Button variant="contained"
|
||||
disabled={!direction?.uuid}
|
||||
onClick={() => {
|
||||
deleteDirective(direction!.uuid).then(() => {
|
||||
let deleted = direction!;
|
||||
setDirection(null);
|
||||
onUpdate?.(deleted);
|
||||
});
|
||||
}}>Delete</Button>
|
||||
</AccordionDetails>
|
||||
</Accordion >
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
interface MedPanelForm {
|
||||
dosage: number;
|
||||
time_actual: Date | null;
|
||||
}
|
||||
|
||||
function MedPanel({ medication }: { medication: Medication }) {
|
||||
const [shorthand, setShorthand] = useState("");
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [log, setLog] = useState<MedicationLog[]>([]);
|
||||
const [nextDose, setNextDose] = useState<MedicationLog | null>(null);
|
||||
const [form, setForm] = useState<MedPanelForm>({ dosage: medication.dosage, time_actual: null });
|
||||
|
||||
const updateShorthand = () => {
|
||||
formatShorthand(medication).then((shorthand) => {
|
||||
setShorthand(shorthand);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
const updateLog = () => {
|
||||
getMedicationLog(medication.uuid, { limit: 100 }).then((log) => {
|
||||
setLog(log);
|
||||
});
|
||||
}
|
||||
|
||||
const updateNextDose = () => {
|
||||
projectNextDose(medication.uuid).then((nextDose) => {
|
||||
setNextDose(nextDose);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
if (!mounted) {
|
||||
updateShorthand();
|
||||
updateLog();
|
||||
updateNextDose();
|
||||
setMounted(true);
|
||||
}
|
||||
|
||||
const med_log_columns: GridColDef[] = [
|
||||
{
|
||||
field: "Action",
|
||||
headerName: "Action", minWidth: 100,
|
||||
renderCell: (params) => {
|
||||
const log = params.row as MedicationLog;
|
||||
return (
|
||||
<Button variant="contained" onClick={() => {
|
||||
if (confirm(`Delete log entry ${log.uuid}?`)) {
|
||||
deleteMedicationLog(log.med_uuid, log.uuid).then(() => {
|
||||
updateLog();
|
||||
});
|
||||
}
|
||||
}}>Delete</Button>
|
||||
)
|
||||
}
|
||||
},
|
||||
{ field: 'dosage', headerName: 'Dosage', minWidth: 100 },
|
||||
{
|
||||
field: 'time_actual', headerName: 'Time Actual', minWidth: 200,
|
||||
renderCell: (params) =>
|
||||
<TimeBody time={parse_rust_naive_date(params.value as string)} />
|
||||
|
||||
},
|
||||
{
|
||||
field: 'time_expected', headerName: 'Time Expected', minWidth: 200,
|
||||
renderCell: (params) =>
|
||||
<TimeBody time={parse_rust_naive_date(params.value as string)} />
|
||||
},
|
||||
{
|
||||
field: 'dose_offset', headerName: 'Dose Offset', minWidth: 100,
|
||||
renderCell: (params) => {
|
||||
const log = params.row as MedicationLog;
|
||||
return (
|
||||
<>
|
||||
{
|
||||
log.dose_offset.toFixed(2)
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
return (
|
||||
<Accordion>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMoreIcon />}
|
||||
>
|
||||
<Typography>
|
||||
{shorthand}
|
||||
{
|
||||
nextDose ? <Typography variant="caption" component="span"> Next Dose:
|
||||
{
|
||||
nextDose.dose_offset >= 0 ? <span style={{ color: "red" }}> now </span> :
|
||||
<TimeBody time={
|
||||
parse_rust_naive_date(nextDose.time_expected)
|
||||
} />}
|
||||
</Typography> : null
|
||||
}
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box sx={{ padding: "1em", textAlign: "left" }}>
|
||||
UUID: {medication.uuid}
|
||||
</Box>
|
||||
<Box sx={{ padding: "1em", textAlign: "left" }}>
|
||||
<TextField label="Dose" variant="standard" type="number" value={form.dosage} onChange={(event) => {
|
||||
setForm({ ...form, dosage: parseInt(event.target.value) });
|
||||
}} />
|
||||
<TextField label="Time" variant="standard" type="datetime-local"
|
||||
onChange={(event) => {
|
||||
setForm({ ...form, time_actual: new Date(event.target.value) });
|
||||
}} />
|
||||
<Button fullWidth variant="contained" onClick={() => {
|
||||
const content = {
|
||||
...nextDose!,
|
||||
dosage: form.dosage,
|
||||
time_actual: format_rust_naive_date(form.time_actual || new Date()),
|
||||
med_uuid: medication.uuid,
|
||||
}
|
||||
postMedicationLog(content).then(() => {
|
||||
setForm({ dosage: medication.dosage, time_actual: null });
|
||||
updateLog();
|
||||
updateNextDose();
|
||||
});
|
||||
}}>Log</Button>
|
||||
</Box>
|
||||
<DataGrid
|
||||
rows={log}
|
||||
density="compact"
|
||||
columns={med_log_columns}
|
||||
getRowId={(row) => row.uuid}
|
||||
autoHeight
|
||||
initialState={{
|
||||
pagination: {
|
||||
paginationModel: {
|
||||
pageSize: 25,
|
||||
}
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</AccordionDetails>
|
||||
</Accordion >
|
||||
)
|
||||
}
|
||||
|
||||
function MedsPage() {
|
||||
const [medications, setMedications] = useState<Medication[] | null>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
const refreshMedications = () => {
|
||||
getDirectives().then((response) => {
|
||||
setMedications(response);
|
||||
});
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
refreshMedications();
|
||||
setMounted(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
'& .MuiTextField-root': { margin: 1 },
|
||||
}}
|
||||
>
|
||||
{
|
||||
medications?.map((medication) => {
|
||||
return <MedPanel medication={medication} key={medication.uuid} />
|
||||
})
|
||||
}
|
||||
<DirectionEditor onUpdate={() => {
|
||||
refreshMedications();
|
||||
}} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default MedsPage
|
11
ui/src/theme.ts
Normal file
11
ui/src/theme.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
import { createTheme } from '@mui/material/styles';
|
||||
|
||||
const theme = createTheme({
|
||||
palette: {
|
||||
primary: {
|
||||
main: "#F8C3CD",
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
export default theme;
|
1
ui/src/vite-env.d.ts
vendored
Normal file
1
ui/src/vite-env.d.ts
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
25
ui/tsconfig.json
Normal file
25
ui/tsconfig.json
Normal file
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
10
ui/tsconfig.node.json
Normal file
10
ui/tsconfig.node.json
Normal file
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
15
ui/vite.config.ts
Normal file
15
ui/vite.config.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
base: "/ui/",
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:3000",
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
1983
ui/yarn.lock
Normal file
1983
ui/yarn.lock
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue