This commit is contained in:
ゆめ 2023-06-18 18:17:28 -05:00
commit 70a93aeb0b
79 changed files with 9396 additions and 0 deletions

1
.env Normal file
View file

@ -0,0 +1 @@
DATABASE_URL=sqlite://data/development.db

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
data
target
config-dev.yaml
config-prod.yaml

2698
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

52
Cargo.toml Normal file
View 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
View 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
View 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
View file

View file

@ -0,0 +1,2 @@
-- This file should undo anything in `up.sql`
drop table sessions;

View 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
);

View file

@ -0,0 +1,3 @@
-- This file should undo anything in `up.sql`
drop table medications;
drop table medication_logs;

View 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
View 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
View 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
View 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
View 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
View 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"));
}
}

View 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()),
}
}
}

View 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()
}
}

View 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
View 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
View 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
View 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
View 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
View 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;

View 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))
}
}

View 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);
}
}
}
}

View file

@ -0,0 +1 @@
pub mod chrome;

199
src/apps/webcheck/mod.rs Normal file
View 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()))
}
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,4 @@
pub mod med;
#[cfg(test)]
mod med_test;
pub mod session;

9
src/models/session.rs Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1 @@
346497

16
ui/index.html Normal file
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

42
ui/src/App.css Normal file
View 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
View 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
View 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
View 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
View 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;
}

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

View 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();
}
}} /></>;
}
}

View 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>
}

View 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
View 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
View 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>,
)

View 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
View 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
View 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;

View 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
View 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
View 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
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

25
ui/tsconfig.json Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load diff