001package edu.pdx.cs.joy.grader; 002 003import com.google.common.annotations.VisibleForTesting; 004import edu.pdx.cs.joy.grader.gradebook.Grade; 005import edu.pdx.cs.joy.grader.gradebook.GradeBook; 006import edu.pdx.cs.joy.grader.gradebook.Student; 007import edu.pdx.cs.joy.grader.gradebook.XmlGradeBookParser; 008 009import java.io.IOException; 010import java.io.InputStream; 011import java.nio.file.FileVisitOption; 012import java.nio.file.Files; 013import java.nio.file.Path; 014import java.time.LocalDateTime; 015import java.util.ArrayList; 016import java.util.List; 017import java.util.Objects; 018import java.util.Optional; 019import java.util.function.Function; 020import java.util.jar.Attributes; 021import java.util.jar.Manifest; 022import java.util.regex.Matcher; 023import java.util.regex.Pattern; 024import java.util.stream.Stream; 025 026import static edu.pdx.cs.joy.grader.TestedProjectSubmissionOutputParser.*; 027 028public class FindUngradedSubmissions { 029 private final SubmissionDetailsProvider submissionDetailsProvider; 030 private final TestOutputPathProvider testOutputProvider; 031 private final TestOutputDetailsProvider testOutputDetailsProvider; 032 private final GradeBookProvider gradeBookProvider; 033 034 @VisibleForTesting 035 FindUngradedSubmissions(SubmissionDetailsProvider submissionDetailsProvider, TestOutputPathProvider testOutputProvider, TestOutputDetailsProvider testOutputDetailsProvider, GradeBookProvider gradeBookProvider) { 036 Objects.requireNonNull(gradeBookProvider, "GradeBook provider cannot be null"); 037 this.submissionDetailsProvider = submissionDetailsProvider; 038 this.testOutputProvider = testOutputProvider; 039 this.testOutputDetailsProvider = testOutputDetailsProvider; 040 this.gradeBookProvider = gradeBookProvider; 041 } 042 043 public FindUngradedSubmissions(String gradeBookXmlFile) { 044 this(new SubmissionDetailsProviderFromZipFile(), 045 new TestOutputProviderInParentDirectory(), 046 new TestOutputDetailsProviderFromTestOutputFile(), 047 new GradeBookProviderFromXmlFile(gradeBookXmlFile)); 048 } 049 050 @VisibleForTesting 051 SubmissionAnalysis analyzeSubmission(Path submissionPath) { 052 SubmissionDetails submission = this.submissionDetailsProvider.getSubmissionDetails(submissionPath); 053 Path submissionDirectory = submissionPath.getParent(); 054 Path testOutputPath = this.testOutputProvider.getTestOutput(submissionDirectory, submission.studentId()); 055 boolean needsToBeTested; 056 boolean needsToBeGraded; 057 boolean needsToBeRecorded; 058 String reason; 059 060 if (!Files.exists(testOutputPath)) { 061 needsToBeTested = true; 062 needsToBeGraded = true; 063 needsToBeRecorded = false; 064 reason = "Test output file does not exist: " + testOutputPath; 065 066 } else { 067 TestOutputDetails testOutput = this.testOutputDetailsProvider.getTestOutputDetails(testOutputPath); 068 if (submittedAfterTesting(submission, testOutput)) { 069 needsToBeTested = true; 070 needsToBeGraded = true; 071 needsToBeRecorded = false; 072 reason = "Submission on " + submission.submissionTime() + " is after testing on " + testOutput.testedSubmissionTime(); 073 074 } else if (!testOutput.hasGrade()) { 075 needsToBeTested = false; 076 needsToBeGraded = !testOutput.hasBeenReviewed(); 077 needsToBeRecorded = false; 078 reason = "Test output file does not have a grade: " + testOutputPath; 079 080 } else { 081 needsToBeTested = false; 082 needsToBeGraded = false; 083 needsToBeRecorded = doesCareNeedToBeRecorded(submission, testOutput); 084 reason = "Test output file was graded after submission: " + testOutputPath; 085 } 086 } 087 088 return new SubmissionAnalysis(submissionPath, needsToBeTested, needsToBeGraded, reason, testOutputPath, needsToBeRecorded); 089 } 090 091 private boolean doesCareNeedToBeRecorded(SubmissionDetails submission, TestOutputDetails testOutput) { 092 // If test output doesn't have project name or grade, can't check 093 if (testOutput.projectName() == null || testOutput.grade() == null) { 094 return false; 095 } 096 097 // Get the gradebook 098 java.util.Optional<GradeBook> gradeBookOpt = gradeBookProvider.getGradeBook(); 099 if (gradeBookOpt.isEmpty()) { 100 return false; 101 } 102 103 GradeBook gradeBook = gradeBookOpt.get(); 104 105 // Get the student from the gradebook 106 Optional<Student> studentOpt = gradeBook.getStudent(submission.studentId()); 107 if (studentOpt.isEmpty()) { 108 return false; // Student not in gradebook, nothing to record 109 } 110 111 Student student = studentOpt.get(); 112 113 // Get the grade for the assignment 114 Grade grade = student.getGrade(testOutput.projectName()); 115 if (grade == null) { 116 return true; // No grade recorded for this assignment 117 } 118 119 // Compare grades - if different, needs to be recorded 120 return grade.getScore() != testOutput.grade(); 121 } 122 123 private static boolean submittedAfterTesting(SubmissionDetails submission, TestOutputDetails testOutput) { 124 LocalDateTime submissionTime = submission.submissionTime(); 125 LocalDateTime testedSubmissionTime = testOutput.testedSubmissionTime(); 126 return submissionTime.isAfter(testedSubmissionTime.plusMinutes(1L)); 127 } 128 129 @VisibleForTesting 130 record SubmissionDetails(String studentId, LocalDateTime submissionTime) { 131 132 } 133 134 public static void main(String[] args) { 135 if (args.length == 0) { 136 System.err.println("Usage: java FindUngradedSubmissions -includeReason gradeBookXmlFile submissionZipOrDirectory+"); 137 return; 138 } 139 140 boolean includeReason = false; 141 List<String> fileNames = new ArrayList<>(); 142 143 for (String arg : args) { 144 if (arg.equals("-includeReason")) { 145 includeReason = true; 146 147 } else if (arg.startsWith("-")) { 148 System.err.println("Unknown option: " + arg); 149 return; 150 151 } else { 152 fileNames.add(arg); 153 } 154 } 155 156 // First non-option argument is the required gradebook XML file 157 if (fileNames.isEmpty()) { 158 System.err.println("Missing required gradebook XML file"); 159 System.err.println("Usage: java FindUngradedSubmissions -includeReason gradeBookXmlFile submissionZipOrDirectory+"); 160 return; 161 } 162 163 String gradeBookXmlFile = fileNames.removeFirst(); 164 165 // Validate that the gradebook XML file can be parsed 166 try { 167 XmlGradeBookParser parser = new XmlGradeBookParser(gradeBookXmlFile); 168 parser.parse(); // This will throw an exception if the file is invalid 169 } catch (Exception e) { 170 System.err.println("Error: The first argument must be a valid gradebook XML file"); 171 System.err.println("Cannot parse gradebook from: " + gradeBookXmlFile); 172 System.err.println("Error: " + e.getMessage()); 173 return; 174 } 175 176 Stream<Path> submissions = findSubmissionsIn(fileNames); 177 FindUngradedSubmissions finder = new FindUngradedSubmissions(gradeBookXmlFile); 178 Stream<SubmissionAnalysis> analyses = submissions.map(finder::analyzeSubmission); 179 List<SubmissionAnalysis> needsToBeTested = new ArrayList<>(); 180 List<SubmissionAnalysis> needsToBeGraded = new ArrayList<>(); 181 List<SubmissionAnalysis> gradeNeedsToBeRecorded = new ArrayList<>(); 182 183 analyses.forEach(analysis -> { 184 if (analysis.needsToBeTested()) { 185 needsToBeTested.add(analysis); 186 187 } else if (analysis.needsToBeGraded()) { 188 needsToBeGraded.add(analysis); 189 190 } else if (analysis.gradeNeedsToBeRecorded()) { 191 gradeNeedsToBeRecorded.add(analysis); 192 } 193 }); 194 195 printOutAnalyses(needsToBeTested, "tested", SubmissionAnalysis::submission, includeReason); 196 printOutAnalyses(needsToBeGraded, "graded", SubmissionAnalysis::testOutput, includeReason); 197 printOutAnalyses(gradeNeedsToBeRecorded, "recorded", SubmissionAnalysis::testOutput, includeReason); 198 } 199 200 private static void printOutAnalyses(List<SubmissionAnalysis> analyses, String action, Function<SubmissionAnalysis, Path> getPath, boolean includeReason) { 201 int size = analyses.size(); 202 String description = (size == 1 ? " submission needs" : " submissions need"); 203 System.out.println(size + description + " to be " + action + ": "); 204 analyses.forEach(analysis -> { 205 System.out.print(" " + getPath.apply(analysis)); 206 if (includeReason) { 207 System.out.print(" " + analysis.reason); 208 } 209 System.out.println(); 210 }); 211 } 212 213 private static Stream<Path> findSubmissionsIn(List<String> fileNames) { 214 return fileNames.stream() 215 .map(Path::of) 216 .filter(Files::exists) 217 .flatMap(FindUngradedSubmissions::findSubmissionsIn); 218 } 219 220 private static Stream<? extends Path> findSubmissionsIn(Path path) { 221 if (Files.isDirectory(path)) { 222 try { 223 // If we put the walk into a try-with-resources, the consumer of the stream will encounter and 224 // exception, because the stream will be closed immediately. 225 Stream<Path> walk = Files.walk(path, FileVisitOption.FOLLOW_LINKS); 226 return walk.filter(FindUngradedSubmissions::isZipFile); 227 228 } catch (IOException e) { 229 throw new RuntimeException("Error while walking through directory: " + path, e); 230 } 231 } else if (isZipFile(path)) { 232 return Stream.of(path); 233 } else { 234 return Stream.empty(); 235 } 236 } 237 238 private static boolean isZipFile(Path p) { 239 return Files.isRegularFile(p) && p.getFileName().toString().endsWith(".zip"); 240 } 241 242 interface SubmissionDetailsProvider { 243 SubmissionDetails getSubmissionDetails(Path submission); 244 } 245 246 interface TestOutputPathProvider { 247 Path getTestOutput(Path submissionDirectory, String studentId); 248 } 249 250 interface TestOutputDetailsProvider { 251 TestOutputDetails getTestOutputDetails(Path testOutput); 252 } 253 254 interface GradeBookProvider { 255 java.util.Optional<GradeBook> getGradeBook(); 256 } 257 258 @VisibleForTesting 259 record TestOutputDetails(Path testOutput, LocalDateTime testedSubmissionTime, boolean hasGrade, String projectName, Double grade, boolean hasBeenReviewed) { 260 } 261 262 @VisibleForTesting 263 record SubmissionAnalysis (Path submission, boolean needsToBeTested, boolean needsToBeGraded, String reason, Path testOutput, boolean gradeNeedsToBeRecorded) { 264 265 } 266 267 private static class SubmissionDetailsProviderFromZipFile implements SubmissionDetailsProvider { 268 @Override 269 public SubmissionDetails getSubmissionDetails(Path submission) { 270 try (InputStream zipFile = Files.newInputStream(submission)) { 271 Manifest manifest = ProjectSubmissionsProcessor.getManifestFromZipFile(zipFile); 272 return getSubmissionDetails(manifest); 273 274 } catch (IOException | StudentEmailAttachmentProcessor.SubmissionException e) { 275 throw new RuntimeException(e); 276 } 277 } 278 279 private SubmissionDetails getSubmissionDetails(Manifest manifest) throws StudentEmailAttachmentProcessor.SubmissionException { 280 Attributes attrs = manifest.getMainAttributes(); 281 String studentId = ProjectSubmissionsProcessor.getStudentIdFromManifestAttributes(attrs); 282 LocalDateTime submissionTime = ProjectSubmissionsProcessor.getSubmissionTime(attrs); 283 return new SubmissionDetails(studentId, submissionTime); 284 } 285 } 286 287 private static class TestOutputProviderInParentDirectory implements TestOutputPathProvider { 288 @Override 289 public Path getTestOutput(Path submissionDirectory, String studentId) { 290 return submissionDirectory.resolve(studentId + ".out"); 291 } 292 } 293 294 private static class GradeBookProviderFromXmlFile implements GradeBookProvider { 295 private final String xmlFilePath; 296 private GradeBook gradeBook; 297 298 GradeBookProviderFromXmlFile(String xmlFilePath) { 299 this.xmlFilePath = xmlFilePath; 300 } 301 302 @Override 303 public java.util.Optional<GradeBook> getGradeBook() { 304 if (gradeBook == null && xmlFilePath != null) { 305 try { 306 XmlGradeBookParser parser = new XmlGradeBookParser(xmlFilePath); 307 gradeBook = parser.parse(); 308 } catch (Exception e) { 309 String message = "Error loading gradebook from " + xmlFilePath + ": " + e.getMessage(); 310 System.err.println(message); 311 throw new RuntimeException(message, e); 312 } 313 } 314 return java.util.Optional.ofNullable(gradeBook); 315 } 316 } 317 318 @VisibleForTesting 319 static class TestOutputDetailsProviderFromTestOutputFile implements TestOutputDetailsProvider { 320 321 public static Double parseGrade(String line) { 322 if (line.contains("out of")) { 323 String[] parts = line.split("out of"); 324 if (parts.length == 2) { 325 try { 326 return Double.parseDouble(parts[0].trim()); 327 } catch (NumberFormatException e) { 328 return Double.NaN; 329 } 330 } 331 } 332 return null; 333 } 334 335 private static final Pattern PROJECT_NAME_PATTERN = Pattern.compile(".*The Joy of Coding Project (\\d+): .*"); 336 337 public static String parseProjectName(String line) { 338 if (line.contains("The Joy of Coding Project")) { 339 Matcher matcher = PROJECT_NAME_PATTERN.matcher(line); 340 if (matcher.matches()) { 341 return "Project" + matcher.group(1); 342 } 343 } 344 return null; 345 } 346 347 @Override 348 public TestOutputDetails getTestOutputDetails(Path testOutput) { 349 try { 350 return parseTestOutputDetails(testOutput, Files.lines(testOutput)); 351 } catch (IOException | TestedProjectSubmissionOutputParsingException e) { 352 throw new RuntimeException("While parsing " + testOutput, e); 353 } 354 } 355 356 static TestOutputDetails parseTestOutputDetails(Path testOutput, Stream<String> lines) throws TestedProjectSubmissionOutputParsingException { 357 ProjectScore projectScore = parseTestedSubmissionOutput(lines); 358 return new TestOutputDetails(testOutput, projectScore.getSubmissionTime(), !Double.isNaN(projectScore.getScore()), projectScore.getProjectName(), projectScore.getScore(), projectScore.isReviewed()); 359 } 360 361 } 362}