diff options
author | Nathan Ringo <nathan@remexre.com> | 2024-01-16 00:57:41 -0600 |
---|---|---|
committer | Nathan Ringo <nathan@remexre.com> | 2024-01-16 00:57:41 -0600 |
commit | ed778ab2060c6131caf98231a97873d7ea490d5a (patch) | |
tree | bfa6ceca8fe2e209562c1e995c598d80be0e4501 /src | |
parent | 54f497163f57dacd8d621a2a3c89e1f06ac370d0 (diff) |
The start of database functionality.
Diffstat (limited to 'src')
-rw-r--r-- | src/bin/add-student-x500s.rs | 144 | ||||
-rw-r--r-- | src/bin/lambo.rs (renamed from src/main.rs) | 60 | ||||
-rw-r--r-- | src/config.rs | 17 | ||||
-rw-r--r-- | src/handlers/x500_mapper.rs | 21 | ||||
-rw-r--r-- | src/lib.rs | 2 |
5 files changed, 207 insertions, 37 deletions
diff --git a/src/bin/add-student-x500s.rs b/src/bin/add-student-x500s.rs new file mode 100644 index 0000000..14023b1 --- /dev/null +++ b/src/bin/add-student-x500s.rs @@ -0,0 +1,144 @@ +use anyhow::{bail, Context, Result}; +use clap::{value_parser, ArgAction, Parser}; +use futures::{stream, FutureExt, StreamExt}; +use lambo::config::Config; +use sqlx::sqlite::SqlitePoolOptions; +use std::{fs, path::PathBuf}; +use stderrlog::StdErrLog; + +#[derive(Debug, Parser)] +struct Args { + /// The path to the lambo configuration file. + config_path: PathBuf, + + /// The path to the CSV file, as exported from Canvas's gradebook. + csv_path: PathBuf, + + /// Decreases the log level. + #[clap( + short, + long, + conflicts_with("verbose"), + action = ArgAction::Count, + value_parser = value_parser!(u8).range(..=2) + )] + quiet: u8, + + /// Increases the log level. + #[clap( + short, + long, + conflicts_with("quiet"), + action = ArgAction::Count, + value_parser = value_parser!(u8).range(..=3) + )] + verbose: u8, +} + +#[tokio::main] +async fn main() -> Result<()> { + // Parse the arguments. + let args = Args::parse(); + + // Set up logging. + { + let mut logger = StdErrLog::new(); + match args.quiet { + 0 => logger.verbosity(1 + args.verbose as usize), + 1 => logger.verbosity(0), + 2 => logger.quiet(true), + // UNREACHABLE: A maximum of two occurrences of quiet are allowed. + _ => unreachable!(), + }; + // UNWRAP: No other logger should be set up. + logger.show_module_names(true).init().unwrap() + } + + // Parse the config file. + let config = Config::read_from_file(&args.config_path)?; + + // Connect to the database. + let db = SqlitePoolOptions::new() + .connect(&config.database_url) + .await + .with_context(|| format!("failed to connect to database at {:?}", config.database_url))?; + + // Run any necessary migrations. + sqlx::migrate!().run(&db).await.with_context(|| { + format!( + "failed to run migrations on database at {:?}", + config.database_url + ) + })?; + + // Read the CSV file. + let csv_string = fs::read_to_string(&args.csv_path) + .with_context(|| format!("failed to read {:?}", args.csv_path))?; + + // Skip the first two lines; Canvas puts two lines of headers in... + let (i, _) = csv_string + .match_indices('\n') + .nth(1) + .context("invalid CSV file (not enough lines)")?; + let csv_string = &csv_string[i..]; + + // Parse the CSV file. + let mut csv = csv::ReaderBuilder::new() + .has_headers(false) + .from_reader(csv_string.as_bytes()) + .records() + .collect::<Result<Vec<_>, _>>() + .with_context(|| format!("failed to parse {:?}", args.csv_path))?; + + // Remove the test student. + csv.retain(|record| &record[0] != "Student, Test"); + + // Collect all the X.500s, checking that they were of the right form. + let x500s = csv + .into_iter() + .map(|record| { + let email = &record[3]; + if let Some(x500) = email.strip_suffix("@umn.edu") { + Ok(x500.to_string()) + } else { + bail!("not a valid UMN email: {:?}", email) + } + }) + .collect::<Result<Vec<_>>>()?; + + // Insert them all in the database. + // + // Looks like sqlx doesn't actually have bulk insert? WTF? + // + // https://github.com/launchbadge/sqlx/issues/294 + let db = &db; + let errors = stream::iter(x500s) + .map(|x500| async move { + sqlx::query!( + "INSERT OR IGNORE INTO student_x500s (x500) VALUES (?)", + x500 + ) + .execute(db) + .await + .context("failed to insert X.500s") + }) + .filter_map(|future| future.map(|r| r.err())) + .collect::<Vec<_>>() + .await; + if !errors.is_empty() { + log::error!("encountered {} errors:", errors.len()); + for error in errors { + log::error!("{:?}", error); + } + bail!("failed to insert X.500s") + } + + // Count the number of X.500s. + let x500_count = sqlx::query!("SELECT COUNT(x500) as count from student_x500s") + .fetch_one(db) + .await + .context("failed to get a count of X.500s")?; + log::info!("We now have {} student X.500s", x500_count.count); + + Ok(()) +} diff --git a/src/main.rs b/src/bin/lambo.rs index c9dbd7a..e3e81a6 100644 --- a/src/main.rs +++ b/src/bin/lambo.rs @@ -1,26 +1,14 @@ -mod handlers; - -use crate::handlers::*; use anyhow::{Context as _, Result}; use clap::{value_parser, ArgAction, Parser}; -use serde::Deserialize; -use serenity::{ - all::{ActivityData, GatewayIntents, GuildMemberUpdateEvent, Member, Ready}, - async_trait, - client::{Context, EventHandler}, - Client, -}; -use std::{fs, path::PathBuf}; +use lambo::{config::Config, handlers::*}; +use serenity::{all::GatewayIntents, Client}; +use sqlx::sqlite::SqlitePoolOptions; +use std::path::PathBuf; use stderrlog::StdErrLog; -#[derive(Debug, Deserialize)] -struct Config { - discord_token: String, -} - #[derive(Debug, Parser)] struct Args { - /// The path to the configuration file. + /// The path to the lambo configuration file. config_path: PathBuf, /// Decreases the log level. @@ -46,6 +34,7 @@ struct Args { #[tokio::main] async fn main() -> Result<()> { + // Parse the arguments. let args = Args::parse(); // Set up logging. @@ -62,23 +51,32 @@ async fn main() -> Result<()> { logger.show_module_names(true).init().unwrap() } - let config_str = fs::read_to_string(&args.config_path) - .with_context(|| format!("failed to read {}", args.config_path.display()))?; - let config: Config = toml::from_str(&config_str) - .with_context(|| format!("failed to parse {}", args.config_path.display()))?; - drop(config_str); + // Parse the config file. + let config = Config::read_from_file(&args.config_path)?; - let handler = MultiHandler(vec![Box::new(PresenceSetter), Box::new(X500Mapper)]); + // Connect to the database. + let db = SqlitePoolOptions::new() + .connect(&config.database_url) + .await + .with_context(|| format!("failed to connect to database at {:?}", config.database_url))?; + + // Run any necessary migrations. + sqlx::migrate!().run(&db).await.with_context(|| { + format!( + "failed to run migrations on database at {:?}", + config.database_url + ) + })?; - let mut client = Client::builder( - &config.discord_token, - GatewayIntents::default() | GatewayIntents::GUILD_MEMBERS, - ) - .event_handler(handler) - .await - .context("failed to create Discord client")?; + // Create the handlers. + let handler = MultiHandler(vec![Box::new(PresenceSetter), Box::new(X500Mapper(db))]); - client + // Start up the client. + let intents = GatewayIntents::default() | GatewayIntents::GUILD_MEMBERS; + Client::builder(&config.discord_token, intents) + .event_handler(handler) + .await + .context("failed to create Discord client")? .start() .await .context("failed to start Discord client")?; diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..14a4c19 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,17 @@ +use anyhow::{Context, Result}; +use serde::Deserialize; +use std::{fs, path::Path}; + +#[derive(Debug, Deserialize)] +pub struct Config { + pub database_url: String, + pub discord_token: String, +} + +impl Config { + pub fn read_from_file(path: &Path) -> Result<Config> { + let config_str = fs::read_to_string(path) + .with_context(|| format!("failed to read {}", path.display()))?; + toml::from_str(&config_str).with_context(|| format!("failed to parse {}", path.display())) + } +} diff --git a/src/handlers/x500_mapper.rs b/src/handlers/x500_mapper.rs index f477e97..1dd8955 100644 --- a/src/handlers/x500_mapper.rs +++ b/src/handlers/x500_mapper.rs @@ -1,22 +1,31 @@ use serenity::{ - all::{GuildMemberUpdateEvent, Member}, + all::{GuildMemberUpdateEvent, Member, UserId}, async_trait, client::{Context, EventHandler}, }; +use sqlx::{Database, Pool}; /// A handler that notices people with an X.500 in their nicknames that matches a student's, and /// records it in the database. -pub struct X500Mapper; +pub struct X500Mapper<DB: Database>(pub Pool<DB>); + +impl<DB: Database> X500Mapper<DB> { + async fn notice_member(&self, nick: &str, uid: UserId) { + dbg!((nick, uid)); + } +} #[async_trait] -impl EventHandler for X500Mapper { +impl<DB: Database> EventHandler for X500Mapper<DB> { async fn guild_member_update( &self, _ctx: Context, - old_if_available: Option<Member>, - new: Option<Member>, + _old_if_available: Option<Member>, + _new: Option<Member>, event: GuildMemberUpdateEvent, ) { - dbg!((old_if_available, new, event)); + if let Some(nick) = event.nick { + self.notice_member(&nick, event.user.id).await + } } } diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..61d095d --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,2 @@ +pub mod config; +pub mod handlers; |