Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 8 additions & 56 deletions package-lock.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@
import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE;

import java.util.EnumSet;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
Expand All @@ -18,12 +21,15 @@
import de.tum.cit.aet.artemis.exercise.domain.ExerciseType;
import de.tum.cit.aet.artemis.exercise.domain.ExerciseVersion;
import de.tum.cit.aet.artemis.exercise.dto.versioning.ExerciseSnapshotDTO;
import de.tum.cit.aet.artemis.exercise.dto.versioning.ProgrammingExerciseSnapshotDTO;
import de.tum.cit.aet.artemis.exercise.repository.ExerciseVersionRepository;
import de.tum.cit.aet.artemis.fileupload.repository.FileUploadExerciseRepository;
import de.tum.cit.aet.artemis.modeling.repository.ModelingExerciseRepository;
import de.tum.cit.aet.artemis.programming.domain.RepositoryType;
import de.tum.cit.aet.artemis.programming.domain.SynchronizationTarget;
import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseRepository;
import de.tum.cit.aet.artemis.programming.service.GitService;
import de.tum.cit.aet.artemis.programming.service.ProgrammingExerciseSynchronizationService;
import de.tum.cit.aet.artemis.quiz.repository.QuizExerciseRepository;
import de.tum.cit.aet.artemis.text.repository.TextExerciseRepository;

