Skip to content

Commit 0f172fd

Browse files
committed
Add CSV groups output, and optional expanding
1 parent b65a635 commit 0f172fd

3 files changed

Lines changed: 253 additions & 65 deletions

File tree

src/bin/trainee-tracker.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,10 @@ async fn main() {
8888
"/groups/google",
8989
get(trainee_tracker::frontend::list_google_groups),
9090
)
91+
.route(
92+
"/groups/google.csv",
93+
get(trainee_tracker::frontend::list_google_groups_csv),
94+
)
9195
.layer(session_layer)
9296
.with_state(server_state);
9397

src/frontend.rs

Lines changed: 72 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,13 @@
1-
use std::{
2-
collections::{BTreeMap, BTreeSet},
3-
str::FromStr,
4-
};
1+
use std::collections::{BTreeMap, BTreeSet};
52

63
use anyhow::Context;
74
use askama::Template;
85
use axum::{
96
extract::{OriginalUri, Path, Query, State},
10-
response::Html,
7+
response::{Html, IntoResponse, Response},
118
};
12-
use email_address::EmailAddress;
139
use futures::future::join_all;
14-
use gsuite_api::{
15-
types::{Group, Member},
16-
Response,
17-
};
18-
use http::Uri;
10+
use http::{header::CONTENT_TYPE, StatusCode, Uri};
1911
use serde::Deserialize;
2012
use tower_sessions::Session;
2113

