From 14e7616b88eac3fab15cafc62186de13cad06c8b Mon Sep 17 00:00:00 2001 From: philipp Date: Wed, 24 Apr 2024 15:39:07 +0200 Subject: [PATCH 1/9] calc-general-boatcat (#435) Reviewed-on: https://git.hofer.link/Ruderverein-Donau-Linz/rowt/pulls/435 --- frontend/tests/log.spec.ts | 16 ++++++++-------- src/model/boat.rs | 12 ++++++++++-- src/model/logbook.rs | 7 +++++++ src/tera/log.rs | 1 + templates/includes/forms/log.html.tera | 8 +++----- 5 files changed, 29 insertions(+), 15 deletions(-) diff --git a/frontend/tests/log.spec.ts b/frontend/tests/log.spec.ts index 64d2c09..b12f0cb 100644 --- a/frontend/tests/log.spec.ts +++ b/frontend/tests/log.spec.ts @@ -12,10 +12,10 @@ test("Cox can start and cancel trip", async ({ page }, testInfo) => { await page.getByRole("link", { name: "Ausfahrt eintragen" }).click(); if (testInfo.project.name.includes("Mobile")) { // No left boat selector on mobile views - await page.getByText("Kaputtes Boot :-( (7 x)").nth(1).click(); + await page.getByText("Kaputtes Boot :-( (7x)").nth(1).click(); await page.getByRole("option", { name: "Joe" }).click(); } else { - await page.getByText('2x').click(); + await page.getByText('2x', { exact: true }).click(); await page.getByText("Joe", { exact: true }).click(); } await page.getByPlaceholder("Ruderer auswählen").click(); @@ -53,10 +53,10 @@ test("Cox can start and finish trip", async ({ page }, testInfo) => { await page.getByRole("link", { name: "Ausfahrt eintragen" }).click(); if (testInfo.project.name.includes("Mobile")) { // No left boat selector on mobile views - await page.getByText("Kaputtes Boot :-( (7 x)").nth(1).click(); + await page.getByText("Kaputtes Boot :-( (7x)").nth(1).click(); await page.getByRole("option", { name: "Joe" }).click(); } else { - await page.getByText('2x').click(); + await page.getByText('2x', { exact: true }).click(); await page.getByText("Joe", { exact: true }).click(); } await page.getByPlaceholder("Ruderer auswählen").click(); @@ -105,10 +105,10 @@ test("Kiosk can start and cancel trip", async ({ page }, testInfo) => { await page.goto("/log/kiosk/ekrv2019/Linz"); if (testInfo.project.name.includes("Mobile")) { // No left boat selector on mobile views - await page.getByText("Kaputtes Boot :-( (7 x)").nth(1).click(); + await page.getByText("Kaputtes Boot :-( (7x)").nth(1).click(); await page.getByRole("option", { name: "Joe" }).click(); } else { - await page.getByText('2x').click(); + await page.getByText('2x', { exact: true }).click(); await page.getByText("Joe", { exact: true }).click(); } await page.getByPlaceholder("Ruderer auswählen").click(); @@ -139,10 +139,10 @@ test("Kiosk can start and finish trip", async ({ page }, testInfo) => { if (testInfo.project.name.includes("Mobile")) { // No left boat selector on mobile views - await page.getByText("Kaputtes Boot :-( (7 x)").nth(1).click(); + await page.getByText("Kaputtes Boot :-( (7x)").nth(1).click(); await page.getByRole("option", { name: "Joe" }).click(); } else { - await page.getByText('2x').click(); + await page.getByText('2x', { exact: true }).click(); await page.getByText("Joe", { exact: true }).click(); } await page.getByPlaceholder("Ruderer auswählen").click(); diff --git a/src/model/boat.rs b/src/model/boat.rs index 5860d32..115acec 100644 --- a/src/model/boat.rs +++ b/src/model/boat.rs @@ -21,9 +21,9 @@ pub struct Boat { pub boatbuilder: Option, pub default_destination: Option, #[serde(default = "bool::default")] - convert_handoperated_possible: bool, + pub convert_handoperated_possible: bool, #[serde(default = "bool::default")] - default_shipmaster_only_steering: bool, + pub default_shipmaster_only_steering: bool, #[serde(default = "bool::default")] skull: bool, #[serde(default = "bool::default")] @@ -46,6 +46,7 @@ pub struct BoatWithDetails { damage: BoatDamage, on_water: bool, reserved_today: bool, + cat: String, } #[derive(FromForm)] @@ -179,11 +180,18 @@ AND date('now') BETWEEN start_date AND end_date;", if boat.is_locked(db).await { damage = BoatDamage::Locked; } + let cat = if boat.default_shipmaster_only_steering { + format!("{}+", boat.amount_seats - 1) + } else { + format!("{}x", boat.amount_seats) + }; + res.push(BoatWithDetails { damage, on_water: boat.on_water(db).await, reserved_today: boat.reserved_today(db).await, boat, + cat, }); } res diff --git a/src/model/logbook.rs b/src/model/logbook.rs index 5d83f12..156ad8e 100644 --- a/src/model/logbook.rs +++ b/src/model/logbook.rs @@ -126,6 +126,7 @@ pub enum LogbookCreateError { NotYourEntry, ArrivalSetButNotRemainingTwo, OnlyAllowedToEndTripsEndingToday, + CantChangeHandoperatableStatusForThisBoat, } impl From for LogbookCreateError { @@ -302,6 +303,12 @@ ORDER BY departure DESC return Err(LogbookCreateError::BoatNotFound); }; + if log.shipmaster_only_steering != boat.default_shipmaster_only_steering { + if !boat.convert_handoperated_possible { + return Err(LogbookCreateError::CantChangeHandoperatableStatusForThisBoat); + } + } + if boat.amount_seats == 1 && log.rowers.is_empty() { log.rowers = vec![created_by_user.id]; } diff --git a/src/tera/log.rs b/src/tera/log.rs index 241f36c..2c618b0 100644 --- a/src/tera/log.rs +++ b/src/tera/log.rs @@ -214,6 +214,7 @@ async fn create_logbook( Err(LogbookCreateError::NotYourEntry) => Flash::error(Redirect::to("/log"), "Nicht deine Ausfahrt!"), Err(LogbookCreateError::ArrivalSetButNotRemainingTwo) => Flash::error(Redirect::to("/log"), "Ankunftszeit gesetzt aber nicht Distanz + Strecke"), Err(LogbookCreateError::OnlyAllowedToEndTripsEndingToday) => Flash::error(Redirect::to("/log"), "Nur Ausfahrten, die in der letzten Woche enden dürfen eingetragen werden. Für einen Nachtrag schreibe alle Daten Philipp (Tel. nr. siehe Signal oder it@rudernlinz.at)."), + Err(LogbookCreateError::CantChangeHandoperatableStatusForThisBoat) => Flash::error(Redirect::to("/log"), "Handsteuer-Status dieses Boots kann nicht verändert werden."), } } diff --git a/templates/includes/forms/log.html.tera b/templates/includes/forms/log.html.tera index 99d308e..c70fb8c 100644 --- a/templates/includes/forms/log.html.tera +++ b/templates/includes/forms/log.html.tera @@ -3,16 +3,14 @@ Inputs: boats #} {% macro show_boats() %} - {% for amount_seats, grouped_boats in boats | group_by(attribute="amount_seats") %} + {% for amount_seats, grouped_boats in boats | group_by(attribute="cat") %}
{% if grouped_boats[0].external %} Vereinsfremde Boote - {% elif grouped_boats[0].default_shipmaster_only_steering %} - {{ grouped_boats[0].amount_seats - 1 }}+ {% else %} - {{ amount_seats }}x + {{ grouped_boats[0].cat }} {% endif %} ({{ grouped_boats | length }}) @@ -78,7 +76,7 @@ {% endmacro new %} {% macro boat_select(id="boat_id") %} - {{ macros::select(label="Boot", data=boats, name="boat_id", id=id, display=["name", " (","amount_seats", " x)"], extras=["default_shipmaster_only_steering", "amount_seats", "on_water", "default_destination"], wrapper_class="col-span-4", show_seats=true) }} + {{ macros::select(label="Boot", data=boats, name="boat_id", id=id, display=["name", " (","cat",")"], extras=["default_shipmaster_only_steering", "amount_seats", "on_water", "default_destination"], wrapper_class="col-span-4", show_seats=true) }} {% endmacro boat_select %} {% macro rower_select(id, selected, amount_seats='', class='', init='false', cox_on_boat='', steering_person_id='') %} {#{% if not amount_seats or amount_seats > 1 %}#} From 3a39315a011c5c4f969f8a2b548a1756fde31130 Mon Sep 17 00:00:00 2001 From: philipp Date: Tue, 30 Apr 2024 11:59:33 +0200 Subject: [PATCH 2/9] show waterlevel for the next days --- Cargo.lock | 117 +++++++++++++++++++++++++++++++++++- Cargo.toml | 2 + migration.sql | 11 ++++ src/lib.rs | 2 + src/main.rs | 3 + src/model/mod.rs | 5 ++ src/model/waterlevel.rs | 72 ++++++++++++++++++++++ src/scheduled/mod.rs | 33 ++++++++++ src/scheduled/waterlevel.rs | 112 ++++++++++++++++++++++++++++++++++ staging-diff.sql | 12 ++++ templates/planned.html.tera | 4 +- 11 files changed, 369 insertions(+), 4 deletions(-) create mode 100644 src/model/waterlevel.rs create mode 100644 src/scheduled/mod.rs create mode 100644 src/scheduled/waterlevel.rs diff --git a/Cargo.lock b/Cargo.lock index dc13d37..b6ef5d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -485,6 +485,26 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +[[package]] +name = "crc32fast" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3855a8a784b474f333699ef2bbca9db2c4a1f6d9088a90a2d25b1eb53111eaa" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "cron" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8c3e73077b4b4a6ab1ea5047c37c57aee77657bc8ecd6f29b0af082d0b0c07" +dependencies = [ + "chrono", + "nom", + "once_cell", +] + [[package]] name = "crossbeam-channel" version = "0.5.12" @@ -774,6 +794,16 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fcfdc7a0362c9f4444381a9e697c79d435fe65b52a37466fc2c1184cee9edc6" +[[package]] +name = "flate2" +version = "1.0.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f54427cfd1c7829e2a139fcefea601bf088ebca651d2bf53ebc600eac295dae" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "flume" version = "0.11.0" @@ -1301,6 +1331,17 @@ version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" +[[package]] +name = "job_scheduler_ng" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87c252207f323e2996d087759ebdcff8f608cd3eaa9896909a0c2dd3050a3c6a" +dependencies = [ + "chrono", + "cron", + "uuid", +] + [[package]] name = "js-sys" version = "0.3.69" @@ -2227,6 +2268,7 @@ dependencies = [ "futures", "ics", "itertools", + "job_scheduler_ng", "lettre", "log", "openssl", @@ -2236,6 +2278,7 @@ dependencies = [ "serde_json", "sqlx", "tera", + "ureq", ] [[package]] @@ -2284,10 +2327,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9d5a6813c0759e4609cd494e8e725babae6a2ca7b62a5536a13daaec6fcb7ba" dependencies = [ "ring", - "rustls-webpki", + "rustls-webpki 0.101.7", "sct", ] +[[package]] +name = "rustls" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" +dependencies = [ + "log", + "ring", + "rustls-pki-types", + "rustls-webpki 0.102.3", + "subtle", + "zeroize", +] + [[package]] name = "rustls-pemfile" version = "1.0.4" @@ -2297,6 +2354,12 @@ dependencies = [ "base64 0.21.7", ] +[[package]] +name = "rustls-pki-types" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "beb461507cee2c2ff151784c52762cf4d9ff6a61f3e80968600ed24fa837fa54" + [[package]] name = "rustls-webpki" version = "0.101.7" @@ -2307,6 +2370,17 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustls-webpki" +version = "0.102.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3bce581c0dd41bce533ce695a1437fa16a7ab5ac3ccfa99fe1a620a7885eabf" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.15" @@ -2590,7 +2664,7 @@ dependencies = [ "once_cell", "paste", "percent-encoding", - "rustls", + "rustls 0.21.10", "rustls-pemfile", "serde", "serde_json", @@ -2603,7 +2677,7 @@ dependencies = [ "tokio-stream", "tracing", "url", - "webpki-roots", + "webpki-roots 0.25.4", ] [[package]] @@ -3232,6 +3306,25 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "2.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d11a831e3c0b56e438a28308e7c810799e3c118417f342d30ecec080105395cd" +dependencies = [ + "base64 0.22.0", + "flate2", + "log", + "once_cell", + "rustls 0.22.4", + "rustls-pki-types", + "rustls-webpki 0.102.3", + "serde", + "serde_json", + "url", + "webpki-roots 0.26.1", +] + [[package]] name = "url" version = "2.5.0" @@ -3255,6 +3348,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" +[[package]] +name = "uuid" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a183cf7feeba97b4dd1c0d46788634f6221d87fa961b305bed08c851829efcc0" +dependencies = [ + "getrandom", +] + [[package]] name = "valuable" version = "0.1.0" @@ -3364,6 +3466,15 @@ version = "0.25.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" +[[package]] +name = "webpki-roots" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3de34ae270483955a94f4b21bdaaeb83d508bb84a01435f393818edb0012009" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "whoami" version = "1.5.1" diff --git a/Cargo.toml b/Cargo.toml index ae25e8b..9c950fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,8 @@ futures = "0.3" lettre = "0.11" csv = "1.3" itertools = "0.12" +job_scheduler_ng = "2.0" +ureq = { version = "2.9", features = ["json"] } [target.'cfg(not(windows))'.dependencies] openssl = { version = "0.10", features = [ "vendored" ] } diff --git a/migration.sql b/migration.sql index 4ce8ee2..103fd47 100644 --- a/migration.sql +++ b/migration.sql @@ -175,3 +175,14 @@ CREATE TABLE IF NOT EXISTS "boat_reservation" ( "created_at" datetime not null default CURRENT_TIMESTAMP ); +CREATE TABLE IF NOT EXISTS "waterlevel" ( + "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, + "day" DATE NOT NULL, + "time" TEXT NOT NULL, + "max" INTEGER NOT NULL, + "min" INTEGER NOT NULL, + "mittel" INTEGER NOT NULL, + "tumax" INTEGER NOT NULL, + "tumin" INTEGER NOT NULL, + "tumittel" INTEGER NOT NULL +); diff --git a/src/lib.rs b/src/lib.rs index 8ead4e5..1ec5a1a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,8 @@ pub mod tera; #[cfg(feature = "rest")] pub mod rest; +pub mod scheduled; + #[cfg(test)] #[macro_export] macro_rules! testdb { diff --git a/src/main.rs b/src/main.rs index ef60226..2377915 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ use std::str::FromStr; #[cfg(feature = "rest")] use rot::rest; +use rot::scheduled; #[cfg(feature = "rowing-tera")] use rot::tera; @@ -26,6 +27,8 @@ async fn rocket() -> _ { .await .unwrap(); + scheduled::schedule(&db).await; + let rocket = rocket::build().manage(db); #[cfg(feature = "rowing-tera")] diff --git a/src/model/mod.rs b/src/model/mod.rs index 55c6bcc..290b27c 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -5,6 +5,7 @@ use sqlx::SqlitePool; use self::{ planned_event::{PlannedEvent, PlannedEventWithUserAndTriptype}, trip::{Trip, TripWithUserAndType}, + waterlevel::Waterlevel, }; pub mod boat; @@ -27,6 +28,7 @@ pub mod tripdetails; pub mod triptype; pub mod user; pub mod usertrip; +pub mod waterlevel; #[derive(Serialize, Debug)] pub struct Day { @@ -34,6 +36,7 @@ pub struct Day { planned_events: Vec, trips: Vec, is_pinned: bool, + max_waterlevel: Option, } impl Day { @@ -44,6 +47,7 @@ impl Day { planned_events: PlannedEvent::get_pinned_for_day(db, day).await, trips: Trip::get_pinned_for_day(db, day).await, is_pinned, + max_waterlevel: Waterlevel::max_waterlevel_for_day(db, day).await, } } else { Self { @@ -51,6 +55,7 @@ impl Day { planned_events: PlannedEvent::get_for_day(db, day).await, trips: Trip::get_for_day(db, day).await, is_pinned, + max_waterlevel: Waterlevel::max_waterlevel_for_day(db, day).await, } } } diff --git a/src/model/waterlevel.rs b/src/model/waterlevel.rs new file mode 100644 index 0000000..a064a21 --- /dev/null +++ b/src/model/waterlevel.rs @@ -0,0 +1,72 @@ +use std::ops::DerefMut; + +use chrono::NaiveDate; +use rocket::serde::{Deserialize, Serialize}; +use sqlx::{FromRow, Sqlite, SqlitePool, Transaction}; + +#[derive(FromRow, Debug, Serialize, Deserialize, Eq, Hash, PartialEq, Clone)] +pub struct Waterlevel { + pub id: i64, + pub day: NaiveDate, + pub time: String, + pub max: i64, + pub min: i64, + pub mittel: i64, + pub tumax: i64, + pub tumin: i64, + pub tumittel: i64, +} + +impl Waterlevel { + pub async fn find_by_id(db: &SqlitePool, id: i32) -> Option { + sqlx::query_as!(Self, "SELECT * FROM waterlevel WHERE id like ?", id) + .fetch_one(db) + .await + .ok() + } + pub async fn find_by_id_tx(db: &mut Transaction<'_, Sqlite>, id: i32) -> Option { + sqlx::query_as!(Self, "SELECT * FROM waterlevel WHERE id like ?", id) + .fetch_one(db.deref_mut()) + .await + .ok() + } + + pub async fn create( + db: &mut Transaction<'_, Sqlite>, + day: NaiveDate, + time: String, + max: i64, + min: i64, + mittel: i64, + tumax: i64, + tumin: i64, + tumittel: i64, + ) -> Result<(), String> { + sqlx::query!( + "INSERT INTO waterlevel(day, time, max, min, mittel, tumax, tumin, tumittel) VALUES (?,?,?,?,?,?,?,?)", + day, time, max, min, mittel, tumax, tumin, tumittel + ) + .execute(db.deref_mut()) + .await + .map_err(|e| e.to_string())?; + Ok(()) + } + + pub async fn max_waterlevel_for_day(db: &SqlitePool, day: NaiveDate) -> Option { + sqlx::query!( + "SELECT MAX(mittel) as max FROM waterlevel WHERE day = ?", + day + ) + .fetch_one(db) + .await + .unwrap() + .max + } + + pub async fn delete_all(db: &mut Transaction<'_, Sqlite>) { + sqlx::query!("DELETE FROM waterlevel;") + .execute(db.deref_mut()) + .await + .unwrap(); + } +} diff --git a/src/scheduled/mod.rs b/src/scheduled/mod.rs new file mode 100644 index 0000000..60e9da0 --- /dev/null +++ b/src/scheduled/mod.rs @@ -0,0 +1,33 @@ +mod waterlevel; + +use std::time::Duration; + +use job_scheduler_ng::{Job, JobScheduler}; +use rocket::tokio::{self, task}; +use sqlx::SqlitePool; + +pub async fn schedule(db: &SqlitePool) { + let db = db.clone(); + + waterlevel::update(&db).await.unwrap(); + + tokio::task::spawn(async { + let mut sched = JobScheduler::new(); + + // Every hour + sched.add(Job::new("0 0 * * * * *".parse().unwrap(), move || { + let db_clone = db.clone(); + // Use block_in_place to run async code in the synchronous function + task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async { + waterlevel::update(&db_clone).await.unwrap(); + }); + }); + })); + + loop { + sched.tick(); + std::thread::sleep(Duration::from_secs(60)); + } + }); +} diff --git a/src/scheduled/waterlevel.rs b/src/scheduled/waterlevel.rs new file mode 100644 index 0000000..e90080f --- /dev/null +++ b/src/scheduled/waterlevel.rs @@ -0,0 +1,112 @@ +use chrono::{DateTime, FixedOffset, NaiveDate, NaiveTime}; +use serde::{Deserialize, Serialize}; +use sqlx::SqlitePool; + +use crate::model::waterlevel::Waterlevel; + +pub async fn update(db: &SqlitePool) -> Result<(), String> { + let mut tx = db.begin().await.unwrap(); + + // 1. Delete water levels starting from yesterday + Waterlevel::delete_all(&mut tx).await; + + // 2. Fetch + let station = fetch()?; + for d in station.data { + let (Some(max), Some(min), Some(mittel), Some(tumax), Some(tumin), Some(tumittel)) = + (d.max, d.min, d.mittel, d.tumax, d.tumin, d.tumittel) + else { + println!("Ignored invalid values: {d:?}"); + continue; + }; + + let Ok(datetime): Result, _> = d.timestamp.parse() else { + return Err("Failed to parse datetime from hydro json".into()); + }; + let date: NaiveDate = datetime.naive_utc().date(); + + // Extract time component and format as string + let time: NaiveTime = datetime.naive_utc().time(); + let time_str = time.format("%H:%M").to_string(); + + Waterlevel::create( + &mut tx, date, time_str, max, min, mittel, tumax, tumin, tumittel, + ) + .await? + } + + // 3. Save in DB + tx.commit().await.unwrap(); + + Ok(()) +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +struct Station { + station_no: String, + station_latitude: String, + station_longitude: String, + parametertype_name: String, + ts_shortname: String, + ts_name: String, + ts_unitname: String, + ts_unitsymbol: String, + ts_precision: String, + rows: String, + columns: String, + data: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +struct Data { + timestamp: String, + max: Option, + min: Option, + mittel: Option, + tumax: Option, + tumin: Option, + tumittel: Option, +} + +fn fetch() -> Result { + let url = "https://hydro.ooe.gv.at/daten/internet/stations/OG/207068/S/forecast.json"; + + match ureq::get(url).call() { + Ok(response) => { + let forecast: Result, _> = response.into_json(); + + if let Ok(data) = forecast { + if data.len() == 1 { + return Ok(data[0].clone()); + } else { + return Err(format!( + "Expected 1 station (Linz); got {} while fetching from {url}. Maybe the hydro data format changed?", + data.len() + )); + } + } else { + return Err(format!( + "Failed to parse the json received by {url}: {}", + forecast.err().unwrap() + )); + } + } + Err(_) => { + return Err(format!( + "Could not fetch {url}, do you have internet? Maybe their server is down?" + )); + } + } +} + +//#[cfg(test)] +//mod test { +// use crate::testdb; +// +// use super::*; +// #[sqlx::test] +// fn test_fetch_succ() { +// let pool = testdb!(); +// fetch(); +// } +//} diff --git a/staging-diff.sql b/staging-diff.sql index 6fb21fc..0bf96db 100644 --- a/staging-diff.sql +++ b/staging-diff.sql @@ -1,3 +1,15 @@ +CREATE TABLE IF NOT EXISTS "waterlevel" ( + "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, + "day" DATE NOT NULL, + "time" TEXT NOT NULL, + "max" INTEGER NOT NULL, + "min" INTEGER NOT NULL, + "mittel" INTEGER NOT NULL, + "tumax" INTEGER NOT NULL, + "tumin" INTEGER NOT NULL, + "tumittel" INTEGER NOT NULL +); + -- test user INSERT INTO user(name) VALUES('Marie'); INSERT INTO "user_role" (user_id, role_id) VALUES((SELECT id from user where name = 'Marie'),(SELECT id FROM role where name = 'Donau Linz')); diff --git a/templates/planned.html.tera b/templates/planned.html.tera index 638b32f..13597d0 100644 --- a/templates/planned.html.tera +++ b/templates/planned.html.tera @@ -91,7 +91,9 @@

{{ day.day| date(format="%d.%m.%Y") }} - {{ day.day | date(format="%A", locale="de_AT") }} + {{ day.day | date(format="%A", locale="de_AT") }} + {% if day.max_waterlevel %}• 🌊{{ day.max_waterlevel }}cm{% endif %} +

{% if day.planned_events | length > 0 or day.trips | length > 0 %}
From 9fda9cbde2d710bdc3c56332e407e1093f5995f3 Mon Sep 17 00:00:00 2001 From: philipp Date: Tue, 30 Apr 2024 14:35:30 +0200 Subject: [PATCH 3/9] deployed :-) --- staging-diff.sql | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/staging-diff.sql b/staging-diff.sql index 0bf96db..6fb21fc 100644 --- a/staging-diff.sql +++ b/staging-diff.sql @@ -1,15 +1,3 @@ -CREATE TABLE IF NOT EXISTS "waterlevel" ( - "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, - "day" DATE NOT NULL, - "time" TEXT NOT NULL, - "max" INTEGER NOT NULL, - "min" INTEGER NOT NULL, - "mittel" INTEGER NOT NULL, - "tumax" INTEGER NOT NULL, - "tumin" INTEGER NOT NULL, - "tumittel" INTEGER NOT NULL -); - -- test user INSERT INTO user(name) VALUES('Marie'); INSERT INTO "user_role" (user_id, role_id) VALUES((SELECT id from user where name = 'Marie'),(SELECT id FROM role where name = 'Donau Linz')); From dea6520aa94622db22172b38c9eba3272dd2feb9 Mon Sep 17 00:00:00 2001 From: philipp Date: Tue, 30 Apr 2024 15:13:42 +0200 Subject: [PATCH 4/9] add tooltip + link --- templates/planned.html.tera | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/planned.html.tera b/templates/planned.html.tera index 13597d0..f9a0048 100644 --- a/templates/planned.html.tera +++ b/templates/planned.html.tera @@ -92,7 +92,7 @@

{{ day.day| date(format="%d.%m.%Y") }} {{ day.day | date(format="%A", locale="de_AT") }} - {% if day.max_waterlevel %}• 🌊{{ day.max_waterlevel }}cm{% endif %} + {% if day.max_waterlevel %}• 🌊{{ day.max_waterlevel }} cm{% endif %}

{% if day.planned_events | length > 0 or day.trips | length > 0 %} From 25fe4c23ef64b31ab07cef974ae6927fe7ed1caa Mon Sep 17 00:00:00 2001 From: philipp Date: Tue, 30 Apr 2024 15:47:40 +0200 Subject: [PATCH 5/9] remove unnecessary async --- src/main.rs | 2 +- src/scheduled/mod.rs | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/main.rs b/src/main.rs index 2377915..8c1bf89 100644 --- a/src/main.rs +++ b/src/main.rs @@ -27,7 +27,7 @@ async fn rocket() -> _ { .await .unwrap(); - scheduled::schedule(&db).await; + scheduled::schedule(&db); let rocket = rocket::build().manage(db); diff --git a/src/scheduled/mod.rs b/src/scheduled/mod.rs index 60e9da0..381a546 100644 --- a/src/scheduled/mod.rs +++ b/src/scheduled/mod.rs @@ -6,18 +6,19 @@ use job_scheduler_ng::{Job, JobScheduler}; use rocket::tokio::{self, task}; use sqlx::SqlitePool; -pub async fn schedule(db: &SqlitePool) { +pub fn schedule(db: &SqlitePool) { let db = db.clone(); - waterlevel::update(&db).await.unwrap(); - tokio::task::spawn(async { + waterlevel::update(&db).await.unwrap(); + let mut sched = JobScheduler::new(); // Every hour sched.add(Job::new("0 0 * * * * *".parse().unwrap(), move || { let db_clone = db.clone(); - // Use block_in_place to run async code in the synchronous function + // Use block_in_place to run async code in the synchronous function; TODO: Make it + // nicer one's rust (stable) support async closures task::block_in_place(|| { tokio::runtime::Handle::current().block_on(async { waterlevel::update(&db_clone).await.unwrap(); From 2189b082c015ca436207574f8255ceb7c4b1bccc Mon Sep 17 00:00:00 2001 From: philipp Date: Tue, 30 Apr 2024 15:56:21 +0200 Subject: [PATCH 6/9] use recommended method of 'sleep' --- src/scheduled/mod.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/scheduled/mod.rs b/src/scheduled/mod.rs index 381a546..a27df12 100644 --- a/src/scheduled/mod.rs +++ b/src/scheduled/mod.rs @@ -3,7 +3,7 @@ mod waterlevel; use std::time::Duration; use job_scheduler_ng::{Job, JobScheduler}; -use rocket::tokio::{self, task}; +use rocket::tokio::{self, task, time}; use sqlx::SqlitePool; pub fn schedule(db: &SqlitePool) { @@ -26,9 +26,10 @@ pub fn schedule(db: &SqlitePool) { }); })); + let mut interval = time::interval(Duration::from_secs(60)); loop { sched.tick(); - std::thread::sleep(Duration::from_secs(60)); + interval.tick().await; } }); } From d6ecd8759390f1728a5367241667b42a536c2925 Mon Sep 17 00:00:00 2001 From: philipp Date: Tue, 30 Apr 2024 17:02:22 +0200 Subject: [PATCH 7/9] better phrasing --- templates/planned.html.tera | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/planned.html.tera b/templates/planned.html.tera index f9a0048..0e9b965 100644 --- a/templates/planned.html.tera +++ b/templates/planned.html.tera @@ -92,7 +92,7 @@

{{ day.day| date(format="%d.%m.%Y") }} {{ day.day | date(format="%A", locale="de_AT") }} - {% if day.max_waterlevel %}• 🌊{{ day.max_waterlevel }} cm{% endif %} + {% if day.max_waterlevel %}• 🌊{{ day.max_waterlevel }} cm{% endif %}

{% if day.planned_events | length > 0 or day.trips | length > 0 %} From e4ef1f1584bba7d5db45660dcbfd536ee6e659f0 Mon Sep 17 00:00:00 2001 From: philipp Date: Thu, 16 May 2024 14:41:15 +0200 Subject: [PATCH 8/9] add weather infos --- Rocket.toml | 1 + migration.sql | 8 +++ src/main.rs | 12 ++-- src/model/mod.rs | 5 ++ src/model/weather.rs | 56 ++++++++++++++++ src/scheduled/mod.rs | 10 ++- src/scheduled/weather.rs | 126 ++++++++++++++++++++++++++++++++++++ src/tera/mod.rs | 1 + templates/planned.html.tera | 11 +++- 9 files changed, 224 insertions(+), 6 deletions(-) create mode 100644 src/model/weather.rs create mode 100644 src/scheduled/weather.rs diff --git a/Rocket.toml b/Rocket.toml index b20da06..b9d5057 100644 --- a/Rocket.toml +++ b/Rocket.toml @@ -4,3 +4,4 @@ rss_key = "rss-key-for-ci" limits = { file = "10 MiB", data-form = "10 MiB"} smtp_pw = "8kIjlLH79Ky6D3jQ" usage_log_path = "./usage.txt" +openweathermap_key = "c8dab8f91b5b815d76e9879cbaecd8d5" diff --git a/migration.sql b/migration.sql index 103fd47..c530488 100644 --- a/migration.sql +++ b/migration.sql @@ -186,3 +186,11 @@ CREATE TABLE IF NOT EXISTS "waterlevel" ( "tumin" INTEGER NOT NULL, "tumittel" INTEGER NOT NULL ); + +CREATE TABLE IF NOT EXISTS "weather" ( + "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, + "day" DATE NOT NULL, + "max_temp" FLOAT NOT NULL, + "wind_gust" FLOAT NOT NULL, + "rain_mm" FLOAT NOT NULL +); diff --git a/src/main.rs b/src/main.rs index 8c1bf89..6cc87a7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,9 +4,9 @@ use std::str::FromStr; #[cfg(feature = "rest")] use rot::rest; -use rot::scheduled; #[cfg(feature = "rowing-tera")] use rot::tera; +use rot::{scheduled, tera::Config}; use sqlx::{pool::PoolOptions, sqlite::SqliteConnectOptions, ConnectOptions}; @@ -27,9 +27,7 @@ async fn rocket() -> _ { .await .unwrap(); - scheduled::schedule(&db); - - let rocket = rocket::build().manage(db); + let rocket = rocket::build().manage(db.clone()); #[cfg(feature = "rowing-tera")] let rocket = tera::config(rocket); @@ -37,5 +35,11 @@ async fn rocket() -> _ { #[cfg(feature = "rest")] let rocket = rest::config(rocket); + let config: Config = rocket + .figment() + .extract() + .expect("Config extraction failed"); + scheduled::schedule(&db, &config); + rocket } diff --git a/src/model/mod.rs b/src/model/mod.rs index 290b27c..a927799 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -6,6 +6,7 @@ use self::{ planned_event::{PlannedEvent, PlannedEventWithUserAndTriptype}, trip::{Trip, TripWithUserAndType}, waterlevel::Waterlevel, + weather::Weather, }; pub mod boat; @@ -29,6 +30,7 @@ pub mod triptype; pub mod user; pub mod usertrip; pub mod waterlevel; +pub mod weather; #[derive(Serialize, Debug)] pub struct Day { @@ -37,6 +39,7 @@ pub struct Day { trips: Vec, is_pinned: bool, max_waterlevel: Option, + weather: Option, } impl Day { @@ -48,6 +51,7 @@ impl Day { trips: Trip::get_pinned_for_day(db, day).await, is_pinned, max_waterlevel: Waterlevel::max_waterlevel_for_day(db, day).await, + weather: Weather::find_by_day(db, day).await, } } else { Self { @@ -56,6 +60,7 @@ impl Day { trips: Trip::get_for_day(db, day).await, is_pinned, max_waterlevel: Waterlevel::max_waterlevel_for_day(db, day).await, + weather: Weather::find_by_day(db, day).await, } } } diff --git a/src/model/weather.rs b/src/model/weather.rs new file mode 100644 index 0000000..c2363e8 --- /dev/null +++ b/src/model/weather.rs @@ -0,0 +1,56 @@ +use std::ops::DerefMut; + +use chrono::NaiveDate; +use rocket::serde::{Deserialize, Serialize}; +use sqlx::{FromRow, Sqlite, SqlitePool, Transaction}; + +#[derive(FromRow, Debug, Serialize, Deserialize, PartialEq, Clone)] +pub struct Weather { + pub id: i64, + pub day: NaiveDate, + pub max_temp: f64, + pub wind_gust: f64, + pub rain_mm: f64, +} + +impl Weather { + pub async fn find_by_day(db: &SqlitePool, day: NaiveDate) -> Option { + sqlx::query_as!(Self, "SELECT * FROM weather WHERE day = ?", day) + .fetch_one(db) + .await + .ok() + } + pub async fn find_by_id_tx(db: &mut Transaction<'_, Sqlite>, day: NaiveDate) -> Option { + sqlx::query_as!(Self, "SELECT * FROM weather WHERE day = ?", day) + .fetch_one(db.deref_mut()) + .await + .ok() + } + + pub async fn create( + db: &mut Transaction<'_, Sqlite>, + day: NaiveDate, + max_temp: f64, + wind_gust: f64, + rain_mm: f64, + ) -> Result<(), String> { + sqlx::query!( + "INSERT INTO weather(day, max_temp, wind_gust, rain_mm) VALUES (?,?,?,?)", + day, + max_temp, + wind_gust, + rain_mm + ) + .execute(db.deref_mut()) + .await + .map_err(|e| e.to_string())?; + Ok(()) + } + + pub async fn delete_all(db: &mut Transaction<'_, Sqlite>) { + sqlx::query!("DELETE FROM weather;") + .execute(db.deref_mut()) + .await + .unwrap(); + } +} diff --git a/src/scheduled/mod.rs b/src/scheduled/mod.rs index a27df12..93d09ec 100644 --- a/src/scheduled/mod.rs +++ b/src/scheduled/mod.rs @@ -1,4 +1,5 @@ mod waterlevel; +mod weather; use std::time::Duration; @@ -6,11 +7,15 @@ use job_scheduler_ng::{Job, JobScheduler}; use rocket::tokio::{self, task, time}; use sqlx::SqlitePool; -pub fn schedule(db: &SqlitePool) { +use crate::tera::Config; + +pub fn schedule(db: &SqlitePool, config: &Config) { let db = db.clone(); + let openweathermap_key = config.openweathermap_key.clone(); tokio::task::spawn(async { waterlevel::update(&db).await.unwrap(); + weather::update(&db, &openweathermap_key).await.unwrap(); let mut sched = JobScheduler::new(); @@ -22,6 +27,9 @@ pub fn schedule(db: &SqlitePool) { task::block_in_place(|| { tokio::runtime::Handle::current().block_on(async { waterlevel::update(&db_clone).await.unwrap(); + weather::update(&db_clone, &openweathermap_key) + .await + .unwrap(); }); }); })); diff --git a/src/scheduled/weather.rs b/src/scheduled/weather.rs new file mode 100644 index 0000000..afabf65 --- /dev/null +++ b/src/scheduled/weather.rs @@ -0,0 +1,126 @@ +use chrono::DateTime; +use serde::{Deserialize, Serialize}; +use sqlx::SqlitePool; + +use crate::model::weather::Weather; + +pub async fn update(db: &SqlitePool, api_key: &str) -> Result<(), String> { + let mut tx = db.begin().await.unwrap(); + + // 1. Delete weather data + Weather::delete_all(&mut tx).await; + + // 2. Fetch + let data = fetch(api_key)?; + for d in data.daily { + let Some(date) = DateTime::from_timestamp(d.dt, 0) else { + println!("Skipping {} because convertion to datetime failed", d.dt); + continue; + }; + let max_temp = d.temp.max; + let wind_gust = d.wind_gust; + let Some(rain_mm) = d.rain else { + println!( + "Skipping weather import of {} as there's no rain prognosed", + date + ); + continue; + }; + + Weather::create( + &mut tx, + date.naive_utc().into(), + max_temp, + wind_gust, + rain_mm, + ) + .await? + } + + // 3. Save in DB + tx.commit().await.unwrap(); + + Ok(()) +} +#[derive(Serialize, Deserialize, Debug, Clone)] +struct Data { + lat: f64, + lon: f64, + timezone: String, + timezone_offset: i64, + daily: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +struct Daily { + dt: i64, + sunrise: i64, + sunset: i64, + moonrise: i64, + moonset: i64, + moon_phase: f64, + summary: String, + temp: Temp, + feels_like: FeelsLike, + pressure: i64, + humidity: i64, + dew_point: f64, + wind_speed: f64, + wind_deg: i64, + wind_gust: f64, + weather: Vec, + clouds: i64, + pop: f64, + rain: Option, + uvi: f64, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +struct Temp { + day: f64, + min: f64, + max: f64, + night: f64, + eve: f64, + morn: f64, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +struct FeelsLike { + day: f64, + night: f64, + eve: f64, + morn: f64, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +struct DailyWeather { + id: i64, + main: String, + description: String, + icon: String, +} + +fn fetch(api_key: &str) -> Result { + let url = format!("https://api.openweathermap.org/data/3.0/onecall?lat=48.31970&lon=14.29451&units=metric&exclude=current,minutely,hourly,alert&appid={api_key}"); + + match ureq::get(&url).call() { + Ok(response) => { + let data: Result = response.into_json(); + + if let Ok(data) = data { + return Ok(data); + } else { + return Err(format!( + "Failed to parse the json received by {url}: {}", + data.err().unwrap() + )); + } + } + Err(_) => { + return Err(format!( + "Could not fetch {url}, do you have internet? Maybe their server is down?" + )); + } + } +} diff --git a/src/tera/mod.rs b/src/tera/mod.rs index 33bbf53..4b67064 100644 --- a/src/tera/mod.rs +++ b/src/tera/mod.rs @@ -174,6 +174,7 @@ pub struct Config { rss_key: String, smtp_pw: String, usage_log_path: String, + pub openweathermap_key: String, } pub fn config(rocket: Rocket) -> Rocket { diff --git a/templates/planned.html.tera b/templates/planned.html.tera index 0e9b965..378e4a0 100644 --- a/templates/planned.html.tera +++ b/templates/planned.html.tera @@ -92,9 +92,18 @@

{{ day.day| date(format="%d.%m.%Y") }} {{ day.day | date(format="%A", locale="de_AT") }} - {% if day.max_waterlevel %}• 🌊{{ day.max_waterlevel }} cm{% endif %} + {% if day.max_waterlevel %} + • 🌊{{ day.max_waterlevel }} cm + {% endif %}

+ {% if day.weather %} +
+ Max temp: {{ day.weather.max_temp }}° • Windböe: {{ day.weather.wind_gust }} km/h • Regen: {{ day.weather.rain_mm }} mm +
+ {% endif %} {% if day.planned_events | length > 0 or day.trips | length > 0 %}
{# --- START Events --- #} From 7e2c185c0328dc7855222ea19897d0b957264bd6 Mon Sep 17 00:00:00 2001 From: philipp Date: Thu, 16 May 2024 16:35:49 +0200 Subject: [PATCH 9/9] no rain level -> 0; round values --- src/scheduled/weather.rs | 8 +------- templates/planned.html.tera | 2 +- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/src/scheduled/weather.rs b/src/scheduled/weather.rs index afabf65..113f8ab 100644 --- a/src/scheduled/weather.rs +++ b/src/scheduled/weather.rs @@ -19,13 +19,7 @@ pub async fn update(db: &SqlitePool, api_key: &str) -> Result<(), String> { }; let max_temp = d.temp.max; let wind_gust = d.wind_gust; - let Some(rain_mm) = d.rain else { - println!( - "Skipping weather import of {} as there's no rain prognosed", - date - ); - continue; - }; + let rain_mm = d.rain.unwrap_or(0.); Weather::create( &mut tx, diff --git a/templates/planned.html.tera b/templates/planned.html.tera index 378e4a0..c7b1034 100644 --- a/templates/planned.html.tera +++ b/templates/planned.html.tera @@ -101,7 +101,7 @@ {% if day.weather %}
- Max temp: {{ day.weather.max_temp }}° • Windböe: {{ day.weather.wind_gust }} km/h • Regen: {{ day.weather.rain_mm }} mm + Max temp: {{ day.weather.max_temp | round }}° • Windböe: {{ day.weather.wind_gust | round }} km/h • Regen: {{ day.weather.rain_mm | round }} mm
{% endif %} {% if day.planned_events | length > 0 or day.trips | length > 0 %}