diff --git a/config.prod.json b/config.prod.json index e8aa5bf..1cf12de 100644 --- a/config.prod.json +++ b/config.prod.json @@ -9,6 +9,7 @@ "google_apis_client_secret": "$CYF_TRAINEE_TRACKER_GOOGLE_APIS_CLIENT_SECRET", "github_email_mapping_sheet_id": "1ahDEnO8odD9oLtO_EBcvcmEaF0qsX-I4iLCIY0XLjt0", "reviewer_staff_info_sheet_id": "1CKDrXtx5lkgfZ8E2mjvsDup2K8UyBsq99CIV0qOxrP0", + "mentoring_records_sheet_id": "1PYOb__p0nT0vPWPtbbLT0KXGv53xpkNLSCaPFAfRGrk", "slack_client_id": "85239491699.9205264014663", "slack_client_secret": "$CYF_TRAINEE_TRACKER_SLACK_CLIENT_SECRET", diff --git a/src/auth.rs b/src/auth.rs index 1373ca5..d1e9f04 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -107,6 +107,7 @@ pub async fn handle_google_oauth_callback( "{}/api/oauth-callbacks/google-drive", server_state.config.public_base_url ); + let mut client = Client::new( server_state.config.google_apis_client_id.clone(), (*server_state.config.google_apis_client_secret).clone(), @@ -119,6 +120,11 @@ pub async fn handle_google_oauth_callback( .get_access_token(¶ms.code, params.state.to_string().as_str()) .await .context("Failed to get access token")?; + + if access_token.access_token.is_empty() { + return Err(Error::Fatal(anyhow!("Google gave an empty token"))); + } + session .insert( auth_state.google_scope.token_session_key(), diff --git a/src/config.rs b/src/config.rs index febdfba..04967e6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -39,6 +39,8 @@ pub struct Config { pub github_email_mapping_sheet_id: String, + pub mentoring_records_sheet_id: String, + pub reviewer_staff_info_sheet_id: String, // Legacy hack until all trainees are in the sheet. diff --git a/src/course.rs b/src/course.rs index 752eba1..b055a50 100644 --- a/src/course.rs +++ b/src/course.rs @@ -7,6 +7,7 @@ use std::{ use crate::{ config::CourseScheduleWithRegisterSheetId, github_accounts::{get_trainees, Trainee}, + mentoring::{get_mentoring_records, MentoringRecord}, newtypes::{GithubLogin, Region}, octocrab::all_pages, prs::{get_prs, Pr, PrState}, @@ -376,11 +377,18 @@ impl Batch { .map(|(region, _count)| region) .collect() } + + pub fn has_mentoring_records(&self) -> bool { + self.trainees + .iter() + .any(|trainee| trainee.mentoring_record.is_some()) + } } #[derive(Debug)] pub struct TraineeWithSubmissions { pub trainee: Trainee, + pub mentoring_record: Option, pub modules: IndexMap, } @@ -651,6 +659,7 @@ pub async fn get_batch_with_submissions( octocrab: &Octocrab, sheets_client: SheetsClient, github_email_mapping_sheet_id: &str, + mentoring_records_sheet_id: &str, github_org: &str, batch_github_slug: &str, course: &Course, @@ -664,6 +673,9 @@ pub async fn get_batch_with_submissions( ) .await?; + let mentoring_records = + get_mentoring_records(sheets_client.clone(), mentoring_records_sheet_id).await?; + let batch_members = get_batch_members( octocrab, sheets_client, @@ -734,6 +746,9 @@ pub async fn get_batch_with_submissions( modules.insert(module_name.clone(), module_with_submissions); } + + let mentoring_record = mentoring_records.get(&trainee_name); + let trainee = TraineeWithSubmissions { trainee: Trainee { github_login, @@ -744,6 +759,7 @@ pub async fn get_batch_with_submissions( }), region, }, + mentoring_record, modules, }; trainees.push(trainee); diff --git a/src/frontend.rs b/src/frontend.rs index 13a9d2f..d37fd16 100644 --- a/src/frontend.rs +++ b/src/frontend.rs @@ -113,6 +113,7 @@ pub async fn get_trainee_batch( &octocrab, sheets_client, &server_state.config.github_email_mapping_sheet_id, + &server_state.config.mentoring_records_sheet_id, github_org, &batch_github_slug, &course, diff --git a/src/lib.rs b/src/lib.rs index 15f05de..da989d9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,6 +20,7 @@ pub mod frontend; pub mod github_accounts; pub mod google_auth; pub mod google_groups; +pub mod mentoring; pub mod newtypes; pub mod octocrab; pub mod prs; diff --git a/src/mentoring.rs b/src/mentoring.rs new file mode 100644 index 0000000..10b1051 --- /dev/null +++ b/src/mentoring.rs @@ -0,0 +1,135 @@ +use std::collections::{btree_map::Entry, BTreeMap}; + +use anyhow::Context; +use chrono::{NaiveDate, Utc}; +use serde::Serialize; +use tracing::warn; + +use crate::{ + sheets::{cell_date, cell_string, SheetsClient}, + Error, +}; + +pub struct MentoringRecords { + records: BTreeMap, +} + +impl MentoringRecords { + pub fn get(&self, name: &str) -> Option { + self.records.get(name).cloned() + } +} + +#[derive(Clone, Debug, Serialize)] +pub struct MentoringRecord { + pub last_date: NaiveDate, +} + +impl MentoringRecord { + pub fn is_recent(&self) -> bool { + let now = Utc::now().date_naive(); + let time_since = now.signed_duration_since(self.last_date); + time_since.num_days() <= 14 + } +} + +pub async fn get_mentoring_records( + client: SheetsClient, + mentoring_records_sheet_id: &str, +) -> Result { + let data = client + .get(mentoring_records_sheet_id, true, &[]) + .await + .map_err(|err| { + err.with_context(|| { + format!( + "Failed to get spreadsheet with ID {}", + mentoring_records_sheet_id + ) + }) + })?; + let expected_sheet_title = "Feedback"; + let sheet = data + .body + .sheets + .into_iter() + .find(|sheet| { + sheet + .properties + .as_ref() + .map(|properties| properties.title.as_str()) + == Some(expected_sheet_title) + }) + .ok_or_else(|| { + Error::Fatal(anyhow::anyhow!( + "Couldn't find sheet '{}' in spreadsheet with ID {}", + expected_sheet_title, + mentoring_records_sheet_id + )) + })?; + + let mut mentoring_records = MentoringRecords { + records: BTreeMap::new(), + }; + + for sheet_data in sheet.data { + if sheet_data.start_column != 0 || sheet_data.start_row != 0 { + return Err(Error::Fatal(anyhow::anyhow!( + "Start column and row were {} and {}, expected 0 and 0", + sheet_data.start_column, + sheet_data.start_row + ))); + } + + for (row_number, row) in sheet_data.row_data.into_iter().enumerate() { + let cells = row.values; + if cells.len() < 6 { + warn!( + "Parsing mentoring data from Google Sheet with ID {}: Not enough columns for row {} - expected at least 6, got {} containing: {}", + mentoring_records_sheet_id, + row_number, + cells.len(), + format!("{:#?}", cells), + ); + continue; + } + if row_number == 0 { + let headings = cells + .iter() + .take(6) + .enumerate() + .map(|(col_number, cell)| { + cell_string(cell) + .with_context(|| format!("Failed to get row 0 column {}", col_number)) + }) + .collect::, _>>()?; + if headings != ["Name", "Region", "Date", "Staff", "Status", "Notes"] { + return Err(Error::Fatal(anyhow::anyhow!( + "Mentoring data sheet contained wrong headings: {}", + headings.join(", ") + ))); + } + } else { + if cells[0].effective_value.is_none() { + break; + } + let name = cell_string(&cells[0]) + .with_context(|| format!("Failed to read name from row {}", row_number + 1))?; + let date = cell_date(&cells[2]) + .with_context(|| format!("Failed to parse date from row {}", row_number + 1))?; + let entry = mentoring_records.records.entry(name); + match entry { + Entry::Vacant(entry) => { + entry.insert(MentoringRecord { last_date: date }); + } + Entry::Occupied(mut entry) => { + if entry.get().last_date < date { + entry.insert(MentoringRecord { last_date: date }); + } + } + } + } + } + } + Ok(mentoring_records) +} diff --git a/src/sheets.rs b/src/sheets.rs index d4b33a5..ea62400 100644 --- a/src/sheets.rs +++ b/src/sheets.rs @@ -26,6 +26,12 @@ pub(crate) fn cell_bool(cell: &CellData) -> Result { } } +pub(crate) fn cell_date(cell: &CellData) -> Result { + let date_string = &cell.formatted_value; + chrono::NaiveDate::parse_from_str(date_string, "%Y-%m-%d") + .with_context(|| format!("Failed to parse {} as a date", date_string)) +} + pub(crate) async fn sheets_client( session: &Session, server_state: ServerState, diff --git a/templates/trainee-batch.html b/templates/trainee-batch.html index 6732e1a..6b0b1e0 100644 --- a/templates/trainee-batch.html +++ b/templates/trainee-batch.html @@ -42,6 +42,15 @@ td.pr-unknown { background-color: grey; } + td.mentoring-recent { + background-color: var(--green); + } + td.mentoring-stale { + background-color: var(--orange); + } + td.mentoring-unknown { + background-color: grey; + } .trainee-on-track { background-color: var(--green); } @@ -89,6 +98,7 @@

{{ course.name }} - {{ batch.name }}

GitHub Region + {% if batch.has_mentoring_records() %}Last check-in{% endif %} {% for (module_name, module) in course.modules %} {{module_name}} {% endfor %} @@ -96,6 +106,7 @@

{{ course.name }} - {{ batch.name }}

+ {% if batch.has_mentoring_records() %}{% endif %} {% for (module_name, module) in course.modules %} {% for (sprint_number, sprint) in module.sprints.iter().enumerate() %} Sprint {{ sprint_number + 1 }} @@ -105,6 +116,7 @@

{{ course.name }} - {{ batch.name }}

+ {% if batch.has_mentoring_records() %}{% endif %} {% for (module_name, module) in course.modules %} {% for sprint in module.sprints %} {% for assignment in sprint.assignments %} @@ -119,6 +131,18 @@

{{ course.name }} - {{ batch.name }}

{{ trainee.trainee.name }} - @{{ trainee.trainee.github_login }} - {{ trainee.trainee.email }} - {{ trainee.progress_score() / 100 }}% {{ trainee.trainee.region }} + {% if batch.has_mentoring_records() %} + {% match trainee.mentoring_record %} + {% when Some(mentoring_record) %} + {% if mentoring_record.is_recent() %} + {{ mentoring_record.last_date }} + {% else %} + {{ mentoring_record.last_date }} + {% endif %} + {% when None %} + Unknown + {% endmatch %} + {% endif %} {% for (module_name, module) in trainee.modules %} {% for sprint in module.sprints %} {% for submission in sprint.submissions %}