diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..123caeb --- /dev/null +++ b/shell.nix @@ -0,0 +1,7 @@ +let + pkgs = import (fetchTarball "https://github.com/NixOS/nixpkgs/archive/refs/tags/25.11.tar.gz") {}; +in pkgs.mkShell { + packages = with pkgs; [ + openssl pkg-config sqlite + ]; +} diff --git a/src/data.rs b/src/data.rs index 26f0ba9..0f336f7 100644 --- a/src/data.rs +++ b/src/data.rs @@ -1,5 +1,5 @@ -use std::collections::HashMap; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Player { pub username: String, @@ -18,7 +18,7 @@ pub struct Game { pub code: String, pub teams: Vec, pub start_time: String, - pub end_time: String + pub end_time: String, } impl Team { @@ -35,5 +35,5 @@ impl Team { #[derive(Debug, Clone, Deserialize, Serialize)] pub struct TeamLegend { pub name: String, - pub color: String + pub color: String, } diff --git a/src/db.rs b/src/db.rs index c3320e7..ff1e910 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,14 +1,16 @@ -use actix_web::{web, Error, error}; -use std::collections::HashMap; use crate::data::{Game, Player, Team}; +use actix_web::{Error, error, web}; +use std::collections::HashMap; pub type Pool = r2d2::Pool; pub type Connection = r2d2::PooledConnection; type GamesResult = Result, rusqlite::Error>; -type TeamsResult = Result, rusqlite::Error>; +type TeamsResult = Result, rusqlite::Error>; -const GAMES_QUERY: &str = "select code, group_concat(team), max(start_time), max(end_time) from games group by code"; -const TEAMS_QUERY: &str = "select game, team, min(color), group_concat(player) from teams group by game, team"; +const GAMES_QUERY: &str = + "select code, group_concat(team), max(start_time), max(end_time) from games group by code"; +const TEAMS_QUERY: &str = + "select game, team, min(color), group_concat(player) from teams group by game, team"; pub async fn games(pool: &Pool) -> Result, Error> { let pool = pool.clone(); @@ -16,30 +18,26 @@ pub async fn games(pool: &Pool) -> Result, Error> { let conn = web::block(move || pool.get()) .await? .map_err(error::ErrorInternalServerError)?; - web::block(move || { - get_all_games(conn, teams_result) - }) - .await? - .map_err(error::ErrorInternalServerError) + web::block(move || get_all_games(conn, teams_result)) + .await? + .map_err(error::ErrorInternalServerError) } -pub async fn teams(pool: &Pool) -> Result, Error> { +pub async fn teams(pool: &Pool) -> Result, Error> { let pool = pool.clone(); let conn = web::block(move || pool.get()) .await? .map_err(error::ErrorInternalServerError)?; - web::block(move || { - get_all_teams(conn) - }) - .await? - .map_err(error::ErrorInternalServerError) + web::block(move || get_all_teams(conn)) + .await? + .map_err(error::ErrorInternalServerError) } -fn get_all_games(conn: Connection, all_teams: HashMap) -> GamesResult { +fn get_all_games(conn: Connection, all_teams: HashMap) -> GamesResult { let mut games_stmt = conn.prepare(GAMES_QUERY)?; - games_stmt.query_map([], |games_row| { - Ok( - { + games_stmt + .query_map([], |games_row| { + Ok({ let game_name: String = games_row.get(0)?; let teams: String = games_row.get(1)?; let teams: Vec = teams @@ -47,39 +45,41 @@ fn get_all_games(conn: Connection, all_teams: HashMap) -> GamesResu .map(|part| all_teams.get(&format!("{game_name}_{part}")).unwrap()) .map(|part| part.to_owned()) .collect(); - Game{ + Game { code: game_name, teams, start_time: games_row.get(2)?, - end_time: games_row.get(3)? + end_time: games_row.get(3)?, } - } - ) - }).and_then(Iterator::collect) + }) + }) + .and_then(Iterator::collect) } fn get_all_teams(conn: Connection) -> TeamsResult { let mut teams_stmt = conn.prepare(TEAMS_QUERY)?; - teams_stmt.query_map([], |teams_row|{ - let game_name: String = teams_row.get(0)?; - let team_name: String = teams_row.get(1)?; - let color: String = teams_row.get(2)?; - let key = format!("{game_name}_{team_name}"); - let players: String = teams_row.get(3)?; - let players: Vec = players - .split(',') - .map(|part| Player{username: part.to_string()}) - .collect(); - Ok( - ( + teams_stmt + .query_map([], |teams_row| { + let game_name: String = teams_row.get(0)?; + let team_name: String = teams_row.get(1)?; + let color: String = teams_row.get(2)?; + let key = format!("{game_name}_{team_name}"); + let players: String = teams_row.get(3)?; + let players: Vec = players + .split(',') + .map(|part| Player { + username: part.to_string(), + }) + .collect(); + Ok(( key, - Team{ + Team { name: team_name, color, players, - scores: HashMap::new() - } - ) - ) - }).and_then(Iterator::collect) + scores: HashMap::new(), + }, + )) + }) + .and_then(Iterator::collect) } diff --git a/src/geo_utils.rs b/src/geo_utils.rs index a8e2cbe..4cbe00d 100644 --- a/src/geo_utils.rs +++ b/src/geo_utils.rs @@ -1,13 +1,12 @@ - +use crate::Team; use geo as geo_types; -use std::convert::TryFrom; -use std::fs; use geo::algorithm::contains::Contains; use geojson::{FeatureCollection, GeoJson, Geometry, Value}; -use crate::Team; +use std::convert::TryFrom; +use std::fs; pub struct GeoUtils { - feature_collection: FeatureCollection + feature_collection: FeatureCollection, } impl GeoUtils { @@ -16,11 +15,14 @@ impl GeoUtils { for feature in &mut fc.features { feature.set_property("color", "#888888ff"); } - Self {feature_collection: fc} + Self { + feature_collection: fc, + } } fn load_feature_collection() -> FeatureCollection { - let txt: String = fs::read_to_string("Neighborhood_Map_Atlas_Neighborhoods.geojson").expect("file should be present"); + let txt: String = fs::read_to_string("Neighborhood_Map_Atlas_Neighborhoods.geojson") + .expect("file should be present"); let geojson_str = txt; let geojson: GeoJson = geojson_str.parse::().unwrap(); FeatureCollection::try_from(geojson).unwrap() @@ -30,7 +32,7 @@ impl GeoUtils { self.feature_collection.clone() } - pub fn get_colored_collection_copy(self, teams: Vec) -> FeatureCollection { + pub fn get_colored_collection_copy(self, teams: Vec) -> FeatureCollection { let mut copy = self.feature_collection.clone(); for feature in &mut copy.features { let name = feature.property("S_HOOD").unwrap_or_default(); @@ -38,7 +40,7 @@ impl GeoUtils { let mut max_team: (Option, i32) = (None, 0); for team in &teams { let score = team.scores.get(name).unwrap_or(&0); - if *score > max_team.1 { + if *score > max_team.1 { max_team = (Some(team.color.clone()), *score); } else if *score > 0 && *score == max_team.1 { // Tie doesn't count @@ -61,7 +63,7 @@ impl GeoUtils { let name = feature.property("S_HOOD").unwrap_or_default(); let name = name.as_str().unwrap_or("unknown"); let geometry: Geometry = feature.clone().geometry.expect("Feature has no geometry"); - + match geometry.value { Value::Polygon(coords) => { // Outer ring @@ -75,14 +77,17 @@ impl GeoUtils { } } Value::MultiPolygon(coords) => { - let multipoly = coords.iter().map(|poly_coords| { - let exterior = poly_coords[0] - .iter() - .map(|coord| (coord[0], coord[1])) - .collect::>() - .into(); - geo_types::Polygon::new(exterior, vec![]) - }).collect::>>(); + let multipoly = coords + .iter() + .map(|poly_coords| { + let exterior = poly_coords[0] + .iter() + .map(|coord| (coord[0], coord[1])) + .collect::>() + .into(); + geo_types::Polygon::new(exterior, vec![]) + }) + .collect::>>(); let multipolygon = geo_types::MultiPolygon::new(multipoly); if multipolygon.contains(&point) { return String::from(name); @@ -94,4 +99,3 @@ impl GeoUtils { String::from("Outside") } } - diff --git a/src/main.rs b/src/main.rs index 7f945c5..b711458 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,11 @@ -pub mod osm; mod data; -mod geo_utils; mod db; +mod geo_utils; +pub mod osm; +use crate::data::Game; +use crate::data::{Team, TeamLegend}; +use crate::osm::scores; use actix::ActorFutureExt; use actix::Addr; use actix::AtomicResponse; @@ -11,23 +14,21 @@ use actix::Message; use actix::WrapFuture; use actix::dev::MessageResponse; use actix::dev::OneshotSender; -use r2d2_sqlite::SqliteConnectionManager; +use actix::prelude::Actor; +use actix_web::{App, HttpResponse, HttpServer, Responder, get, web}; +use chrono::NaiveDateTime; use db::Pool; +use r2d2_sqlite::SqliteConnectionManager; +use reqwest::Client; +use std::error::Error; +use std::ops::Deref; +use std::sync::Arc; +use std::time::Duration; use tera::Context; use tera::Tera; use tokio::sync::Mutex; -use crate::data::Game; -use crate::osm::scores; -use std::error::Error; -use std::sync::Arc; -use chrono::NaiveDateTime; -use reqwest::Client; -use xml::reader::EventReader; -use actix_web::{get, App, web, HttpResponse, HttpServer, Responder}; -use crate::data::{Team, TeamLegend}; use tokio::time; -use std::time::Duration; -use actix::prelude::Actor; +use xml::reader::EventReader; const DATE_FORMAT: &str = "%Y-%m-%dT%H:%M:%S"; @@ -36,12 +37,12 @@ const DATE_FORMAT: &str = "%Y-%m-%dT%H:%M:%S"; enum Messages { SynScores, UpdateScores, - GetGames + GetGames, } #[derive(Debug)] enum Responses { - GamesResult(Vec) + GamesResult(Vec), } impl MessageResponse for Responses @@ -58,7 +59,7 @@ where #[derive(Default)] struct GamesActor { - games: Arc>> + games: Arc>>, } impl Actor for GamesActor { @@ -77,34 +78,45 @@ impl Handler for GamesActor { // - update should only update games that are within 5 minutes of start or end time? match msg { Messages::SynScores => { - let games: Vec = games.lock().await.iter().map(|v| v.to_owned()).collect(); - update_scores(games).await.expect("score calculation failed?") - }, + let games: Vec = + games.lock().await.iter().map(|v| v.to_owned()).collect(); + update_scores(games) + .await + .expect("score calculation failed?") + } Messages::UpdateScores => { let games = games.lock().await.iter().map(|v| v.to_owned()).collect(); - update_scores(games).await.expect("score calculation failed?") - }, - Messages::GetGames => { - games.lock().await.iter().map(|v| v.to_owned()).collect() - }, + update_scores(games) + .await + .expect("score calculation failed?") + } + Messages::GetGames => games.lock().await.iter().map(|v| v.to_owned()).collect(), } } .into_actor(self) .map(move |games: Vec, this, _| { this.games = Arc::new(Mutex::new(games.clone())); Responses::GamesResult(games) - }) + }), )) } } #[get("/legend-data/{game_code}")] -async fn legend_data(game_code: web::Path, games: web::Data>>>) -> impl Responder { +async fn legend_data( + game_code: web::Path, + games: web::Data>>>, +) -> impl Responder { let games = games.lock().await; if let Some(game) = games.iter().find(|g| g.code == *game_code) { let teams = &game.teams; - let legends: Vec = teams.iter() - .map(|t| TeamLegend{name: t.name.clone(), color:t.color.clone()}).collect(); + let legends: Vec = teams + .iter() + .map(|t| TeamLegend { + name: t.name.clone(), + color: t.color.clone(), + }) + .collect(); return HttpResponse::Ok() .content_type("application/json") .body(serde_json::to_string(&legends).unwrap()); @@ -115,9 +127,13 @@ async fn legend_data(game_code: web::Path, games: web::Data, games_actor: web::Data>) -> impl Responder { +async fn geojson_endpoint( + game_code: web::Path, + games_actor: web::Data>, +) -> impl Responder { if let Ok(Responses::GamesResult(games)) = games_actor.send(Messages::GetGames).await - && let Some(game) = games.iter().find(|g| g.code == *game_code) { + && let Some(game) = games.iter().find(|g| g.code == *game_code) + { let teams = &game.teams; let utils = geo_utils::GeoUtils::new(); let feature_collection = utils.get_colored_collection_copy(teams.to_vec()); @@ -130,7 +146,7 @@ async fn geojson_endpoint(game_code: web::Path, games_actor: web::Data) -> impl Responder { let mut context = Context::new(); context.insert("game_code", game_code.as_str()); @@ -139,6 +155,22 @@ async fn game_page(game_code: web::Path) -> impl Responder { HttpResponse::Ok().body(body) } +#[get("/admin")] +async fn admin() -> impl Responder { + let mut context = Context::new(); + let body = Tera::one_off(include_str!("templates/admin.tera"), &context, false) + .expect("Failed to render template"); + HttpResponse::Ok().body(body) +} + +#[get("/admin/games")] +async fn list_of_games(games: web::Data>>>) -> impl Responder { + let games = games.lock().await; + let games_json = serde_json::to_string(&*games).unwrap(); + HttpResponse::Ok() + .content_type("application/json") + .body(games_json) +} #[actix_web::main] async fn main() -> std::io::Result<()> { @@ -149,11 +181,14 @@ async fn main() -> std::io::Result<()> { let manager = SqliteConnectionManager::file("mapbattle.db"); let pool = Pool::new(manager).unwrap(); - let games = db::games(&pool).await.expect("retirieving games from db should work fine"); + let games = db::games(&pool) + .await + .expect("retirieving games from db should work fine"); let state = Arc::new(Mutex::new(games)); - let actor_addr = GamesActor{ - games: state.clone() - }.start(); + let actor_addr = GamesActor { + games: state.clone(), + } + .start(); let sync_result = actor_addr.send(Messages::SynScores).await; match sync_result { Ok(_) => println!("Initial score syncing was successful"), @@ -179,29 +214,32 @@ async fn main() -> std::io::Result<()> { .service(game_page) .service(geojson_endpoint) .service(legend_data) + //.service(admin) + //.service(list_of_games) }) .bind(("0.0.0.0", 8080))? .run() .await } -async fn update_scores(games: Vec)-> Result, Box> { - let client = Client::builder() - .user_agent("MapBattle/0.1") - .build()?; +async fn update_scores(games: Vec) -> Result, Box> { + let client = Client::builder().user_agent("MapBattle/0.1").build()?; let mut result: Vec = vec![]; for game in games.iter() { let mut neogame = game.clone(); let teams: &mut Vec = &mut neogame.teams; - let start_date = NaiveDateTime::parse_from_str(&game.start_time, DATE_FORMAT).expect("failed to parse start date"); - let end_date = NaiveDateTime::parse_from_str(&game.end_time, DATE_FORMAT).expect("failed to parse end date"); + let start_date = NaiveDateTime::parse_from_str(&game.start_time, DATE_FORMAT) + .expect("failed to parse start date"); + let end_date = NaiveDateTime::parse_from_str(&game.end_time, DATE_FORMAT) + .expect("failed to parse end date"); for team in teams { team.scores.clear(); for player in team.players.clone() { println!("Processing player: {} ({})", player.username, team.name); - let body = scores::get_changesets_for_user(player.username.clone(), &client).await?; + let body = + scores::get_changesets_for_user(player.username.clone(), &client).await?; let reader = EventReader::from_str(&body); let changesets = scores::extract_changesets_from_reader(reader); let changesets = scores::time_bound_changesets(changesets, start_date, end_date); diff --git a/src/osm.rs b/src/osm.rs index b157bfc..1738498 100644 --- a/src/osm.rs +++ b/src/osm.rs @@ -7,32 +7,34 @@ mod network { } pub async fn get_changesets(user: String, client: &Client) -> Result> { - let endpoint = format!("https://api.openstreetmap.org/api/0.6/changesets?display_name={user}"); + let endpoint = + format!("https://api.openstreetmap.org/api/0.6/changesets?display_name={user}"); get_data(endpoint, client).await } } pub mod scores { + use crate::geo_utils; use crate::osm::network; use chrono::NaiveDateTime; + use geo as geo_types; use reqwest::Client; use std::{collections::HashMap, error::Error}; - use xml::{attribute::OwnedAttribute, reader::{EventReader, XmlEvent}}; - use geo as geo_types; - use crate::geo_utils; + use xml::{ + attribute::OwnedAttribute, + reader::{EventReader, XmlEvent}, + }; const DATE_FORMAT: &str = "%Y-%m-%dT%H:%M:%SZ"; pub struct Team { - pub players: Vec + pub players: Vec, } pub struct Input { pub start_time: NaiveDateTime, pub end_time: NaiveDateTime, - pub teams: Vec + pub teams: Vec, } - pub async fn calculate(input: Input)-> Result<(), Box> { - let client = Client::builder() - .user_agent("MapBattle/0.1") - .build()?; + pub async fn calculate(input: Input) -> Result<(), Box> { + let client = Client::builder().user_agent("MapBattle/0.1").build()?; for team in input.teams { for player in team.players { let raw_changesets = network::get_changesets(player.clone(), &client).await?; @@ -47,77 +49,115 @@ pub mod scores { } // Public function for getting changesets for a user - pub async fn get_changesets_for_user(user: String, client: &Client) -> Result> { + pub async fn get_changesets_for_user( + user: String, + client: &Client, + ) -> Result> { network::get_changesets(user, client).await } // Public function for extracting changesets from reader pub fn extract_changesets_from_reader(reader: EventReader<&[u8]>) -> Vec> { - reader.into_iter().filter_map(|event| match event { - Ok(XmlEvent::StartElement { name, attributes, .. }) => { - if name.local_name.eq("changeset") { - Some(attributes) - } else { + reader + .into_iter() + .filter_map(|event| match event { + Ok(XmlEvent::StartElement { + name, attributes, .. + }) => { + if name.local_name.eq("changeset") { + Some(attributes) + } else { + None + } + } + Err(e) => { + eprintln!("Warning: XML parsing error: {}", e); None } - }, - Err(e) => { - eprintln!("Warning: XML parsing error: {}", e); - None - }, - _ => None - }).collect() + _ => None, + }) + .collect() } // Public function for time bounding changesets - pub fn time_bound_changesets(input: Vec>, start_date: NaiveDateTime, end_date: NaiveDateTime) -> Vec> { - input.into_iter().filter(|list| { - match list.iter().find(|att| att.name.local_name.eq("created_at")) { - Some(created_at) => { - match NaiveDateTime::parse_from_str(&created_at.value, DATE_FORMAT) { - Ok(date) => date > start_date && date < end_date, - Err(e) => { - eprintln!("Warning: Failed to parse date '{}': {}", created_at.value, e); - false + pub fn time_bound_changesets( + input: Vec>, + start_date: NaiveDateTime, + end_date: NaiveDateTime, + ) -> Vec> { + input + .into_iter() + .filter( + |list| match list.iter().find(|att| att.name.local_name.eq("created_at")) { + Some(created_at) => { + match NaiveDateTime::parse_from_str(&created_at.value, DATE_FORMAT) { + Ok(date) => date > start_date && date < end_date, + Err(e) => { + eprintln!( + "Warning: Failed to parse date '{}': {}", + created_at.value, e + ); + false + } } } - } - None => false - } - }).collect() + None => false, + }, + ) + .collect() } // Public function for converting changesets to points - pub async fn changesets_to_points(changesets: Vec>) -> HashMap { + pub async fn changesets_to_points( + changesets: Vec>, + ) -> HashMap { let mut result: HashMap = HashMap::new(); for changeset in changesets { - let min_lat = changeset.clone().into_iter().find(|att| att.name.local_name.eq("min_lat")); + let min_lat = changeset + .clone() + .into_iter() + .find(|att| att.name.local_name.eq("min_lat")); if min_lat.is_none() { continue; } let min_lat = min_lat.unwrap().value; - let min_lon = changeset.clone().into_iter().find(|att| att.name.local_name.eq("min_lon")); + let min_lon = changeset + .clone() + .into_iter() + .find(|att| att.name.local_name.eq("min_lon")); if min_lon.is_none() { continue; } let min_lon = min_lon.unwrap().value; let min_lat: f64 = min_lat.parse().unwrap_or_default(); let min_lon: f64 = min_lon.parse().unwrap_or_default(); - let score = changeset.into_iter().find(|att| att.name.local_name.eq("changes_count")).unwrap().value; - let score:i32 = score.parse().expect("changes count returned as not a string"); + let score = changeset + .into_iter() + .find(|att| att.name.local_name.eq("changes_count")) + .unwrap() + .value; + let score: i32 = score + .parse() + .expect("changes count returned as not a string"); let point = geo_types::Point::new(min_lon, min_lat); let territory = geo_utils::GeoUtils::new().get_territory_for(point); - result.entry(territory).and_modify(|val| *val += score).or_insert(score); + result + .entry(territory) + .and_modify(|val| *val += score) + .or_insert(score); } result } - fn time_bound(input: Vec>, start_date: NaiveDateTime, end_date: NaiveDateTime) -> Vec> { + fn time_bound( + input: Vec>, + start_date: NaiveDateTime, + end_date: NaiveDateTime, + ) -> Vec> { time_bound_changesets(input, start_date, end_date) } async fn to_points(changesets: Vec>) -> HashMap { changesets_to_points(changesets).await } - -} \ No newline at end of file +} diff --git a/src/templates/admin.tera b/src/templates/admin.tera new file mode 100644 index 0000000..0b53cbd --- /dev/null +++ b/src/templates/admin.tera @@ -0,0 +1,75 @@ + + + + Admin Page + + + +

games

+
+ + + + + + + + + +
Games table
codeteamstart_timeend_time
+ + +