diff --git a/Cargo.lock b/Cargo.lock index 9749f00..8b7cf1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1275,6 +1275,7 @@ dependencies = [ "dotenv", "log", "once_cell", + "regex", "reqwest", "sea-orm", "serde", diff --git a/Cargo.toml b/Cargo.toml index 3fbabe2..77a5128 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ chrono = { version = "^0.4", features = ["serde"] } dotenv = "^0.15" log = "^0" once_cell = "^1" +regex = "^1" reqwest = { version = "^0.11", features = ["json"] } sea-orm = { version = "^0", features = [ "sqlx-postgres", diff --git a/migrations/20220719082103_coins.up.sql b/migrations/20220719082103_coins.up.sql index 36bfe8f..81f5ffe 100644 --- a/migrations/20220719082103_coins.up.sql +++ b/migrations/20220719082103_coins.up.sql @@ -9,6 +9,11 @@ CREATE TABLE coins max_year integer, obverse_thumbnail text, reverse_thumbnail text, + weight real NOT NULL, + diameter real NOT NULL, + thickness real NOT NULL, + material character(3) NOT NULL, + purity real NOT NULL, PRIMARY KEY (id) ); diff --git a/public/images/flags/usa-48.svg b/public/images/flags/usa-48.svg new file mode 100644 index 0000000..01545ed --- /dev/null +++ b/public/images/flags/usa-48.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/usa-50.svg b/public/images/flags/usa-50.svg new file mode 100644 index 0000000..154fd6a --- /dev/null +++ b/public/images/flags/usa-50.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/images/flags/usa-roundel.svg b/public/images/flags/usa-roundel.svg new file mode 100644 index 0000000..50cb230 --- /dev/null +++ b/public/images/flags/usa-roundel.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/resources/css/guide.scss b/resources/css/guide.scss new file mode 100644 index 0000000..a47f053 --- /dev/null +++ b/resources/css/guide.scss @@ -0,0 +1,23 @@ +.flag { + height: 1.4em; + vertical-align: bottom; + border-radius: 4px; +} + +.coins { + width: 100%; +} + +td.thumbnail { + width: 90px; +} + +img.thumbnail { + height: 90px; + width: 90px; +} + +caption { + caption-side: bottom; + text-align: left; +} \ No newline at end of file diff --git a/resources/css/main.scss b/resources/css/main.scss index 15fb5bb..b9b761d 100644 --- a/resources/css/main.scss +++ b/resources/css/main.scss @@ -1,3 +1,4 @@ @use 'generic'; @use 'layout'; -@use 'nav'; \ No newline at end of file +@use 'nav'; +@use 'guide'; \ No newline at end of file diff --git a/src/coin.rs b/src/coin.rs index 3c1169c..1202240 100644 --- a/src/coin.rs +++ b/src/coin.rs @@ -1,3 +1,10 @@ +use crate::db::get_db_pool; +use crate::orm::coins; +use regex::Regex; +use sea_orm::{prelude::*, query::*, ActiveValue, DatabaseConnection, EntityTrait}; +use serde::{Deserialize, Deserializer, Serialize}; +use std::collections::HashMap; + // https://en.numista.com/api/doc/index.php //{ // "id": 38581, @@ -12,3 +19,151 @@ // "obverse_thumbnail": "https://en.numista.com/catalogue/photos/abkhazia/98-180.jpg", // "reverse_thumbnail": "https://en.numista.com/catalogue/photos/abkhazia/99-180.jpg" //}, + +#[derive(Debug, Deserialize, Serialize)] +pub struct Coin { + pub id: i32, + pub title: String, + pub issuer: Issuer, + + pub min_year: i32, + pub max_year: i32, + + pub obverse: Engraving, + pub reverse: Engraving, + + pub weight: f32, + pub size: f32, + pub thickness: f32, + pub composition: Composition, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct Issuer { + pub code: String, + pub name: String, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct Engraving { + pub thumbnail: String, +} + +#[derive(Debug, Serialize)] +pub struct Composition { + pub material: String, + pub purity: Option, +} + +impl Composition { + pub fn to_ticker(&self) -> String { + match self.material.as_str() { + "Gold" => "XAU", + "Silver" => "XAG", + _ => unreachable!(), + } + .to_owned() + } +} + +impl<'de> Deserialize<'de> for Composition { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Debug, Deserialize)] + struct CompositionWrapper { + text: String, + } + + let wrapper: CompositionWrapper = Deserialize::deserialize(deserializer)?; + + let regex = Regex::new(r"(Gold|Silver)(?:\s\(\.(\d{3})\))").unwrap(); + let caps = regex.captures(&wrapper.text).unwrap(); + + Ok(Composition { + material: caps.get(1).unwrap().as_str().to_owned(), + purity: caps + .get(2) + .map(|v| v.as_str().parse::().unwrap() as f32 / 1000.0), + }) + } +} + +/// Numista IDs for relevant US coins. +pub const USA_COINS: [u32; 15] = [ + 1492, 10085, 5830, 10591, 30785, 51, 4455, 3573, 5580, 54, 45, 52, 2835, 943, 10865, +]; + +pub async fn init_coins(db: &DatabaseConnection) { + let api_key = std::env::var("NUMISTA_API_KEY").expect("NUMISTA_API_KEY must be set."); + let new_coins: Vec = Vec::with_capacity(USA_COINS.len()); + + let usa_coins = coins::Entity::find() + .filter(coins::Column::NumistaId.is_in(USA_COINS)) + .all(get_db_pool()) + .await + .expect("Couldn't query coins from database") + .into_iter() + .map(|coin| (coin.numista_id, coin)) + .collect::>(); + + for coin in USA_COINS { + if !usa_coins.contains_key(&(coin as i32)) { + let coin = reqwest::Client::new() + .get(format!( + "https://api.numista.com/api/v3/types/{}?lang=en", + coin + )) + .header(reqwest::header::EXPECT, "application/json") + .header("Numista-API-Key", api_key.to_owned()) + .send() + .await + .expect("Failed to query Numista API.") + .json::() + .await + .expect("Failed to deserialize Numista API result."); + + log::debug!("Pulled new Numista coin: {:?}", coin); + + let new_coin = coins::ActiveModel { + id: ActiveValue::NotSet, + numista_id: ActiveValue::set(coin.id), + issuer: ActiveValue::set(coin.issuer.code), + title: ActiveValue::set(coin.title), + min_year: ActiveValue::set(Some(coin.min_year)), + max_year: ActiveValue::set(Some(coin.max_year)), + obverse_thumbnail: ActiveValue::set(Some(coin.obverse.thumbnail)), + reverse_thumbnail: ActiveValue::set(Some(coin.reverse.thumbnail)), + weight: ActiveValue::set(coin.weight), + diameter: ActiveValue::set(coin.size), + thickness: ActiveValue::set(coin.thickness), + material: ActiveValue::set(coin.composition.to_ticker()), + purity: ActiveValue::set(coin.composition.purity.unwrap_or(1.0)), + } + .insert(db) + .await + .expect("Failed to insert a new coin."); + } + } +} + +#[cfg(test)] +mod tests { + #[test] + fn test_composition_deserialize() { + let silver = r#"{ "text": "Silver (.400)" }"#; + let silver_comp: super::Composition = + serde_json::from_str(silver).expect("Failed to parse silver"); + + assert_eq!(silver_comp.material, "Silver"); + assert_eq!(silver_comp.purity, Some(0.4)); + + let gold = r#"{ "text": "Gold (.900)" }"#; + let gold_comp: super::Composition = + serde_json::from_str(gold).expect("Failed to parse gold"); + + assert_eq!(gold_comp.material, "Gold"); + assert_eq!(gold_comp.purity, Some(0.9)); + } +} diff --git a/src/guide/mod.rs b/src/guide/mod.rs new file mode 100644 index 0000000..5a9fdca --- /dev/null +++ b/src/guide/mod.rs @@ -0,0 +1 @@ +pub mod usa; \ No newline at end of file diff --git a/src/guide/usa.rs b/src/guide/usa.rs new file mode 100644 index 0000000..e03aee1 --- /dev/null +++ b/src/guide/usa.rs @@ -0,0 +1,50 @@ +use crate::coin::USA_COINS; +use crate::db::get_db_pool; +use crate::middleware::ClientCtx; +use crate::orm::coins; +use actix_web::{error, get, Error, Responder}; +use askama_actix::Template; +use sea_orm::{prelude::*, EntityTrait}; +use std::collections::HashMap; + +#[derive(Template)] +#[template(path = "part/coin.html")] +pub struct CoinTemplate<'a> { + pub client: &'a ClientCtx, + pub coin: &'a coins::Model, +} + +#[derive(Template)] +#[template(path = "guides/usa.html")] +pub struct GuideTemplate { + pub client: ClientCtx, + pub coins: HashMap, +} + +impl GuideTemplate { + pub fn show_coin(&self, id: i32) -> String { + match self.coins.get(&id) { + Some(coin) => CoinTemplate { + client: &self.client, + coin: &coin, + } + .render() + .unwrap_or("Failed to render coin row.".to_string()), + None => format!("Requested coin `N{}` not in database.", id), + } + } +} + +#[get("/guide/usa")] +pub async fn view_index(client: ClientCtx) -> Result { + let coins = coins::Entity::find() + .filter(coins::Column::NumistaId.is_in(USA_COINS)) + .all(get_db_pool()) + .await + .map_err(error::ErrorInternalServerError)? + .into_iter() + .map(|coin| (coin.numista_id, coin)) + .collect::>(); + + Ok(GuideTemplate { client, coins }) +} diff --git a/src/main.rs b/src/main.rs index b4bb050..28f1f42 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,11 @@ +use crate::coin::init_coins; use crate::db::init_db_pool; use crate::price::init_spot_prices; use actix_web::{web::Data, App, HttpServer}; +mod coin; mod db; +mod guide; mod middleware; mod orm; mod price; @@ -13,7 +16,8 @@ async fn main() -> std::io::Result<()> { dotenv::dotenv().expect("Could not open .env file"); let db = init_db_pool().await; - let prices = init_spot_prices(db).await; + init_spot_prices(db).await; + init_coins(db).await; // Recast db in a Data wrapper. let db = Data::new(db); @@ -23,6 +27,8 @@ async fn main() -> std::io::Result<()> { .app_data(Data::clone(&db)) .service(crate::web::view_css) .service(crate::web::view_index) + .service(crate::web::view_flag) + .service(crate::guide::usa::view_index) }) .bind(("127.0.0.1", 8080))? .run() diff --git a/src/orm/coins.rs b/src/orm/coins.rs index 91815eb..5d4cbc8 100644 --- a/src/orm/coins.rs +++ b/src/orm/coins.rs @@ -18,6 +18,13 @@ pub struct Model { pub obverse_thumbnail: Option, #[sea_orm(column_type = "Text", nullable)] pub reverse_thumbnail: Option, + + weight: f32, + diameter: f32, + thickness: f32, + + material: String, + purity: f32, } #[derive(Copy, Clone, Debug, EnumIter)] diff --git a/src/price.rs b/src/price.rs index 1cc0866..f518c1d 100644 --- a/src/price.rs +++ b/src/price.rs @@ -1,12 +1,9 @@ -use crate::{db::get_db_pool, orm::spots}; -use actix_web::web::Data; +use crate::orm::spots; use chrono::NaiveDateTime; use sea_orm::{prelude::*, query::*, ActiveValue, DatabaseConnection, EntityTrait}; use serde::Deserialize; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; -/// Const for parsing atomic values to real currency. -pub const ATOMIC_TO_CURRENCY: f32 = 0.001; /// Mathematical constant for convering 1 troy ounce to grams. pub const TROY_OUNCE_TO_GRAM: f32 = 31.1035; @@ -229,7 +226,7 @@ pub async fn store_spot_prices(db: &DatabaseConnection, api: ApiData) -> Result< }; spots::Entity::insert_many(vec![copper, silver, gold]) - .exec(get_db_pool()) + .exec(db) .await?; Ok(()) diff --git a/src/web.rs b/src/web.rs index b7ee436..3bb0de0 100644 --- a/src/web.rs +++ b/src/web.rs @@ -1,7 +1,8 @@ use crate::middleware::ClientCtx; use actix_files::NamedFile; -use actix_web::{get, Error, Responder}; +use actix_web::{get, Error, HttpRequest, Responder, Result}; use askama_actix::Template; +use std::path::PathBuf; #[derive(Template)] #[template(path = "index.html")] @@ -19,3 +20,12 @@ pub async fn view_css() -> Result { let path = "public/assets/style.css"; Ok(NamedFile::open(path)?) } + +#[get("/images/flags/{filename:.*}")] +pub async fn view_flag(req: HttpRequest) -> Result { + let filename: PathBuf = req.match_info().query("filename").parse().unwrap(); + Ok(NamedFile::open(format!( + "public/images/flags/{}", + filename.to_str().expect("No filename.") + ))?) +} diff --git a/templates/guides/usa.html b/templates/guides/usa.html new file mode 100644 index 0000000..e16de59 --- /dev/null +++ b/templates/guides/usa.html @@ -0,0 +1,97 @@ +{% extends "container/public.html" %} + +{% block content %} +
+

United States Constitutional Coins

+

+ On April 2nd, 1972, the 2nd United States Congress passed the Coinage Act of 1972. + This officially defined the currency of the United States as gold, silver, and copper. + The Gold Eagle equaled 247 ½ grains (17.5g), the Silver Dollar equaled 371 ¼ grains (27.0g), and the Copper Cent + was 11 pennyweights (17.1g). +

+

This would be the definition of money in the country until February 12th, 1873.

+
+ +
+

+ Post-Civil War + +

+ + + + + + + + + + {{ self.show_coin(1492)|safe }} + {{ self.show_coin(10085)|safe }} + {{ self.show_coin(5830)|safe }} + {{ self.show_coin(10591)|safe }} + {{ self.show_coin(30785)|safe }} + {{ self.show_coin(51)|safe }} + {{ self.show_coin(4455)|safe }} + {{ self.show_coin(3573)|safe }} + {{ self.show_coin(5580)|safe }} + {{ self.show_coin(54)|safe }} + +
+ Though silver had been officially demonetized, it remained in currency for almost a century after. +
+ +
+
+ +
+

+ World War 2 Coins + +

+ + + + + + + + + + {{ self.show_coin(45)|safe }} + +
+ During the Second World War, nickel became a strategic material used as an alloy in materiel and + industrial equipment. The U.S. would produce Nickels with 35% silver content between 1942 and 1945. +
+ +
+
+ +
+

+ Post-War Coins + +

+ + + + + + + + + + {{ self.show_coin(52)|safe }} + {{ self.show_coin(2835)|safe }} + {{ self.show_coin(943)|safe }} + {{ self.show_coin(10865)|safe }} + +
+ The U.S. kept silver in its currency until the year before President Nixon ended Bretton-Woods and + officially removed the U.S. Dollar from the gold standard. +
+ +
+
+{% endblock %} \ No newline at end of file diff --git a/templates/part/coin.html b/templates/part/coin.html new file mode 100644 index 0000000..b406eea --- /dev/null +++ b/templates/part/coin.html @@ -0,0 +1,15 @@ + + + {% if let Some(thumbnail) = coin.obverse_thumbnail %} + + {% endif %} + + + {% if let Some(thumbnail) = coin.reverse_thumbnail %} + + {% endif %} + + +

{{ coin.title }}

+ + \ No newline at end of file