Skip to content
Open
Show file tree
Hide file tree
Changes from 13 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
2 changes: 1 addition & 1 deletion .github/workflows/bean-instantiations.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
MAX_STARTUP_DEPENDENCY_CHAIN_LENGTH: 9
MAX_DEFERRED_CHAIN_LENGTH: 16
MIN_INSTANTIATED_BEANS: 20
MAX_INSTANTIATED_BEANS: 102
MAX_INSTANTIATED_BEANS: 103
MIN_DEFERRED_CHAIN_LENGTH: 1

steps:
Expand Down
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.ProgrammingExerciseEditorSyncTarget;
import de.tum.cit.aet.artemis.programming.domain.RepositoryType;
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.ProgrammingExerciseEditorSyncService;
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,11 @@ public class ExerciseVersionService {

private final UserRepository userRepository;

private final ProgrammingExerciseEditorSyncService programmingExerciseEditorSyncService;

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

public boolean isRepositoryTypeVersionable(RepositoryType repositoryType) {
Expand Down Expand Up @@ -122,6 +131,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 +162,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();
ProgrammingExerciseEditorSyncTarget target = null;
Long auxiliaryRepositoryId = null;

if (commitIdChanged(previousProgrammingData.templateParticipation(), newProgrammingData.templateParticipation())) {
target = ProgrammingExerciseEditorSyncTarget.TEMPLATE_REPOSITORY;
}
else if (commitIdChanged(previousProgrammingData.solutionParticipation(), newProgrammingData.solutionParticipation())) {
target = ProgrammingExerciseEditorSyncTarget.SOLUTION_REPOSITORY;
}
else if (!Objects.equals(previousProgrammingData.testsCommitId(), newProgrammingData.testsCommitId())) {
target = ProgrammingExerciseEditorSyncTarget.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 = ProgrammingExerciseEditorSyncTarget.AUXILIARY_REPOSITORY;
auxiliaryRepositoryId = auxiliary.id();
break;
}
}
}

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

if (target != null) {
programmingExerciseEditorSyncService.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 ProgrammingExerciseEditorSyncTarget {
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.ProgrammingExerciseEditorSyncTarget;

@JsonInclude(JsonInclude.Include.NON_EMPTY)
public record ProgrammingExerciseEditorSyncEventDTO(ProgrammingExerciseEditorSyncTarget 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.ProgrammingExerciseEditorSyncTarget;
import de.tum.cit.aet.artemis.programming.dto.ProgrammingExerciseEditorSyncEventDTO;

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

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

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

private final WebsocketMessagingService websocketMessagingService;

public ProgrammingExerciseEditorSyncService(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, ProgrammingExerciseEditorSyncTarget target, @Nullable Long auxiliaryRepositoryId) {
var payload = new ProgrammingExerciseEditorSyncEventDTO(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 @@ -38,12 +38,14 @@
import de.tum.cit.aet.artemis.programming.domain.AuxiliaryRepository;
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.ProgrammingExerciseEditorSyncTarget;
import de.tum.cit.aet.artemis.programming.domain.Repository;
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.ProgrammingExerciseEditorSyncService;
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,23 +64,24 @@ 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, ProgrammingExerciseEditorSyncService programmingExerciseEditorSyncService) {
super(userRepository, authCheckService, gitService, repositoryService, programmingExerciseRepository, repositoryAccessService, programmingExerciseEditorSyncService,
localVCServletService);
this.auxiliaryRepositoryRepository = auxiliaryRepositoryRepository;
}

@Override
Repository getRepository(Long auxiliaryRepositoryId, RepositoryActionType repositoryActionType, boolean pullOnGet, boolean writeAccess) throws GitAPIException {
final var auxiliaryRepository = auxiliaryRepositoryRepository.findByIdElseThrow(auxiliaryRepositoryId);
final AuxiliaryRepository auxiliaryRepository = auxiliaryRepositoryRepository.findByIdElseThrow(auxiliaryRepositoryId);
User user = userRepository.getUserWithGroupsAndAuthorities();
repositoryAccessService.checkAccessTestOrAuxRepositoryElseThrow(false, auxiliaryRepository.getExercise(), user, "auxiliary");
final var repoUri = auxiliaryRepository.getVcsRepositoryUri();
final LocalVCRepositoryUri repoUri = auxiliaryRepository.getVcsRepositoryUri();
return gitService.getOrCheckoutRepository(repoUri, pullOnGet, writeAccess);
}

@Override
LocalVCRepositoryUri getRepositoryUri(Long auxiliaryRepositoryId) {
var auxRepo = auxiliaryRepositoryRepository.findByIdElseThrow(auxiliaryRepositoryId);
AuxiliaryRepository auxRepo = auxiliaryRepositoryRepository.findByIdElseThrow(auxiliaryRepositoryId);
return auxRepo.getVcsRepositoryUri();
}

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);
ResponseEntity<Void> 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);
ResponseEntity<Void> 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);
ResponseEntity<Void> 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);
ResponseEntity<Void> 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);
ResponseEntity<Void> 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);
ResponseEntity<Map<String, String>> 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(), ProgrammingExerciseEditorSyncTarget.AUXILIARY_REPOSITORY, auxiliaryRepositoryId);
}
catch (Exception e) {
log.debug("Could not broadcast auxiliary repository change for {}: {}", auxiliaryRepositoryId, e.getMessage());
}
}
}
Loading
Loading