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}