aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorNathan Ringo <nathan@remexre.com>2024-01-16 00:57:41 -0600
committerNathan Ringo <nathan@remexre.com>2024-01-16 00:57:41 -0600
commited778ab2060c6131caf98231a97873d7ea490d5a (patch)
treebfa6ceca8fe2e209562c1e995c598d80be0e4501 /src
parent54f497163f57dacd8d621a2a3c89e1f06ac370d0 (diff)
The start of database functionality.
Diffstat (limited to 'src')
-rw-r--r--src/bin/add-student-x500s.rs144
-rw-r--r--src/bin/lambo.rs (renamed from src/main.rs)60
-rw-r--r--src/config.rs17
-rw-r--r--src/handlers/x500_mapper.rs21
-rw-r--r--src/lib.rs2
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;