Expand Down Expand Up @@ -53,9 +59,12 @@ public class ExerciseVersionService {

private final UserRepository userRepository;

private final ProgrammingExerciseSynchronizationService programmingExerciseSynchronizationService;

public ExerciseVersionService(ExerciseVersionRepository exerciseVersionRepository, GitService gitService, ProgrammingExerciseRepository programmingExerciseRepository,
QuizExerciseRepository quizExerciseRepository, TextExerciseRepository textExerciseRepository, ModelingExerciseRepository modelingExerciseRepository,
FileUploadExerciseRepository fileUploadExerciseRepository, UserRepository userRepository) {
FileUploadExerciseRepository fileUploadExerciseRepository, UserRepository userRepository,
ProgrammingExerciseSynchronizationService programmingExerciseSynchronizationService) {
this.exerciseVersionRepository = exerciseVersionRepository;
this.gitService = gitService;
this.programmingExerciseRepository = programmingExerciseRepository;
Expand All @@ -64,6 +73,7 @@ public ExerciseVersionService(ExerciseVersionRepository exerciseVersionRepositor
this.modelingExerciseRepository = modelingExerciseRepository;
this.fileUploadExerciseRepository = fileUploadExerciseRepository;
this.userRepository = userRepository;
this.programmingExerciseSynchronizationService = programmingExerciseSynchronizationService;
}

public boolean isRepositoryTypeVersionable(RepositoryType repositoryType) {
Expand Down Expand Up @@ -122,6 +132,7 @@ public void createExerciseVersion(Exercise targetExercise, User author) {
}
exerciseVersion.setExerciseSnapshot(exerciseSnapshot);
ExerciseVersion savedExerciseVersion = exerciseVersionRepository.save(exerciseVersion);
this.determineSynchronizationForActiveEditors(exercise.getId(), exerciseSnapshot, previousVersion.map(ExerciseVersion::getExerciseSnapshot).orElse(null));
log.info("Exercise version {} has been created for exercise {}", savedExerciseVersion.getId(), exercise.getId());
}
catch (Exception e) {
Expand Down Expand Up @@ -152,4 +163,62 @@ private Exercise fetchExerciseEagerly(Exercise exercise) {
case FILE_UPLOAD -> fileUploadExerciseRepository.findForVersioningById(exercise.getId()).orElse(null);
};
}

/**
* Compare two exercise snapshots and return the change that should be broadcast to clients.
*
* @param exerciseId the exercise id
* @param newSnapshot the new snapshot
* @param previousSnapshot the previous snapshot (optional)
*/
private void determineSynchronizationForActiveEditors(Long exerciseId, ExerciseSnapshotDTO newSnapshot, ExerciseSnapshotDTO previousSnapshot) {
if (previousSnapshot == null || newSnapshot.programmingData() == null || previousSnapshot.programmingData() == null) {
return;
}

var newProgrammingData = newSnapshot.programmingData();
var previousProgrammingData = previousSnapshot.programmingData();
SynchronizationTarget target = null;
Long auxiliaryRepositoryId = null;

if (commitIdChanged(previousProgrammingData.templateParticipation(), newProgrammingData.templateParticipation())) {
target = SynchronizationTarget.TEMPLATE_REPOSITORY;
}
else if (commitIdChanged(previousProgrammingData.solutionParticipation(), newProgrammingData.solutionParticipation())) {
target = SynchronizationTarget.SOLUTION_REPOSITORY;
}
else if (!Objects.equals(previousProgrammingData.testsCommitId(), newProgrammingData.testsCommitId())) {
target = SynchronizationTarget.TESTS_REPOSITORY;
}
else {
var previousAuxiliaries = Optional.ofNullable(previousProgrammingData.auxiliaryRepositories()).orElseGet(List::of).stream().collect(
Collectors.toMap(ProgrammingExerciseSnapshotDTO.AuxiliaryRepositorySnapshotDTO::id, ProgrammingExerciseSnapshotDTO.AuxiliaryRepositorySnapshotDTO::commitId));
for (var auxiliary : Optional.ofNullable(newProgrammingData.auxiliaryRepositories()).orElseGet(List::of)) {
var previousCommitId = previousAuxiliaries.get(auxiliary.id());
if (!Objects.equals(previousCommitId, auxiliary.commitId())) {
target = SynchronizationTarget.AUXILIARY_REPOSITORY;
auxiliaryRepositoryId = auxiliary.id();
break;
}
}
}

if (target == null && !Objects.equals(previousSnapshot.problemStatement(), newSnapshot.problemStatement())) {
target = SynchronizationTarget.PROBLEM_STATEMENT;
}

if (target != null) {
programmingExerciseSynchronizationService.broadcastChange(exerciseId, target, auxiliaryRepositoryId);
}
}

private boolean commitIdChanged(ProgrammingExerciseSnapshotDTO.ParticipationSnapshotDTO previousParticipation,
ProgrammingExerciseSnapshotDTO.ParticipationSnapshotDTO newParticipation) {
if (previousParticipation == null && newParticipation == null) {
return false;
}
var previousCommitId = previousParticipation == null ? null : previousParticipation.commitId();
var newCommitId = newParticipation == null ? null : newParticipation.commitId();
return !Objects.equals(previousCommitId, newCommitId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package de.tum.cit.aet.artemis.programming.domain;

import com.fasterxml.jackson.annotation.JsonInclude;

@JsonInclude(JsonInclude.Include.NON_EMPTY)
public enum SynchronizationTarget {
PROBLEM_STATEMENT, TEMPLATE_REPOSITORY, SOLUTION_REPOSITORY, TESTS_REPOSITORY, AUXILIARY_REPOSITORY
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package de.tum.cit.aet.artemis.programming.dto;

import org.jspecify.annotations.Nullable;

import com.fasterxml.jackson.annotation.JsonInclude;

import de.tum.cit.aet.artemis.programming.domain.SynchronizationTarget;

@JsonInclude(JsonInclude.Include.NON_EMPTY)
public record ProgrammingExerciseSynchronizationDTO(SynchronizationTarget target, @Nullable Long auxiliaryRepositoryId, @Nullable String clientInstanceId) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package de.tum.cit.aet.artemis.programming.service;

import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE;

import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Lazy;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import de.tum.cit.aet.artemis.communication.service.WebsocketMessagingService;
import de.tum.cit.aet.artemis.programming.domain.SynchronizationTarget;
import de.tum.cit.aet.artemis.programming.dto.ProgrammingExerciseSynchronizationDTO;

@Profile(PROFILE_CORE)
@Lazy
@Service
public class ProgrammingExerciseSynchronizationService {

private static final Logger log = LoggerFactory.getLogger(ProgrammingExerciseSynchronizationService.class);

public static final String CLIENT_INSTANCE_HEADER = "X-Artemis-Client-Instance-ID";

private final WebsocketMessagingService websocketMessagingService;

public ProgrammingExerciseSynchronizationService(WebsocketMessagingService websocketMessagingService) {
this.websocketMessagingService = websocketMessagingService;
}

public static String getSynchronizationTopic(long exerciseId) {
return "/topic/programming-exercises/" + exerciseId + "/synchronization";
}

/**
* Retrieves the client instance id from the current request, if available.
*
* @return the client instance id or null if no request context is available
*/
public static String getClientInstanceId() {
RequestAttributes attributes = RequestContextHolder.getRequestAttributes();
if (attributes instanceof ServletRequestAttributes servletRequestAttributes) {
return servletRequestAttributes.getRequest().getHeader(CLIENT_INSTANCE_HEADER);
}
return null;
}

/**
* Broadcast a single change to all active editors.
*
* @param exerciseId the exercise id
* @param target the target data type associated with this change (e.g. template repository, solution repository, auxiliary repository, problem statement)
* @param auxiliaryRepositoryId (optional) the id of the auxiliary repository associated with this change
*/
public void broadcastChange(long exerciseId, SynchronizationTarget target, @Nullable Long auxiliaryRepositoryId) {
var payload = new ProgrammingExerciseSynchronizationDTO(target, auxiliaryRepositoryId, getClientInstanceId());
websocketMessagingService.sendMessage(getSynchronizationTopic(exerciseId), payload).exceptionally(exception -> {
log.warn("Cannot send synchronization message for exercise {}", exerciseId, exception);
return null;
});
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,13 @@
import de.tum.cit.aet.artemis.programming.domain.FileType;
import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise;
import de.tum.cit.aet.artemis.programming.domain.Repository;
import de.tum.cit.aet.artemis.programming.domain.SynchronizationTarget;
import de.tum.cit.aet.artemis.programming.dto.FileMove;
import de.tum.cit.aet.artemis.programming.dto.RepositoryStatusDTO;
import de.tum.cit.aet.artemis.programming.repository.AuxiliaryRepositoryRepository;
import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseRepository;
import de.tum.cit.aet.artemis.programming.service.GitService;
import de.tum.cit.aet.artemis.programming.service.ProgrammingExerciseSynchronizationService;
import de.tum.cit.aet.artemis.programming.service.RepositoryAccessService;
import de.tum.cit.aet.artemis.programming.service.RepositoryService;
import de.tum.cit.aet.artemis.programming.service.localvc.LocalVCRepositoryUri;
Expand All @@ -62,8 +64,9 @@ public class AuxiliaryRepositoryResource extends RepositoryResource {

public AuxiliaryRepositoryResource(UserRepository userRepository, AuthorizationCheckService authCheckService, GitService gitService, RepositoryService repositoryService,
ProgrammingExerciseRepository programmingExerciseRepository, RepositoryAccessService repositoryAccessService, Optional<LocalVCServletService> localVCServletService,
AuxiliaryRepositoryRepository auxiliaryRepositoryRepository) {
super(userRepository, authCheckService, gitService, repositoryService, programmingExerciseRepository, repositoryAccessService, localVCServletService);
AuxiliaryRepositoryRepository auxiliaryRepositoryRepository, ProgrammingExerciseSynchronizationService programmingExerciseSynchronizationService) {
super(userRepository, authCheckService, gitService, repositoryService, programmingExerciseRepository, repositoryAccessService, programmingExerciseSynchronizationService,
localVCServletService);
this.auxiliaryRepositoryRepository = auxiliaryRepositoryRepository;
}

Expand Down Expand Up @@ -118,31 +121,39 @@ public ResponseEntity<byte[]> getFile(@PathVariable Long auxiliaryRepositoryId,
@EnforceAtLeastTutor
@FeatureToggle(Feature.ProgrammingExercises)
public ResponseEntity<Void> createFile(@PathVariable Long auxiliaryRepositoryId, @RequestParam("file") String filePath, HttpServletRequest request) {
return super.createFile(auxiliaryRepositoryId, filePath, request);
var response = super.createFile(auxiliaryRepositoryId, filePath, request);
broadcastAuxiliaryRepositoryChange(auxiliaryRepositoryId);
return response;
}

@Override
@PostMapping(value = "{auxiliaryRepositoryId}/folder", produces = MediaType.APPLICATION_JSON_VALUE)
@EnforceAtLeastTutor
@FeatureToggle(Feature.ProgrammingExercises)
public ResponseEntity<Void> createFolder(@PathVariable Long auxiliaryRepositoryId, @RequestParam("folder") String folderPath, HttpServletRequest request) {
return super.createFolder(auxiliaryRepositoryId, folderPath, request);
var response = super.createFolder(auxiliaryRepositoryId, folderPath, request);
broadcastAuxiliaryRepositoryChange(auxiliaryRepositoryId);
return response;
}

@Override
@PostMapping(value = "{auxiliaryRepositoryId}/rename-file", produces = MediaType.APPLICATION_JSON_VALUE)
@EnforceAtLeastTutor
@FeatureToggle(Feature.ProgrammingExercises)
public ResponseEntity<Void> renameFile(@PathVariable Long auxiliaryRepositoryId, @RequestBody FileMove fileMove) {
return super.renameFile(auxiliaryRepositoryId, fileMove);
var response = super.renameFile(auxiliaryRepositoryId, fileMove);
broadcastAuxiliaryRepositoryChange(auxiliaryRepositoryId);
return response;
}

@Override
@DeleteMapping(value = "{auxiliaryRepositoryId}/file", produces = MediaType.APPLICATION_JSON_VALUE)
@EnforceAtLeastTutor
@FeatureToggle(Feature.ProgrammingExercises)
public ResponseEntity<Void> deleteFile(@PathVariable Long auxiliaryRepositoryId, @RequestParam("file") String filename) {
return super.deleteFile(auxiliaryRepositoryId, filename);
var response = super.deleteFile(auxiliaryRepositoryId, filename);
broadcastAuxiliaryRepositoryChange(auxiliaryRepositoryId);
return response;
}

@Override
Expand All @@ -165,7 +176,9 @@ public ResponseEntity<Void> commitChanges(@PathVariable Long auxiliaryRepository
@EnforceAtLeastTutor
@FeatureToggle(Feature.ProgrammingExercises)
public ResponseEntity<Void> resetToLastCommit(@PathVariable Long auxiliaryRepositoryId) {
return super.resetToLastCommit(auxiliaryRepositoryId);
var response = super.resetToLastCommit(auxiliaryRepositoryId);
broadcastAuxiliaryRepositoryChange(auxiliaryRepositoryId);
return response;
}

@Override
Expand Down Expand Up @@ -209,6 +222,21 @@ public ResponseEntity<Map<String, String>> updateAuxiliaryFiles(@PathVariable("a
FileSubmissionError error = new FileSubmissionError(auxiliaryRepositoryId, "checkoutFailed");
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, error.getMessage(), error);
}
return saveFilesAndCommitChanges(auxiliaryRepositoryId, submissions, commit, repository);
var response = saveFilesAndCommitChanges(auxiliaryRepositoryId, submissions, commit, repository);
if (!commit && !submissions.isEmpty()) {
this.broadcastAuxiliaryRepositoryChange(auxiliaryRepositoryId);
}
return response;
}

private void broadcastAuxiliaryRepositoryChange(Long auxiliaryRepositoryId) {
try {
AuxiliaryRepository auxiliaryRepository = auxiliaryRepositoryRepository.findByIdElseThrow(auxiliaryRepositoryId);
ProgrammingExercise exercise = auxiliaryRepository.getExercise();
this.broadcastRepositoryUpdates(exercise.getId(), SynchronizationTarget.AUXILIARY_REPOSITORY, auxiliaryRepositoryId);
}
catch (Exception e) {
log.debug("Could not broadcast auxiliary repository change for {}: {}", auxiliaryRepositoryId, e.getMessage());
}
}
}
Loading
Loading