Skip to content

Commit 85f46cf

Browse files
Adds basic exercise progress tracking functionality for teachers
The Angular web application adds a new component hierarchy that allows teachers to access the details of their courses and view the exercises that comprise them. For each exercise, they can also view a screen with real-time synchronized student progress statistics and download the files of their proposals, as well as the original exercise template and its proposed solution (if any) on demand, incorporating it in the directory of their choice in their local file system. To achieve this, the web application introduces some technical innovations, such as the refactoring of some components for the reuse of functionality or the generation of a Web Socket handler. In addition, there have been introduced some other fixes: - The way in which the paths of the modified files are saved in each proposed resolution has been modified, and it is not backwards-compatible. - The implementation of the server to interpret encrypted authorization tokens has been completed.
1 parent e23ee8a commit 85f46cf

File tree

84 files changed

+4329
-1946
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

84 files changed

+4329
-1946
lines changed

vscode4teaching-extension/src/components/courses/CoursesTreeProvider.ts

+2-5
Original file line numberDiff line numberDiff line change
@@ -375,11 +375,8 @@ export class CoursesProvider implements vscode.TreeDataProvider<V4TItem> {
375375
// Check if provided path corresponds to an existing directory
376376
if (fs.lstatSync(exerciseRoute.fsPath).isDirectory()) {
377377
// Read directory contents and check if it contains "template" and "solution" folders
378-
const directoryEntries = fs.readdirSync(exerciseRoute.fsPath, { withFileTypes: true });
379-
if (directoryEntries.length === 2
380-
&& directoryEntries.every(dirent => dirent.isDirectory())
381-
&& directoryEntries.flatMap(dirent => dirent.name).every(name => name === "template" || name === "solution")
382-
) {
378+
const directoryEntries = fs.readdirSync(exerciseRoute.fsPath, { withFileTypes: true }).filter(dirent => dirent.isDirectory());
379+
if (directoryEntries.flatMap(dirent => dirent.name).every(name => name === "template" || name === "solution")) {
383380
const templateDir = path.join(exerciseRoute.fsPath, "template");
384381
const solutionDir = path.join(exerciseRoute.fsPath, "solution");
385382
// If these directories both contain any file, exercise is saved with its solution

vscode4teaching-extension/src/components/dashboard/DashboardWebview.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -304,20 +304,20 @@ export class DashboardWebview {
304304
}
305305

306306

307-
private async findLastModifiedFile(folder: vscode.WorkspaceFolder, fileRoute: string): Promise<{ uri: vscode.Uri; relativePath: string }> {
307+
private async findLastModifiedFile(folder: vscode.WorkspaceFolder, fileRoute: string): Promise<{ uri: vscode.Uri; relativePath: string } | undefined> {
308308
if (fileRoute === "null") {
309309
return this.findMainFile(folder);
310310
}
311311

312-
const fileRegex = /^\/[^\/]+\/[^\/]+\/[^\/]+\/(.+)$/;
312+
const fileRegex = /^\/(.+)$/;
313313
const regexResults = fileRegex.exec(fileRoute);
314314
if (regexResults && regexResults.length > 1) {
315315
const match: vscode.Uri[] = await vscode.workspace.findFiles(new vscode.RelativePattern(folder, regexResults[1]));
316316
if (match.length === 1) {
317317
return { uri: match[0], relativePath: regexResults[1] };
318318
}
319319
}
320-
return this.findMainFile(folder);
320+
return undefined;
321321
}
322322

323323
private async findMainFile(folder: vscode.WorkspaceFolder): Promise<{ uri: vscode.Uri; relativePath: string }> {
@@ -682,7 +682,7 @@ export class DashboardWebview {
682682
if (eui.modifiedFiles && eui.modifiedFiles.length > 0) {
683683
for (const fileName of eui.modifiedFiles) {
684684
const lastFile = await this.findLastModifiedFile(wsF, fileName);
685-
if (lastFile.uri) {
685+
if (lastFile !== undefined && lastFile.uri) {
686686
relativePaths.push(lastFile.relativePath);
687687
uris.push(lastFile.uri);
688688
}

vscode4teaching-extension/src/extension.ts

+9-6
Original file line numberDiff line numberDiff line change
@@ -344,12 +344,15 @@ export function activate(context: vscode.ExtensionContext) {
344344
}
345345
};
346346

347-
const showExerciseDashboard = vscode.commands.registerCommand("vscode4teaching.showexercisedashboard", (item: V4TItem) => {
347+
const showExerciseDashboard = vscode.commands.registerCommand("vscode4teaching.showexercisedashboard", async (item: V4TItem) => {
348348
if (item.item && instanceOfExercise(item.item) && item.item.course) {
349-
showDashboardFunction(item.item, item.item.course, false);
350-
} else {
351-
vscode.window.showErrorMessage("Not performabble action. Please try downloading exercise and accessing Dashboard.");
349+
const exercise = (await APIClient.getExercise(item.item.id)).data;
350+
if (exercise.course) {
351+
showDashboardFunction(exercise, exercise.course, false);
352+
return;
353+
}
352354
}
355+
vscode.window.showErrorMessage("Not performable action. Please try downloading exercise and accessing Dashboard.");
353356
});
354357

355358
const showCurrentExerciseDashboard = vscode.commands.registerCommand("vscode4teaching.showcurrentexercisedashboard", () => {
@@ -667,7 +670,7 @@ function setStudentEvents(jszipFile: JSZip, cwd: vscode.WorkspaceFolder, zipUri:
667670
const pattern = new vscode.RelativePattern(cwd, "**/*");
668671
const fsw = vscode.workspace.createFileSystemWatcher(pattern);
669672
changeEvent = fsw.onDidChange((e: vscode.Uri) => {
670-
EUIUpdateService.addModifiedPath(e);
673+
EUIUpdateService.addModifiedPath(e, cwd.uri);
671674
if (uploadTimeout) {
672675
global.clearTimeout(uploadTimeout);
673676
}
@@ -679,7 +682,7 @@ function setStudentEvents(jszipFile: JSZip, cwd: vscode.WorkspaceFolder, zipUri:
679682
}, 500);
680683
});
681684
createEvent = fsw.onDidCreate((e: vscode.Uri) => {
682-
EUIUpdateService.addModifiedPath(e);
685+
EUIUpdateService.addModifiedPath(e, cwd.uri);
683686
if (uploadTimeout) {
684687
global.clearTimeout(uploadTimeout);
685688
}
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,15 @@
11
import escapeRegExp from "lodash.escaperegexp";
2+
import * as fs from "fs";
23
import * as vscode from "vscode";
34
import { APIClient } from "../client/APIClient";
45
import { FileZipUtil } from "../utils/FileZipUtil";
56

67
export class EUIUpdateService {
78

8-
public static addModifiedPath(uri: vscode.Uri) {
9-
const matches = (this.URI_REGEX.exec(uri.path));
10-
if (!matches) {
11-
return null;
9+
public static addModifiedPath(uri: vscode.Uri, cwdUri: vscode.Uri) {
10+
if(uri.path.startsWith(cwdUri.path) && fs.statSync(uri.fsPath).isFile()) {
11+
this.modifiedPaths.add(uri.path.slice(cwdUri.path.length));
1212
}
13-
matches.shift();
14-
this.modifiedPaths.add(matches[0]);
1513
}
1614

1715
public static async updateExercise(exerciseId: number) {
@@ -21,9 +19,5 @@ export class EUIUpdateService {
2119
this.modifiedPaths.clear();
2220
}
2321

24-
// Regex to extract the path for the file.
25-
// Group is the path with username, course and exercise included.
26-
private static readonly URI_REGEX: RegExp = new RegExp(escapeRegExp(vscode.Uri.file(FileZipUtil.downloadDir).path) + "(\/[^\/]+\/[^\/]+\/[^\/]+\/.+)$", "i");
2722
private static modifiedPaths: Set<string> = new Set();
28-
2923
}

vscode4teaching-server/src/main/java/com/vscode4teaching/vscode4teachingserver/controllers/CourseController.java

+9-9
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ public ResponseEntity<Course> addCourse(HttpServletRequest request, @Valid @Requ
6969
throws TeacherNotFoundException {
7070
logger.info("Request to POST '/api/courses' with body '{}'", courseDTO);
7171
Course course = new Course(courseDTO.getName());
72-
Course savedCourse = courseService.registerNewCourse(course, jwtTokenUtil.getUsernameFromToken(request));
72+
Course savedCourse = courseService.registerNewCourse(course, jwtTokenUtil.getUsernameFromAuthenticatedRequest(request));
7373
return new ResponseEntity<>(savedCourse, HttpStatus.CREATED);
7474
}
7575

@@ -79,15 +79,15 @@ public ResponseEntity<Course> updateCourse(HttpServletRequest request, @PathVari
7979
@Valid @RequestBody CourseDTO courseDTO) throws CourseNotFoundException, NotInCourseException {
8080
logger.info("Request to PUT '/api/courses/{}' with body '{}'", id, courseDTO);
8181
Course course = new Course(courseDTO.getName());
82-
Course savedCourse = courseService.editCourse(id, course, jwtTokenUtil.getUsernameFromToken(request));
82+
Course savedCourse = courseService.editCourse(id, course, jwtTokenUtil.getUsernameFromAuthenticatedRequest(request));
8383
return ResponseEntity.ok(savedCourse);
8484
}
8585

8686
@DeleteMapping("/courses/{id}")
8787
public ResponseEntity<Void> deleteCourse(HttpServletRequest request, @PathVariable @Min(1) Long id)
8888
throws CourseNotFoundException, NotInCourseException, NotCreatorException {
8989
logger.info("Request to DELETE '/api/courses/{}'", id);
90-
courseService.deleteCourse(id, jwtTokenUtil.getUsernameFromToken(request));
90+
courseService.deleteCourse(id, jwtTokenUtil.getUsernameFromAuthenticatedRequest(request));
9191
return ResponseEntity.noContent().build();
9292
}
9393

@@ -104,7 +104,7 @@ public ResponseEntity<List<Course>> getUserCourses(@PathVariable @Min(1) Long id
104104
public ResponseEntity<Set<User>> getUsersInCourse(@PathVariable @Min(1) Long courseId, HttpServletRequest request)
105105
throws CourseNotFoundException, NotInCourseException {
106106
logger.info("Request to GET '/api/courses/{}/users'", courseId);
107-
return ResponseEntity.ok(courseService.getUsersInCourse(courseId, jwtTokenUtil.getUsernameFromToken(request)));
107+
return ResponseEntity.ok(courseService.getUsersInCourse(courseId, jwtTokenUtil.getUsernameFromAuthenticatedRequest(request)));
108108
}
109109

110110
@PostMapping("/courses/{courseId}/users")
@@ -114,7 +114,7 @@ public ResponseEntity<Course> addUserToCourse(@PathVariable @Min(1) Long courseI
114114
throws UserNotFoundException, CourseNotFoundException, NotInCourseException {
115115
logger.info("Request to POST '/api/courses/{}/users' with body '{}'", courseId, userRequest);
116116
return ResponseEntity.ok(courseService.addUsersToCourse(courseId, userRequest.getIds(),
117-
jwtTokenUtil.getUsernameFromToken(request)));
117+
jwtTokenUtil.getUsernameFromAuthenticatedRequest(request)));
118118
}
119119

120120
@DeleteMapping("/courses/{courseId}/users")
@@ -124,14 +124,14 @@ public ResponseEntity<Course> removeUsersFromCourse(@PathVariable @Min(1) Long c
124124
throws UserNotFoundException, CourseNotFoundException, NotInCourseException, CantRemoveCreatorException {
125125
logger.info("Request to DELETE '/api/courses/{}/users' with body '{}'", courseId, userRequest);
126126
return ResponseEntity.ok(courseService.removeUsersFromCourse(courseId, userRequest.getIds(),
127-
jwtTokenUtil.getUsernameFromToken(request)));
127+
jwtTokenUtil.getUsernameFromAuthenticatedRequest(request)));
128128
}
129129

130130
@GetMapping("/courses/{courseId}/code")
131131
public ResponseEntity<String> getCode(@PathVariable Long courseId, HttpServletRequest request)
132132
throws UserNotFoundException, CourseNotFoundException, NotInCourseException {
133133
logger.info("Request to GET '/api/courses/{}/code'", courseId);
134-
return ResponseEntity.ok(courseService.getCourseCode(courseId, jwtTokenUtil.getUsernameFromToken(request)));
134+
return ResponseEntity.ok(courseService.getCourseCode(courseId, jwtTokenUtil.getUsernameFromAuthenticatedRequest(request)));
135135
}
136136

137137
@Deprecated // VERSION 2.1 AND LATER ARE NOT USING THIS METHOD, READ DOCS FOR FURTHER INFORMATION
@@ -140,7 +140,7 @@ public ResponseEntity<String> getCode(@PathVariable Long courseId, HttpServletRe
140140
public ResponseEntity<Course> getExercisesWithCode(HttpServletRequest request, @PathVariable String courseCode)
141141
throws CourseNotFoundException, UserNotFoundException {
142142
logger.info("Request to GET '/api/courses/code/{}' (deprecated API endpoint)", courseCode);
143-
return ResponseEntity.ok(courseService.joinCourseWithSharingCode(courseCode, jwtTokenUtil.getUsernameFromToken(request)));
143+
return ResponseEntity.ok(courseService.joinCourseWithSharingCode(courseCode, jwtTokenUtil.getUsernameFromAuthenticatedRequest(request)));
144144
}
145145

146146
@GetMapping("/v2/courses/code/{courseCode}")
@@ -156,6 +156,6 @@ public ResponseEntity<Course> getCourseInformationBySharingCode(@PathVariable St
156156
public ResponseEntity<Course> joinCourse(HttpServletRequest request, @PathVariable String courseCode)
157157
throws CourseNotFoundException, UserNotFoundException {
158158
logger.info("Request to PUT '/api/courses/code/{}'", courseCode);
159-
return ResponseEntity.ok(courseService.joinCourseWithSharingCode(courseCode, jwtTokenUtil.getUsernameFromToken(request)));
159+
return ResponseEntity.ok(courseService.joinCourseWithSharingCode(courseCode, jwtTokenUtil.getUsernameFromAuthenticatedRequest(request)));
160160
}
161161
}

vscode4teaching-server/src/main/java/com/vscode4teaching/vscode4teachingserver/controllers/ExerciseController.java

+9-9
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ public ExerciseController(CourseService courseService, ExerciseInfoService exerc
4949
public ResponseEntity<List<Exercise>> getExercises(HttpServletRequest request, @PathVariable @Min(1) Long courseId)
5050
throws CourseNotFoundException, NotInCourseException {
5151
logger.info("Request to GET '/api/courses/{}/exercises'", courseId);
52-
List<Exercise> exercises = courseService.getExercises(courseId, jwtTokenUtil.getUsernameFromToken(request));
52+
List<Exercise> exercises = courseService.getExercises(courseId, jwtTokenUtil.getUsernameFromAuthenticatedRequest(request));
5353
return exercises.isEmpty() ? ResponseEntity.noContent().build() : ResponseEntity.ok(exercises);
5454
}
5555

@@ -61,7 +61,7 @@ public ResponseEntity<Exercise> addExercise(HttpServletRequest request, @PathVar
6161
logger.info("Request to POST '/api/courses/{}/exercises' with body '{}' (deprecated API endpoint)", courseId, exerciseDTO);
6262
Exercise exercise = new Exercise(exerciseDTO.name);
6363
Exercise savedExercise = courseService.addExerciseToCourse(courseId, exercise,
64-
jwtTokenUtil.getUsernameFromToken(request));
64+
jwtTokenUtil.getUsernameFromAuthenticatedRequest(request));
6565
return new ResponseEntity<>(savedExercise, HttpStatus.CREATED);
6666
}
6767

@@ -77,7 +77,7 @@ public ResponseEntity<List<Exercise>> addExercises(HttpServletRequest request, @
7777
exercise.setIncludesTeacherSolution(exerciseDTO.includesTeacherSolution);
7878
exercise.setSolutionIsPublic(exerciseDTO.solutionIsPublic);
7979
exercise.setAllowEditionAfterSolutionDownloaded(exerciseDTO.allowEditionAfterSolutionDownloaded);
80-
savedExercises.add(courseService.addExerciseToCourse(courseId, exercise, jwtTokenUtil.getUsernameFromToken(request)));
80+
savedExercises.add(courseService.addExerciseToCourse(courseId, exercise, jwtTokenUtil.getUsernameFromAuthenticatedRequest(request)));
8181
}
8282
return new ResponseEntity<>(savedExercises, HttpStatus.CREATED);
8383
}
@@ -98,44 +98,44 @@ public ResponseEntity<Exercise> updateExercise(HttpServletRequest request, @Path
9898
exercise.setIncludesTeacherSolution(exerciseDTO.includesTeacherSolution);
9999
exercise.setSolutionIsPublic(exerciseDTO.solutionIsPublic);
100100
exercise.setAllowEditionAfterSolutionDownloaded(exerciseDTO.allowEditionAfterSolutionDownloaded);
101-
return ResponseEntity.ok(courseService.editExercise(exerciseId, exercise, jwtTokenUtil.getUsernameFromToken(request)));
101+
return ResponseEntity.ok(courseService.editExercise(exerciseId, exercise, jwtTokenUtil.getUsernameFromAuthenticatedRequest(request)));
102102
}
103103

104104
@DeleteMapping("/exercises/{exerciseId}")
105105
@JsonView(ExerciseViews.CourseView.class)
106106
public ResponseEntity<Void> deleteExercise(HttpServletRequest request, @PathVariable @Min(1) Long exerciseId)
107107
throws ExerciseNotFoundException, NotInCourseException {
108108
logger.info("Request to DELETE '/api/exercises/{}'", exerciseId);
109-
courseService.deleteExercise(exerciseId, jwtTokenUtil.getUsernameFromToken(request));
109+
courseService.deleteExercise(exerciseId, jwtTokenUtil.getUsernameFromAuthenticatedRequest(request));
110110
return ResponseEntity.noContent().build();
111111
}
112112

113113
@GetMapping("/exercises/{exerciseId}/code")
114114
public ResponseEntity<String> getCode(@PathVariable Long exerciseId, HttpServletRequest request)
115115
throws UserNotFoundException, ExerciseNotFoundException, NotInCourseException {
116116
logger.info("Request to GET '/api/exercises/{}/code'", exerciseId);
117-
return ResponseEntity.ok(courseService.getExerciseCode(exerciseId, jwtTokenUtil.getUsernameFromToken(request)));
117+
return ResponseEntity.ok(courseService.getExerciseCode(exerciseId, jwtTokenUtil.getUsernameFromAuthenticatedRequest(request)));
118118
}
119119

120120
@GetMapping("/exercises/{exerciseId}/info")
121121
@JsonView(ExerciseUserInfoViews.GeneralView.class)
122122
public ResponseEntity<ExerciseUserInfo> getExerciseUserInfo(@PathVariable Long exerciseId, HttpServletRequest request) throws NotFoundException {
123123
logger.info("Request to GET '/api/exercises/{}/info'", exerciseId);
124-
return ResponseEntity.ok(exerciseInfoService.getExerciseUserInfo(exerciseId, jwtTokenUtil.getUsernameFromToken(request)));
124+
return ResponseEntity.ok(exerciseInfoService.getExerciseUserInfo(exerciseId, jwtTokenUtil.getUsernameFromAuthenticatedRequest(request)));
125125
}
126126

127127
@PutMapping("/exercises/{exerciseId}/info")
128128
@JsonView(ExerciseUserInfoViews.GeneralView.class)
129129
public ResponseEntity<ExerciseUserInfo> updateExerciseUserInfo(@PathVariable Long exerciseId, @RequestBody ExerciseUserInfoDTO exerciseUserInfoDTO, HttpServletRequest request) throws NotFoundException {
130130
logger.info("Request to PUT '/api/exercises/{}/info' with body '{}'", exerciseId, exerciseUserInfoDTO);
131-
return ResponseEntity.ok(exerciseInfoService.updateExerciseUserInfo(exerciseId, jwtTokenUtil.getUsernameFromToken(request), exerciseUserInfoDTO.getStatus(), exerciseUserInfoDTO.getModifiedFiles()));
131+
return ResponseEntity.ok(exerciseInfoService.updateExerciseUserInfo(exerciseId, jwtTokenUtil.getUsernameFromAuthenticatedRequest(request), exerciseUserInfoDTO.getStatus(), exerciseUserInfoDTO.getModifiedFiles()));
132132
}
133133

134134
@GetMapping("/exercises/{exerciseId}/info/teacher")
135135
@JsonView(ExerciseUserInfoViews.GeneralView.class)
136136
public ResponseEntity<List<ExerciseUserInfo>> getAllExerciseUserInfo(@PathVariable Long exerciseId, HttpServletRequest request) throws NotInCourseException, ExerciseNotFoundException {
137137
logger.info("Request to GET '/api/exercises/{}/info/teacher", exerciseId);
138-
List<ExerciseUserInfo> euis = exerciseInfoService.getAllStudentExerciseUserInfo(exerciseId, jwtTokenUtil.getUsernameFromToken(request));
138+
List<ExerciseUserInfo> euis = exerciseInfoService.getAllStudentExerciseUserInfo(exerciseId, jwtTokenUtil.getUsernameFromAuthenticatedRequest(request));
139139
return !euis.isEmpty() ? ResponseEntity.ok(euis) : ResponseEntity.noContent().build();
140140
}
141141
}

vscode4teaching-server/src/main/java/com/vscode4teaching/vscode4teachingserver/controllers/ExerciseSingleFileController.java

+2-4
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
11
package com.vscode4teaching.vscode4teachingserver.controllers;
22

33
import com.vscode4teaching.vscode4teachingserver.controllers.dtos.UploadFileResponse;
4-
import com.vscode4teaching.vscode4teachingserver.model.Exercise;
54
import com.vscode4teaching.vscode4teachingserver.security.jwt.JWTTokenUtil;
65
import com.vscode4teaching.vscode4teachingserver.services.ExerciseSingleFileService;
76
import com.vscode4teaching.vscode4teachingserver.services.exceptions.ExerciseFinishedException;
87
import com.vscode4teaching.vscode4teachingserver.services.exceptions.NotFoundException;
98
import com.vscode4teaching.vscode4teachingserver.services.exceptions.NotInCourseException;
10-
import io.swagger.models.Response;
119
import org.slf4j.Logger;
1210
import org.slf4j.LoggerFactory;
1311
import org.springframework.http.HttpStatus;
@@ -48,7 +46,7 @@ public ResponseEntity<UploadFileResponse> uploadSingleFile(@PathVariable Long ex
4846
throws NotInCourseException, NotFoundException, IOException, ExerciseFinishedException {
4947
logger.info("Request to {} '/api/exercises/{}/file' with relativePath {} and a file", request.getMethod(), exerciseId, relativePath);
5048

51-
String username = jwtTokenUtil.getUsernameFromToken(request);
49+
String username = jwtTokenUtil.getUsernameFromAuthenticatedRequest(request);
5250

5351
File savedFile = exerciseSingleFileService.saveExerciseSingleFile(exerciseId, username, file, relativePath);
5452
return ResponseEntity.ok(new UploadFileResponse(savedFile.getName(), savedFile.toURI().toURL().openConnection().getContentType(), savedFile.length()));
@@ -66,7 +64,7 @@ public ResponseEntity<Void> deleteSingleFile(@PathVariable Long exerciseId,
6664

6765
logger.info("Request to DELETE '/api/exercises/{}/file' with relativePath {}", exerciseId, relativePath);
6866

69-
String username = jwtTokenUtil.getUsernameFromToken(request);
67+
String username = jwtTokenUtil.getUsernameFromAuthenticatedRequest(request);
7068

7169
if (exerciseSingleFileService.deleteExerciseSingleFile(exerciseId, username, relativePath)) {
7270
return new ResponseEntity<>(HttpStatus.OK);

0 commit comments

Comments
 (0)