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