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