Added the capability to upload CSV files
This commit is contained in:
parent
90b02eef79
commit
d1e736d7a7
19 changed files with 678 additions and 99 deletions
|
|
@ -33,13 +33,26 @@ async fn main() {
|
|||
"/",
|
||||
Router::new()
|
||||
.route("/", get(routes::ui::index))
|
||||
.route("/accounts/id/:id", get(routes::ui::account))
|
||||
.route("/accounts/id/:id", get(routes::ui::account::list))
|
||||
.route(
|
||||
"/accounts/id/:id/transactions/add",
|
||||
get(routes::ui::account::add_transactions_view),
|
||||
)
|
||||
.route(
|
||||
"/accounts/id/:id/transactions/add",
|
||||
post(routes::ui::account::add_transactions_action),
|
||||
)
|
||||
.route("/rules", get(routes::ui::rules::list))
|
||||
.route("/rules/new", get(routes::ui::rules::new_view))
|
||||
.route("/rules/new", post(routes::ui::rules::new_action))
|
||||
.route("/categories", get(routes::ui::categories::list))
|
||||
.route("/categories/new", get(routes::ui::categories::new_view))
|
||||
.route("/categories/new", post(routes::ui::categories::new_action))
|
||||
.nest(
|
||||
"/static",
|
||||
Router::new().route("/styles.css", get(routes::static_routes::styles)),
|
||||
Router::new()
|
||||
.route("/styles.css", get(routes::static_routes::styles))
|
||||
.route("/csv.js", get(routes::static_routes::csv)),
|
||||
),
|
||||
)
|
||||
.nest(
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use hyper::StatusCode;
|
|||
use serde::Deserialize;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use accounters::models::Transaction;
|
||||
use accounters::models::{transaction::TxConflictResolutionMode, Transaction};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TransactionContent {
|
||||
|
|
@ -14,6 +14,8 @@ pub struct TransactionContent {
|
|||
timestamp: DateTime<Utc>,
|
||||
category: Option<String>,
|
||||
amount: i32,
|
||||
#[serde(default)]
|
||||
error_on_conflict: Option<bool>,
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
|
|
@ -21,6 +23,11 @@ pub async fn create(
|
|||
Path(account): Path<i32>,
|
||||
Json(txcnt): Json<TransactionContent>,
|
||||
) -> (StatusCode, String) {
|
||||
let error_on_conflict = if txcnt.error_on_conflict.is_some_and(|x| x) {
|
||||
TxConflictResolutionMode::Error
|
||||
} else {
|
||||
TxConflictResolutionMode::Nothing
|
||||
};
|
||||
match Transaction::new(
|
||||
db.as_ref(),
|
||||
account,
|
||||
|
|
@ -28,6 +35,7 @@ pub async fn create(
|
|||
&txcnt.timestamp,
|
||||
None,
|
||||
txcnt.amount,
|
||||
error_on_conflict,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
|
|
|||
|
|
@ -10,3 +10,11 @@ pub async fn styles() -> impl IntoResponse {
|
|||
fs::read_to_string("static/styles.css").unwrap(),
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn csv() -> impl IntoResponse {
|
||||
(
|
||||
StatusCode::OK,
|
||||
[(CONTENT_TYPE, "application/javascript")],
|
||||
fs::read_to_string("static/csv.js").unwrap(),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,15 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use axum::{extract::State, response::IntoResponse};
|
||||
use hyper::{header::CONTENT_TYPE, StatusCode};
|
||||
use serde::Deserialize;
|
||||
use sqlx::SqlitePool;
|
||||
use tera::{Context, Tera};
|
||||
|
||||
use crate::users::UserToken;
|
||||
use accounters::models::{Account, Transaction};
|
||||
use accounters::models::Account;
|
||||
|
||||
pub mod account;
|
||||
pub mod categories;
|
||||
pub mod rules;
|
||||
|
||||
pub async fn index(
|
||||
|
|
@ -37,66 +35,3 @@ pub async fn index(
|
|||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AccountViewParams {
|
||||
movements: Option<i32>,
|
||||
}
|
||||
|
||||
pub async fn account(
|
||||
State(db): State<Arc<SqlitePool>>,
|
||||
State(tmpls): State<Arc<Tera>>,
|
||||
uid: UserToken,
|
||||
Path(account_id): Path<i32>,
|
||||
Query(AccountViewParams { movements }): Query<AccountViewParams>,
|
||||
) -> impl IntoResponse {
|
||||
let mut ctx = Context::new();
|
||||
|
||||
let account = match Account::get_by_id(db.as_ref(), account_id).await {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
[(CONTENT_TYPE, "text/plain")],
|
||||
format!("{e}"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if account.get_user() != uid.user_id {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
[(CONTENT_TYPE, "text/plain")],
|
||||
String::from("You cannot access this resource"),
|
||||
);
|
||||
}
|
||||
|
||||
let txs = match Transaction::list(
|
||||
db.as_ref(),
|
||||
account.get_id(),
|
||||
movements.unwrap_or(10),
|
||||
0,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
[(CONTENT_TYPE, "text/plain")],
|
||||
format!("Error at loading transactions: {e}"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
ctx.insert("account", &account);
|
||||
ctx.insert("transactions", &txs);
|
||||
ctx.insert("n_txs", &txs.len());
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
[(CONTENT_TYPE, "text/html;charset=utf-8")],
|
||||
tmpls.render("accounts.html", &ctx).unwrap(),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
147
webserver/src/routes/ui/account.rs
Normal file
147
webserver/src/routes/ui/account.rs
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use hyper::{header::CONTENT_TYPE, StatusCode};
|
||||
use serde::Deserialize;
|
||||
use sqlx::SqlitePool;
|
||||
use tera::{Context, Tera};
|
||||
|
||||
use crate::users::UserToken;
|
||||
use accounters::models::{transaction::TxConflictResolutionMode, Account, Transaction};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AccountViewParams {
|
||||
movements: Option<i32>,
|
||||
}
|
||||
|
||||
pub async fn list(
|
||||
State(db): State<Arc<SqlitePool>>,
|
||||
State(tmpls): State<Arc<Tera>>,
|
||||
uid: UserToken,
|
||||
Path(account_id): Path<i32>,
|
||||
Query(AccountViewParams { movements }): Query<AccountViewParams>,
|
||||
) -> impl IntoResponse {
|
||||
let mut ctx = Context::new();
|
||||
|
||||
let account = match Account::get_by_id(db.as_ref(), account_id).await {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
[(CONTENT_TYPE, "text/plain")],
|
||||
format!("{e}"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if account.get_user() != uid.user_id {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
[(CONTENT_TYPE, "text/plain")],
|
||||
String::from("You cannot access this resource"),
|
||||
);
|
||||
}
|
||||
|
||||
let txs = match Transaction::list(
|
||||
db.as_ref(),
|
||||
account.get_id(),
|
||||
movements.unwrap_or(10),
|
||||
0,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
[(CONTENT_TYPE, "text/plain")],
|
||||
format!("Error at loading transactions: {e}"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
ctx.insert("account", &account);
|
||||
ctx.insert("transactions", &txs);
|
||||
ctx.insert("n_txs", &txs.len());
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
[(CONTENT_TYPE, "text/html;charset=utf-8")],
|
||||
tmpls.render("accounts.html", &ctx).unwrap(),
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn add_transactions_view(
|
||||
State(db): State<Arc<SqlitePool>>,
|
||||
State(tmpls): State<Arc<Tera>>,
|
||||
uid: UserToken,
|
||||
Path(account_id): Path<i32>,
|
||||
) -> impl IntoResponse {
|
||||
let mut ctxt = Context::new();
|
||||
ctxt.insert("account_id", &account_id);
|
||||
|
||||
let account = match Account::get_by_id(db.as_ref(), account_id).await {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
[(CONTENT_TYPE, "text/plain")],
|
||||
format!("{e:?}"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if account.get_user() != uid.user_id {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
[(CONTENT_TYPE, "text/plain")],
|
||||
String::from("You cannot access this resource"),
|
||||
);
|
||||
}
|
||||
|
||||
ctxt.insert("account", &account);
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
[(CONTENT_TYPE, "text/html;charset=utf-8")],
|
||||
tmpls.render("accounts_add_txs.html", &ctxt).unwrap(),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct CreateTransactionRequest {
|
||||
date: DateTime<Utc>,
|
||||
description: String,
|
||||
amount: f32,
|
||||
}
|
||||
|
||||
pub async fn add_transactions_action(
|
||||
State(db): State<Arc<SqlitePool>>,
|
||||
uid: UserToken,
|
||||
Path(account_id): Path<i32>,
|
||||
Json(body): Json<Vec<CreateTransactionRequest>>,
|
||||
) -> impl IntoResponse {
|
||||
// TODO missing user id check
|
||||
for tx in body.iter() {
|
||||
if let Err(e) = Transaction::new(
|
||||
db.as_ref(),
|
||||
account_id,
|
||||
&tx.description,
|
||||
&tx.date,
|
||||
None,
|
||||
(tx.amount * 100.0).round() as i32,
|
||||
TxConflictResolutionMode::Nothing,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}"));
|
||||
}
|
||||
}
|
||||
(StatusCode::OK, String::new())
|
||||
}
|
||||
72
webserver/src/routes/ui/categories.rs
Normal file
72
webserver/src/routes/ui/categories.rs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use accounters::models::categories::Category;
|
||||
use axum::{
|
||||
extract::{Form, State},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use hyper::{header::CONTENT_TYPE, StatusCode};
|
||||
use serde::Deserialize;
|
||||
use sqlx::SqlitePool;
|
||||
use tera::{Context, Tera};
|
||||
|
||||
use crate::users::UserToken;
|
||||
|
||||
pub async fn list(
|
||||
State(db): State<Arc<SqlitePool>>,
|
||||
State(tmpl): State<Arc<Tera>>,
|
||||
uid: UserToken,
|
||||
) -> impl IntoResponse {
|
||||
match Category::list(db.as_ref()).await {
|
||||
Ok(categories) => {
|
||||
let mut ctx = Context::new();
|
||||
ctx.insert("categories", &categories);
|
||||
(
|
||||
StatusCode::OK,
|
||||
[(CONTENT_TYPE, "text/html;charset=utf-8")],
|
||||
tmpl.render("categories_list.html", &ctx).unwrap(),
|
||||
)
|
||||
}
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
[(CONTENT_TYPE, "text/plain;charset=utf-8")],
|
||||
format!("{e}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn new_view(State(tmpl): State<Arc<Tera>>, uid: UserToken) -> impl IntoResponse {
|
||||
(
|
||||
StatusCode::OK,
|
||||
[(CONTENT_TYPE, "text/html;charset=utf-8")],
|
||||
tmpl.render("categories_new.html", &Context::new()).unwrap(),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct NewRuleParams {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
pub async fn new_action(
|
||||
State(db): State<Arc<SqlitePool>>,
|
||||
State(tmpls): State<Arc<Tera>>,
|
||||
uid: UserToken,
|
||||
Form(params): Form<NewRuleParams>,
|
||||
) -> impl IntoResponse {
|
||||
match Category::new(db.as_ref(), ¶ms.name, ¶ms.description).await {
|
||||
Ok(_) => (
|
||||
StatusCode::OK,
|
||||
[(CONTENT_TYPE, "text/html;charset=utf-8")],
|
||||
tmpls
|
||||
.render("rules_new_success.html", &Context::new())
|
||||
.unwrap(),
|
||||
),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
[(CONTENT_TYPE, "text/plain;charset=utf-8")],
|
||||
format!("{e}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue