Skip to content

Commit c0a82cc

Browse files
authored
Add time since last mentoring session (#26)
1 parent 14b13ea commit c0a82cc

File tree

9 files changed

+192
-0
lines changed

9 files changed

+192
-0
lines changed

config.prod.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"google_apis_client_secret": "$CYF_TRAINEE_TRACKER_GOOGLE_APIS_CLIENT_SECRET",
1010
"github_email_mapping_sheet_id": "1ahDEnO8odD9oLtO_EBcvcmEaF0qsX-I4iLCIY0XLjt0",
1111
"reviewer_staff_info_sheet_id": "1CKDrXtx5lkgfZ8E2mjvsDup2K8UyBsq99CIV0qOxrP0",
12+
"mentoring_records_sheet_id": "1PYOb__p0nT0vPWPtbbLT0KXGv53xpkNLSCaPFAfRGrk",
1213

1314
"slack_client_id": "85239491699.9205264014663",
1415
"slack_client_secret": "$CYF_TRAINEE_TRACKER_SLACK_CLIENT_SECRET",

src/auth.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ pub async fn handle_google_oauth_callback(
107107
"{}/api/oauth-callbacks/google-drive",
108108
server_state.config.public_base_url
109109
);
110+
110111
let mut client = Client::new(
111112
server_state.config.google_apis_client_id.clone(),
112113
(*server_state.config.google_apis_client_secret).clone(),
@@ -119,6 +120,11 @@ pub async fn handle_google_oauth_callback(
119120
.get_access_token(&params.code, params.state.to_string().as_str())
120121
.await
121122
.context("Failed to get access token")?;
123+
124+
if access_token.access_token.is_empty() {
125+
return Err(Error::Fatal(anyhow!("Google gave an empty token")));
126+
}
127+
122128
session
123129
.insert(
124130
auth_state.google_scope.token_session_key(),

src/config.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ pub struct Config {
3636

3737
pub github_email_mapping_sheet_id: String,
3838

39+
pub mentoring_records_sheet_id: String,
40+
3941
pub reviewer_staff_info_sheet_id: String,
4042
}
4143

src/course.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use std::{
77
use crate::{
88
config::CourseScheduleWithRegisterSheetId,
99
github_accounts::{get_trainees, Trainee},
10+
mentoring::{get_mentoring_records, MentoringRecord},
1011
newtypes::{GithubLogin, Region},
1112
octocrab::all_pages,
1213
prs::{get_prs, Pr, PrState},
@@ -376,11 +377,18 @@ impl Batch {
376377
.map(|(region, _count)| region)
377378
.collect()
378379
}
380+
381+
pub fn has_mentoring_records(&self) -> bool {
382+
self.trainees
383+
.iter()
384+
.any(|trainee| trainee.mentoring_record.is_some())
385+
}
379386
}
380387

381388
#[derive(Debug)]
382389
pub struct TraineeWithSubmissions {
383390
pub trainee: Trainee,
391+
pub mentoring_record: Option<MentoringRecord>,
384392
pub modules: IndexMap<String, ModuleWithSubmissions>,
385393
}
386394

@@ -645,6 +653,7 @@ pub async fn get_batch_with_submissions(
645653
octocrab: &Octocrab,
646654
sheets_client: SheetsClient,
647655
github_email_mapping_sheet_id: &str,
656+
mentoring_records_sheet_id: &str,
648657
github_org: &str,
649658
batch_github_slug: &str,
650659
course: &Course,
@@ -657,6 +666,9 @@ pub async fn get_batch_with_submissions(
657666
)
658667
.await?;
659668

669+
let mentoring_records =
670+
get_mentoring_records(sheets_client.clone(), mentoring_records_sheet_id).await?;
671+
660672
let batch_members = get_batch_members(
661673
octocrab,
662674
sheets_client,
@@ -726,6 +738,9 @@ pub async fn get_batch_with_submissions(
726738

727739
modules.insert(module_name.clone(), module_with_submissions);
728740
}
741+
742+
let mentoring_record = mentoring_records.get(&trainee_name);
743+
729744
let trainee = TraineeWithSubmissions {
730745
trainee: Trainee {
731746
github_login,
@@ -736,6 +751,7 @@ pub async fn get_batch_with_submissions(
736751
}),
737752
region,
738753
},
754+
mentoring_record,
739755
modules,
740756
};
741757
trainees.push(trainee);

src/frontend.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ pub async fn get_trainee_batch(
113113
&octocrab,
114114
sheets_client,
115115
&server_state.config.github_email_mapping_sheet_id,
116+
&server_state.config.mentoring_records_sheet_id,
116117
github_org,
117118
&batch_github_slug,
118119
&course,

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ pub mod frontend;
2020
pub mod github_accounts;
2121
pub mod google_auth;
2222
pub mod google_groups;
23+
pub mod mentoring;
2324
pub mod newtypes;
2425
pub mod octocrab;
2526
pub mod prs;

src/mentoring.rs

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
use std::collections::{btree_map::Entry, BTreeMap};
2+
3+
use anyhow::Context;
4+
use chrono::{NaiveDate, Utc};
5+
use serde::Serialize;
6+
use tracing::warn;
7+
8+
use crate::{
9+
sheets::{cell_date, cell_string, SheetsClient},
10+
Error,
11+
};
12+
13+
pub struct MentoringRecords {
14+
records: BTreeMap<String, MentoringRecord>,
15+
}
16+
17+
impl MentoringRecords {
18+
pub fn get(&self, name: &str) -> Option<MentoringRecord> {
19+
self.records.get(name).cloned()
20+
}
21+
}
22+
23+
#[derive(Clone, Debug, Serialize)]
24+
pub struct MentoringRecord {
25+
pub last_date: NaiveDate,
26+
}
27+
28+
impl MentoringRecord {
29+
pub fn is_recent(&self) -> bool {
30+
let now = Utc::now().date_naive();
31+
let time_since = now.signed_duration_since(self.last_date);
32+
time_since.num_days() <= 14
33+
}
34+
}
35+
36+
pub async fn get_mentoring_records(
37+
client: SheetsClient,
38+
mentoring_records_sheet_id: &str,
39+
) -> Result<MentoringRecords, Error> {
40+
let data = client
41+
.get(mentoring_records_sheet_id, true, &[])
42+
.await
43+
.map_err(|err| {
44+
err.with_context(|| {
45+
format!(
46+
"Failed to get spreadsheet with ID {}",
47+
mentoring_records_sheet_id
48+
)
49+
})
50+
})?;
51+
let expected_sheet_title = "Feedback";
52+
let sheet = data
53+
.body
54+
.sheets
55+
.into_iter()
56+
.find(|sheet| {
57+
sheet
58+
.properties
59+
.as_ref()
60+
.map(|properties| properties.title.as_str())
61+
== Some(expected_sheet_title)
62+
})
63+
.ok_or_else(|| {
64+
Error::Fatal(anyhow::anyhow!(
65+
"Couldn't find sheet '{}' in spreadsheet with ID {}",
66+
expected_sheet_title,
67+
mentoring_records_sheet_id
68+
))
69+
})?;
70+
71+
let mut mentoring_records = MentoringRecords {
72+
records: BTreeMap::new(),
73+
};
74+
75+
for sheet_data in sheet.data {
76+
if sheet_data.start_column != 0 || sheet_data.start_row != 0 {
77+
return Err(Error::Fatal(anyhow::anyhow!(
78+
"Start column and row were {} and {}, expected 0 and 0",
79+
sheet_data.start_column,
80+
sheet_data.start_row
81+
)));
82+
}
83+
84+
for (row_number, row) in sheet_data.row_data.into_iter().enumerate() {
85+
let cells = row.values;
86+
if cells.len() < 6 {
87+
warn!(
88+
"Parsing mentoring data from Google Sheet with ID {}: Not enough columns for row {} - expected at least 6, got {} containing: {}",
89+
mentoring_records_sheet_id,
90+
row_number,
91+
cells.len(),
92+
format!("{:#?}", cells),
93+
);
94+
continue;
95+
}
96+
if row_number == 0 {
97+
let headings = cells
98+
.iter()
99+
.take(6)
100+
.enumerate()
101+
.map(|(col_number, cell)| {
102+
cell_string(cell)
103+
.with_context(|| format!("Failed to get row 0 column {}", col_number))
104+
})
105+
.collect::<Result<Vec<_>, _>>()?;
106+
if headings != ["Name", "Region", "Date", "Staff", "Status", "Notes"] {
107+
return Err(Error::Fatal(anyhow::anyhow!(
108+
"Mentoring data sheet contained wrong headings: {}",
109+
headings.join(", ")
110+
)));
111+
}
112+
} else {
113+
if cells[0].effective_value.is_none() {
114+
break;
115+
}
116+
let name = cell_string(&cells[0])
117+
.with_context(|| format!("Failed to read name from row {}", row_number + 1))?;
118+
let date = cell_date(&cells[2])
119+
.with_context(|| format!("Failed to parse date from row {}", row_number + 1))?;
120+
let entry = mentoring_records.records.entry(name);
121+
match entry {
122+
Entry::Vacant(entry) => {
123+
entry.insert(MentoringRecord { last_date: date });
124+
}
125+
Entry::Occupied(mut entry) => {
126+
if entry.get().last_date < date {
127+
entry.insert(MentoringRecord { last_date: date });
128+
}
129+
}
130+
}
131+
}
132+
}
133+
}
134+
Ok(mentoring_records)
135+
}

src/sheets.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ pub(crate) fn cell_bool(cell: &CellData) -> Result<bool, anyhow::Error> {
2626
}
2727
}
2828

29+
pub(crate) fn cell_date(cell: &CellData) -> Result<chrono::NaiveDate, anyhow::Error> {
30+
let date_string = &cell.formatted_value;
31+
chrono::NaiveDate::parse_from_str(date_string, "%Y-%m-%d")
32+
.with_context(|| format!("Failed to parse {} as a date", date_string))
33+
}
34+
2935
pub(crate) async fn sheets_client(
3036
session: &Session,
3137
server_state: ServerState,

templates/trainee-batch.html

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,15 @@
4242
td.pr-unknown {
4343
background-color: grey;
4444
}
45+
td.mentoring-recent {
46+
background-color: var(--green);
47+
}
48+
td.mentoring-stale {
49+
background-color: var(--orange);
50+
}
51+
td.mentoring-unknown {
52+
background-color: grey;
53+
}
4554
.trainee-on-track {
4655
background-color: var(--green);
4756
}
@@ -89,13 +98,15 @@ <h1>{{ course.name }} - {{ batch.name }}</h1>
8998
<tr>
9099
<th>GitHub</th>
91100
<th>Region</th>
101+
{% if batch.has_mentoring_records() %}<th>Last check-in</th>{% endif %}
92102
{% for (module_name, module) in course.modules %}
93103
<th colspan="{{ module.assignment_count() }}">{{module_name}}</th>
94104
{% endfor %}
95105
</tr>
96106
<tr>
97107
<th></th>
98108
<th></th>
109+
{% if batch.has_mentoring_records() %}<th></th>{% endif %}
99110
{% for (module_name, module) in course.modules %}
100111
{% for (sprint_number, sprint) in module.sprints.iter().enumerate() %}
101112
<th colspan="{{ sprint.assignment_count() }}">Sprint {{ sprint_number + 1 }}</th>
@@ -105,6 +116,7 @@ <h1>{{ course.name }} - {{ batch.name }}</h1>
105116
<tr>
106117
<th></th>
107118
<th></th>
119+
{% if batch.has_mentoring_records() %}<th></th>{% endif %}
108120
{% for (module_name, module) in course.modules %}
109121
{% for sprint in module.sprints %}
110122
{% for assignment in sprint.assignments %}
@@ -119,6 +131,18 @@ <h1>{{ course.name }} - {{ batch.name }}</h1>
119131
<tr>
120132
<th class="{{ css_classes_for_trainee_status(&trainee.status()) }}">{{ trainee.trainee.name }} - <a href="https://github.com/{{trainee.trainee.github_login}}">@{{ trainee.trainee.github_login }}</a> - {{ trainee.trainee.email }} - {{ trainee.progress_score() / 100 }}%</th>
121133
<td>{{ trainee.trainee.region }}</td>
134+
{% if batch.has_mentoring_records() %}
135+
{% match trainee.mentoring_record %}
136+
{% when Some(mentoring_record) %}
137+
{% if mentoring_record.is_recent() %}
138+
<td class="mentoring-recent">{{ mentoring_record.last_date }}</td>
139+
{% else %}
140+
<td class="mentoring-stale">{{ mentoring_record.last_date }}</td>
141+
{% endif %}
142+
{% when None %}
143+
<td class="mentoring-unknown">Unknown</td>
144+
{% endmatch %}
145+
{% endif %}
122146
{% for (module_name, module) in trainee.modules %}
123147
{% for sprint in module.sprints %}
124148
{% for submission in sprint.submissions %}

0 commit comments

Comments
 (0)