0d2245936a940c9c3faaec831b0ca4ef82d3329b
[idea/community.git] / python / educational-core / student / src / com / jetbrains / edu / learning / courseGeneration / StudyProjectGenerator.java
1 package com.jetbrains.edu.learning.courseGeneration;
2
3 import com.google.gson.*;
4 import com.google.gson.stream.JsonReader;
5 import com.intellij.facet.ui.ValidationResult;
6 import com.intellij.ide.projectView.ProjectView;
7 import com.intellij.openapi.application.ApplicationManager;
8 import com.intellij.openapi.application.PathManager;
9 import com.intellij.openapi.diagnostic.Logger;
10 import com.intellij.openapi.fileEditor.FileEditorManager;
11 import com.intellij.openapi.progress.ProgressManager;
12 import com.intellij.openapi.project.Project;
13 import com.intellij.openapi.ui.Messages;
14 import com.intellij.openapi.util.io.FileUtil;
15 import com.intellij.openapi.util.text.StringUtil;
16 import com.intellij.openapi.vfs.LocalFileSystem;
17 import com.intellij.openapi.vfs.VirtualFile;
18 import com.intellij.openapi.vfs.VirtualFileManager;
19 import com.intellij.openapi.vfs.newvfs.NewVirtualFile;
20 import com.intellij.openapi.vfs.newvfs.impl.VirtualDirectoryImpl;
21 import com.intellij.platform.templates.github.ZipUtil;
22 import com.intellij.psi.PsiFile;
23 import com.intellij.psi.PsiManager;
24 import com.intellij.util.containers.ContainerUtil;
25 import com.jetbrains.edu.learning.StudyProjectComponent;
26 import com.jetbrains.edu.learning.StudySerializationUtils;
27 import com.jetbrains.edu.learning.StudyTaskManager;
28 import com.jetbrains.edu.learning.StudyUtils;
29 import com.jetbrains.edu.learning.core.EduNames;
30 import com.jetbrains.edu.learning.core.EduUtils;
31 import com.jetbrains.edu.learning.courseFormat.Course;
32 import com.jetbrains.edu.learning.courseFormat.Lesson;
33 import com.jetbrains.edu.learning.courseFormat.Task;
34 import com.jetbrains.edu.learning.courseFormat.TaskFile;
35 import com.jetbrains.edu.learning.statistics.EduUsagesCollector;
36 import com.jetbrains.edu.learning.stepic.CourseInfo;
37 import com.jetbrains.edu.learning.stepic.EduStepicConnector;
38 import com.jetbrains.edu.learning.stepic.StepicUser;
39 import org.apache.commons.codec.binary.Base64;
40 import org.jetbrains.annotations.NotNull;
41 import org.jetbrains.annotations.Nullable;
42
43 import java.io.*;
44 import java.util.*;
45
46 import static com.jetbrains.edu.learning.StudyUtils.execCancelable;
47
48 public class StudyProjectGenerator {
49   public static final String AUTHOR_ATTRIBUTE = "authors";
50   public static final String LANGUAGE_ATTRIBUTE = "language";
51   public static final String ADAPTIVE_COURSE_PREFIX = "__AdaptivePyCharmPython__";
52   public static final File OUR_COURSES_DIR = new File(PathManager.getConfigPath(), "courses");
53   private static final Logger LOG = Logger.getInstance(StudyProjectGenerator.class.getName());
54   private static final String COURSE_NAME_ATTRIBUTE = "name";
55   private static final String COURSE_DESCRIPTION = "description";
56   private static final String CACHE_NAME = "courseNames.txt";
57   private final List<SettingsListener> myListeners = ContainerUtil.newArrayList();
58   @Nullable public StepicUser myUser;
59   private List<CourseInfo> myCourses = new ArrayList<>();
60   private List<Integer> myEnrolledCoursesIds = new ArrayList<>();
61   protected CourseInfo mySelectedCourseInfo;
62
63   public void setCourses(List<CourseInfo> courses) {
64     myCourses = courses;
65   }
66
67   public boolean isLoggedIn() {
68     return myUser != null && !StringUtil.isEmptyOrSpaces(myUser.getPassword()) && !StringUtil.isEmptyOrSpaces(myUser.getEmail());
69   }
70
71   public void setEnrolledCoursesIds(@NotNull final List<Integer> coursesIds) {
72     myEnrolledCoursesIds = coursesIds;
73   }
74
75   @NotNull
76   public List<Integer> getEnrolledCoursesIds() {
77     return myEnrolledCoursesIds;
78   }
79
80   public void setSelectedCourse(@NotNull final CourseInfo courseName) {
81     mySelectedCourseInfo = courseName;
82   }
83
84   public void generateProject(@NotNull final Project project, @NotNull final VirtualFile baseDir) {
85     if (myUser != null) {
86       StudyTaskManager.getInstance(project).setUser(myUser);
87     }
88     final Course course = getCourse(project);
89     if (course == null) {
90       LOG.warn("Course is null");
91       Messages.showWarningDialog("Some problems occurred while creating the course", "Error in Course Creation");
92       return;
93     }
94     final File courseDirectory = StudyUtils.getCourseDirectory(project, course);
95     StudyTaskManager.getInstance(project).setCourse(course);
96     ApplicationManager.getApplication().runWriteAction(() -> {
97       StudyGenerator.createCourse(course, baseDir, courseDirectory, project);
98       course.setCourseDirectory(courseDirectory.getAbsolutePath());
99       VirtualFileManager.getInstance().refreshWithoutFileWatcher(true);
100       StudyProjectComponent.getInstance(project).registerStudyToolWindow(course);
101       openFirstTask(course, project);
102       EduUsagesCollector.projectTypeCreated(course.isAdaptive() ? EduNames.ADAPTIVE : EduNames.STUDY);
103     });
104   }
105
106   @Nullable
107   public Course getCourse(@NotNull final Project project) {
108
109     final File courseFile = new File(new File(OUR_COURSES_DIR, mySelectedCourseInfo.getName()), EduNames.COURSE_META_FILE);
110     if (courseFile.exists()) {
111       final Course course = readCourseFromCache(courseFile, false);
112       if (course != null && course.isUpToDate()) {
113         return course;
114       }
115       return getCourseFromStepic(project);
116     }
117     else if (myUser != null) {
118       final File adaptiveCourseFile = new File(new File(OUR_COURSES_DIR, ADAPTIVE_COURSE_PREFIX +
119                                                                          mySelectedCourseInfo.getName() + "_" +
120                                                                          myUser.getEmail()), EduNames.COURSE_META_FILE);
121       if (adaptiveCourseFile.exists()) {
122         return readCourseFromCache(adaptiveCourseFile, true);
123       }
124     }
125     return getCourseFromStepic(project);
126   }
127
128   private Course getCourseFromStepic(@NotNull Project project) {
129     return ProgressManager.getInstance().runProcessWithProgressSynchronously(() -> {
130       ProgressManager.getInstance().getProgressIndicator().setIndeterminate(true);
131       return execCancelable(() -> {
132         final Course course = EduStepicConnector.getCourse(project, mySelectedCourseInfo);
133         if (course != null) {
134           flushCourse(project, course);
135           course.initCourse(false);
136         }
137         return course;
138       });
139     }, "Creating Course", true, project);
140   }
141
142   @Nullable
143   private static Course readCourseFromCache(@NotNull File courseFile, boolean isAdaptive) {
144     Reader reader = null;
145     try {
146       reader = new InputStreamReader(new FileInputStream(courseFile), "UTF-8");
147       Gson gson = new GsonBuilder().registerTypeAdapter(Course.class, new StudySerializationUtils.Json.CourseTypeAdapter(courseFile)).create();
148       final Course course = gson.fromJson(reader, Course.class);
149       course.initCourse(isAdaptive);
150       return course;
151     }
152     catch (UnsupportedEncodingException e) {
153       LOG.warn(e.getMessage());
154     }
155     catch (FileNotFoundException e) {
156       LOG.warn(e.getMessage());
157     }
158     finally {
159       StudyUtils.closeSilently(reader);
160     }
161     return null;
162   }
163
164   public static void openFirstTask(@NotNull final Course course, @NotNull final Project project) {
165     LocalFileSystem.getInstance().refresh(false);
166     final Lesson firstLesson = StudyUtils.getFirst(course.getLessons());
167     if (firstLesson == null) return;
168     final Task firstTask = StudyUtils.getFirst(firstLesson.getTaskList());
169     if (firstTask == null) return;
170     final VirtualFile taskDir = firstTask.getTaskDir(project);
171     if (taskDir == null) return;
172     final Map<String, TaskFile> taskFiles = firstTask.getTaskFiles();
173     VirtualFile activeVirtualFile = null;
174     for (Map.Entry<String, TaskFile> entry : taskFiles.entrySet()) {
175       final String name = entry.getKey();
176       final TaskFile taskFile = entry.getValue();
177       final VirtualFile virtualFile = ((VirtualDirectoryImpl)taskDir).refreshAndFindChild(name);
178       if (virtualFile != null) {
179         FileEditorManager.getInstance(project).openFile(virtualFile, true);
180         if (!taskFile.getAnswerPlaceholders().isEmpty()) {
181           activeVirtualFile = virtualFile;
182         }
183       }
184     }
185     if (activeVirtualFile != null) {
186       final PsiFile file = PsiManager.getInstance(project).findFile(activeVirtualFile);
187       ProjectView.getInstance(project).select(file, activeVirtualFile, true);
188       FileEditorManager.getInstance(project).openFile(activeVirtualFile, true);
189     }
190     else {
191       String first = StudyUtils.getFirst(taskFiles.keySet());
192       if (first != null) {
193         NewVirtualFile firstFile = ((VirtualDirectoryImpl)taskDir).refreshAndFindChild(first);
194         if (firstFile != null) {
195           FileEditorManager.getInstance(project).openFile(firstFile, true);
196         }
197       }
198     }
199   }
200
201   public static void flushCourse(@NotNull final Project project, @NotNull final Course course) {
202     final File courseDirectory = StudyUtils.getCourseDirectory(project, course);
203     FileUtil.createDirectory(courseDirectory);
204     flushCourseJson(course, courseDirectory);
205
206     int lessonIndex = 1;
207     for (Lesson lesson : course.getLessons()) {
208       if (lesson.getName().equals(EduNames.PYCHARM_ADDITIONAL)) {
209         flushAdditionalFiles(courseDirectory, lesson);
210       }
211       else {
212         final File lessonDirectory = new File(courseDirectory, EduNames.LESSON + String.valueOf(lessonIndex));
213         flushLesson(lessonDirectory, lesson);
214         lessonIndex += 1;
215       }
216     }
217   }
218
219   private static void flushAdditionalFiles(File courseDirectory, Lesson lesson) {
220     final List<Task> taskList = lesson.getTaskList();
221     if (taskList.size() != 1) return;
222     final Task task = taskList.get(0);
223     for (Map.Entry<String, String> entry : task.getTestsText().entrySet()) {
224       final String name = entry.getKey();
225       final String text = entry.getValue();
226       final File file = new File(courseDirectory, name);
227       FileUtil.createIfDoesntExist(file);
228       try {
229         if (EduUtils.isImage(name)) {
230           FileUtil.writeToFile(file, Base64.decodeBase64(text));
231         }
232         else {
233           FileUtil.writeToFile(file, text);
234         }
235       }
236       catch (IOException e) {
237         LOG.error("ERROR copying file " + name);
238       }
239     }
240   }
241
242   public static void flushLesson(@NotNull final File lessonDirectory, @NotNull final Lesson lesson) {
243     FileUtil.createDirectory(lessonDirectory);
244     int taskIndex = 1;
245     for (Task task : lesson.taskList) {
246       final File taskDirectory = new File(lessonDirectory, EduNames.TASK + String.valueOf(taskIndex));
247       flushTask(task, taskDirectory);
248       taskIndex += 1;
249     }
250   }
251
252   public static void flushTask(@NotNull final Task task, @NotNull final File taskDirectory) {
253     FileUtil.createDirectory(taskDirectory);
254     for (Map.Entry<String, TaskFile> taskFileEntry : task.taskFiles.entrySet()) {
255       final String name = taskFileEntry.getKey();
256       final TaskFile taskFile = taskFileEntry.getValue();
257       final File file = new File(taskDirectory, name);
258       FileUtil.createIfDoesntExist(file);
259
260       try {
261         if (EduUtils.isImage(taskFile.name)) {
262           FileUtil.writeToFile(file, Base64.decodeBase64(taskFile.text));
263         }
264         else {
265           FileUtil.writeToFile(file, taskFile.text);
266         }
267       }
268       catch (IOException e) {
269         LOG.error("ERROR copying file " + name);
270       }
271     }
272     final Map<String, String> testsText = task.getTestsText();
273     for (Map.Entry<String, String> entry : testsText.entrySet()) {
274       final File testsFile = new File(taskDirectory, entry.getKey());
275       if (testsFile.exists()) {
276         FileUtil.delete(testsFile);
277       }
278       FileUtil.createIfDoesntExist(testsFile);
279       try {
280         FileUtil.writeToFile(testsFile, entry.getValue());
281       }
282       catch (IOException e) {
283         LOG.error("ERROR copying tests file");
284       }
285     }
286     final File taskText = new File(taskDirectory, "task.html");
287     FileUtil.createIfDoesntExist(taskText);
288     try {
289       FileUtil.writeToFile(taskText, task.getText());
290     }
291     catch (IOException e) {
292       LOG.error("ERROR copying tests file");
293     }
294   }
295
296   public static void flushCourseJson(@NotNull final Course course, @NotNull final File courseDirectory) {
297     final Gson gson = new GsonBuilder().setPrettyPrinting().
298       excludeFieldsWithoutExposeAnnotation().create();
299     final String json = gson.toJson(course);
300     final File courseJson = new File(courseDirectory, EduNames.COURSE_META_FILE);
301     final FileOutputStream fileOutputStream;
302     try {
303       fileOutputStream = new FileOutputStream(courseJson);
304       OutputStreamWriter outputStreamWriter = new OutputStreamWriter(fileOutputStream, "UTF-8");
305       try {
306         outputStreamWriter.write(json);
307       }
308       catch (IOException e) {
309         Messages.showErrorDialog(e.getMessage(), "Failed to Generate Json");
310         LOG.info(e);
311       }
312       finally {
313         try {
314           outputStreamWriter.close();
315         }
316         catch (IOException e) {
317           LOG.info(e);
318         }
319       }
320     }
321     catch (FileNotFoundException | UnsupportedEncodingException e) {
322       LOG.info(e);
323     }
324   }
325
326   /**
327    * Writes courses to cache file {@link StudyProjectGenerator#CACHE_NAME}
328    */
329   @SuppressWarnings("IOResourceOpenedButNotSafelyClosed")
330   public static void flushCache(List<CourseInfo> courses) {
331     File cacheFile = new File(OUR_COURSES_DIR, CACHE_NAME);
332     PrintWriter writer = null;
333     try {
334       if (!createCacheFile(cacheFile)) return;
335       Gson gson = new GsonBuilder().setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES).create();
336
337       final Set<CourseInfo> courseInfos = new HashSet<>();
338       courseInfos.addAll(courses);
339       courseInfos.addAll(getCoursesFromCache());
340
341       writer = new PrintWriter(cacheFile);
342       for (CourseInfo courseInfo : courseInfos) {
343         final String json = gson.toJson(courseInfo);
344         writer.println(json);
345       }
346     }
347     catch (IOException e) {
348       LOG.error(e);
349     }
350     finally {
351       StudyUtils.closeSilently(writer);
352     }
353   }
354
355   private static boolean createCacheFile(File cacheFile) throws IOException {
356     if (!OUR_COURSES_DIR.exists()) {
357       final boolean created = OUR_COURSES_DIR.mkdirs();
358       if (!created) {
359         LOG.error("Cannot flush courses cache. Can't create courses directory");
360         return false;
361       }
362     }
363     if (!cacheFile.exists()) {
364       final boolean created = cacheFile.createNewFile();
365       if (!created) {
366         LOG.error("Cannot flush courses cache. Can't create " + CACHE_NAME + " file");
367         return false;
368       }
369     }
370     return true;
371   }
372
373   // Supposed to be called under progress
374   public List<CourseInfo> getCourses(boolean force) {
375     if (OUR_COURSES_DIR.exists()) {
376       myCourses = getCoursesFromCache();
377     }
378     if (force || myCourses.isEmpty()) {
379       myCourses = execCancelable(EduStepicConnector::getCourses);
380       flushCache(myCourses);
381     }
382     if (myCourses.isEmpty()) {
383       myCourses = getBundledIntro();
384     }
385     return myCourses;
386   }
387
388  @NotNull
389   public List<CourseInfo> getCoursesUnderProgress(boolean force, @NotNull final String progressTitle, @NotNull final Project project) {
390     try {
391       return ProgressManager.getInstance()
392         .runProcessWithProgressSynchronously(() -> {
393           ProgressManager.getInstance().getProgressIndicator().setIndeterminate(true);
394           return getCourses(force);
395         }, progressTitle, true, project);
396     }
397     catch (RuntimeException e) {
398       return Collections.singletonList(CourseInfo.INVALID_COURSE);
399     }
400   }
401
402   public void addSettingsStateListener(@NotNull SettingsListener listener) {
403     myListeners.add(listener);
404   }
405
406   public interface SettingsListener {
407     void stateChanged(ValidationResult result);
408   }
409
410   public void fireStateChanged(ValidationResult result) {
411     for (SettingsListener listener : myListeners) {
412       listener.stateChanged(result);
413     }
414   }
415
416   public static List<CourseInfo> getBundledIntro() {
417     final File introCourse = new File(OUR_COURSES_DIR, "Introduction to Python");
418     if (introCourse.exists()) {
419       final CourseInfo courseInfo = getCourseInfo(introCourse);
420
421       return Collections.singletonList(courseInfo);
422     }
423     return Collections.emptyList();
424   }
425
426   public static List<CourseInfo> getCoursesFromCache() {
427     List<CourseInfo> courses = new ArrayList<>();
428     final File cacheFile = new File(OUR_COURSES_DIR, CACHE_NAME);
429     if (!cacheFile.exists()) {
430       return courses;
431     }
432     try {
433       final FileInputStream inputStream = new FileInputStream(cacheFile);
434       try {
435         BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, "UTF-8"));
436         try {
437           String line;
438           while ((line = reader.readLine()) != null) {
439             Gson gson = new GsonBuilder().setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES).create();
440             final CourseInfo courseInfo = gson.fromJson(line, CourseInfo.class);
441             courses.add(courseInfo);
442           }
443         }
444         catch (IOException | JsonSyntaxException e) {
445           LOG.error(e.getMessage());
446         }
447         finally {
448           StudyUtils.closeSilently(reader);
449         }
450       }
451       catch (UnsupportedEncodingException e) {
452         LOG.error(e.getMessage());
453       }
454       finally {
455         StudyUtils.closeSilently(inputStream);
456       }
457     }
458     catch (FileNotFoundException e) {
459       LOG.error(e.getMessage());
460     }
461     return courses;
462   }
463
464   /**
465    * Adds course from zip archive to courses
466    *
467    * @return added course name or null if course is invalid
468    */
469   @Nullable
470   public CourseInfo addLocalCourse(String zipFilePath) {
471     File file = new File(zipFilePath);
472     try {
473       String fileName = file.getName();
474       String unzippedName = fileName.substring(0, fileName.indexOf("."));
475       File courseDir = new File(OUR_COURSES_DIR, unzippedName);
476       ZipUtil.unzip(null, courseDir, file, null, null, true);
477       CourseInfo courseName = addCourse(myCourses, courseDir);
478       flushCache(myCourses);
479       if (courseName != null && !courseName.getName().equals(unzippedName)) {
480         //noinspection ResultOfMethodCallIgnored
481         courseDir.renameTo(new File(OUR_COURSES_DIR, courseName.getName()));
482         //noinspection ResultOfMethodCallIgnored
483         courseDir.delete();
484       }
485       return courseName;
486     }
487     catch (IOException e) {
488       LOG.error("Failed to unzip course archive");
489       LOG.error(e);
490     }
491     return null;
492   }
493
494   /**
495    * Adds course to courses specified in params
496    *
497    * @param courses
498    * @param courseDir must be directory containing course file
499    * @return added course name or null if course is invalid
500    */
501   @Nullable
502   private static CourseInfo addCourse(List<CourseInfo> courses, File courseDir) {
503     if (courseDir.isDirectory()) {
504       File[] courseFiles = courseDir.listFiles((dir, name) -> name.equals(EduNames.COURSE_META_FILE));
505       if (courseFiles == null || courseFiles.length != 1) {
506         LOG.info("User tried to add course with more than one or without course files");
507         return null;
508       }
509       File courseFile = courseFiles[0];
510       CourseInfo courseInfo = getCourseInfo(courseFile);
511       if (courseInfo != null) {
512         courses.add(courseInfo);
513       }
514       return courseInfo;
515     }
516     return null;
517   }
518
519   /**
520    * Parses course json meta file and finds course name
521    *
522    * @return information about course or null if course file is invalid
523    */
524   @Nullable
525   private static CourseInfo getCourseInfo(File courseFile) {
526     if (courseFile.isDirectory()) {
527       File[] courseFiles = courseFile.listFiles((dir, name) -> name.equals(EduNames.COURSE_META_FILE));
528       if (courseFiles == null || courseFiles.length != 1) {
529         LOG.info("More than one or without course files");
530         return null;
531       }
532       courseFile = courseFiles[0];
533     }
534     CourseInfo courseInfo = null;
535     BufferedReader reader = null;
536     try {
537       if (courseFile.getName().equals(EduNames.COURSE_META_FILE)) {
538         reader = new BufferedReader(new InputStreamReader(new FileInputStream(courseFile), "UTF-8"));
539         JsonReader r = new JsonReader(reader);
540         JsonParser parser = new JsonParser();
541         JsonElement el = parser.parse(r);
542         String courseName = el.getAsJsonObject().get(COURSE_NAME_ATTRIBUTE).getAsString();
543         String courseDescription = el.getAsJsonObject().get(COURSE_DESCRIPTION).getAsString();
544         JsonArray courseAuthors = el.getAsJsonObject().get(AUTHOR_ATTRIBUTE).getAsJsonArray();
545         String language = el.getAsJsonObject().get(LANGUAGE_ATTRIBUTE).getAsString();
546         courseInfo = new CourseInfo();
547         courseInfo.setName(courseName);
548         courseInfo.setDescription(courseDescription);
549         courseInfo.setType("pycharm " + language);
550         final ArrayList<StepicUser> authors = new ArrayList<>();
551         for (JsonElement author : courseAuthors) {
552           final JsonObject authorAsJsonObject = author.getAsJsonObject();
553           final StepicUser stepicUser = new StepicUser();
554           stepicUser.setFirstName(authorAsJsonObject.get("first_name").getAsString());
555           stepicUser.setLastName(authorAsJsonObject.get("last_name").getAsString());
556           authors.add(stepicUser);
557         }
558         courseInfo.setAuthors(authors);
559       }
560     }
561     catch (Exception e) {
562       //error will be shown in UI
563     }
564     finally {
565       StudyUtils.closeSilently(reader);
566     }
567     return courseInfo;
568   }
569 }