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
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -8,6 +8,7 @@ version = "0.1.0"
|
|||
dependencies = [
|
||||
"chrono",
|
||||
"futures",
|
||||
"md-5",
|
||||
"regex",
|
||||
"serde",
|
||||
"sqlx",
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ chrono = { version = "0.4", features = ["serde"] }
|
|||
sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite", "chrono"] }
|
||||
futures = "0.3"
|
||||
regex = "1"
|
||||
md-5 = "0.10"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
|
|
|||
2
build.rs
2
build.rs
|
|
@ -10,5 +10,5 @@ fn main() {
|
|||
))
|
||||
.status()
|
||||
.unwrap();
|
||||
println!("cargo:rerun-if-changed=templates/*")
|
||||
println!("cargo:rerun-if-changed=templates/")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,8 +44,10 @@ CREATE TABLE IF NOT EXISTS transactions (
|
|||
transaction_timestamp DATETIME,
|
||||
category INTEGER,
|
||||
amount INTEGER,
|
||||
hash TEXT,
|
||||
FOREIGN KEY (account) REFERENCES accounts(account_id),
|
||||
FOREIGN KEY (category) REFERENCES categories(category_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_transactions_ts ON transactions(account, transaction_timestamp);
|
||||
CREATE INDEX idx_transactions_hash ON transactions(hash);
|
||||
|
|
|
|||
|
|
@ -2,8 +2,16 @@ use chrono::prelude::*;
|
|||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{FromRow, Result, Sqlite, SqlitePool};
|
||||
|
||||
use md5::{Digest, Md5};
|
||||
|
||||
use crate::models::rules::Rule;
|
||||
|
||||
pub enum TxConflictResolutionMode {
|
||||
Nothing,
|
||||
Error,
|
||||
Duplicate,
|
||||
}
|
||||
|
||||
#[derive(FromRow, Serialize, Deserialize, Debug)]
|
||||
pub struct Transaction {
|
||||
transaction_id: i32,
|
||||
|
|
@ -12,6 +20,8 @@ pub struct Transaction {
|
|||
transaction_timestamp: DateTime<Utc>,
|
||||
category: Option<i32>,
|
||||
amount: i32,
|
||||
#[serde(default, skip_serializing)]
|
||||
hash: Option<String>,
|
||||
}
|
||||
|
||||
impl Transaction {
|
||||
|
|
@ -22,21 +32,47 @@ impl Transaction {
|
|||
ts: &DateTime<Utc>,
|
||||
category: Option<i32>,
|
||||
amount: i32,
|
||||
on_conflict: TxConflictResolutionMode,
|
||||
) -> Result<Self> {
|
||||
let res = sqlx::query(concat!(
|
||||
let hash = Transaction::get_tx_hash(account, &desc, &ts, amount);
|
||||
let tx_db = match sqlx::query("SELECT * FROM transactions WHERE hash=? LIMIT 1")
|
||||
.bind(&hash)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
{
|
||||
Ok(row) => Some(Transaction::from_row(&row)?),
|
||||
Err(sqlx::Error::RowNotFound) => None,
|
||||
Err(e) => {
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(tx) = tx_db {
|
||||
match on_conflict {
|
||||
TxConflictResolutionMode::Nothing => {
|
||||
return Ok(tx);
|
||||
}
|
||||
TxConflictResolutionMode::Error => {
|
||||
return Err(sqlx::Error::RowNotFound);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
sqlx::query(concat!(
|
||||
"INSERT INTO transactions(",
|
||||
"account, description, transaction_timestamp, category, amount",
|
||||
") VALUES (?,?,?,?,?) RETURNING *"
|
||||
"account, description, transaction_timestamp, category, amount, hash",
|
||||
") VALUES (?,?,?,?,?,?) RETURNING *"
|
||||
))
|
||||
.bind(account)
|
||||
.bind(desc)
|
||||
.bind(ts)
|
||||
.bind(category)
|
||||
.bind(amount)
|
||||
.bind(hash)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Transaction::from_row(&res)
|
||||
.await
|
||||
.map(|x| Transaction::from_row(&x).unwrap())
|
||||
}
|
||||
|
||||
pub async fn list(
|
||||
|
|
@ -165,19 +201,42 @@ impl Transaction {
|
|||
}
|
||||
|
||||
pub async fn set_description(&mut self, pool: &SqlitePool, desc: &str) -> Result<()> {
|
||||
sqlx::query("UPDATE transactions SET description=? WHERE transaction_id=?")
|
||||
sqlx::query("UPDATE transactions SET description=?, hash=? WHERE transaction_id=?")
|
||||
.bind(desc)
|
||||
.bind(Transaction::get_tx_hash(
|
||||
self.account,
|
||||
desc,
|
||||
&self.transaction_timestamp,
|
||||
self.amount,
|
||||
))
|
||||
.bind(self.transaction_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
self.description = desc.to_string();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_tx_hash(account: i32, description: &str, ts: &DateTime<Utc>, amount: i32) -> String {
|
||||
let mut hasher = Md5::new();
|
||||
hasher.update(format!(
|
||||
"{}/{}/{}/{}",
|
||||
account,
|
||||
description,
|
||||
ts.to_rfc3339(),
|
||||
amount
|
||||
));
|
||||
let mut out = String::new();
|
||||
out.reserve(32);
|
||||
for byte in hasher.finalize().iter() {
|
||||
out.push_str(&format!("{:02x?}", byte));
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Transaction;
|
||||
use super::{Transaction, TxConflictResolutionMode};
|
||||
use crate::models::{account::Account, users::User};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
|
|
@ -206,6 +265,7 @@ mod tests {
|
|||
&chrono::Utc::now(),
|
||||
None,
|
||||
100,
|
||||
TxConflictResolutionMode::Nothing,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
|
|
|||
47
static/csv.js
Normal file
47
static/csv.js
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
function parse(text) {
|
||||
let state = 0;
|
||||
let idx = 0;
|
||||
let current = '';
|
||||
let curr_row = [];
|
||||
let rows = [];
|
||||
|
||||
while(idx < text.length) {
|
||||
switch (text[idx]) {
|
||||
case '\\':
|
||||
current += text[idx++];
|
||||
break;
|
||||
|
||||
case '"':
|
||||
if(current.length == 0) {
|
||||
while(text.length > idx && text[++idx] != '"')
|
||||
current += text[idx];
|
||||
}
|
||||
break;
|
||||
|
||||
case ',':
|
||||
if (/^\d+(\.\d+)?$/.test(current)) {
|
||||
let asnum = parseFloat(current);
|
||||
curr_row.push(asnum);
|
||||
} else {
|
||||
curr_row.push(current);
|
||||
}
|
||||
current = '';
|
||||
break;
|
||||
|
||||
case '\n':
|
||||
curr_row.push(current);
|
||||
current = '';
|
||||
rows.push(curr_row);
|
||||
curr_row = [];
|
||||
break;
|
||||
|
||||
default:
|
||||
current += text[idx];
|
||||
break;
|
||||
}
|
||||
idx++;
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export default parse;
|
||||
|
|
@ -544,6 +544,40 @@ video {
|
|||
--tw-backdrop-sepia: ;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.container {
|
||||
max-width: 640px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.container {
|
||||
max-width: 768px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.container {
|
||||
max-width: 1024px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.container {
|
||||
max-width: 1280px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1536px) {
|
||||
.container {
|
||||
max-width: 1536px;
|
||||
}
|
||||
}
|
||||
|
||||
.block {
|
||||
display: block;
|
||||
}
|
||||
|
|
@ -560,10 +594,6 @@ video {
|
|||
height: 100%;
|
||||
}
|
||||
|
||||
.flex-grow {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.grow {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
|
@ -572,6 +602,10 @@ video {
|
|||
flex-direction: column;
|
||||
}
|
||||
|
||||
.overflow-auto {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.border {
|
||||
border-width: 1px;
|
||||
}
|
||||
|
|
@ -581,10 +615,24 @@ video {
|
|||
background-color: rgb(214 211 209 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.p-2 {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.p-4 {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.text-lg {
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.75rem;
|
||||
}
|
||||
|
||||
.hover\:bg-stone-200:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(231 229 228 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.hover\:bg-stone-400:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(168 162 158 / var(--tw-bg-opacity));
|
||||
|
|
|
|||
|
|
@ -1,14 +1,19 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Account {{account.account_name}}{% endblock title %}
|
||||
{% block body %}
|
||||
<div>{{account.account_name}}</div>
|
||||
<div class="flex">
|
||||
<span class="text-lg grow">{{account.account_name}}</span>
|
||||
<div>
|
||||
<a href="/accounts/id/{{account.account_id}}/transactions/add">+</a>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Descripción</th>
|
||||
<th>Fecha</th>
|
||||
<th>Cantidad</th>
|
||||
<th>Categoría</th>
|
||||
<th>Description</th>
|
||||
<th>Date</th>
|
||||
<th>Amount</th>
|
||||
<th>Category</th>
|
||||
</tr>
|
||||
{% for tx in transactions %}
|
||||
<tr>
|
||||
|
|
@ -19,7 +24,7 @@
|
|||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
<p>Cargados {{n_txs}} movimientos</p>
|
||||
<p>Loaded {{n_txs}} transactions</p>
|
||||
</div>
|
||||
{% endblock body %}
|
||||
|
||||
|
|
|
|||
176
templates/accounts_add_txs.html
Normal file
176
templates/accounts_add_txs.html
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Account {{account.account_name}}{% endblock title %}
|
||||
{% block body %}
|
||||
<div class="flex flex-col">
|
||||
<div>
|
||||
<span class="text-lg grow">Add transactions to {{account.account_name}}</span>
|
||||
</div>
|
||||
<div>
|
||||
<form id="file-form">
|
||||
<div><input id="file-input" type="file" name="file"></div>
|
||||
<div><input id="file-submit" type="submit" value="Upload transactions" disabled></div>
|
||||
</form>
|
||||
</div>
|
||||
<div id="file-content">
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/static/csv.js"></script>
|
||||
<script type="module">
|
||||
import csv_parse from "/static/csv.js";
|
||||
|
||||
const form_elem = document.getElementById('file-form')
|
||||
form_elem.onsubmit = (evt) => {
|
||||
evt.preventDefault();
|
||||
console.log('Unable to send');
|
||||
}
|
||||
|
||||
const mappers = [
|
||||
['None', null],
|
||||
['Date dd/mm/yyyy', el => {
|
||||
let split = el.split('/');
|
||||
return new Date(
|
||||
parseInt(split[2], 10),
|
||||
parseInt(split[1], 10),
|
||||
parseInt(split[0], 10),
|
||||
);
|
||||
}],
|
||||
['Date yyyy/mm/dd', el => {
|
||||
let split = el.split('/');
|
||||
return new Date(
|
||||
parseInt(split[0], 10),
|
||||
parseInt(split[1], 10),
|
||||
parseInt(split[2], 10),
|
||||
);
|
||||
}],
|
||||
['Description', el => el],
|
||||
['Amount', el => parseFloat(el)]
|
||||
];
|
||||
|
||||
function appendOptions(el) {
|
||||
el.replaceChildren(...mappers.map((e, idx)=>{
|
||||
let option = document.createElement('option');
|
||||
option.setAttribute('value', idx);
|
||||
option.textContent = e[0];
|
||||
return option;
|
||||
}));
|
||||
}
|
||||
|
||||
document.getElementById('file-input').onchange = (evt) => {
|
||||
let files = evt.target.files;
|
||||
if(files.length > 0) {
|
||||
let file = files[0];
|
||||
if(file.type != 'text/csv') {
|
||||
window.alert("File not valid");
|
||||
return;
|
||||
}
|
||||
file.text().then(content => {
|
||||
let line_end = content.indexOf('\n');
|
||||
if(line_end == -1) {
|
||||
window.alert("File is not a valid CSV");
|
||||
return;
|
||||
}
|
||||
|
||||
let table_content = csv_parse(content);
|
||||
let table_header = table_content.splice(0,1)[0];
|
||||
|
||||
let table = document.createElement('table');
|
||||
let thead = document.createElement('thead');
|
||||
let trhead = document.createElement('tr');
|
||||
trhead.replaceChildren(...table_header.map(e =>{
|
||||
let elem = document.createElement('th');
|
||||
let text = document.createElement('div');
|
||||
text.textContent = e;
|
||||
elem.appendChild(text);
|
||||
|
||||
let container = document.createElement('div');
|
||||
let sel_el = document.createElement('select');
|
||||
sel_el.id = 'column_' + e;
|
||||
appendOptions(sel_el);
|
||||
container.appendChild(sel_el);
|
||||
elem.appendChild(container);
|
||||
|
||||
return elem;
|
||||
}));
|
||||
thead.appendChild(trhead);
|
||||
table.appendChild(thead);
|
||||
|
||||
form_elem.onsubmit = (evt) => {
|
||||
evt.preventDefault();
|
||||
console.log(table_header);
|
||||
console.log(table_content);
|
||||
|
||||
let mapper = {
|
||||
date: null,
|
||||
amount: null,
|
||||
description: null
|
||||
};
|
||||
|
||||
table_header.forEach((e, idx)=>{
|
||||
let option = document.getElementById('column_'+e).selectedIndex;
|
||||
switch(option){
|
||||
case 1:
|
||||
case 2:
|
||||
mapper.date = row => mappers[option][1](row[idx]);
|
||||
break;
|
||||
case 3:
|
||||
mapper.description = row => mappers[option][1](row[idx]);
|
||||
break;
|
||||
case 4:
|
||||
mapper.amount = row => mappers[option][1](row[idx]);
|
||||
break;
|
||||
}
|
||||
});
|
||||
if(mapper.date == null) {
|
||||
alert('Missing date mapping');
|
||||
return;
|
||||
} else if(mapper.amount == null) {
|
||||
alert('Missing amount mapping');
|
||||
return;
|
||||
} else if(mapper.description == null) {
|
||||
alert('Missing description mapping');
|
||||
return;
|
||||
}
|
||||
let out = table_content.map(e=>{
|
||||
return {
|
||||
date: mapper.date(e),
|
||||
amount: mapper.amount(e),
|
||||
description: mapper.description(e)
|
||||
};
|
||||
});
|
||||
fetch('add', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(out)
|
||||
}).then(e=>window.location.href='..');
|
||||
};
|
||||
|
||||
document.getElementById('file-submit').removeAttribute('disabled');
|
||||
|
||||
let tbody = document.createElement('tbody');
|
||||
tbody.replaceChildren(...table_content.map(row => {
|
||||
let row_elem = document.createElement('tr');
|
||||
row_elem.replaceChildren(...row.map(cell => {
|
||||
let td = document.createElement('td');
|
||||
td.textContent = cell;
|
||||
return td;
|
||||
}));
|
||||
return row_elem;
|
||||
}))
|
||||
|
||||
table.appendChild(tbody);
|
||||
|
||||
let content_div = document.getElementById('file-content');
|
||||
content_div.replaceChildren(table);
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
th, td {
|
||||
padding: 0.25rem 1rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock body %}
|
||||
|
||||
|
|
@ -13,8 +13,7 @@
|
|||
}
|
||||
|
||||
.sidebar {
|
||||
max-width: 12rem;
|
||||
flex-grow: 1;
|
||||
width: 12rem;
|
||||
}
|
||||
|
||||
.sidebar a {
|
||||
|
|
@ -26,10 +25,11 @@
|
|||
<body>
|
||||
<div class="flex h-full">
|
||||
<aside class="sidebar bg-stone-300 p-4 flex flex-col">
|
||||
<a class="hover:bg-stone-400" href="/">Inicio</a>
|
||||
<a class="hover:bg-stone-400" href="/rules">Reglas</a>
|
||||
<a class="hover:bg-stone-400" href="/">Start</a>
|
||||
<a class="hover:bg-stone-400" href="/rules">Rules</a>
|
||||
<a class="hover:bg-stone-400" href="/categories">Categories</a>
|
||||
</aside>
|
||||
<div class="p-4 grow">
|
||||
<div class="p-4 grow h-full overflow-auto">
|
||||
{% block body %}
|
||||
{% endblock body %}
|
||||
</div>
|
||||
|
|
|
|||
25
templates/categories_list.html
Normal file
25
templates/categories_list.html
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Categories{% endblock title %}
|
||||
{% block body %}
|
||||
<div>
|
||||
<a href="/categories/new">New</a>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th span="col">Id</th>
|
||||
<th span="col">Name</th>
|
||||
<th span="col">Description</th>
|
||||
<tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for category in categories %}
|
||||
<tr>
|
||||
<td>{{category.category_id}}</td>
|
||||
<td>{{category.name}}</td>
|
||||
<td>{{category.description}}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock body %}
|
||||
28
templates/categories_new.html
Normal file
28
templates/categories_new.html
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Create category{% endblock title %}
|
||||
{% block body %}
|
||||
<form action="/categories/new" method="post" class="flex flex-col">
|
||||
<label class="grow">
|
||||
Name
|
||||
<input type="text" name="name" />
|
||||
</label>
|
||||
<label class="grow">
|
||||
Description
|
||||
<input type="text" name="description" />
|
||||
</label>
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
<style>
|
||||
label {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
border: 1px solid black;
|
||||
border-radius: 3px;
|
||||
padding: 0.25rem;
|
||||
margin: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock body %}
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Index{% endblock title %}
|
||||
{% block body %}
|
||||
{% for account in accounts %}
|
||||
<a href="/accounts/id/{{account.account_id}}">{{account.account_name}}({{account.account_id}})</a>
|
||||
{% endfor %}
|
||||
<div><h2 class="text-lg">Accounts</h2></div>
|
||||
<div>
|
||||
{% for account in accounts %}
|
||||
<a class="p-2 hover:bg-stone-200" href="/accounts/id/{{account.account_id}}">{{account.account_name}}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock body %}
|
||||
|
|
|
|||
|
|
@ -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