Added trivial web ui.

This commit is contained in:
didier amyot committed 2023-01-30 07:52:38 -05:00
1 parent f99513a8c8
commit 894f969d0f
11 files changed
+1382 -19

No files matched your search

Generated
+1116 -6
View File
File diff suppressed because it is too large. Load diff
+6
View File
@@ -21,6 +21,12 @@ regex = "1.7.0"
lazy_static = "1.4.0" lazy_static = "1.4.0"
tempfile = "3.3.0" tempfile = "3.3.0"
fraction = "0.13.0" fraction = "0.13.0"
actix-web = "4.3.0"
tokio = { version = "1.24.2", features = ["full"] }
minijinja = { version = "0.30.2", features = ["source"] }
actix-utils = "3.0.1"
actix-web-lab = "0.18.9"
minijinja-autoreload = "0.30.2"
[dependencies.apache-avro] [dependencies.apache-avro]
version = "0.14.0" version = "0.14.0"
+12
View File
@@ -22,3 +22,15 @@ nix build .#development-environment
nix develop nix develop
``` ```
## Workwork
* [ ] Parametrize `CURRENT_TIMESTAMP`
### Todo
#### API
#### Front end
* [ ] return hello world.
+6 -6
View File
@@ -190,16 +190,16 @@ pub fn is_owner(tx: &Connection, account_id: &AccountId, user_id: &UserId) -> Cu
Ok(option.is_some()) Ok(option.is_some())
} }
pub struct Receipt { pub struct AccountChangeReceipt {
pub account_id: AccountId, pub account_id: AccountId,
pub version: i64, pub version: i64,
pub timestamp: i64, pub timestamp: i64,
pub amount: i64, pub amount: i64,
} }
impl Receipt { impl AccountChangeReceipt {
pub fn from_row(row: &Row) -> Result<Receipt> { pub fn from_row(row: &Row) -> Result<AccountChangeReceipt> {
Ok(Receipt { Ok(AccountChangeReceipt {
account_id: AccountId::from_id(row.get("id")?), account_id: AccountId::from_id(row.get("id")?),
version: row.get("version")?, version: row.get("version")?,
amount: row.get("amount")?, amount: row.get("amount")?,
@@ -224,7 +224,7 @@ fn update_account_history(tx: &Connection, account_id: &AccountId) -> CustomResu
pub fn add_to_account(tx: &Connection, pub fn add_to_account(tx: &Connection,
account_id: &AccountId, account_id: &AccountId,
amount: i64) -> CustomResult<Receipt> { amount: i64) -> CustomResult<AccountChangeReceipt> {
let q = " let q = "
UPDATE accounts SET amount = CASE account_type UPDATE accounts SET amount = CASE account_type
WHEN 'security_emitter' WHEN 'security_emitter'
@@ -236,7 +236,7 @@ pub fn add_to_account(tx: &Connection,
RETURNING id, version, amount, UNIXEPOCH(CURRENT_TIMESTAMP) as timestamp RETURNING id, version, amount, UNIXEPOCH(CURRENT_TIMESTAMP) as timestamp
"; ";
let params = params![amount, account_id]; let params = params![amount, account_id];
let ret = tx.query_row(q, params, Receipt::from_row).map_err(Error::SqlError); let ret = tx.query_row(q, params, AccountChangeReceipt::from_row).map_err(Error::SqlError);
update_account_history(tx, account_id)?; update_account_history(tx, account_id)?;
return ret; return ret;
} }
+1 -1
View File
@@ -20,7 +20,7 @@ pub mod users {
pub mod accounts { pub mod accounts {
pub use super::_accounts::{Account, pub use super::_accounts::{Account,
Receipt, AccountChangeReceipt,
create_checking_account, create_checking_account,
create_security_emitter_account, create_security_emitter_account,
is_owner, is_owner,
+80 -3
View File
@@ -1,7 +1,7 @@
use std::path::PathBuf; use std::path::PathBuf;
use clap::{Arg, ArgAction, ArgMatches, Command, value_parser};
use clap::parser::ValuesRef; use clap::parser::ValuesRef;
use clap::{value_parser, Arg, ArgAction, ArgMatches, Command};
use log::{debug, error, info}; use log::{debug, error, info};
use rusqlite::Connection; use rusqlite::Connection;
use serde::Serialize; use serde::Serialize;
@@ -10,14 +10,16 @@ use simple_logger::SimpleLogger;
use db::securities::list_securities; use db::securities::list_securities;
use crate::db::users::{create_user, list_users}; use crate::db::users::{create_user, list_users};
use crate::event_processor::{process_event_files}; use crate::event_processor::process_event_files;
use crate::migration::apply_migrations; use crate::migration::apply_migrations;
use crate::webui::web_ui_main;
mod db; mod db;
mod event_processor; mod event_processor;
mod migration; mod migration;
mod services; mod services;
mod util; mod util;
mod webui;
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
struct Output<T> { struct Output<T> {
@@ -42,6 +44,8 @@ trait CommandLineUserLogic {
fn list_securities(&mut self, conn: &Connection); fn list_securities(&mut self, conn: &Connection);
fn process_events(&mut self, conn: &mut Connection, files: ValuesRef<PathBuf>); fn process_events(&mut self, conn: &mut Connection, files: ValuesRef<PathBuf>);
fn run_webui(&mut self, conn: &mut Connection, port: u16, enable_template_autoreload: bool);
} }
fn verbosity_arg() -> Arg { fn verbosity_arg() -> Arg {
@@ -130,12 +134,36 @@ fn ls_securities_command_line_parser() -> Command {
.about("List all securities."); .about("List all securities.");
} }
fn run_web_ui_command_line_parser() -> Command {
return Command::new("run")
.arg(Arg::new("port")
.long("port")
.value_parser(clap::value_parser!(u16).range(3000..))
.action(ArgAction::Set)
.default_value("3200"))
.arg(Arg::new("enable-template-autoreload")
.long("enable-template-autoreload")
.required(false)
.num_args(0)
.help("Reload underlying template (for development)")
)
.about("Run web user interface.");
}
fn securities_command_line_parser() -> Command { fn securities_command_line_parser() -> Command {
return Command::new("securities") return Command::new("securities")
.about("Handy command about securities.")
.subcommand(ls_securities_command_line_parser()) .subcommand(ls_securities_command_line_parser())
.arg_required_else_help(true); .arg_required_else_help(true);
} }
fn web_ui_command_line_parser() -> Command {
return Command::new("wui")
.about("Web user interface.")
.subcommand(run_web_ui_command_line_parser())
.arg_required_else_help(true);
}
fn command_line_parser() -> Command { fn command_line_parser() -> Command {
return Command::new("prog") return Command::new("prog")
.arg(verbosity_arg()) .arg(verbosity_arg())
@@ -143,6 +171,7 @@ fn command_line_parser() -> Command {
.subcommand(event_command_line_parser()) .subcommand(event_command_line_parser())
.subcommand(user_command_line_parser()) .subcommand(user_command_line_parser())
.subcommand(securities_command_line_parser()) .subcommand(securities_command_line_parser())
.subcommand(web_ui_command_line_parser())
.arg_required_else_help(true); .arg_required_else_help(true);
} }
@@ -207,6 +236,17 @@ fn process_process_events_command_line(
logic.process_events(conn, files); logic.process_events(conn, files);
} }
fn process_run_wui_command_line(
conn: &mut Connection,
m: &ArgMatches,
logic: &mut dyn CommandLineUserLogic,
) {
debug!("Processing run wui command line {:?}.", m);
let mut port = m.get_one::<u16>("port").unwrap_or(&3200);
let enable_template_autoreload = m.get_flag("enable-template-autoreload");
logic.run_webui(conn, *port, enable_template_autoreload);
}
fn process_securities_command_line( fn process_securities_command_line(
conn: &Connection, conn: &Connection,
m: &ArgMatches, m: &ArgMatches,
@@ -235,6 +275,20 @@ fn process_events_command_line(
} }
} }
fn process_wui_command_line(
conn: &mut Connection,
m: &ArgMatches,
logic: &mut dyn CommandLineUserLogic,
) {
debug!("Processing wui command line {:?}.", m);
match m.subcommand() {
Some(("run", run_wui_match)) => {
process_run_wui_command_line(conn, run_wui_match, logic);
}
_ => unreachable!(),
}
}
fn process_command_line( fn process_command_line(
conn: &mut Connection, conn: &mut Connection,
m: &ArgMatches, m: &ArgMatches,
@@ -245,13 +299,15 @@ fn process_command_line(
Some(("users", user_match)) => { Some(("users", user_match)) => {
process_user_command_line(conn, user_match, logic); process_user_command_line(conn, user_match, logic);
} }
Some(("securities", securities_match)) => { Some(("securities", securities_match)) => {
process_securities_command_line(conn, securities_match, logic); process_securities_command_line(conn, securities_match, logic);
} }
Some(("events", events_match)) => { Some(("events", events_match)) => {
process_events_command_line(conn, events_match, logic); process_events_command_line(conn, events_match, logic);
} }
Some(("wui", wui_match)) => {
process_wui_command_line(conn, wui_match, logic);
}
_ => { _ => {
println!("Received un parsable command. {:?}", m.subcommand()); println!("Received un parsable command. {:?}", m.subcommand());
unreachable!(); unreachable!();
@@ -268,6 +324,7 @@ mod cli_test {
create_user_count: usize, create_user_count: usize,
list_securities_count: usize, list_securities_count: usize,
process_events_count: usize, process_events_count: usize,
run_webui_count: usize,
} }
impl TestCommandLineLogic { impl TestCommandLineLogic {
@@ -277,6 +334,7 @@ mod cli_test {
create_user_count: 0, create_user_count: 0,
list_securities_count: 0, list_securities_count: 0,
process_events_count: 0, process_events_count: 0,
run_webui_count: 0,
}; };
} }
} }
@@ -297,6 +355,10 @@ mod cli_test {
fn process_events(&mut self, conn: &mut Connection, _files: ValuesRef<PathBuf>) { fn process_events(&mut self, conn: &mut Connection, _files: ValuesRef<PathBuf>) {
self.process_events_count += 1; self.process_events_count += 1;
} }
fn run_webui(&mut self, conn: &mut Connection, port: u16, enable_template_autoreload: bool) {
self.run_webui_count += 1;
}
} }
#[test] #[test]
@@ -378,6 +440,16 @@ mod cli_test {
process_command_line(&mut conn, &m, &mut logic); process_command_line(&mut conn, &m, &mut logic);
assert_eq!(logic.process_events_count, 1); assert_eq!(logic.process_events_count, 1);
} }
// #[test]
// fn run_web_ui() {
// let cmd = command_line_parser();
// let m = cmd.get_matches_from(vec!["test", "webui", "run"]);
// let mut logic = TestCommandLineLogic::new();
// let mut conn = Connection::open_in_memory().unwrap();
// process_command_line(&mut conn, &m, &mut logic);
// assert_eq!(logic.run_webui_count, 1);
// }
} }
struct ProCommandLineLogic {} struct ProCommandLineLogic {}
@@ -426,6 +498,11 @@ impl CommandLineUserLogic for ProCommandLineLogic {
println!("fuck me"); println!("fuck me");
} }
fn run_webui(&mut self, conn: &mut Connection, port: u16, enable_template_autoreload: bool) {
info!("Serving web-ui on port {:?}", port);
web_ui_main(port, enable_template_autoreload);
}
} }
fn get_connection(m: &ArgMatches) -> Connection { fn get_connection(m: &ArgMatches) -> Connection {
+3 -3
View File
@@ -8,7 +8,7 @@ use crate::util::*;
fn _move_securities(tx: &Savepoint, fn _move_securities(tx: &Savepoint,
cheque: &Cheque, cheque: &Cheque,
account_id: &AccountId) -> CustomResult<(accounts::Receipt, accounts::Receipt)> { account_id: &AccountId) -> CustomResult<(accounts::AccountChangeReceipt, accounts::AccountChangeReceipt)> {
let drawer_receipt = accounts::add_to_account(tx, &cheque.drawer_account_id, -cheque.amount)?; let drawer_receipt = accounts::add_to_account(tx, &cheque.drawer_account_id, -cheque.amount)?;
let payee_receipt = accounts::add_to_account(tx, account_id, cheque.amount)?; let payee_receipt = accounts::add_to_account(tx, account_id, cheque.amount)?;
if drawer_receipt.amount < 0 { if drawer_receipt.amount < 0 {
@@ -20,8 +20,8 @@ fn _move_securities(tx: &Savepoint,
fn _add_cheque_to_cashed_cheques_log(tx: &Connection, fn _add_cheque_to_cashed_cheques_log(tx: &Connection,
cheque: &Cheque, cheque: &Cheque,
drawer_receipt: &accounts::Receipt, drawer_receipt: &accounts::AccountChangeReceipt,
payee_receipt: &accounts::Receipt) -> Result<(), Error> { payee_receipt: &accounts::AccountChangeReceipt) -> Result<(), Error> {
let q = " let q = "
INSERT INTO cashed_cheques( INSERT INTO cashed_cheques(
drawer_user_id, drawer_user_id,
+129
View File
@@ -0,0 +1,129 @@
mod views;
use actix_utils::future::{ready, Ready};
use minijinja;
use minijinja_autoreload::AutoReloader;
use std::{collections::HashMap, env, fs, io, path::PathBuf};
use std::io::{Error, ErrorKind};
use std::path::Path;
use actix_web::{
dev::{ServiceResponse},
error,
http::{header::ContentType, StatusCode},
middleware::{ErrorHandlerResponse, ErrorHandlers, Logger},
web, App, FromRequest, HttpRequest, HttpResponse, HttpServer, Responder, Result,
};
use actix_web_lab::respond::Html;
struct MiniJinjaRenderer {
tmpl_env: web::Data<minijinja_autoreload::AutoReloader>,
}
impl MiniJinjaRenderer {
fn render(
&self,
tmpl: &str,
ctx: impl Into<minijinja::value::Value>,
) -> actix_web::Result<Html> {
log::info!("Rendering template {:?}", tmpl);
self.tmpl_env
.acquire_env()
.map_err(|_| error::ErrorInternalServerError("could not acquire template env"))?
.get_template(tmpl)
.map_err(|e| error::ErrorInternalServerError(format!("{:?}", e)))?
.render(ctx.into())
.map(Html)
.map_err(|err| {
log::error!("{err}");
error::ErrorInternalServerError("template error")
})
}
}
impl FromRequest for MiniJinjaRenderer {
type Error = actix_web::Error;
type Future = actix_utils::future::Ready<Result<Self, Self::Error>>;
fn from_request(req: &HttpRequest, _pl: &mut actix_web::dev::Payload) -> Self::Future {
let tmpl_env = <web::Data<minijinja_autoreload::AutoReloader>>::extract(req)
.into_inner()
.unwrap();
actix_utils::future::ready(Ok(Self { tmpl_env }))
}
}
#[tokio::main]
pub async fn web_ui_main(port: u16, enable_template_autoreload: bool) -> std::io::Result<()> {
// The closure is invoked every time the environment is outdated to recreate it.
let tmpl_reloader = AutoReloader::new(move |notifier| {
let mut env: minijinja::Environment<'static> = minijinja::Environment::new();
let tmpl_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("templates");
log::info!("Using template dir {:?}", tmpl_path);
// if watch_path is never called, no fs watcher is created
if enable_template_autoreload {
notifier.watch_path(&tmpl_path, true);
}
env.set_source(minijinja::Source::from_path(tmpl_path));
Ok(env)
});
let tmpl_reloader = web::Data::new(tmpl_reloader);
HttpServer::new(move || {
App::new()
.app_data(tmpl_reloader.clone())
.service(views::index)
.wrap(ErrorHandlers::new().handler(StatusCode::NOT_FOUND, not_found))
.wrap(Logger::default())
})
.workers(2)
.bind(("0.0.0.0", port))?
.run()
.await
}
/// Error handler for a 404 Page not found error.
fn not_found<B>(svc_res: ServiceResponse<B>) -> Result<ErrorHandlerResponse<B>> {
let res = get_error_response(&svc_res, "Page not found");
Ok(ErrorHandlerResponse::Response(ServiceResponse::new(
svc_res.into_parts().0,
res.map_into_right_body(),
)))
}
/// Generic error handler.
fn get_error_response<B>(res: &ServiceResponse<B>, error: &str) -> HttpResponse {
let req = res.request();
let tmpl_env = MiniJinjaRenderer::extract(req).into_inner().unwrap();
// Provide a fallback to a simple plain text response in case an error occurs during the
// rendering of the error page.
let fallback = |err: &str| {
HttpResponse::build(res.status())
.content_type(ContentType::plaintext())
.body(err.to_string())
};
let ctx = minijinja::context! {
error => error,
status_code => res.status().as_str(),
};
match tmpl_env.render("error.html", ctx) {
Ok(body) => body
.customize()
.with_status(res.status())
.respond_to(req)
.map_into_boxed_body(),
Err(_) => fallback(error),
}
}
+11
View File
@@ -0,0 +1,11 @@
use std::collections::HashMap;
use actix_web::{Responder, web};
use crate::webui::MiniJinjaRenderer;
#[actix_web::get("/")]
pub async fn index(
tmpl_env: MiniJinjaRenderer,
query: web::Query<HashMap<String, String>>,
) -> actix_web::Result<impl Responder> {
tmpl_env.render("index.html", ())
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ledger</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@1.8.5"></script>
</head>
<body>{% block body %}{% endblock %}</body>
</html>
+5
View File
@@ -0,0 +1,5 @@
{% extends "base.html" %}
{% block body %}
<h1>Hello World!</h1>
{% endblock %}