001package edu.pdx.cs.joy.grader; 002 003import com.google.common.annotations.VisibleForTesting; 004 005import java.io.IOException; 006import java.io.InputStream; 007import java.nio.file.FileVisitOption; 008import java.nio.file.Files; 009import java.nio.file.Path; 010import java.time.LocalDateTime; 011import java.time.ZonedDateTime; 012import java.time.format.DateTimeFormatter; 013import java.time.format.DateTimeParseException; 014import java.util.ArrayList; 015import java.util.List; 016import java.util.function.Consumer; 017import java.util.jar.Attributes; 018import java.util.jar.Manifest; 019import java.util.regex.Matcher; 020import java.util.regex.Pattern; 021import java.util.stream.Stream; 022 023public class FindUngradedSubmissions { 024 private final SubmissionDetailsProvider submissionDetailsProvider; 025 private final TestOutputPathProvider testOutputProvider; 026 private final TestOutputDetailsProvider testOutputDetailsProvider; 027 028 @VisibleForTesting 029 FindUngradedSubmissions(SubmissionDetailsProvider submissionDetailsProvider, TestOutputPathProvider testOutputProvider, TestOutputDetailsProvider testOutputDetailsProvider) { 030 this.submissionDetailsProvider = submissionDetailsProvider; 031 this.testOutputProvider = testOutputProvider; 032 this.testOutputDetailsProvider = testOutputDetailsProvider; 033 } 034 035 public FindUngradedSubmissions() { 036 this(new SubmissionDetailsProviderFromZipFile(), new TestOutputProviderInParentDirectory(), new TestOutputDetailsProviderFromTestOutputFile()); 037 } 038 039 @VisibleForTesting 040 SubmissionAnalysis analyzeSubmission(Path submissionPath) { 041 SubmissionDetails submission = this.submissionDetailsProvider.getSubmissionDetails(submissionPath); 042 Path submissionDirectory = submissionPath.getParent(); 043 Path testOutputPath = this.testOutputProvider.getTestOutput(submissionDirectory, submission.studentId()); 044 boolean needsToBeTested; 045 boolean needsToBeGraded; 046 String reason; 047 048 if (!Files.exists(testOutputPath)) { 049 needsToBeTested = true; 050 needsToBeGraded = true; 051 reason = "Test output file does not exist: " + testOutputPath; 052 053 } else { 054 055 TestOutputDetails testOutput = this.testOutputDetailsProvider.getTestOutputDetails(testOutputPath); 056 if (submittedAfterTesting(submission, testOutput)) { 057 needsToBeTested = true; 058 needsToBeGraded = true; 059 reason = "Submission on " + submission.submissionTime() + " is after testing on " + testOutput.testedSubmissionTime(); 060 061 } else if (!testOutput.hasGrade()) { 062 needsToBeTested = false; 063 needsToBeGraded = true; 064 reason = "Test output file does not have a grade: " + testOutputPath; 065 066 } else { 067 needsToBeTested = false; 068 needsToBeGraded = false; 069 reason = "Test output file was graded after submission: " + testOutputPath; 070 } 071 } 072 073 return new SubmissionAnalysis(submissionPath, needsToBeTested, needsToBeGraded, reason); 074 } 075 076 private static boolean submittedAfterTesting(SubmissionDetails submission, TestOutputDetails testOutput) { 077 LocalDateTime submissionTime = submission.submissionTime(); 078 LocalDateTime testedSubmissionTime = testOutput.testedSubmissionTime(); 079 return submissionTime.isAfter(testedSubmissionTime.plusMinutes(1L)); 080 } 081 082 @VisibleForTesting 083 record SubmissionDetails(String studentId, LocalDateTime submissionTime) { 084 085 } 086 087 public static void main(String[] args) { 088 if (args.length == 0) { 089 System.err.println("Usage: java FindUngradedSubmissions -includeReason submissionZipOrDirectory+"); 090 System.exit(1); 091 } 092 093 boolean includeReason = false; 094 List<String> fileNames = new ArrayList<>(); 095 096 for (String arg : args) { 097 if (arg.equals("-includeReason")) { 098 includeReason = true; 099 100 } else if (arg.startsWith("-")) { 101 System.err.println("Unknown option: " + arg); 102 System.exit(1); 103 104 } else { 105 fileNames.add(arg); 106 } 107 } 108 109 Stream<Path> submissions = findSubmissionsIn(fileNames); 110 FindUngradedSubmissions finder = new FindUngradedSubmissions(); 111 Stream<SubmissionAnalysis> analyses = submissions.map(finder::analyzeSubmission); 112 List<SubmissionAnalysis> needsToBeTested = new ArrayList<>(); 113 List<SubmissionAnalysis> needsToBeGraded = new ArrayList<>(); 114 analyses.forEach(analysis -> { 115 if (analysis.needsToBeTested()) { 116 needsToBeTested.add(analysis); 117 118 } else if (analysis.needsToBeGraded()) { 119 needsToBeGraded.add(analysis); 120 } 121 }); 122 123 printOutAnalyses(needsToBeTested, "tested", includeReason); 124 printOutAnalyses(needsToBeGraded, "graded", includeReason); 125 } 126 127 private static void printOutAnalyses(List<SubmissionAnalysis> analyses, String action, boolean includeReason) { 128 int size = analyses.size(); 129 String description = (size == 1 ? " submission needs" : " submissions need"); 130 System.out.println(size + description + " to be " + action + ": "); 131 analyses.forEach(analysis -> { 132 System.out.print(" " + analysis.submission); 133 if (includeReason) { 134 System.out.print(" " + analysis.reason); 135 } 136 System.out.println(); 137 }); 138 } 139 140 private static Stream<Path> findSubmissionsIn(List<String> fileNames) { 141 return fileNames.stream() 142 .map(Path::of) 143 .filter(Files::exists) 144 .flatMap(FindUngradedSubmissions::findSubmissionsIn); 145 } 146 147 private static Stream<? extends Path> findSubmissionsIn(Path path) { 148 if (Files.isDirectory(path)) { 149 try { 150 // If we put the walk into a try-with-resources, the consumer of the stream will encounter and 151 // exception, because the stream will be closed immediately. 152 Stream<Path> walk = Files.walk(path, FileVisitOption.FOLLOW_LINKS); 153 return walk.filter(FindUngradedSubmissions::isZipFile); 154 155 } catch (IOException e) { 156 throw new RuntimeException("Error while walking through directory: " + path, e); 157 } 158 } else if (isZipFile(path)) { 159 return Stream.of(path); 160 } else { 161 return Stream.empty(); 162 } 163 } 164 165 private static boolean isZipFile(Path p) { 166 return Files.isRegularFile(p) && p.getFileName().toString().endsWith(".zip"); 167 } 168 169 interface SubmissionDetailsProvider { 170 SubmissionDetails getSubmissionDetails(Path submission); 171 } 172 173 interface TestOutputPathProvider { 174 Path getTestOutput(Path submissionDirectory, String studentId); 175 } 176 177 interface TestOutputDetailsProvider { 178 TestOutputDetails getTestOutputDetails(Path testOutput); 179 } 180 181 @VisibleForTesting 182 record TestOutputDetails(LocalDateTime testedSubmissionTime, boolean hasGrade) { 183 } 184 185 @VisibleForTesting 186 record SubmissionAnalysis (Path submission, boolean needsToBeTested, boolean needsToBeGraded, String reason) { 187 188 } 189 190 private static class SubmissionDetailsProviderFromZipFile implements SubmissionDetailsProvider { 191 @Override 192 public SubmissionDetails getSubmissionDetails(Path submission) { 193 try (InputStream zipFile = Files.newInputStream(submission)) { 194 Manifest manifest = ProjectSubmissionsProcessor.getManifestFromZipFile(zipFile); 195 return getSubmissionDetails(manifest); 196 197 } catch (IOException | StudentEmailAttachmentProcessor.SubmissionException e) { 198 throw new RuntimeException(e); 199 } 200 } 201 202 private SubmissionDetails getSubmissionDetails(Manifest manifest) throws StudentEmailAttachmentProcessor.SubmissionException { 203 Attributes attrs = manifest.getMainAttributes(); 204 String studentId = ProjectSubmissionsProcessor.getStudentIdFromManifestAttributes(attrs); 205 LocalDateTime submissionTime = ProjectSubmissionsProcessor.getSubmissionTime(attrs); 206 return new SubmissionDetails(studentId, submissionTime); 207 } 208 } 209 210 private static class TestOutputProviderInParentDirectory implements TestOutputPathProvider { 211 @Override 212 public Path getTestOutput(Path submissionDirectory, String studentId) { 213 return submissionDirectory.resolve(studentId + ".out"); 214 } 215 } 216 217 @VisibleForTesting 218 static class TestOutputDetailsProviderFromTestOutputFile implements TestOutputDetailsProvider { 219 private static final Pattern SUBMISSION_TIME_PATTERN = Pattern.compile(".*Submitted on (.+)"); 220 221 public static LocalDateTime parseSubmissionTime(String line) { 222 if (line.contains("Submitted on")) { 223 Matcher matcher = TestOutputDetailsProviderFromTestOutputFile.SUBMISSION_TIME_PATTERN.matcher(line); 224 if (matcher.matches()) { 225 String timeString = matcher.group(1).trim(); 226 return parseTime(timeString); 227 } else { 228 throw new IllegalArgumentException("Could not parse submission time from line: " + line); 229 } 230 } 231 232 return null; 233 } 234 235 private static LocalDateTime parseTime(String timeString) { 236 try { 237 ZonedDateTime zoned; 238 try { 239 DateTimeFormatter formatter = DateTimeFormatter.ofPattern("E MMM d hh:mm:ss a z yyyy"); 240 zoned = ZonedDateTime.parse(timeString, formatter); 241 242 } catch (DateTimeParseException ex) { 243 // Single-digit day format 244 DateTimeFormatter formatter = DateTimeFormatter.ofPattern("E MMM d hh:mm:ss a z yyyy"); 245 zoned = ZonedDateTime.parse(timeString, formatter); 246 } 247 return zoned.toLocalDateTime(); 248 249 } catch (DateTimeParseException ex) { 250 return LocalDateTime.parse(timeString); 251 } 252 } 253 254 public static Double parseGrade(String line) { 255 if (line.contains("out of")) { 256 String[] parts = line.split("out of"); 257 if (parts.length == 2) { 258 try { 259 return Double.parseDouble(parts[0].trim()); 260 } catch (NumberFormatException e) { 261 return Double.NaN; 262 } 263 } 264 } 265 return null; 266 } 267 268 @Override 269 public TestOutputDetails getTestOutputDetails(Path testOutput) { 270 try { 271 return parseTestOutputDetails(Files.lines(testOutput)); 272 } catch (IOException e) { 273 throw new RuntimeException(e); 274 } 275 } 276 277 static TestOutputDetails parseTestOutputDetails(Stream<String> lines) { 278 TestOutputDetailsCreator creator = new TestOutputDetailsCreator(); 279 lines.forEach(creator); 280 return creator.createTestOutputDetails(); 281 } 282 283 private static class TestOutputDetailsCreator implements Consumer<String> { 284 private LocalDateTime testedSubmissionTime; 285 private Boolean hasGrade; 286 private int lineCount; 287 288 @Override 289 public void accept(String line) { 290 this.lineCount++; 291 292 LocalDateTime submissionTime = parseSubmissionTime(line); 293 if (submissionTime != null) { 294 this.testedSubmissionTime = submissionTime; 295 } 296 297 Double grade = parseGrade(line); 298 if (grade != null) { 299 boolean testOutputHasNotesForStudent = lineCount > 7; 300 this.hasGrade = testOutputHasNotesForStudent || !grade.isNaN(); 301 } 302 } 303 304 public TestOutputDetails createTestOutputDetails() { 305 if (this.testedSubmissionTime == null) { 306 throw new IllegalStateException("Tested submission time was not set"); 307 308 } else if( this.hasGrade == null) { 309 throw new IllegalStateException("Has grade was not set"); 310 } 311 312 return new TestOutputDetails(this.testedSubmissionTime, hasGrade); 313 } 314 } 315 } 316}