@@ -25,7 +17,7 @@ use crate::{
2517
course::{
2618
fetch_batch_metadata, get_batch, Attendance, Batch, BatchMetadata, Course, Submission,
2719
},
28-
google_groups::{groups_client, GoogleGroup},
20+
google_groups::{get_groups, groups_client, GoogleGroup},
2921
octocrab::octocrab,
3022
prs::{MaybeReviewerStaffOnlyDetails, PrState, ReviewerInfo},
3123
reviewer_staff_info::get_reviewer_staff_info,
@@ -248,68 +240,85 @@ pub(crate) struct Redirect {
248240
#[derive(Template)]
249241
#[template(path = "google-groups.html")]
250242
struct GoogleGroups {
251-
pub groups: Vec<GoogleGroup>,
243+
pub groups: BTreeSet<GoogleGroup>,
244+
}
245+
246+
#[derive(Deserialize)]
247+
pub struct GroupListParams {
248+
#[serde(default)]
249+
expand: bool,
252250
}
253251

254252
pub async fn list_google_groups(
255253
session: Session,
256254
State(server_state): State<ServerState>,
257255
OriginalUri(original_uri): OriginalUri,
256+
Query(params): Query<GroupListParams>,
258257
) -> Result<Html<String>, Error> {
259258
let client = groups_client(&session, server_state, original_uri).await?;
260-
let groups_response = client
261-
.groups()
262-
.list_all(
263-
"my_customer",
264-
"codeyourfuture.io",
265-
gsuite_api::types::DirectoryGroupsListOrderBy::Email,
266-
"",
267-
gsuite_api::types::SortOrder::Ascending,
268-
"",
269-
)
270-
.await
271-
.context("Failed to list Google groups")?;
272-
let groups = error_for_status(groups_response)?;
273-
let group_member_futures = groups
259+
let mut groups = get_groups(&client).await?;
260+
if params.expand {
261+
groups
262+
.expand_recursively()
263+
.context("Failed to expand groups recursively")?;
264+
}
265+
Ok(Html(
266+
GoogleGroups {
267+
groups: groups.groups,
268+
}
269+
.render()
270+
.unwrap(),
271+
))
272+
}
273+
274+
pub async fn list_google_groups_csv(
275+
session: Session,
276+
State(server_state): State<ServerState>,
277+
OriginalUri(original_uri): OriginalUri,
278+
Query(params): Query<GroupListParams>,
279+
) -> Result<Csv, Error> {
280+
let client = groups_client(&session, server_state, original_uri).await?;
281+
let mut groups = get_groups(&client).await?;
282+
if params.expand {
283+
groups
284+
.expand_recursively()
285+
.context("Failed to expand groups recursively")?;
286+
}
287+
288+
let member_count = groups
289+
.groups
274290
.iter()
275-
.map(|Group { id, .. }| async { client.members().list_all(id, false, "").await })
276-
.collect::<Vec<_>>();
277-
let group_members = join_all(group_member_futures).await;
291+
.map(|group| group.members.len())
292+
.max()
293+
.unwrap_or(0);
278294

279-
let result = groups
280-
.into_iter()
281-
.zip(group_members.into_iter())
282-
.map(|(group, members)| {
283-
let members =
284-
error_for_status(members.context("Failed to list Google group members")?)?;
285-
Ok(GoogleGroup {
286-
email: EmailAddress::from_str(&group.email).with_context(|| {
287-
format!("Failed to parse group email address {}", group.email)
288-
})?,
289-
members: members
290-
.into_iter()
291-
.map(|Member { email, .. }| {
292-
Ok(EmailAddress::from_str(&email).with_context(|| {
293-
format!(
294-
"Failed to parse group member email address {} (member of {})",
295-
email, group.email
296-
)
297-
})?)
298-
})
299-
.collect::<Result<_, anyhow::Error>>()?,
300-
})
301-
})
302-
.collect::<Result<_, Error>>()?;
303-
Ok(Html(GoogleGroups { groups: result }.render().unwrap()))
295+
// Manually writing a CSV because the CSV crate doesn't like different numbers of fields per record.
296+
let mut out = String::new();
297+
out += "group";
298+
for i in 0..member_count {
299+
out += &format!(",member{}", i + 1);
300+
}
301+
out += "\n";
302+
303+
for group in groups.groups {
304+
out += group.email.as_str();
305+
for member in group.members {
306+
out += ",";
307+
out += member.as_str();
308+
}
309+
out += "\n"
310+
}
311+
Ok(Csv(out))
304312
}
305313

306-
fn error_for_status<T: std::fmt::Debug>(response: Response<T>) -> Result<T, Error> {
307-
if !response.status.is_success() {
308-
Err(Error::Fatal(anyhow::anyhow!(
309-
"Got bad response from Google Groups API: {:?}",
310-
response
311-
)))
312-
} else {
313-
Ok(response.body)
314+
pub struct Csv(String);
315+
316+
impl IntoResponse for Csv {
317+
fn into_response(self) -> axum::response::Response {
318+
Response::builder()
319+
.header(CONTENT_TYPE, "text/csv")
320+
.status(StatusCode::OK)
321+
.body(axum::body::Body::from(self.0))
322+
.expect("Failed to build response")
314323
}
315324
}

src/google_groups.rs

Lines changed: 177 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
1-
use std::collections::BTreeSet;
1+
use std::{
2+
collections::{BTreeMap, BTreeSet},
3+
str::FromStr,
4+
};
25

36
use anyhow::Context;
47
use email_address::EmailAddress;
5-
use gsuite_api::Client;
8+
use futures::future::join_all;
9+
use gsuite_api::{
10+
types::{Group, Member},
11+
Client, Response,
12+
};
613
use http::Uri;
714
use tower_sessions::Session;
815

@@ -45,6 +52,7 @@ pub async fn groups_client(
4552
}
4653
}
4754

55+
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
4856
pub(crate) struct GoogleGroup {
4957
pub email: EmailAddress,
5058
pub members: BTreeSet<EmailAddress>,
@@ -57,3 +65,170 @@ impl GoogleGroup {
5765
format!("https://groups.google.com/a/{domain}/g/{user}")
5866
}
5967
}
68+
69+
#[derive(Debug, PartialEq, Eq)]
70+
pub(crate) struct GoogleGroups {
71+
pub groups: BTreeSet<GoogleGroup>,
72+
}
73+
74+
pub(crate) async fn get_groups(client: &Client) -> Result<GoogleGroups, Error> {
75+
let groups_response = client
76+
.groups()
77+
.list_all(
78+
"my_customer",
79+
"codeyourfuture.io",
80+
gsuite_api::types::DirectoryGroupsListOrderBy::Email,
81+
"",
82+
gsuite_api::types::SortOrder::Ascending,
83+
"",
84+
)
85+
.await
86+
.context("Failed to list Google groups")?;
87+
let groups = error_for_status(groups_response)?;
88+
let group_member_futures = groups
89+
.iter()
90+
.map(|Group { id, .. }| async { client.members().list_all(id, false, "").await })
91+
.collect::<Vec<_>>();
92+
let group_members = join_all(group_member_futures).await;
93+
94+
let groups = groups
95+
.into_iter()
96+
.zip(group_members.into_iter())
97+
.map(|(group, members)| {
98+
let members =
99+
error_for_status(members.context("Failed to list Google group members")?)?;
100+
Ok(GoogleGroup {
101+
email: EmailAddress::from_str(&group.email).with_context(|| {
102+
format!("Failed to parse group email address {}", group.email)
103+
})?,
104+
members: members
105+
.into_iter()
106+
.map(|Member { email, .. }| {
107+
Ok(EmailAddress::from_str(&email).with_context(|| {
108+
format!(
109+
"Failed to parse group member email address {} (member of {})",
110+
email, group.email
111+
)
112+
})?)
113+
})
114+
.collect::<Result<_, anyhow::Error>>()?,
115+
})
116+
})
117+
.collect::<Result<_, Error>>()?;
118+
Ok(GoogleGroups { groups })
119+
}
120+
121+
impl GoogleGroups {
122+
pub(crate) fn expand_recursively(&mut self) -> Result<(), anyhow::Error> {
123+
let mut index = BTreeMap::new();
124+
let groups = self
125+
.groups
126+
.iter()
127+
.map(|GoogleGroup { email, .. }| email.clone())
128+
.collect::<BTreeSet<_>>();
129+
for group in &self.groups {
130+
index.insert(group.email.clone(), group.members.clone());
131+
}
132+
let mut iteration = 0;
133+
loop {
134+
let mut changed = false;
135+
if iteration > 15 {
136+
return Err(anyhow::anyhow!("Reached recursion limit expanding groups"));
137+
}
138+
let mut to_replace: BTreeMap<
139+
EmailAddress,
140+
BTreeMap<EmailAddress, BTreeSet<EmailAddress>>,
141+
> = BTreeMap::new();
142+
for (group, members) in index.iter() {
143+
for member in members.iter() {
144+
if groups.contains(member) {
145+
to_replace
146+
.entry(group.clone())
147+
.or_default()
148+
.insert(member.clone(), index.get(member).unwrap().clone());
149+
}
150+
}
151+
}
152+
for (group, replacements) in to_replace {
153+
for (to_replace, replacements) in replacements {
154+
index.get_mut(&group).unwrap().remove(&to_replace);
155+
index.get_mut(&group).unwrap().extend(replacements);
156+
changed = true;
157+
}
158+
}
159+
if !changed {
160+
break;
161+
}
162+
iteration += 1;
163+
}
164+
self.groups = index
165+
.into_iter()
166+
.map(|(email, members)| GoogleGroup { email, members })
167+
.collect();
168+
Ok(())
169+
}
170+
}
171+
172+
fn error_for_status<T: std::fmt::Debug>(response: Response<T>) -> Result<T, Error> {
173+
if !response.status.is_success() {
174+
Err(Error::Fatal(anyhow::anyhow!(
175+
"Got bad response from Google Groups API: {:?}",
176+
response
177+
)))
178+
} else {
179+
Ok(response.body)
180+
}
181+
}
182+
183+
#[cfg(test)]
184+
mod test {
185+
use email_address::EmailAddress;
186+
use maplit::btreeset;
187+
188+
use crate::google_groups::{GoogleGroup, GoogleGroups};
189+
190+
#[test]
191+
fn test_expand_recursively() {
192+
let outer_group = EmailAddress::new_unchecked("container@example.com");
193+
let inner_group = EmailAddress::new_unchecked("inner@example.com");
194+
let inner_members = btreeset![
195+
EmailAddress::new_unchecked("someone@example.com"),
196+
EmailAddress::new_unchecked("someone-else@example.com")
197+
];
198+
let other_member = EmailAddress::new_unchecked("external@example.com");
199+
let all_members = btreeset![
200+
other_member.clone(),
201+
EmailAddress::new_unchecked("someone@example.com"),
202+
EmailAddress::new_unchecked("someone-else@example.com")
203+
];
204+
205+
let mut input = GoogleGroups {
206+
groups: btreeset![
207+
GoogleGroup {
208+
email: outer_group.clone(),
209+
members: btreeset![inner_group.clone(), other_member.clone()],
210+
},
211+
GoogleGroup {
212+
email: inner_group.clone(),
213+
members: inner_members.clone(),
214+
}
215+
],
216+
};
217+
218+
let want = GoogleGroups {
219+
groups: btreeset![
220+
GoogleGroup {
221+
email: outer_group,
222+
members: all_members,
223+
},
224+
GoogleGroup {
225+
email: inner_group,
226+
members: inner_members,
227+
}
228+
],
229+
};
230+
231+
input.expand_recursively().unwrap();
232+
assert_eq!(input, want);
233+
}
234+
}

0 commit comments

Comments
 (0)