From e0542871bf5220ee4b2f5343524e93e99df06003 Mon Sep 17 00:00:00 2001 From: David Whitlock Date: Sun, 16 Aug 2026 08:52:28 -0700 Subject: [PATCH 1/7] Use the Canvas API to fetch the responses to the end-of-the-term survey. --- .../edu/pdx/cs/joy/grader/GraderTools.java | 5 + .../canvas/ExportCanvasSurveyResponses.java | 378 ++++++++++++++++++ .../ExportCanvasSurveyResponsesTest.java | 130 ++++++ 3 files changed, 513 insertions(+) create mode 100644 grader/src/main/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponses.java create mode 100644 grader/src/test/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponsesTest.java diff --git a/grader/src/main/java/edu/pdx/cs/joy/grader/GraderTools.java b/grader/src/main/java/edu/pdx/cs/joy/grader/GraderTools.java index d34cb2ec3..cd220da46 100644 --- a/grader/src/main/java/edu/pdx/cs/joy/grader/GraderTools.java +++ b/grader/src/main/java/edu/pdx/cs/joy/grader/GraderTools.java @@ -4,6 +4,7 @@ import ch.qos.logback.classic.Logger; import com.google.common.annotations.VisibleForTesting; import edu.pdx.cs.joy.grader.canvas.CompareCanvasAndWebsiteSchedules; +import edu.pdx.cs.joy.grader.canvas.ExportCanvasSurveyResponses; import edu.pdx.cs.joy.grader.canvas.GradesFromCanvasImporter; import edu.pdx.cs.joy.grader.gradebook.ui.GradeBookGUI; import edu.pdx.cs.joy.grader.poa.ui.PlanOfAttackGrader; @@ -87,6 +88,9 @@ private static Class getToolClass(String tool) { case "compareCanvasAndWebsiteSchedules": return CompareCanvasAndWebsiteSchedules.class; + case "exportCanvasSurveyResponses": + return ExportCanvasSurveyResponses.class; + default: usage("Unknown tool: " + tool); return null; @@ -123,6 +127,7 @@ private static void usage(String message) { err.println(" findUngradedSubmissions List submissions that need to be tested or graded"); err.println(" compareCanvasAndWebsiteSchedules"); err.println(" Compare assignment due dates in Canvas and the website schedule"); + err.println(" exportCanvasSurveyResponses Export End of Term Survey responses from a Canvas course"); err.println(" toolArg A command line argument to send to the tool"); err.println(); diff --git a/grader/src/main/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponses.java b/grader/src/main/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponses.java new file mode 100644 index 000000000..5f812dbf6 --- /dev/null +++ b/grader/src/main/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponses.java @@ -0,0 +1,378 @@ +package edu.pdx.cs.joy.grader.canvas; + +import com.google.common.annotations.VisibleForTesting; +import com.opencsv.CSVReader; +import com.opencsv.exceptions.CsvValidationException; +import jakarta.json.Json; +import jakarta.json.JsonArray; +import jakarta.json.JsonObject; +import jakarta.json.JsonReader; +import jakarta.json.JsonString; +import jakarta.json.JsonValue; + +import java.io.IOException; +import java.io.PrintStream; +import java.io.StringReader; +import java.io.Writer; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpHeaders; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Exports the responses to the End of Term Survey from a Canvas Classic Quiz + * as anonymized HTML. + */ +public class ExportCanvasSurveyResponses { + static final URI DEFAULT_CANVAS_BASE_URI = URI.create("https://canvas.pdx.edu"); + static final String SURVEY_TITLE = "End of Term Survey"; + + private static final Pattern NEXT_LINK_PATTERN = Pattern.compile("<([^>]+)>;\\s*rel=\"next\""); + private static final Pattern QUESTION_COLUMN_PATTERN = Pattern.compile("^\\d+:\\s+(.+)$"); + private static final Duration REPORT_POLL_DELAY = Duration.ofSeconds(1); + + private final HttpClient httpClient; + private final URI canvasBaseUri; + + public ExportCanvasSurveyResponses() { + this(HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NEVER).build(), DEFAULT_CANVAS_BASE_URI); + } + + @VisibleForTesting + ExportCanvasSurveyResponses(HttpClient httpClient, URI canvasBaseUri) { + this.httpClient = httpClient; + this.canvasBaseUri = canvasBaseUri; + } + + public static void main(String[] args) throws IOException, InterruptedException { + try { + new ExportCanvasSurveyResponses().run(args); + + } catch (IllegalArgumentException | IllegalStateException ex) { + usage(ex.getMessage()); + } + } + + @VisibleForTesting + void run(String[] args) throws IOException, InterruptedException { + if (args.length == 0) { + throw new IllegalArgumentException("Missing Canvas API token file name"); + } + if (args.length == 1) { + throw new IllegalArgumentException("Missing Canvas course ID"); + } + if (args.length == 2) { + throw new IllegalArgumentException("Missing HTML output file name"); + } + if (args.length > 3) { + throw new IllegalArgumentException("Extraneous command line argument: " + args[3]); + } + + String apiToken = readApiToken(Path.of(args[0])); + int courseId = parseCourseId(args[1]); + Path outputFile = Path.of(args[2]); + + export(apiToken, courseId, outputFile); + } + + @VisibleForTesting + void export(String apiToken, int courseId, Path outputFile) throws IOException, InterruptedException { + CanvasQuiz quiz = findClassicSurvey(apiToken, courseId); + String reportCsv = downloadStudentAnalysisReport(apiToken, courseId, quiz.id()); + Map> responses = parseSurveyResponses(reportCsv); + writeHtml(outputFile, responses); + } + + private CanvasQuiz findClassicSurvey(String apiToken, int courseId) throws IOException, InterruptedException { + List matches = new ArrayList<>(); + URI nextPage = this.canvasBaseUri.resolve("/api/v1/courses/" + courseId + "/quizzes?per_page=100"); + + while (nextPage != null) { + HttpResponse response = invokeCanvas(nextPage, apiToken, HttpRequest.BodyPublishers.noBody()); + for (CanvasQuiz quiz : parseQuizzes(response.body())) { + if (SURVEY_TITLE.equals(quiz.title())) { + matches.add(quiz); + } + } + nextPage = getNextPage(response.headers()); + } + + if (matches.isEmpty()) { + throw new IllegalStateException("Canvas course " + courseId + " has no quiz named \"" + SURVEY_TITLE + "\""); + } + if (matches.size() > 1) { + throw new IllegalStateException("Canvas course " + courseId + " has multiple quizzes named \"" + SURVEY_TITLE + "\""); + } + + CanvasQuiz survey = matches.get(0); + if (survey.quizType() == null) { + throw new IllegalStateException("\"" + SURVEY_TITLE + "\" is not a Classic Quiz"); + } + return survey; + } + + private String downloadStudentAnalysisReport(String apiToken, int courseId, int quizId) throws IOException, InterruptedException { + URI reportsUri = this.canvasBaseUri.resolve("/api/v1/courses/" + courseId + "/quizzes/" + quizId + "/reports"); + String body = """ + {"quiz_report":{"report_type":"student_analysis","includes_all_versions":true}} + """; + HttpResponse response = invokeCanvas(reportsUri, apiToken, HttpRequest.BodyPublishers.ofString(body)); + JsonObject report = parseObject(response.body()); + + JsonString progressUrl = report.getJsonString("progress_url"); + JsonString reportUrl = report.getJsonString("url"); + if (progressUrl == null || reportUrl == null) { + throw new IOException("Canvas did not return a report progress URL"); + } + + waitForReport(apiToken, URI.create(progressUrl.getString())); + JsonObject completedReport = getJsonObject(apiToken, URI.create(reportUrl.getString())); + JsonObject file = completedReport.getJsonObject("file"); + if (file == null || file.getJsonString("url") == null) { + throw new IOException("Canvas did not return a generated report file"); + } + + return downloadReportFile(apiToken, URI.create(file.getString("url"))); + } + + private String downloadReportFile(String apiToken, URI fileUrl) throws IOException, InterruptedException { + URI currentUrl = fileUrl; + boolean includeAuthorization = true; + + while (true) { + HttpResponse response = sendRequest(currentUrl, apiToken, HttpRequest.BodyPublishers.noBody(), includeAuthorization); + if (response.statusCode() >= 200 && response.statusCode() < 300) { + return response.body(); + } + if (response.statusCode() < 300 || response.statusCode() >= 400) { + throw new IOException("Canvas report download from " + currentUrl + " failed with status code " + response.statusCode()); + } + + String location = response.headers().firstValue("Location") + .orElseThrow(() -> new IOException("Canvas report download redirected without a Location header")); + currentUrl = currentUrl.resolve(location); + includeAuthorization = isCanvasUri(currentUrl); + } + } + + private void waitForReport(String apiToken, URI progressUrl) throws IOException, InterruptedException { + while (true) { + JsonObject progress = getJsonObject(apiToken, progressUrl); + String workflowState = progress.getString("workflow_state", ""); + if ("completed".equals(workflowState)) { + return; + } + if ("failed".equals(workflowState)) { + throw new IOException("Canvas failed to generate the student analysis report"); + } + + Thread.sleep(REPORT_POLL_DELAY); + } + } + + private JsonObject getJsonObject(String apiToken, URI uri) throws IOException, InterruptedException { + HttpResponse response = invokeCanvas(uri, apiToken, HttpRequest.BodyPublishers.noBody()); + return parseObject(response.body()); + } + + private HttpResponse invokeCanvas(URI uri, String apiToken, HttpRequest.BodyPublisher body) + throws IOException, InterruptedException { + if (!isCanvasUri(uri)) { + throw new IllegalArgumentException("Canvas API returned an unexpected URI: " + uri); + } + + HttpResponse response = sendRequest(uri, apiToken, body, true); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new IOException("Canvas request to " + uri + " failed with status code " + response.statusCode()); + } + return response; + } + + private HttpResponse sendRequest(URI uri, String apiToken, HttpRequest.BodyPublisher body, boolean includeAuthorization) + throws IOException, InterruptedException { + HttpRequest.Builder request = HttpRequest.newBuilder(uri) + .header("Accept", "application/json"); + if (includeAuthorization) { + request.header("Authorization", "Bearer " + apiToken); + } + if (body.contentLength() == 0) { + request.GET(); + } else { + request.header("Content-Type", "application/json").POST(body); + } + + HttpResponse response = this.httpClient.send(request.build(), HttpResponse.BodyHandlers.ofString()); + return response; + } + + private boolean isCanvasUri(URI uri) { + return this.canvasBaseUri.getScheme().equalsIgnoreCase(uri.getScheme()) + && this.canvasBaseUri.getHost().equalsIgnoreCase(uri.getHost()) + && this.canvasBaseUri.getPort() == uri.getPort(); + } + + @VisibleForTesting + static Map> parseSurveyResponses(String csv) throws IOException { + try (CSVReader reader = new CSVReader(new StringReader(csv))) { + String[] header = reader.readNext(); + if (header == null) { + throw new IOException("Canvas student analysis report is empty"); + } + + Map questionColumns = findQuestionColumns(header); + Map> responses = new LinkedHashMap<>(); + questionColumns.values().forEach(question -> responses.putIfAbsent(question, new ArrayList<>())); + + String[] row; + while ((row = reader.readNext()) != null) { + for (Map.Entry questionColumn : questionColumns.entrySet()) { + int column = questionColumn.getKey(); + if (column < row.length && !row[column].isBlank()) { + responses.get(questionColumn.getValue()).add(row[column]); + } + } + } + return responses; + + } catch (CsvValidationException ex) { + throw new IOException("While parsing the Canvas student analysis report", ex); + } + } + + private static Map findQuestionColumns(String[] header) { + Map questionColumns = new LinkedHashMap<>(); + for (int column = 0; column < header.length; column++) { + Matcher matcher = QUESTION_COLUMN_PATTERN.matcher(header[column]); + if (matcher.matches()) { + questionColumns.put(column, matcher.group(1)); + } + } + if (questionColumns.isEmpty()) { + throw new IllegalArgumentException("Canvas student analysis report has no question columns"); + } + return questionColumns; + } + + private static List parseQuizzes(String json) { + List quizzes = new ArrayList<>(); + try (JsonReader reader = Json.createReader(new StringReader(json))) { + JsonArray values = reader.readArray(); + for (JsonValue value : values) { + if (value.getValueType() != JsonValue.ValueType.OBJECT) { + continue; + } + JsonObject quiz = value.asJsonObject(); + JsonString title = quiz.getJsonString("title"); + if (title != null && quiz.containsKey("id")) { + quizzes.add(new CanvasQuiz(quiz.getInt("id"), title.getString(), quiz.getString("quiz_type", null))); + } + } + } + return quizzes; + } + + private static JsonObject parseObject(String json) { + try (JsonReader reader = Json.createReader(new StringReader(json))) { + return reader.readObject(); + } + } + + private URI getNextPage(HttpHeaders headers) { + for (String linkHeader : headers.allValues("Link")) { + Matcher matcher = NEXT_LINK_PATTERN.matcher(linkHeader); + if (matcher.find()) { + return URI.create(matcher.group(1)); + } + } + return null; + } + + private static String readApiToken(Path apiTokenFile) throws IOException { + if (!Files.exists(apiTokenFile)) { + throw new IllegalArgumentException("Canvas API token file \"" + apiTokenFile + "\" does not exist"); + } + + String apiToken = Files.readString(apiTokenFile).trim(); + if (apiToken.isEmpty()) { + throw new IllegalArgumentException("Canvas API token file \"" + apiTokenFile + "\" is empty"); + } + return apiToken; + } + + private static int parseCourseId(String courseIdText) { + try { + return Integer.parseInt(courseIdText); + } catch (NumberFormatException ex) { + throw new IllegalArgumentException("Canvas course ID \"" + courseIdText + "\" is not an integer"); + } + } + + private static void writeHtml(Path outputFile, Map> responses) throws IOException { + Path parent = outputFile.toAbsolutePath().getParent(); + if (parent != null && !Files.exists(parent)) { + throw new IllegalArgumentException("Parent directory \"" + parent + "\" does not exist"); + } + + try (Writer writer = Files.newBufferedWriter(outputFile, StandardCharsets.UTF_8)) { + writer.write("\n\n\n\n"); + writeEscapedHtml(writer, SURVEY_TITLE); + writer.write("\n\n\n

"); + writeEscapedHtml(writer, SURVEY_TITLE); + writer.write("

\n
    \n"); + + for (Map.Entry> question : responses.entrySet()) { + writer.write("
  1. "); + writeEscapedHtml(writer, question.getKey()); + writer.write("\n
      \n"); + for (String answer : question.getValue()) { + writer.write("
    • "); + writeEscapedHtml(writer, answer); + writer.write("
    • \n"); + } + writer.write("
    \n
  2. \n"); + } + writer.write("
\n\n\n"); + } + } + + private static void writeEscapedHtml(Writer writer, String text) throws IOException { + for (int i = 0; i < text.length(); i++) { + switch (text.charAt(i)) { + case '&' -> writer.write("&"); + case '<' -> writer.write("<"); + case '>' -> writer.write(">"); + case '"' -> writer.write("""); + case '\'' -> writer.write("'"); + default -> writer.write(text.charAt(i)); + } + } + } + + private static void usage(String message) { + PrintStream err = System.err; + err.println("+++ " + message); + err.println(); + err.println("usage: java ExportCanvasSurveyResponses apiTokenFileName courseId htmlFileName"); + err.println(" apiTokenFileName File containing the Canvas API token"); + err.println(" courseId Canvas ID of the course offering"); + err.println(" htmlFileName Output file for anonymized survey responses"); + err.println(); + err.println("Exports the \"" + SURVEY_TITLE + "\" Classic Quiz from Canvas as anonymized HTML"); + err.println(); + System.exit(1); + } + + private record CanvasQuiz(int id, String title, String quizType) { + } +} diff --git a/grader/src/test/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponsesTest.java b/grader/src/test/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponsesTest.java new file mode 100644 index 000000000..ef27dcf6a --- /dev/null +++ b/grader/src/test/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponsesTest.java @@ -0,0 +1,130 @@ +package edu.pdx.cs.joy.grader.canvas; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.http.HttpClient; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class ExportCanvasSurveyResponsesTest { + + @Test + void exportsAnonymizedResponsesFromClassicQuizStudentAnalysisReport(@TempDir File tempDir) + throws IOException, InterruptedException { + List authorizationHeaders = new ArrayList<>(); + List redirectedAuthorizationHeaders = new ArrayList<>(); + HttpServer fileServer = HttpServer.create(new InetSocketAddress(0), 0); + URI fileServerUri = URI.create("http://localhost:" + fileServer.getAddress().getPort()); + fileServer.createContext("/files/student-analysis.csv", exchange -> { + try { + redirectedAuthorizationHeaders.add(exchange.getRequestHeaders().getFirst("Authorization")); + respond(exchange, 200, """ + name,id,section,section_id,submitted,attempt,100: What should future students know?,0.5,101: May we use your answer?,0.0,n correct,n incorrect,score + Student One,1,001,10,2026-08-10,1,Use & write tests,0.5,Yes,0.0,2,0,1 + Student Two,2,001,10,2026-08-10,1,,0.5,No,0.0,1,1,0.5 + """); + } finally { + exchange.close(); + } + }); + fileServer.start(); + + HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); + URI canvasBaseUri = URI.create("http://localhost:" + server.getAddress().getPort()); + server.createContext("/", exchange -> { + try { + authorizationHeaders.add(exchange.getRequestHeaders().getFirst("Authorization")); + respondToCanvasRequest(exchange, canvasBaseUri, fileServerUri); + } finally { + exchange.close(); + } + }); + server.start(); + + try { + File output = new File(tempDir, "survey.html"); + ExportCanvasSurveyResponses exporter = new ExportCanvasSurveyResponses(HttpClient.newHttpClient(), canvasBaseUri); + exporter.export("canvas-token", 42, output.toPath()); + + String html = Files.readString(output.toPath()); + assertThat(html, containsString("What should future students know?")); + assertThat(html, containsString("Use <generics> & write tests")); + assertThat(html, containsString("Yes")); + assertThat(html, not(containsString("Student One"))); + assertThat(html, not(containsString("student.one@example.com"))); + + assertEquals(6, authorizationHeaders.size()); + authorizationHeaders.forEach(header -> assertEquals("Bearer canvas-token", header)); + assertEquals(1, redirectedAuthorizationHeaders.size()); + assertEquals(null, redirectedAuthorizationHeaders.get(0)); + } finally { + server.stop(0); + fileServer.stop(0); + } + } + + private static void respondToCanvasRequest(HttpExchange exchange, URI canvasBaseUri, URI fileServerUri) throws IOException { + String path = exchange.getRequestURI().getPath(); + if (path.equals("/api/v1/courses/42/quizzes") && "page=2".equals(exchange.getRequestURI().getQuery())) { + respond(exchange, 200, """ + [{"id": 9, "title": "End of Term Survey", "quiz_type": "assignment"}] + """); + + } else if (path.equals("/api/v1/courses/42/quizzes")) { + exchange.getResponseHeaders().add("Link", + "<" + canvasBaseUri + "/api/v1/courses/42/quizzes?page=2>; rel=\"next\""); + respond(exchange, 200, """ + [{"id": 8, "title": "Other Quiz", "quiz_type": "assignment"}] + """); + + } else if (path.equals("/api/v1/courses/42/quizzes/9/reports") && "POST".equals(exchange.getRequestMethod())) { + String requestBody = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + assertThat(requestBody, containsString("\"report_type\":\"student_analysis\"")); + assertThat(requestBody, containsString("\"includes_all_versions\":true")); + respond(exchange, 201, """ + { + "progress_url": "%s/progress/4", + "url": "%s/api/v1/courses/42/quizzes/9/reports/7" + } + """.formatted(canvasBaseUri, canvasBaseUri)); + + } else if (path.equals("/progress/4")) { + respond(exchange, 200, """ + {"workflow_state": "completed"} + """); + + } else if (path.equals("/api/v1/courses/42/quizzes/9/reports/7")) { + respond(exchange, 200, """ + {"file": {"url": "%s/files/student-analysis.csv"}} + """.formatted(canvasBaseUri)); + + } else if (path.equals("/files/student-analysis.csv")) { + exchange.getResponseHeaders().add("Location", fileServerUri + "/files/student-analysis.csv"); + exchange.sendResponseHeaders(302, -1); + + } else { + respond(exchange, 404, "{}"); + } + } + + private static void respond(HttpExchange exchange, int statusCode, String body) throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(statusCode, bytes.length); + exchange.getResponseBody().write(bytes); + } +} From d4692653d05c105618b88dd2f498c59836b6697f Mon Sep 17 00:00:00 2001 From: David Whitlock Date: Sun, 16 Aug 2026 08:59:44 -0700 Subject: [PATCH 2/7] Ignore the "Can I use your answers?" question. --- .../cs/joy/grader/canvas/ExportCanvasSurveyResponses.java | 4 +++- .../joy/grader/canvas/ExportCanvasSurveyResponsesTest.java | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/grader/src/main/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponses.java b/grader/src/main/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponses.java index 5f812dbf6..cd910e554 100644 --- a/grader/src/main/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponses.java +++ b/grader/src/main/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponses.java @@ -40,6 +40,8 @@ public class ExportCanvasSurveyResponses { private static final Pattern NEXT_LINK_PATTERN = Pattern.compile("<([^>]+)>;\\s*rel=\"next\""); private static final Pattern QUESTION_COLUMN_PATTERN = Pattern.compile("^\\d+:\\s+(.+)$"); + private static final String CONSENT_QUESTION = + "May I use your answers to these questions (not your name) todescribe this course in the future?"; private static final Duration REPORT_POLL_DELAY = Duration.ofSeconds(1); private final HttpClient httpClient; @@ -254,7 +256,7 @@ private static Map findQuestionColumns(String[] header) { Map questionColumns = new LinkedHashMap<>(); for (int column = 0; column < header.length; column++) { Matcher matcher = QUESTION_COLUMN_PATTERN.matcher(header[column]); - if (matcher.matches()) { + if (matcher.matches() && !CONSENT_QUESTION.equals(matcher.group(1))) { questionColumns.put(column, matcher.group(1)); } } diff --git a/grader/src/test/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponsesTest.java b/grader/src/test/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponsesTest.java index ef27dcf6a..c31c08083 100644 --- a/grader/src/test/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponsesTest.java +++ b/grader/src/test/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponsesTest.java @@ -33,7 +33,7 @@ void exportsAnonymizedResponsesFromClassicQuizStudentAnalysisReport(@TempDir Fil try { redirectedAuthorizationHeaders.add(exchange.getRequestHeaders().getFirst("Authorization")); respond(exchange, 200, """ - name,id,section,section_id,submitted,attempt,100: What should future students know?,0.5,101: May we use your answer?,0.0,n correct,n incorrect,score + name,id,section,section_id,submitted,attempt,100: What should future students know?,0.5,101: May I use your answers to these questions (not your name) todescribe this course in the future?,0.0,n correct,n incorrect,score Student One,1,001,10,2026-08-10,1,Use & write tests,0.5,Yes,0.0,2,0,1 Student Two,2,001,10,2026-08-10,1,,0.5,No,0.0,1,1,0.5 """); @@ -63,7 +63,8 @@ void exportsAnonymizedResponsesFromClassicQuizStudentAnalysisReport(@TempDir Fil String html = Files.readString(output.toPath()); assertThat(html, containsString("What should future students know?")); assertThat(html, containsString("Use <generics> & write tests")); - assertThat(html, containsString("Yes")); + assertThat(html, not(containsString("May I use your answers to these questions"))); + assertThat(html, not(containsString("Yes"))); assertThat(html, not(containsString("Student One"))); assertThat(html, not(containsString("student.one@example.com"))); From 6fdb534fb3792d650fa5b556be9d990f245f2eab Mon Sep 17 00:00:00 2001 From: David Whitlock Date: Sun, 16 Aug 2026 18:38:44 -0700 Subject: [PATCH 3/7] If student hasn't consented to their answers being used, don't include them in the report. --- .../canvas/ExportCanvasSurveyResponses.java | 20 +++++++++++++++++++ .../ExportCanvasSurveyResponsesTest.java | 3 ++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/grader/src/main/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponses.java b/grader/src/main/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponses.java index cd910e554..1d0489c69 100644 --- a/grader/src/main/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponses.java +++ b/grader/src/main/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponses.java @@ -233,11 +233,16 @@ static Map> parseSurveyResponses(String csv) throws IOExcep } Map questionColumns = findQuestionColumns(header); + int consentColumn = findConsentColumn(header); Map> responses = new LinkedHashMap<>(); questionColumns.values().forEach(question -> responses.putIfAbsent(question, new ArrayList<>())); String[] row; while ((row = reader.readNext()) != null) { + if (!hasGivenConsent(row, consentColumn)) { + continue; + } + for (Map.Entry questionColumn : questionColumns.entrySet()) { int column = questionColumn.getKey(); if (column < row.length && !row[column].isBlank()) { @@ -266,6 +271,21 @@ private static Map findQuestionColumns(String[] header) { return questionColumns; } + private static int findConsentColumn(String[] header) { + for (int column = 0; column < header.length; column++) { + Matcher matcher = QUESTION_COLUMN_PATTERN.matcher(header[column]); + if (matcher.matches() && CONSENT_QUESTION.equals(matcher.group(1))) { + return column; + } + } + + throw new IllegalArgumentException("Canvas student analysis report has no consent question"); + } + + private static boolean hasGivenConsent(String[] row, int consentColumn) { + return consentColumn < row.length && "yes".equalsIgnoreCase(row[consentColumn].trim()); + } + private static List parseQuizzes(String json) { List quizzes = new ArrayList<>(); try (JsonReader reader = Json.createReader(new StringReader(json))) { diff --git a/grader/src/test/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponsesTest.java b/grader/src/test/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponsesTest.java index c31c08083..c57cfc6f0 100644 --- a/grader/src/test/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponsesTest.java +++ b/grader/src/test/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponsesTest.java @@ -35,7 +35,7 @@ void exportsAnonymizedResponsesFromClassicQuizStudentAnalysisReport(@TempDir Fil respond(exchange, 200, """ name,id,section,section_id,submitted,attempt,100: What should future students know?,0.5,101: May I use your answers to these questions (not your name) todescribe this course in the future?,0.0,n correct,n incorrect,score Student One,1,001,10,2026-08-10,1,Use & write tests,0.5,Yes,0.0,2,0,1 - Student Two,2,001,10,2026-08-10,1,,0.5,No,0.0,1,1,0.5 + Student Two,2,001,10,2026-08-10,1,This answer must not be included,0.5,No,0.0,1,1,0.5 """); } finally { exchange.close(); @@ -63,6 +63,7 @@ void exportsAnonymizedResponsesFromClassicQuizStudentAnalysisReport(@TempDir Fil String html = Files.readString(output.toPath()); assertThat(html, containsString("What should future students know?")); assertThat(html, containsString("Use <generics> & write tests")); + assertThat(html, not(containsString("This answer must not be included"))); assertThat(html, not(containsString("May I use your answers to these questions"))); assertThat(html, not(containsString("Yes"))); assertThat(html, not(containsString("Student One"))); From aa37b2896f5dd855cebcb6d5ce673b2660cd1e86 Mon Sep 17 00:00:00 2001 From: David Whitlock Date: Sun, 16 Aug 2026 18:44:28 -0700 Subject: [PATCH 4/7] Use the same XHTML format as the old "comments.html" files on the website. --- .../canvas/ExportCanvasSurveyResponses.java | 37 ++++++++++++++----- .../ExportCanvasSurveyResponsesTest.java | 4 ++ 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/grader/src/main/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponses.java b/grader/src/main/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponses.java index 1d0489c69..9d5a1d04c 100644 --- a/grader/src/main/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponses.java +++ b/grader/src/main/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponses.java @@ -38,6 +38,10 @@ public class ExportCanvasSurveyResponses { static final URI DEFAULT_CANVAS_BASE_URI = URI.create("https://canvas.pdx.edu"); static final String SURVEY_TITLE = "End of Term Survey"; + private static final String PAGE_TITLE = "Previously on The Joy of Coding..."; + private static final String INTRODUCTION = "Here are some comments from students who have taken The Joy of Coding."; + private static final String XHTML_DOCTYPE = + ""; private static final Pattern NEXT_LINK_PATTERN = Pattern.compile("<([^>]+)>;\\s*rel=\"next\""); private static final Pattern QUESTION_COLUMN_PATTERN = Pattern.compile("^\\d+:\\s+(.+)$"); private static final String CONSENT_QUESTION = @@ -347,24 +351,37 @@ private static void writeHtml(Path outputFile, Map> respons } try (Writer writer = Files.newBufferedWriter(outputFile, StandardCharsets.UTF_8)) { - writer.write("\n\n\n\n"); - writeEscapedHtml(writer, SURVEY_TITLE); - writer.write("\n\n\n

"); - writeEscapedHtml(writer, SURVEY_TITLE); - writer.write("

\n
    \n"); + writer.write(XHTML_DOCTYPE + "\n"); + writer.write("\n"); + writer.write(" \n"); + writer.write(" "); + writeEscapedHtml(writer, PAGE_TITLE); + writer.write("\n"); + writer.write(" \n"); + writer.write(" \n"); + writer.write("

    "); + writeEscapedHtml(writer, PAGE_TITLE); + writer.write("

    \n"); + writer.write("

    "); + writeEscapedHtml(writer, INTRODUCTION); + writer.write("

    \n"); + writer.write("
      \n"); for (Map.Entry> question : responses.entrySet()) { - writer.write("
    1. "); + writer.write("
    2. "); writeEscapedHtml(writer, question.getKey()); - writer.write("\n
        \n"); + writer.write("\n"); + writer.write("
          \n"); for (String answer : question.getValue()) { - writer.write("
        • "); + writer.write("
        • "); writeEscapedHtml(writer, answer); writer.write("
        • \n"); } - writer.write("
        \n\n"); + writer.write("
      \n"); } - writer.write("
    \n\n\n"); + writer.write("
\n"); + writer.write(" \n"); + writer.write("\n"); } } diff --git a/grader/src/test/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponsesTest.java b/grader/src/test/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponsesTest.java index c57cfc6f0..0a7840a6f 100644 --- a/grader/src/test/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponsesTest.java +++ b/grader/src/test/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponsesTest.java @@ -61,6 +61,10 @@ void exportsAnonymizedResponsesFromClassicQuizStudentAnalysisReport(@TempDir Fil exporter.export("canvas-token", 42, output.toPath()); String html = Files.readString(output.toPath()); + assertThat(html, containsString("Previously on The Joy of Coding...")); + assertThat(html, containsString("

Previously on The Joy of Coding...

")); + assertThat(html, containsString("

Here are some comments from students who have taken The Joy of Coding.

")); assertThat(html, containsString("What should future students know?")); assertThat(html, containsString("Use <generics> & write tests")); assertThat(html, not(containsString("This answer must not be included"))); From 7754777c8acb916a3da09e768ddf0589c6a3b688 Mon Sep 17 00:00:00 2001 From: David Whitlock Date: Sun, 16 Aug 2026 19:03:16 -0700 Subject: [PATCH 5/7] For the first question, display a table with the frequency of each of the multiple-choice answers. --- .../canvas/ExportCanvasSurveyResponses.java | 42 ++++++++++++++++--- .../ExportCanvasSurveyResponsesTest.java | 13 ++++-- 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/grader/src/main/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponses.java b/grader/src/main/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponses.java index 9d5a1d04c..c4641c9d6 100644 --- a/grader/src/main/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponses.java +++ b/grader/src/main/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponses.java @@ -40,6 +40,9 @@ public class ExportCanvasSurveyResponses { private static final String PAGE_TITLE = "Previously on The Joy of Coding..."; private static final String INTRODUCTION = "Here are some comments from students who have taken The Joy of Coding."; + private static final String PREPARATION_QUESTION = "How well prepared were you for the work in this class?"; + private static final List PREPARATION_RESPONSES = + List.of("very good", "good", "fair", "poor", "very poor"); private static final String XHTML_DOCTYPE = ""; private static final Pattern NEXT_LINK_PATTERN = Pattern.compile("<([^>]+)>;\\s*rel=\"next\""); @@ -371,13 +374,19 @@ private static void writeHtml(Path outputFile, Map> respons writer.write("
  • "); writeEscapedHtml(writer, question.getKey()); writer.write("
  • \n"); - writer.write("
      \n"); - for (String answer : question.getValue()) { - writer.write("
    • "); - writeEscapedHtml(writer, answer); - writer.write("
    • \n"); + + if (PREPARATION_QUESTION.equals(question.getKey())) { + writeResponseCounts(writer, question.getValue()); + + } else { + writer.write("
        \n"); + for (String answer : question.getValue()) { + writer.write("
      • "); + writeEscapedHtml(writer, answer); + writer.write("
      • \n"); + } + writer.write("
      \n"); } - writer.write("
    \n"); } writer.write(" \n"); writer.write(" \n"); @@ -385,6 +394,27 @@ private static void writeHtml(Path outputFile, Map> respons } } + private static void writeResponseCounts(Writer writer, List responses) throws IOException { + Map counts = new LinkedHashMap<>(); + PREPARATION_RESPONSES.forEach(response -> counts.put(response, 0)); + for (String response : responses) { + if (counts.containsKey(response)) { + counts.merge(response, 1, Integer::sum); + } + } + + writer.write(" \n"); + writer.write(" \n"); + for (Map.Entry count : counts.entrySet()) { + writer.write(" \n"); + } + writer.write("
    ResponseStudents
    "); + writeEscapedHtml(writer, count.getKey()); + writer.write(""); + writer.write(String.valueOf(count.getValue())); + writer.write("
    \n"); + } + private static void writeEscapedHtml(Writer writer, String text) throws IOException { for (int i = 0; i < text.length(); i++) { switch (text.charAt(i)) { diff --git a/grader/src/test/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponsesTest.java b/grader/src/test/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponsesTest.java index 0a7840a6f..7a54cd41e 100644 --- a/grader/src/test/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponsesTest.java +++ b/grader/src/test/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponsesTest.java @@ -33,9 +33,10 @@ void exportsAnonymizedResponsesFromClassicQuizStudentAnalysisReport(@TempDir Fil try { redirectedAuthorizationHeaders.add(exchange.getRequestHeaders().getFirst("Authorization")); respond(exchange, 200, """ - name,id,section,section_id,submitted,attempt,100: What should future students know?,0.5,101: May I use your answers to these questions (not your name) todescribe this course in the future?,0.0,n correct,n incorrect,score - Student One,1,001,10,2026-08-10,1,Use & write tests,0.5,Yes,0.0,2,0,1 - Student Two,2,001,10,2026-08-10,1,This answer must not be included,0.5,No,0.0,1,1,0.5 + name,id,section,section_id,submitted,attempt,100: How well prepared were you for the work in this class?,0.5,101: What should future students know?,0.5,102: May I use your answers to these questions (not your name) todescribe this course in the future?,0.0,n correct,n incorrect,score + Student One,1,001,10,2026-08-10,1,very good,0.5,Use & write tests,0.5,Yes,0.0,2,0,1 + Student Two,2,001,10,2026-08-10,1,good,0.5,Another useful response,0.5,Yes,0.0,2,0,1 + Student Three,3,001,10,2026-08-10,1,very good,0.5,This answer must not be included,0.5,No,0.0,1,1,0.5 """); } finally { exchange.close(); @@ -65,6 +66,12 @@ void exportsAnonymizedResponsesFromClassicQuizStudentAnalysisReport(@TempDir Fil assertThat(html, containsString("Previously on The Joy of Coding...")); assertThat(html, containsString("

    Previously on The Joy of Coding...

    ")); assertThat(html, containsString("

    Here are some comments from students who have taken The Joy of Coding.

    ")); + assertThat(html, containsString("ResponseStudents")); + assertThat(html, containsString("very good1")); + assertThat(html, containsString("good1")); + assertThat(html, containsString("fair0")); + assertThat(html, containsString("poor0")); + assertThat(html, containsString("very poor0")); assertThat(html, containsString("What should future students know?")); assertThat(html, containsString("Use <generics> & write tests")); assertThat(html, not(containsString("This answer must not be included"))); From 934e9fbd5b0f278f8b206b07668a287d431cfbb5 Mon Sep 17 00:00:00 2001 From: David Whitlock Date: Sun, 16 Aug 2026 19:19:06 -0700 Subject: [PATCH 6/7] Generate the name of the HTML from the name of the term in which the course was offered. --- .../canvas/ExportCanvasSurveyResponses.java | 37 ++++++++++++++----- .../ExportCanvasSurveyResponsesTest.java | 14 ++++--- 2 files changed, 36 insertions(+), 15 deletions(-) diff --git a/grader/src/main/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponses.java b/grader/src/main/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponses.java index c4641c9d6..7d3624881 100644 --- a/grader/src/main/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponses.java +++ b/grader/src/main/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponses.java @@ -47,6 +47,7 @@ public class ExportCanvasSurveyResponses { ""; private static final Pattern NEXT_LINK_PATTERN = Pattern.compile("<([^>]+)>;\\s*rel=\"next\""); private static final Pattern QUESTION_COLUMN_PATTERN = Pattern.compile("^\\d+:\\s+(.+)$"); + private static final Pattern TERM_NAME_PATTERN = Pattern.compile("^(Winter|Spring|Summer|Fall) (\\d{4})$"); private static final String CONSENT_QUESTION = "May I use your answers to these questions (not your name) todescribe this course in the future?"; private static final Duration REPORT_POLL_DELAY = Duration.ofSeconds(1); @@ -81,28 +82,43 @@ void run(String[] args) throws IOException, InterruptedException { if (args.length == 1) { throw new IllegalArgumentException("Missing Canvas course ID"); } - if (args.length == 2) { - throw new IllegalArgumentException("Missing HTML output file name"); - } - if (args.length > 3) { - throw new IllegalArgumentException("Extraneous command line argument: " + args[3]); + if (args.length > 2) { + throw new IllegalArgumentException("Extraneous command line argument: " + args[2]); } String apiToken = readApiToken(Path.of(args[0])); int courseId = parseCourseId(args[1]); - Path outputFile = Path.of(args[2]); - export(apiToken, courseId, outputFile); + export(apiToken, courseId, Path.of(".")); } @VisibleForTesting - void export(String apiToken, int courseId, Path outputFile) throws IOException, InterruptedException { + void export(String apiToken, int courseId, Path outputDirectory) throws IOException, InterruptedException { + Path outputFile = outputDirectory.resolve(outputFileName(getCourseTermName(apiToken, courseId))); CanvasQuiz quiz = findClassicSurvey(apiToken, courseId); String reportCsv = downloadStudentAnalysisReport(apiToken, courseId, quiz.id()); Map> responses = parseSurveyResponses(reportCsv); writeHtml(outputFile, responses); } + private String getCourseTermName(String apiToken, int courseId) throws IOException, InterruptedException { + URI courseUri = this.canvasBaseUri.resolve("/api/v1/courses/" + courseId + "?include%5B%5D=term"); + JsonObject course = getJsonObject(apiToken, courseUri); + JsonObject term = course.getJsonObject("term"); + if (term == null || term.getJsonString("name") == null) { + throw new IOException("Canvas course " + courseId + " has no term name"); + } + return term.getString("name"); + } + + private static String outputFileName(String termName) { + Matcher matcher = TERM_NAME_PATTERN.matcher(termName); + if (!matcher.matches()) { + throw new IllegalArgumentException("Canvas course term \"" + termName + "\" is not a season and year"); + } + return "comments-" + matcher.group(1).toLowerCase() + matcher.group(2) + ".html"; + } + private CanvasQuiz findClassicSurvey(String apiToken, int courseId) throws IOException, InterruptedException { List matches = new ArrayList<>(); URI nextPage = this.canvasBaseUri.resolve("/api/v1/courses/" + courseId + "/quizzes?per_page=100"); @@ -432,10 +448,11 @@ private static void usage(String message) { PrintStream err = System.err; err.println("+++ " + message); err.println(); - err.println("usage: java ExportCanvasSurveyResponses apiTokenFileName courseId htmlFileName"); + err.println("usage: java ExportCanvasSurveyResponses apiTokenFileName courseId"); err.println(" apiTokenFileName File containing the Canvas API token"); err.println(" courseId Canvas ID of the course offering"); - err.println(" htmlFileName Output file for anonymized survey responses"); + err.println(""); + err.println("Writes comments-seasonyear.html for the course's Canvas term"); err.println(); err.println("Exports the \"" + SURVEY_TITLE + "\" Classic Quiz from Canvas as anonymized HTML"); err.println(); diff --git a/grader/src/test/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponsesTest.java b/grader/src/test/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponsesTest.java index 7a54cd41e..21e06072a 100644 --- a/grader/src/test/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponsesTest.java +++ b/grader/src/test/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponsesTest.java @@ -57,11 +57,10 @@ void exportsAnonymizedResponsesFromClassicQuizStudentAnalysisReport(@TempDir Fil server.start(); try { - File output = new File(tempDir, "survey.html"); ExportCanvasSurveyResponses exporter = new ExportCanvasSurveyResponses(HttpClient.newHttpClient(), canvasBaseUri); - exporter.export("canvas-token", 42, output.toPath()); + exporter.export("canvas-token", 42, tempDir.toPath()); - String html = Files.readString(output.toPath()); + String html = Files.readString(new File(tempDir, "comments-summer2026.html").toPath()); assertThat(html, containsString("Previously on The Joy of Coding...")); assertThat(html, containsString("

    Previously on The Joy of Coding...

    ")); @@ -80,7 +79,7 @@ void exportsAnonymizedResponsesFromClassicQuizStudentAnalysisReport(@TempDir Fil assertThat(html, not(containsString("Student One"))); assertThat(html, not(containsString("student.one@example.com"))); - assertEquals(6, authorizationHeaders.size()); + assertEquals(7, authorizationHeaders.size()); authorizationHeaders.forEach(header -> assertEquals("Bearer canvas-token", header)); assertEquals(1, redirectedAuthorizationHeaders.size()); assertEquals(null, redirectedAuthorizationHeaders.get(0)); @@ -92,7 +91,12 @@ void exportsAnonymizedResponsesFromClassicQuizStudentAnalysisReport(@TempDir Fil private static void respondToCanvasRequest(HttpExchange exchange, URI canvasBaseUri, URI fileServerUri) throws IOException { String path = exchange.getRequestURI().getPath(); - if (path.equals("/api/v1/courses/42/quizzes") && "page=2".equals(exchange.getRequestURI().getQuery())) { + if (path.equals("/api/v1/courses/42")) { + respond(exchange, 200, """ + {"term": {"name": "Summer 2026"}} + """); + + } else if (path.equals("/api/v1/courses/42/quizzes") && "page=2".equals(exchange.getRequestURI().getQuery())) { respond(exchange, 200, """ [{"id": 9, "title": "End of Term Survey", "quiz_type": "assignment"}] """); From 1f51db5c71b73755141e3d34259ddb3e45ad942d Mon Sep 17 00:00:00 2001 From: David Whitlock Date: Sun, 16 Aug 2026 19:28:17 -0700 Subject: [PATCH 7/7] Added logging to provide an indication of what the program is doing. --- .../joy/grader/canvas/ExportCanvasSurveyResponses.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/grader/src/main/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponses.java b/grader/src/main/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponses.java index 7d3624881..44a2ce38b 100644 --- a/grader/src/main/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponses.java +++ b/grader/src/main/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponses.java @@ -9,6 +9,8 @@ import jakarta.json.JsonReader; import jakarta.json.JsonString; import jakarta.json.JsonValue; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.io.IOException; import java.io.PrintStream; @@ -38,6 +40,7 @@ public class ExportCanvasSurveyResponses { static final URI DEFAULT_CANVAS_BASE_URI = URI.create("https://canvas.pdx.edu"); static final String SURVEY_TITLE = "End of Term Survey"; + private static final Logger logger = LoggerFactory.getLogger("edu.pdx.cs.joy.grader"); private static final String PAGE_TITLE = "Previously on The Joy of Coding..."; private static final String INTRODUCTION = "Here are some comments from students who have taken The Joy of Coding."; private static final String PREPARATION_QUESTION = "How well prepared were you for the work in this class?"; @@ -94,10 +97,13 @@ void run(String[] args) throws IOException, InterruptedException { @VisibleForTesting void export(String apiToken, int courseId, Path outputDirectory) throws IOException, InterruptedException { + logger.info("Retrieving the Canvas term for course " + courseId); Path outputFile = outputDirectory.resolve(outputFileName(getCourseTermName(apiToken, courseId))); + logger.info("Finding the \"" + SURVEY_TITLE + "\" quiz"); CanvasQuiz quiz = findClassicSurvey(apiToken, courseId); String reportCsv = downloadStudentAnalysisReport(apiToken, courseId, quiz.id()); Map> responses = parseSurveyResponses(reportCsv); + logger.info("Writing anonymized survey responses to " + outputFile); writeHtml(outputFile, responses); } @@ -152,6 +158,7 @@ private String downloadStudentAnalysisReport(String apiToken, int courseId, int String body = """ {"quiz_report":{"report_type":"student_analysis","includes_all_versions":true}} """; + logger.info("Requesting the Canvas student analysis report"); HttpResponse response = invokeCanvas(reportsUri, apiToken, HttpRequest.BodyPublishers.ofString(body)); JsonObject report = parseObject(response.body()); @@ -161,6 +168,7 @@ private String downloadStudentAnalysisReport(String apiToken, int courseId, int throw new IOException("Canvas did not return a report progress URL"); } + logger.info("Waiting for Canvas to generate the student analysis report"); waitForReport(apiToken, URI.create(progressUrl.getString())); JsonObject completedReport = getJsonObject(apiToken, URI.create(reportUrl.getString())); JsonObject file = completedReport.getJsonObject("file"); @@ -168,6 +176,7 @@ private String downloadStudentAnalysisReport(String apiToken, int courseId, int throw new IOException("Canvas did not return a generated report file"); } + logger.info("Downloading the student analysis report"); return downloadReportFile(apiToken, URI.create(file.getString("url"))); }