diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 2296ed26a829..22968f1531b4 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -111,10 +111,10 @@ Prerequisites: - [ ] Test 2 ### Test Coverage - - - - + + + + List of build logs + private final ConcurrentMap> buildLogsMap = new ConcurrentHashMap<>(); + + /** + * Appends a new build log entry to the build logs for the specified build job ID. + * + * @param buildJobId the ID of the build job to append a log message to + * @param message the message to append to the build log + */ + public void appendBuildLogEntry(String buildJobId, String message) { + appendBuildLogEntry(buildJobId, new BuildLogDTO(ZonedDateTime.now(), message + "\n")); } - public void appendBuildLogEntry(String buildLogId, String message) { - appendBuildLogEntry(buildLogId, new BuildLogEntry(ZonedDateTime.now(), message + "\n")); + /** + * Appends a new build log entry to the build logs for the specified build job ID. + * Only the first maxCharsPerLine characters of the log message will be appended. Longer characters will be truncated to avoid memory issues. + * Only the first maxLogLinesPerBuildJob log entries will be stored. Newer logs will be ignored to avoid memory issues + * + * @param buildJobId the ID of the build job to append a log message to + * @param buildLog the build log entry to append to the build log + */ + public void appendBuildLogEntry(String buildJobId, BuildLogDTO buildLog) { + List buildLogs = buildLogsMap.computeIfAbsent(buildJobId, k -> new ArrayList<>()); + if (buildLogs.size() < maxLogLinesPerBuildJob) { + if (buildLog.log() != null && buildLog.log().length() > maxCharsPerLine) { + buildLog = new BuildLogDTO(buildLog.time(), buildLog.log().substring(0, maxCharsPerLine) + "\n"); + } + buildLogs.add(buildLog); + } } - public void appendBuildLogEntry(String buildLogId, BuildLogEntry buildLog) { - buildLogsMap.computeIfAbsent(buildLogId, k -> new ArrayList<>()).add(buildLog); + public void removeBuildLogs(String buildJobId) { + buildLogsMap.remove(buildJobId); } - public void removeBuildLogs(String buildLogId) { - buildLogsMap.remove(buildLogId); + /** + * Retrieves and truncates the build logs for the specified build job ID. Does not modify the original build logs. + * + * @param buildJobId the ID of the build job to retrieve and truncate + * @return a list of truncated build log entries, or null if no logs are found for the specified ID + */ + public List getAndTruncateBuildLogs(String buildJobId) { + List buildLogs = buildLogsMap.get(buildJobId); + + if (buildLogs == null) { + return null; + } + + // Truncate the build logs to maxLogLinesPerBuildJob + if (buildLogs.size() > maxLogLinesPerBuildJob) { + List truncatedBuildLogs = new ArrayList<>(buildLogs.subList(0, maxLogLinesPerBuildJob)); + truncatedBuildLogs.add(new BuildLogDTO(ZonedDateTime.now(), "Truncated build logs...\n")); + buildLogs = truncatedBuildLogs; + } + + return buildLogs; } } diff --git a/src/main/java/de/tum/cit/aet/artemis/buildagent/service/SharedQueueProcessingService.java b/src/main/java/de/tum/cit/aet/artemis/buildagent/service/SharedQueueProcessingService.java index 0823ec5a4f9b..6bf67b2626e2 100644 --- a/src/main/java/de/tum/cit/aet/artemis/buildagent/service/SharedQueueProcessingService.java +++ b/src/main/java/de/tum/cit/aet/artemis/buildagent/service/SharedQueueProcessingService.java @@ -49,11 +49,11 @@ import de.tum.cit.aet.artemis.buildagent.dto.BuildAgentDTO; import de.tum.cit.aet.artemis.buildagent.dto.BuildAgentInformation; import de.tum.cit.aet.artemis.buildagent.dto.BuildJobQueueItem; +import de.tum.cit.aet.artemis.buildagent.dto.BuildLogDTO; import de.tum.cit.aet.artemis.buildagent.dto.BuildResult; import de.tum.cit.aet.artemis.buildagent.dto.JobTimingInfo; import de.tum.cit.aet.artemis.buildagent.dto.ResultQueueItem; import de.tum.cit.aet.artemis.core.security.SecurityUtils; -import de.tum.cit.aet.artemis.programming.domain.build.BuildLogEntry; import de.tum.cit.aet.artemis.programming.domain.build.BuildStatus; /** @@ -266,16 +266,26 @@ private void checkAvailabilityAndProcessNextBuild() { processBuild(buildJob); } catch (RejectedExecutionException e) { - log.error("Couldn't add build job to threadpool: {}\n Concurrent Build Jobs Count: {} Active tasks in pool: {}, Concurrent Build Jobs Size: {}", buildJob, + // TODO: we should log this centrally and not on the local node + log.error("Couldn't add build job to thread pool: {}\n Concurrent Build Jobs Count: {} Active tasks in pool: {}, Concurrent Build Jobs Size: {}", buildJob, localProcessingJobs.get(), localCIBuildExecutorService.getActiveCount(), localCIBuildExecutorService.getMaximumPoolSize(), e); // Add the build job back to the queue if (buildJob != null) { processingJobs.remove(buildJob.id()); - buildJob = new BuildJobQueueItem(buildJob, new BuildAgentDTO("", "", "")); - log.info("Adding build job back to the queue: {}", buildJob); - queue.add(buildJob); + // At most try out the build job 5 times when they get rejected + if (buildJob.retryCount() >= 5) { + // TODO: we should log this centrally and not on the local node + log.error("Build job was rejected 5 times. Not adding build job back to the queue: {}", buildJob); + } + else { + // NOTE: we increase the retry count here, because the build job was not processed successfully + // TODO: we should try to run this job on a different build agent to avoid getting the same error again + buildJob = new BuildJobQueueItem(buildJob, new BuildAgentDTO("", "", ""), buildJob.retryCount() + 1); + log.info("Adding build job {} back to the queue with retry count {}", buildJob, buildJob.retryCount()); + queue.add(buildJob); + } localProcessingJobs.decrementAndGet(); } @@ -360,7 +370,9 @@ private BuildAgentInformation getUpdatedLocalBuildAgentInformation(BuildJobQueue } private List getProcessingJobsOfNode(String memberAddress) { - return processingJobs.values().stream().filter(job -> Objects.equals(job.buildAgent().memberAddress(), memberAddress)).toList(); + // NOTE: we should not use streams with IMap, because it can be unstable, when many items are added at the same time and there is a slow network condition + List processingJobsList = new ArrayList<>(processingJobs.values()); + return processingJobsList.stream().filter(job -> Objects.equals(job.buildAgent().memberAddress(), memberAddress)).toList(); } private void removeOfflineNodes() { @@ -397,7 +409,7 @@ private void processBuild(BuildJobQueueItem buildJob) { buildJob.exerciseId(), buildJob.retryCount(), buildJob.priority(), BuildStatus.SUCCESSFUL, buildJob.repositoryInfo(), jobTimingInfo, buildJob.buildConfig(), null); - List buildLogs = buildLogsMap.getBuildLogs(buildJob.id()); + List buildLogs = buildLogsMap.getAndTruncateBuildLogs(buildJob.id()); buildLogsMap.removeBuildLogs(buildJob.id()); ResultQueueItem resultQueueItem = new ResultQueueItem(buildResult, finishedJob, buildLogs, null); @@ -435,7 +447,7 @@ private void processBuild(BuildJobQueueItem buildJob) { job = new BuildJobQueueItem(buildJob, completionDate, status); - List buildLogs = buildLogsMap.getBuildLogs(buildJob.id()); + List buildLogs = buildLogsMap.getAndTruncateBuildLogs(buildJob.id()); buildLogsMap.removeBuildLogs(buildJob.id()); BuildResult failedResult = new BuildResult(buildJob.buildConfig().branch(), buildJob.buildConfig().assignmentCommitHash(), buildJob.buildConfig().testCommitHash(), @@ -549,7 +561,8 @@ private void resumeBuildAgent() { private boolean nodeIsAvailable() { log.debug("Currently processing jobs on this node: {}, active threads in Pool: {}, maximum pool size of thread executor : {}", localProcessingJobs.get(), localCIBuildExecutorService.getActiveCount(), localCIBuildExecutorService.getMaximumPoolSize()); - return localProcessingJobs.get() < localCIBuildExecutorService.getMaximumPoolSize(); + return localProcessingJobs.get() < localCIBuildExecutorService.getMaximumPoolSize() + && localCIBuildExecutorService.getActiveCount() < localCIBuildExecutorService.getMaximumPoolSize() && localCIBuildExecutorService.getQueue().isEmpty(); } public class QueuedBuildJobItemListener implements ItemListener { diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/domain/AnswerPost.java b/src/main/java/de/tum/cit/aet/artemis/communication/domain/AnswerPost.java index aaedef17af0a..9469d9e6d818 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/domain/AnswerPost.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/domain/AnswerPost.java @@ -10,14 +10,18 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToMany; import jakarta.persistence.Table; +import jakarta.persistence.Transient; import org.hibernate.annotations.Cache; import org.hibernate.annotations.CacheConcurrencyStrategy; +import org.hibernate.annotations.SQLRestriction; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonIncludeProperties; import com.fasterxml.jackson.annotation.JsonProperty; +import de.tum.cit.aet.artemis.communication.domain.conversation.Conversation; import de.tum.cit.aet.artemis.core.domain.Course; /** @@ -35,10 +39,20 @@ public class AnswerPost extends Posting { @OneToMany(mappedBy = "answerPost", cascade = CascadeType.REMOVE, orphanRemoval = true, fetch = FetchType.EAGER) private Set reactions = new HashSet<>(); + /*** + * The value 1 represents an answer post, given by the enum {{@link PostingType}} + */ + @OneToMany(mappedBy = "postId", cascade = CascadeType.REMOVE, orphanRemoval = true, fetch = FetchType.LAZY) + @SQLRestriction("post_type = 1") + private Set savedPosts = new HashSet<>(); + @ManyToOne @JsonIncludeProperties({ "id", "exercise", "lecture", "course", "courseWideContext", "conversation", "author" }) private Post post; + @Transient + private boolean isSaved = false; + @JsonProperty("resolvesPost") public Boolean doesResolvePost() { return resolvesPost; @@ -76,6 +90,25 @@ public void setPost(Post post) { this.post = post; } + @JsonIgnore + public Set getSavedPosts() { + return savedPosts; + } + + @JsonProperty("isSaved") + public boolean getIsSaved() { + return isSaved; + } + + public void setIsSaved(boolean isSaved) { + this.isSaved = isSaved; + } + + @JsonIgnore + public Conversation getConversation() { + return getPost().getConversation(); + } + /** * Helper method to extract the course an AnswerPost belongs to, which is found in different locations based on the parent Post's context * diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/domain/Post.java b/src/main/java/de/tum/cit/aet/artemis/communication/domain/Post.java index 4ff2d48fedf5..3bb92cb6a540 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/domain/Post.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/domain/Post.java @@ -16,14 +16,17 @@ import jakarta.persistence.OneToMany; import jakarta.persistence.OneToOne; import jakarta.persistence.Table; +import jakarta.persistence.Transient; import jakarta.validation.constraints.Size; import org.hibernate.annotations.Cache; import org.hibernate.annotations.CacheConcurrencyStrategy; +import org.hibernate.annotations.SQLRestriction; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonIncludeProperties; +import com.fasterxml.jackson.annotation.JsonProperty; import de.tum.cit.aet.artemis.communication.domain.conversation.Conversation; import de.tum.cit.aet.artemis.core.domain.Course; @@ -54,6 +57,13 @@ public class Post extends Posting { @OneToMany(mappedBy = "post", cascade = CascadeType.REMOVE, orphanRemoval = true, fetch = FetchType.EAGER) private Set answers = new HashSet<>(); + /*** + * The value 0 represents a post, given by the enum {{@link PostingType}} + */ + @OneToMany(mappedBy = "postId", cascade = CascadeType.REMOVE, orphanRemoval = true, fetch = FetchType.LAZY) + @SQLRestriction("post_type = 0") + private Set savedPosts = new HashSet<>(); + @ElementCollection(fetch = FetchType.EAGER) @CollectionTable(name = "post_tag", joinColumns = @JoinColumn(name = "post_id")) @Column(name = "text") @@ -96,6 +106,9 @@ public class Post extends Posting { @Column(name = "vote_count") private int voteCount; + @Transient + private boolean isSaved = false; + public Post() { } @@ -222,6 +235,20 @@ public void setVoteCount(Integer voteCount) { this.voteCount = voteCount != null ? voteCount : 0; } + @JsonIgnore + public Set getSavedPosts() { + return savedPosts; + } + + @JsonProperty("isSaved") + public boolean getIsSaved() { + return isSaved; + } + + public void setIsSaved(boolean isSaved) { + this.isSaved = isSaved; + } + /** * Helper method to extract the course a Post belongs to, which is found in different locations based on the Post's context * diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/domain/Posting.java b/src/main/java/de/tum/cit/aet/artemis/communication/domain/Posting.java index ad60a1130916..4ae7c6fe800e 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/domain/Posting.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/domain/Posting.java @@ -18,6 +18,7 @@ import com.fasterxml.jackson.annotation.JsonIncludeProperties; import com.fasterxml.jackson.annotation.JsonProperty; +import de.tum.cit.aet.artemis.communication.domain.conversation.Conversation; import de.tum.cit.aet.artemis.core.domain.Course; import de.tum.cit.aet.artemis.core.domain.DomainObject; import de.tum.cit.aet.artemis.core.domain.User; @@ -118,4 +119,6 @@ public void setAuthorRole(UserRole authorRole) { @Transient public abstract Course getCoursePostingBelongsTo(); + + public abstract Conversation getConversation(); } diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/domain/PostingType.java b/src/main/java/de/tum/cit/aet/artemis/communication/domain/PostingType.java new file mode 100644 index 000000000000..aedad4d1b55c --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/communication/domain/PostingType.java @@ -0,0 +1,23 @@ +package de.tum.cit.aet.artemis.communication.domain; + +import java.util.Arrays; + +public enum PostingType { + + POST((short) 0), ANSWER((short) 1); + + private final short databaseKey; + + PostingType(short databaseKey) { + this.databaseKey = databaseKey; + } + + public short getDatabaseKey() { + return databaseKey; + } + + public static PostingType fromDatabaseKey(short databaseKey) { + return Arrays.stream(PostingType.values()).filter(type -> type.getDatabaseKey() == databaseKey).findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unknown database key: " + databaseKey)); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/domain/SavedPost.java b/src/main/java/de/tum/cit/aet/artemis/communication/domain/SavedPost.java new file mode 100644 index 000000000000..88d1c79b96c4 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/communication/domain/SavedPost.java @@ -0,0 +1,83 @@ +package de.tum.cit.aet.artemis.communication.domain; + +import java.time.ZonedDateTime; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Enumerated; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +import de.tum.cit.aet.artemis.core.domain.DomainObject; +import de.tum.cit.aet.artemis.core.domain.User; + +@Entity +@Table(name = "saved_post") +public class SavedPost extends DomainObject { + + @ManyToOne + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "post_id", nullable = false) + private Long postId; + + @Enumerated + @Column(name = "post_type", nullable = false) + private PostingType postType; + + @Enumerated + @Column(name = "status", nullable = false) + private SavedPostStatus status; + + @Column(name = "completed_at") + private ZonedDateTime completedAt; + + public SavedPost() { + } + + public SavedPost(User user, Long postId, PostingType postType, SavedPostStatus status, ZonedDateTime completedAt) { + this.user = user; + this.postId = postId; + this.postType = postType; + this.status = status; + this.completedAt = completedAt; + } + + public Long getPostId() { + return postId; + } + + public void setPostId(Long postId) { + this.postId = postId; + } + + public void setStatus(SavedPostStatus status) { + this.status = status; + } + + public User getUser() { + return user; + } + + public SavedPostStatus getStatus() { + return status; + } + + public void setCompletedAt(ZonedDateTime completedAt) { + this.completedAt = completedAt; + } + + public void setPostType(PostingType postType) { + this.postType = postType; + } + + public PostingType getPostType() { + return postType; + } + + public ZonedDateTime getCompletedAt() { + return completedAt; + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/domain/SavedPostStatus.java b/src/main/java/de/tum/cit/aet/artemis/communication/domain/SavedPostStatus.java new file mode 100644 index 000000000000..b2fd523277be --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/communication/domain/SavedPostStatus.java @@ -0,0 +1,23 @@ +package de.tum.cit.aet.artemis.communication.domain; + +import java.util.Arrays; + +public enum SavedPostStatus { + + IN_PROGRESS((short) 0), COMPLETED((short) 1), ARCHIVED((short) 2); + + private final short databaseKey; + + SavedPostStatus(short databaseKey) { + this.databaseKey = databaseKey; + } + + public short getDatabaseKey() { + return databaseKey; + } + + public static SavedPostStatus fromDatabaseKey(short databaseKey) { + return Arrays.stream(SavedPostStatus.values()).filter(type -> type.getDatabaseKey() == databaseKey).findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unknown database key: " + databaseKey)); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/dto/AuthorDTO.java b/src/main/java/de/tum/cit/aet/artemis/communication/dto/AuthorDTO.java new file mode 100644 index 000000000000..8feb1dd746c1 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/communication/dto/AuthorDTO.java @@ -0,0 +1,13 @@ +package de.tum.cit.aet.artemis.communication.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.cit.aet.artemis.core.domain.User; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record AuthorDTO(Long id, String name, String imageUrl) { + + public AuthorDTO(User user) { + this(user.getId(), user.getName(), user.getImageUrl()); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/dto/FeedbackChannelRequestDTO.java b/src/main/java/de/tum/cit/aet/artemis/communication/dto/FeedbackChannelRequestDTO.java new file mode 100644 index 000000000000..d38b1c1d90f2 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/communication/dto/FeedbackChannelRequestDTO.java @@ -0,0 +1,7 @@ +package de.tum.cit.aet.artemis.communication.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record FeedbackChannelRequestDTO(ChannelDTO channel, String feedbackDetailText) { +} diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/dto/PostingConversationDTO.java b/src/main/java/de/tum/cit/aet/artemis/communication/dto/PostingConversationDTO.java new file mode 100644 index 000000000000..9c93cd4d47e5 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/communication/dto/PostingConversationDTO.java @@ -0,0 +1,34 @@ +package de.tum.cit.aet.artemis.communication.dto; + +import de.tum.cit.aet.artemis.communication.domain.ConversationType; +import de.tum.cit.aet.artemis.communication.domain.conversation.Channel; +import de.tum.cit.aet.artemis.communication.domain.conversation.Conversation; +import de.tum.cit.aet.artemis.communication.domain.conversation.GroupChat; + +public record PostingConversationDTO(Long id, String title, ConversationType type) { + + public PostingConversationDTO(Conversation conversation) { + this(conversation.getId(), determineTitle(conversation), determineType(conversation)); + } + + private static String determineTitle(Conversation conversation) { + if (conversation instanceof Channel) { + return ((Channel) conversation).getName(); + } + else if (conversation instanceof GroupChat) { + return ((GroupChat) conversation).getName(); + } + else { + return "Chat"; + } + } + + private static ConversationType determineType(Conversation conversation) { + if (conversation instanceof Channel) { + return ConversationType.CHANNEL; + } + else { + return ConversationType.DIRECT; + } + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/dto/PostingDTO.java b/src/main/java/de/tum/cit/aet/artemis/communication/dto/PostingDTO.java new file mode 100644 index 000000000000..a394237230c0 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/communication/dto/PostingDTO.java @@ -0,0 +1,40 @@ +package de.tum.cit.aet.artemis.communication.dto; + +import java.time.ZonedDateTime; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.cit.aet.artemis.communication.domain.AnswerPost; +import de.tum.cit.aet.artemis.communication.domain.Posting; +import de.tum.cit.aet.artemis.communication.domain.PostingType; +import de.tum.cit.aet.artemis.communication.domain.UserRole; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record PostingDTO(Long id, AuthorDTO author, UserRole role, ZonedDateTime creationDate, ZonedDateTime updatedDate, String content, boolean isSaved, short savedPostStatus, + List reactions, PostingConversationDTO conversation, short postingType, Long referencePostId) { + + public PostingDTO(Posting post, boolean isSaved, short savedPostStatus) { + this(post.getId(), new AuthorDTO(post.getAuthor()), post.getAuthorRole(), post.getCreationDate(), post.getUpdatedDate(), post.getContent(), isSaved, savedPostStatus, + post.getReactions().stream().map(ReactionDTO::new).toList(), new PostingConversationDTO(post.getConversation()), getSavedPostType(post).getDatabaseKey(), + getReferencePostId(post)); + } + + static PostingType getSavedPostType(Posting posting) { + if (posting instanceof AnswerPost) { + return PostingType.ANSWER; + } + else { + return PostingType.POST; + } + } + + static Long getReferencePostId(Posting posting) { + if (posting instanceof AnswerPost) { + return ((AnswerPost) posting).getPost().getId(); + } + else { + return posting.getId(); + } + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/dto/ReactionDTO.java b/src/main/java/de/tum/cit/aet/artemis/communication/dto/ReactionDTO.java new file mode 100644 index 000000000000..a81a00799ece --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/communication/dto/ReactionDTO.java @@ -0,0 +1,12 @@ +package de.tum.cit.aet.artemis.communication.dto; + +import java.time.ZonedDateTime; + +import de.tum.cit.aet.artemis.communication.domain.Reaction; + +public record ReactionDTO(Long id, AuthorDTO user, ZonedDateTime creationDate, String emojiId) { + + public ReactionDTO(Reaction reaction) { + this(reaction.getId(), new AuthorDTO(reaction.getUser()), reaction.getCreationDate(), reaction.getEmojiId()); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/repository/AnswerPostRepository.java b/src/main/java/de/tum/cit/aet/artemis/communication/repository/AnswerPostRepository.java index db61138b3a73..43ac921f2d8a 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/repository/AnswerPostRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/repository/AnswerPostRepository.java @@ -31,5 +31,12 @@ default AnswerPost findAnswerMessageByIdElseThrow(Long answerPostId) { return getValueElseThrow(findById(answerPostId).filter(answerPost -> answerPost.getPost().getConversation() != null), answerPostId); } + @NotNull + default AnswerPost findAnswerPostOrMessageByIdElseThrow(Long answerPostId) { + return getValueElseThrow(findById(answerPostId), answerPostId); + } + long countAnswerPostsByPostIdIn(List postIds); + + List findByIdIn(List idList); } diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/repository/ConversationMessageRepository.java b/src/main/java/de/tum/cit/aet/artemis/communication/repository/ConversationMessageRepository.java index 2952c5213432..16c5be3aedc8 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/repository/ConversationMessageRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/repository/ConversationMessageRepository.java @@ -114,6 +114,7 @@ private PageImpl findPostsWithSpecification(Pageable pageable, Specificati LEFT JOIN FETCH p.conversation LEFT JOIN FETCH p.reactions LEFT JOIN FETCH p.tags + LEFT JOIN FETCH p.savedPosts LEFT JOIN FETCH p.answers a LEFT JOIN FETCH a.reactions LEFT JOIN FETCH a.post diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/repository/PostRepository.java b/src/main/java/de/tum/cit/aet/artemis/communication/repository/PostRepository.java index aacfbc33d179..1ea95f1d6657 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/repository/PostRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/repository/PostRepository.java @@ -49,4 +49,6 @@ default Post findPostOrMessagePostByIdElseThrow(Long postId) throws EntityNotFou List findAllByConversationId(Long conversationId); List findAllByCourseId(Long courseId); + + List findByIdIn(List idList); } diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/repository/SavedPostRepository.java b/src/main/java/de/tum/cit/aet/artemis/communication/repository/SavedPostRepository.java new file mode 100644 index 000000000000..e0a00a5896aa --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/communication/repository/SavedPostRepository.java @@ -0,0 +1,143 @@ +package de.tum.cit.aet.artemis.communication.repository; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import java.time.ZonedDateTime; +import java.util.List; + +import org.springframework.cache.annotation.CacheConfig; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.cache.annotation.Caching; +import org.springframework.context.annotation.Profile; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import de.tum.cit.aet.artemis.communication.domain.PostingType; +import de.tum.cit.aet.artemis.communication.domain.SavedPost; +import de.tum.cit.aet.artemis.communication.domain.SavedPostStatus; +import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository; + +@Profile(PROFILE_CORE) +@Repository +@CacheConfig(cacheNames = "savedPosts") +public interface SavedPostRepository extends ArtemisJpaRepository { + + /*** + * Get the amount of saved posts of a user. E.g. for checking if maximum allowed bookmarks are reached. + * Cached by user id. + * + * @param userId to query for + * + * @return The amount of bookmarks of the user. + */ + @Cacheable(key = "'saved_post_count_' + #userId") + Long countByUserId(Long userId); + + /*** + * Get a single saved post by user id, connected post/answer post id and posting type. Not cached. + * + * @param userId of the bookmark + * @param postId of the bookmark + * @param postType of the bookmark + * + * @return The saved post if exists, null otherwise. + */ + SavedPost findSavedPostByUserIdAndPostIdAndPostType(Long userId, Long postId, PostingType postType); + + /*** + * Query all post ids that a user has saved by a certain posting type. Cached by user id and post type. + * + * @param userId of the bookmarks + * @param postType of the bookmarks + * + * @return List of ids of posts/answer posts of the given user, filtered by the given post type. + */ + @Query(""" + SELECT s.postId + FROM SavedPost s + WHERE s.user.id = :userId AND s.postType = :postType + """) + @Cacheable(key = "'saved_post_type_' + #postType.getDatabaseKey() + '_' + #userId") + List findSavedPostIdsByUserIdAndPostType(@Param("userId") Long userId, @Param("postType") PostingType postType); + + /*** + * Query all saved posts of a user by status. E.g. for displaying the saved posts. Cached by user id and status. + * + * @param userId of the bookmarks + * @param status of the bookmarks + * + * @return List of saved posts of the given user, filtered by the given status. + */ + @Cacheable(key = "'saved_post_status_' + #status.getDatabaseKey() + '_' + #userId") + List findSavedPostsByUserIdAndStatusOrderByCompletedAtDescIdDesc(Long userId, SavedPostStatus status); + + /*** + * Query all SavedPosts for a certain user. Not cached. + * + * @param userId of the bookmarks + * + * @return List of saved posts of the given user. + */ + List findSavedPostsByUserId(Long userId); + + /*** + * Query to get all SavedPosts that are completed before a certain cutoff date. E.g. for cleanup. + * + * @param cutoffDate the date from where to query the saved posts + * + * @return List of saved posts which were completed before the given date + */ + List findByCompletedAtBefore(ZonedDateTime cutoffDate); + + /*** + * Saving should clear the cached queries for a given user + * The value "saved_post_type_0" represents a post, given by the enum {{@link PostingType}} + * The value "saved_post_type_1" represents an answer post, given by the enum {{@link PostingType}} + * The value "saved_post_status_0" represents in progress, given by the enum {{@link SavedPostStatus}} + * The value "saved_post_status_1" represents in completed, given by the enum {{@link SavedPostStatus}} + * The value "saved_post_status_2" represents in archived, given by the enum {{@link SavedPostStatus}} + * + * @param savedPost to create / update + * + * @return Newly stored saved post + */ + @Caching(evict = { @CacheEvict(key = "'saved_post_type_0_' + #savedPost.user.id"), @CacheEvict(key = "'saved_post_type_1_' + #savedPost.user.id"), + @CacheEvict(key = "'saved_post_status_0_' + #savedPost.user.id"), @CacheEvict(key = "'saved_post_status_1_' + #savedPost.user.id"), + @CacheEvict(key = "'saved_post_status_2_' + #savedPost.user.id"), @CacheEvict(key = "'saved_post_count_' + #savedPost.user.id"), }) + @Override + S save(S savedPost); + + /*** + * Deleting should clear the cached queries for a given user + * The value "saved_post_type_0" represents a post, given by the enum {{@link PostingType}} + * The value "saved_post_type_1" represents an answer post, given by the enum {{@link PostingType}} + * The value "saved_post_status_0" represents in progress, given by the enum {{@link SavedPostStatus}} + * The value "saved_post_status_1" represents in completed, given by the enum {{@link SavedPostStatus}} + * The value "saved_post_status_2" represents in archived, given by the enum {{@link SavedPostStatus}} + * + * @param savedPost to delete + */ + @Caching(evict = { @CacheEvict(key = "'saved_post_type_0_' + #savedPost.user.id"), @CacheEvict(key = "'saved_post_type_1_' + #savedPost.user.id"), + @CacheEvict(key = "'saved_post_status_0_' + #savedPost.user.id"), @CacheEvict(key = "'saved_post_status_1_' + #savedPost.user.id"), + @CacheEvict(key = "'saved_post_status_2_' + #savedPost.user.id"), @CacheEvict(key = "'saved_post_count_' + #savedPost.user.id"), }) + @Override + void delete(SavedPost savedPost); + + /*** + * The value "sp.postType = 0" represents a post, given by the enum {{@link PostingType}} + * + * @return List of saved posts that do not have a post entity connected to them + */ + @Query("SELECT sp FROM SavedPost sp " + "LEFT JOIN Post p ON sp.postId = p.id " + "WHERE sp.postType = 0 AND p.id IS NULL") + List findOrphanedPostReferences(); + + /*** + * The value "sp.postType = 1" represents an answer post, given by the enum {{@link PostingType}} + * + * @return List of saved posts that do not have an answer post entity connected to them + */ + @Query("SELECT sp FROM SavedPost sp " + "LEFT JOIN AnswerPost ap ON sp.postId = ap.id " + "WHERE sp.postType = 1 AND ap.id IS NULL") + List findOrphanedAnswerReferences(); +} diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/service/AnswerMessageService.java b/src/main/java/de/tum/cit/aet/artemis/communication/service/AnswerMessageService.java index fa370edc0737..f7645c202f63 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/service/AnswerMessageService.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/service/AnswerMessageService.java @@ -21,6 +21,7 @@ import de.tum.cit.aet.artemis.communication.repository.ConversationMessageRepository; import de.tum.cit.aet.artemis.communication.repository.ConversationParticipantRepository; import de.tum.cit.aet.artemis.communication.repository.PostRepository; +import de.tum.cit.aet.artemis.communication.repository.SavedPostRepository; import de.tum.cit.aet.artemis.communication.repository.conversation.ConversationRepository; import de.tum.cit.aet.artemis.communication.service.conversation.ConversationService; import de.tum.cit.aet.artemis.communication.service.conversation.auth.ChannelAuthorizationService; @@ -59,10 +60,11 @@ public class AnswerMessageService extends PostingService { @SuppressWarnings("PMD.ExcessiveParameterList") public AnswerMessageService(SingleUserNotificationService singleUserNotificationService, CourseRepository courseRepository, AuthorizationCheckService authorizationCheckService, UserRepository userRepository, AnswerPostRepository answerPostRepository, ConversationMessageRepository conversationMessageRepository, - ConversationService conversationService, ExerciseRepository exerciseRepository, LectureRepository lectureRepository, + ConversationService conversationService, ExerciseRepository exerciseRepository, LectureRepository lectureRepository, SavedPostRepository savedPostRepository, WebsocketMessagingService websocketMessagingService, ConversationParticipantRepository conversationParticipantRepository, ChannelAuthorizationService channelAuthorizationService, PostRepository postRepository, ConversationRepository conversationRepository) { - super(courseRepository, userRepository, exerciseRepository, lectureRepository, authorizationCheckService, websocketMessagingService, conversationParticipantRepository); + super(courseRepository, userRepository, exerciseRepository, lectureRepository, authorizationCheckService, websocketMessagingService, conversationParticipantRepository, + savedPostRepository); this.answerPostRepository = answerPostRepository; this.conversationMessageRepository = conversationMessageRepository; this.conversationService = conversationService; @@ -205,6 +207,7 @@ public void deleteAnswerMessageById(Long courseId, Long answerMessageId) { // delete answerPostRepository.deleteById(answerMessageId); + preparePostForBroadcast(updatedMessage); broadcastForPost(new PostDTO(updatedMessage, MetisCrudAction.UPDATE), course.getId(), null, null); } diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/service/ConversationMessagingService.java b/src/main/java/de/tum/cit/aet/artemis/communication/service/ConversationMessagingService.java index a54058431b76..06f9409bddc0 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/service/ConversationMessagingService.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/service/ConversationMessagingService.java @@ -40,6 +40,7 @@ import de.tum.cit.aet.artemis.communication.dto.PostDTO; import de.tum.cit.aet.artemis.communication.repository.ConversationMessageRepository; import de.tum.cit.aet.artemis.communication.repository.ConversationParticipantRepository; +import de.tum.cit.aet.artemis.communication.repository.SavedPostRepository; import de.tum.cit.aet.artemis.communication.repository.SingleUserNotificationRepository; import de.tum.cit.aet.artemis.communication.service.conversation.ConversationService; import de.tum.cit.aet.artemis.communication.service.conversation.auth.ChannelAuthorizationService; @@ -78,9 +79,10 @@ public class ConversationMessagingService extends PostingService { protected ConversationMessagingService(CourseRepository courseRepository, ExerciseRepository exerciseRepository, LectureRepository lectureRepository, ConversationMessageRepository conversationMessageRepository, AuthorizationCheckService authorizationCheckService, WebsocketMessagingService websocketMessagingService, UserRepository userRepository, ConversationService conversationService, ConversationParticipantRepository conversationParticipantRepository, - ConversationNotificationService conversationNotificationService, ChannelAuthorizationService channelAuthorizationService, + ConversationNotificationService conversationNotificationService, ChannelAuthorizationService channelAuthorizationService, SavedPostRepository savedPostRepository, GroupNotificationService groupNotificationService, SingleUserNotificationRepository singleUserNotificationRepository) { - super(courseRepository, userRepository, exerciseRepository, lectureRepository, authorizationCheckService, websocketMessagingService, conversationParticipantRepository); + super(courseRepository, userRepository, exerciseRepository, lectureRepository, authorizationCheckService, websocketMessagingService, conversationParticipantRepository, + savedPostRepository); this.conversationService = conversationService; this.conversationMessageRepository = conversationMessageRepository; this.conversationNotificationService = conversationNotificationService; @@ -154,6 +156,7 @@ public void notifyAboutMessageCreation(CreatedConversationMessage createdConvers Set recipientSummaries; ConversationNotification notification = conversationNotificationService.createNotification(createdMessage, conversation, course, createdConversationMessage.mentionedUsers()); + preparePostForBroadcast(createdMessage); PostDTO postDTO = new PostDTO(createdMessage, MetisCrudAction.CREATE, notification); createdMessage.getConversation().hideDetails(); if (createdConversationMessage.completeConversation() instanceof Channel channel && channel.getIsCourseWide()) { @@ -284,7 +287,6 @@ private Set filterNotificationRecipients(User author, Conversation convers public Page getMessages(Pageable pageable, @Valid PostContextFilterDTO postContextFilter, User requestingUser, Long courseId) { conversationService.isMemberOrCreateForCourseWideElseThrow(postContextFilter.conversationId(), requestingUser, Optional.of(ZonedDateTime.now())); - // The following query loads posts, answerPosts and reactions to avoid too many database calls (due to eager references) Page conversationPosts = conversationMessageRepository.findMessages(postContextFilter, pageable, requestingUser.getId()); setAuthorRoleOfPostings(conversationPosts.getContent(), courseId); @@ -342,6 +344,7 @@ public Post updateMessage(Long courseId, Long postId, Post messagePost) { updatedPost.setConversation(conversation); // emit a post update via websocket + preparePostForBroadcast(updatedPost); broadcastForPost(new PostDTO(updatedPost, MetisCrudAction.UPDATE), course.getId(), null, null); return updatedPost; @@ -369,7 +372,7 @@ public void deleteMessageById(Long courseId, Long postId) { conversation = conversationService.getConversationById(conversation.getId()); conversationService.notifyAllConversationMembersAboutUpdate(conversation); - + preparePostForBroadcast(post); broadcastForPost(new PostDTO(post, MetisCrudAction.DELETE), course.getId(), null, null); } @@ -400,6 +403,8 @@ public Post changeDisplayPriority(Long courseId, Long postId, DisplayPriority di Post updatedMessage = conversationMessageRepository.save(message); message.getConversation().hideDetails(); + preparePostForBroadcast(message); + preparePostForBroadcast(updatedMessage); broadcastForPost(new PostDTO(message, MetisCrudAction.UPDATE), course.getId(), null, null); return updatedMessage; } diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/service/PostingService.java b/src/main/java/de/tum/cit/aet/artemis/communication/service/PostingService.java index f3a01dab6ba6..786675a38986 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/service/PostingService.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/service/PostingService.java @@ -21,6 +21,7 @@ import de.tum.cit.aet.artemis.communication.domain.ConversationNotificationRecipientSummary; import de.tum.cit.aet.artemis.communication.domain.Post; import de.tum.cit.aet.artemis.communication.domain.Posting; +import de.tum.cit.aet.artemis.communication.domain.PostingType; import de.tum.cit.aet.artemis.communication.domain.UserRole; import de.tum.cit.aet.artemis.communication.domain.conversation.Channel; import de.tum.cit.aet.artemis.communication.domain.conversation.Conversation; @@ -29,6 +30,7 @@ import de.tum.cit.aet.artemis.communication.dto.MetisCrudAction; import de.tum.cit.aet.artemis.communication.dto.PostDTO; import de.tum.cit.aet.artemis.communication.repository.ConversationParticipantRepository; +import de.tum.cit.aet.artemis.communication.repository.SavedPostRepository; import de.tum.cit.aet.artemis.core.domain.Course; import de.tum.cit.aet.artemis.core.domain.CourseInformationSharingConfiguration; import de.tum.cit.aet.artemis.core.domain.User; @@ -53,6 +55,8 @@ public abstract class PostingService { protected final LectureRepository lectureRepository; + protected final SavedPostRepository savedPostRepository; + protected final ConversationParticipantRepository conversationParticipantRepository; protected final AuthorizationCheckService authorizationCheckService; @@ -65,7 +69,7 @@ public abstract class PostingService { protected PostingService(CourseRepository courseRepository, UserRepository userRepository, ExerciseRepository exerciseRepository, LectureRepository lectureRepository, AuthorizationCheckService authorizationCheckService, WebsocketMessagingService websocketMessagingService, - ConversationParticipantRepository conversationParticipantRepository) { + ConversationParticipantRepository conversationParticipantRepository, SavedPostRepository savedPostRepository) { this.courseRepository = courseRepository; this.userRepository = userRepository; this.exerciseRepository = exerciseRepository; @@ -73,6 +77,28 @@ protected PostingService(CourseRepository courseRepository, UserRepository userR this.authorizationCheckService = authorizationCheckService; this.websocketMessagingService = websocketMessagingService; this.conversationParticipantRepository = conversationParticipantRepository; + this.savedPostRepository = savedPostRepository; + } + + /** + * Helper method to prepare the post included in the websocket message and initiate the broadcasting + * + * @param post post that should be broadcast + */ + public void preparePostForBroadcast(Post post) { + try { + var user = userRepository.getUser(); + var savedPostIds = savedPostRepository.findSavedPostIdsByUserIdAndPostType(user.getId(), PostingType.POST); + post.setIsSaved(savedPostIds.contains(post.getId())); + var savedAnswerIds = savedPostRepository.findSavedPostIdsByUserIdAndPostType(user.getId(), PostingType.ANSWER); + post.getAnswers().forEach(answer -> answer.setIsSaved(savedAnswerIds.contains(answer.getId()))); + } + catch (Exception e) { + post.setIsSaved(false); + post.getAnswers().forEach(answer -> { + answer.setIsSaved(false); + }); + } } /** @@ -89,6 +115,7 @@ protected void preparePostAndBroadcast(AnswerPost updatedAnswerPost, Course cour // we need to remove the existing AnswerPost (based on unchanged id in updatedAnswerPost) and add the updatedAnswerPost afterwards updatedPost.removeAnswerPost(updatedAnswerPost); updatedPost.addAnswerPost(updatedAnswerPost); + preparePostForBroadcast(updatedPost); broadcastForPost(new PostDTO(updatedPost, MetisCrudAction.UPDATE, notification), course.getId(), null, null); } diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/service/ReactionService.java b/src/main/java/de/tum/cit/aet/artemis/communication/service/ReactionService.java index a1b9b2b71ec8..562e30dfd48b 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/service/ReactionService.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/service/ReactionService.java @@ -138,6 +138,7 @@ public void deleteReactionById(Long reactionId, Long courseId) { updatedPost.removeAnswerPost(updatedAnswerPost); updatedPost.addAnswerPost(updatedAnswerPost); } + plagiarismPostService.preparePostForBroadcast(updatedPost); plagiarismPostService.broadcastForPost(new PostDTO(updatedPost, MetisCrudAction.UPDATE), course.getId(), null, null); reactionRepository.deleteById(reactionId); } @@ -201,6 +202,7 @@ private Reaction createReactionForPost(Reaction reaction, Post posting, User use Post updatedPost = postRepository.save(post); updatedPost.setConversation(post.getConversation()); + plagiarismPostService.preparePostForBroadcast(post); plagiarismPostService.broadcastForPost(new PostDTO(post, MetisCrudAction.UPDATE), course.getId(), null, null); return savedReaction; } diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/service/SavedPostScheduleService.java b/src/main/java/de/tum/cit/aet/artemis/communication/service/SavedPostScheduleService.java new file mode 100644 index 000000000000..25e5922031f5 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/communication/service/SavedPostScheduleService.java @@ -0,0 +1,68 @@ +package de.tum.cit.aet.artemis.communication.service; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_SCHEDULING; + +import java.time.ZonedDateTime; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import de.tum.cit.aet.artemis.communication.domain.SavedPost; +import de.tum.cit.aet.artemis.communication.repository.SavedPostRepository; + +@Service +@Profile(PROFILE_SCHEDULING) +public class SavedPostScheduleService { + + private static final int DAYS_UNTIL_ARCHIVED_ARE_DELETED = 100; + + private static final Logger log = LoggerFactory.getLogger(SavedPostScheduleService.class); + + private final SavedPostRepository savedPostRepository; + + public SavedPostScheduleService(SavedPostRepository savedPostRepository) { + this.savedPostRepository = savedPostRepository; + } + + /** + * Cleans up all archived/completed posts that are older than specified cutoff date + */ + @Scheduled(cron = "0 0 0 * * *") + public void cleanupArchivedSavedPosts() { + ZonedDateTime cutoffDate = ZonedDateTime.now().minusDays(DAYS_UNTIL_ARCHIVED_ARE_DELETED); + + List oldPosts = savedPostRepository.findByCompletedAtBefore(cutoffDate); + if (!oldPosts.isEmpty()) { + savedPostRepository.deleteAll(oldPosts); + log.info("Deleted {} archived saved posts", oldPosts.size()); + } + } + + /** + * Cleans up all saved posts where the post entity does not exist anymore + */ + @Scheduled(cron = "0 0 0 * * *") + public void cleanupOrphanedSavedPosts() { + List orphanedPosts = savedPostRepository.findOrphanedPostReferences(); + if (!orphanedPosts.isEmpty()) { + savedPostRepository.deleteAll(orphanedPosts); + log.info("Deleted {} orphaned post references", orphanedPosts.size()); + } + } + + /** + * Cleans up all saved posts where the answer post entity does not exist anymore + */ + @Scheduled(cron = "0 0 0 * * *") + public void cleanupOrphanedSavedAnswerPosts() { + List orphanedPosts = savedPostRepository.findOrphanedAnswerReferences(); + if (!orphanedPosts.isEmpty()) { + savedPostRepository.deleteAll(orphanedPosts); + log.info("Deleted {} orphaned answer post references", orphanedPosts.size()); + } + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/service/SavedPostService.java b/src/main/java/de/tum/cit/aet/artemis/communication/service/SavedPostService.java new file mode 100644 index 000000000000..14172c6d3d05 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/communication/service/SavedPostService.java @@ -0,0 +1,123 @@ +package de.tum.cit.aet.artemis.communication.service; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import java.time.ZonedDateTime; +import java.util.List; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import de.tum.cit.aet.artemis.communication.domain.Post; +import de.tum.cit.aet.artemis.communication.domain.Posting; +import de.tum.cit.aet.artemis.communication.domain.PostingType; +import de.tum.cit.aet.artemis.communication.domain.SavedPost; +import de.tum.cit.aet.artemis.communication.domain.SavedPostStatus; +import de.tum.cit.aet.artemis.communication.repository.SavedPostRepository; +import de.tum.cit.aet.artemis.core.repository.UserRepository; + +@Profile(PROFILE_CORE) +@Service +public class SavedPostService { + + private static final int MAX_SAVED_POSTS_PER_USER = 100; + + private final SavedPostRepository savedPostRepository; + + private final UserRepository userRepository; + + public SavedPostService(SavedPostRepository savedPostRepository, UserRepository userRepository) { + this.savedPostRepository = savedPostRepository; + this.userRepository = userRepository; + } + + /** + * Saves a post for the currently logged-in user, if post is already saved it returns + * + * @param post post to save + */ + public void savePostForCurrentUser(Posting post) { + var existingSavedPost = this.getSavedPostForCurrentUser(post); + + if (existingSavedPost != null) { + return; + } + + PostingType type = post instanceof Post ? PostingType.POST : PostingType.ANSWER; + var author = userRepository.getUser(); + var savedPost = new SavedPost(author, post.getId(), type, SavedPostStatus.IN_PROGRESS, null); + savedPostRepository.save(savedPost); + } + + /** + * Removes a bookmark of a post for the currently logged-in user, if post is not saved it returns + * + * @param post post to remove from bookmarks + * @return false if the saved post was not found, true if post was found and deleted + */ + public boolean removeSavedPostForCurrentUser(Posting post) { + var existingSavedPost = this.getSavedPostForCurrentUser(post); + + if (existingSavedPost == null) { + return false; + } + + savedPostRepository.delete(existingSavedPost); + + return true; + } + + /** + * Updates the status of a bookmark, will return if no bookmark is present + * + * @param post post to change status + * @param status status to change towards + */ + public void updateStatusOfSavedPostForCurrentUser(Posting post, SavedPostStatus status) { + var existingSavedPost = this.getSavedPostForCurrentUser(post); + + if (existingSavedPost == null) { + return; + } + + existingSavedPost.setStatus(status); + existingSavedPost.setCompletedAt(status == SavedPostStatus.IN_PROGRESS ? null : ZonedDateTime.now()); + savedPostRepository.save(existingSavedPost); + } + + /** + * Retrieve the saved posts for a given status + * + * @param status status to query + * @return a list of all saved posts of the current user with the given status + */ + public List getSavedPostsForCurrentUserByStatus(SavedPostStatus status) { + var currentUser = userRepository.getUser(); + + return savedPostRepository.findSavedPostsByUserIdAndStatusOrderByCompletedAtDescIdDesc(currentUser.getId(), status); + } + + /** + * Checks if maximum amount of saved posts limit is reached + * + * @return true if max saved post it reached, false otherwise + */ + public boolean isMaximumSavedPostsReached() { + var currentUser = userRepository.getUser(); + + return MAX_SAVED_POSTS_PER_USER <= savedPostRepository.countByUserId(currentUser.getId()); + } + + /** + * Helper method to retrieve a bookmark for the current user + * + * @param post post to search bookmark for + * @return The saved post for the given posting if present + */ + private SavedPost getSavedPostForCurrentUser(Posting post) { + PostingType type = post instanceof Post ? PostingType.POST : PostingType.ANSWER; + var author = userRepository.getUser(); + + return savedPostRepository.findSavedPostByUserIdAndPostIdAndPostType(author.getId(), post.getId(), type); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/service/conversation/ChannelService.java b/src/main/java/de/tum/cit/aet/artemis/communication/service/conversation/ChannelService.java index c78f44893d8e..791847b75670 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/service/conversation/ChannelService.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/service/conversation/ChannelService.java @@ -17,18 +17,21 @@ import org.springframework.util.StringUtils; import de.tum.cit.aet.artemis.communication.domain.ConversationParticipant; +import de.tum.cit.aet.artemis.communication.domain.NotificationType; import de.tum.cit.aet.artemis.communication.domain.conversation.Channel; import de.tum.cit.aet.artemis.communication.dto.ChannelDTO; import de.tum.cit.aet.artemis.communication.dto.MetisCrudAction; import de.tum.cit.aet.artemis.communication.repository.ConversationParticipantRepository; import de.tum.cit.aet.artemis.communication.repository.conversation.ChannelRepository; import de.tum.cit.aet.artemis.communication.service.conversation.errors.ChannelNameDuplicateException; +import de.tum.cit.aet.artemis.communication.service.notifications.SingleUserNotificationService; import de.tum.cit.aet.artemis.core.domain.Course; import de.tum.cit.aet.artemis.core.domain.User; import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException; import de.tum.cit.aet.artemis.core.repository.UserRepository; import de.tum.cit.aet.artemis.exam.domain.Exam; import de.tum.cit.aet.artemis.exercise.domain.Exercise; +import de.tum.cit.aet.artemis.exercise.repository.StudentParticipationRepository; import de.tum.cit.aet.artemis.lecture.domain.Lecture; @Profile(PROFILE_CORE) @@ -47,12 +50,18 @@ public class ChannelService { private final UserRepository userRepository; + private final StudentParticipationRepository studentParticipationRepository; + + private final SingleUserNotificationService singleUserNotificationService; + public ChannelService(ConversationParticipantRepository conversationParticipantRepository, ChannelRepository channelRepository, ConversationService conversationService, - UserRepository userRepository) { + UserRepository userRepository, StudentParticipationRepository studentParticipationRepository, SingleUserNotificationService singleUserNotificationService) { this.conversationParticipantRepository = conversationParticipantRepository; this.channelRepository = channelRepository; this.conversationService = conversationService; this.userRepository = userRepository; + this.studentParticipationRepository = studentParticipationRepository; + this.singleUserNotificationService = singleUserNotificationService; } /** @@ -405,4 +414,40 @@ private static String generateChannelNameFromTitle(@NotNull String prefix, Optio } return channelName; } + + /** + * Creates a feedback-specific channel for an exercise within a course. + * + * @param course in which the channel is being created. + * @param exerciseId of the exercise associated with the feedback channel. + * @param channelDTO containing the properties of the channel to be created, such as name, description, and visibility. + * @param feedbackDetailText used to identify the students affected by the feedback. + * @param requestingUser initiating the channel creation request. + * @return the created {@link Channel} object with its properties. + * @throws BadRequestAlertException if the channel name starts with an invalid prefix (e.g., "$"). + */ + public Channel createFeedbackChannel(Course course, Long exerciseId, ChannelDTO channelDTO, String feedbackDetailText, User requestingUser) { + Channel channelToCreate = new Channel(); + channelToCreate.setName(channelDTO.getName()); + channelToCreate.setIsPublic(channelDTO.getIsPublic()); + channelToCreate.setIsAnnouncementChannel(channelDTO.getIsAnnouncementChannel()); + channelToCreate.setIsArchived(false); + channelToCreate.setDescription(channelDTO.getDescription()); + + if (channelToCreate.getName() != null && channelToCreate.getName().trim().startsWith("$")) { + throw new BadRequestAlertException("User generated channels cannot start with $", "channel", "channelNameInvalid"); + } + + Channel createdChannel = createChannel(course, channelToCreate, Optional.of(requestingUser)); + + List userLogins = studentParticipationRepository.findAffectedLoginsByFeedbackDetailText(exerciseId, feedbackDetailText); + + if (userLogins != null && !userLogins.isEmpty()) { + var registeredUsers = registerUsersToChannel(false, false, false, userLogins, course, createdChannel); + registeredUsers.forEach(user -> singleUserNotificationService.notifyClientAboutConversationCreationOrDeletion(createdChannel, user, requestingUser, + NotificationType.CONVERSATION_ADD_USER_CHANNEL)); + } + + return createdChannel; + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/MailService.java b/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/MailService.java index fc6f7e9e1256..88926325aa4e 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/MailService.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/MailService.java @@ -5,6 +5,8 @@ import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; import java.net.URL; +import java.util.HashMap; +import java.util.List; import java.util.Locale; import java.util.Set; @@ -64,6 +66,8 @@ public class MailService implements InstantNotificationService { private final MailSendingService mailSendingService; + private final List markdownCustomRendererServices; + // notification related variables private static final String NOTIFICATION = "notification"; @@ -89,11 +93,16 @@ public class MailService implements InstantNotificationService { private static final String WEEKLY_SUMMARY_NEW_EXERCISES = "weeklySummaryNewExercises"; - public MailService(MessageSource messageSource, SpringTemplateEngine templateEngine, TimeService timeService, MailSendingService mailSendingService) { + private final HashMap renderedPosts; + + public MailService(MessageSource messageSource, SpringTemplateEngine templateEngine, TimeService timeService, MailSendingService mailSendingService, + MarkdownCustomLinkRendererService markdownCustomLinkRendererService, MarkdownCustomReferenceRendererService markdownCustomReferenceRendererService) { this.messageSource = messageSource; this.templateEngine = templateEngine; this.timeService = timeService; this.mailSendingService = mailSendingService; + markdownCustomRendererServices = List.of(markdownCustomLinkRendererService, markdownCustomReferenceRendererService); + renderedPosts = new HashMap<>(); } /** @@ -266,14 +275,30 @@ public void sendNotification(Notification notification, User user, Object notifi // Render markdown content of post to html try { - Parser parser = Parser.builder().build(); - HtmlRenderer renderer = HtmlRenderer.builder().build(); - String postContent = post.getContent(); - String renderedPostContent = renderer.render(parser.parse(postContent)); + String renderedPostContent; + + // To avoid having to re-render the same post multiple times we store it in a hash map + if (renderedPosts.containsKey(post.getId())) { + renderedPostContent = renderedPosts.get(post.getId()); + } + else { + Parser parser = Parser.builder().build(); + HtmlRenderer renderer = HtmlRenderer.builder() + .attributeProviderFactory(attributeContext -> new MarkdownRelativeToAbsolutePathAttributeProvider(artemisServerUrl.toString())) + .nodeRendererFactory(new MarkdownImageBlockRendererFactory(artemisServerUrl.toString())).build(); + String postContent = post.getContent(); + renderedPostContent = markdownCustomRendererServices.stream().reduce(renderer.render(parser.parse(postContent)), (s, service) -> service.render(s), + (s1, s2) -> s2); + if (post.getId() != null) { + renderedPosts.put(post.getId(), renderedPostContent); + } + } + post.setContent(renderedPostContent); } catch (Exception e) { // In case something goes wrong, leave content of post as-is + log.error("Error while parsing post content", e); } } else { diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/MarkdownCustomLinkRendererService.java b/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/MarkdownCustomLinkRendererService.java new file mode 100644 index 000000000000..1f06aef2404c --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/MarkdownCustomLinkRendererService.java @@ -0,0 +1,71 @@ +package de.tum.cit.aet.artemis.communication.service.notifications; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import java.net.URL; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * This service implements the rendering of markdown tags that represent a link. + * It takes the tag, transforms it into an tag, and sets the corresponding href. + */ +@Profile(PROFILE_CORE) +@Service +public class MarkdownCustomLinkRendererService implements MarkdownCustomRendererService { + + private static final Logger log = LoggerFactory.getLogger(MarkdownCustomLinkRendererService.class); + + private final Set supportedTags; + + @Value("${server.url}") + private URL artemisServerUrl; + + public MarkdownCustomLinkRendererService() { + this.supportedTags = Set.of("programming", "modeling", "quiz", "text", "file-upload", "lecture", "attachment", "lecture-unit", "slide", "faq"); + } + + /** + * Takes a string and replaces all occurrences of custom markdown tags (e.g. [programming], [faq], etc.) with a link + * + * @param content string to render + * + * @return the newly rendered string. + */ + public String render(String content) { + String tagPattern = String.join("|", supportedTags); + // The pattern checks for the occurrence of any tag and then extracts the link from it + Pattern pattern = Pattern.compile("\\[(" + tagPattern + ")\\](.*?)\\((.*?)\\)(.*?)\\[/\\1\\]"); + Matcher matcher = pattern.matcher(content); + String parsedContent = content; + + while (matcher.find()) { + try { + String textStart = matcher.group(2); + String link = matcher.group(3); + String textEnd = matcher.group(4); + String text = (textStart + " " + textEnd).trim(); + + String absoluteUrl = UriComponentsBuilder.fromUri(artemisServerUrl.toURI()).path(link).build().toUriString(); + + parsedContent = parsedContent.substring(0, matcher.start()) + "" + text + "" + parsedContent.substring(matcher.end()); + } + catch (Exception e) { + log.error("Not able to render tag. Replacing with empty.", e); + parsedContent = parsedContent.substring(0, matcher.start()) + parsedContent.substring(matcher.end()); + } + + matcher = pattern.matcher(parsedContent); + } + + return parsedContent; + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/MarkdownCustomReferenceRendererService.java b/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/MarkdownCustomReferenceRendererService.java new file mode 100644 index 000000000000..3ff199e10b77 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/MarkdownCustomReferenceRendererService.java @@ -0,0 +1,72 @@ +package de.tum.cit.aet.artemis.communication.service.notifications; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import java.util.HashMap; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +/** + * This service implements the rendering of markdown tags that represent a reference (to e.g. a user). + * These references cannot directly represent a link, so they are rendered as their text only. + */ +@Profile(PROFILE_CORE) +@Service +public class MarkdownCustomReferenceRendererService implements MarkdownCustomRendererService { + + private static final Logger log = LoggerFactory.getLogger(MarkdownCustomReferenceRendererService.class); + + private final Set supportedTags; + + private final HashMap startingCharacters; + + public MarkdownCustomReferenceRendererService() { + supportedTags = Set.of("user", "channel"); + startingCharacters = new HashMap<>(); + startingCharacters.put("user", "@"); + startingCharacters.put("channel", "#"); + } + + /** + * Takes a string and replaces all occurrences of custom markdown tags (e.g. [user], [channel], etc.) with text. + * To make it better readable, it prepends an appropriate character. (e.g. for users an @, for channels a #) + * + * @param content string to render + * + * @return the newly rendered string. + */ + @Override + public String render(String content) { + String tagPattern = String.join("|", supportedTags); + Pattern pattern = Pattern.compile("\\[(" + tagPattern + ")\\](.*?)\\((.*?)\\)(.*?)\\[/\\1\\]"); + Matcher matcher = pattern.matcher(content); + String parsedContent = content; + + while (matcher.find()) { + try { + String tag = matcher.group(1); + String startingCharacter = startingCharacters.get(tag); + startingCharacter = startingCharacter == null ? "" : startingCharacter; + String textStart = matcher.group(2); + String textEnd = matcher.group(4); + String text = startingCharacter + (textStart + " " + textEnd).trim(); + + parsedContent = parsedContent.substring(0, matcher.start()) + text + parsedContent.substring(matcher.end()); + } + catch (Exception e) { + log.error("Not able to render tag. Replacing with empty.", e); + parsedContent = parsedContent.substring(0, matcher.start()) + parsedContent.substring(matcher.end()); + } + + matcher = pattern.matcher(parsedContent); + } + + return parsedContent; + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/MarkdownCustomRendererService.java b/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/MarkdownCustomRendererService.java new file mode 100644 index 000000000000..e96eaa8f7a01 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/MarkdownCustomRendererService.java @@ -0,0 +1,6 @@ +package de.tum.cit.aet.artemis.communication.service.notifications; + +public interface MarkdownCustomRendererService { + + String render(String content); +} diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/MarkdownImageBlockRenderer.java b/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/MarkdownImageBlockRenderer.java new file mode 100644 index 000000000000..8653e90aa2ea --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/MarkdownImageBlockRenderer.java @@ -0,0 +1,44 @@ +package de.tum.cit.aet.artemis.communication.service.notifications; + +import java.util.Map; +import java.util.Set; + +import org.commonmark.node.Image; +import org.commonmark.node.Node; +import org.commonmark.node.Text; +import org.commonmark.renderer.NodeRenderer; +import org.commonmark.renderer.html.HtmlNodeRendererContext; +import org.commonmark.renderer.html.HtmlWriter; + +public class MarkdownImageBlockRenderer implements NodeRenderer { + + private final String baseUrl; + + private final HtmlWriter html; + + MarkdownImageBlockRenderer(HtmlNodeRendererContext context, String baseUrl) { + html = context.getWriter(); + this.baseUrl = baseUrl; + } + + @Override + public Set> getNodeTypes() { + return Set.of(Image.class); + } + + @Override + public void render(Node node) { + Image image = (Image) node; + + html.tag("a", Map.of("href", baseUrl + image.getDestination())); + + try { + html.text(((Text) image.getFirstChild()).getLiteral()); + } + catch (Exception e) { + html.text(image.getDestination()); + } + + html.tag("/a"); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/MarkdownImageBlockRendererFactory.java b/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/MarkdownImageBlockRendererFactory.java new file mode 100644 index 000000000000..4516fc87af81 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/MarkdownImageBlockRendererFactory.java @@ -0,0 +1,19 @@ +package de.tum.cit.aet.artemis.communication.service.notifications; + +import org.commonmark.renderer.NodeRenderer; +import org.commonmark.renderer.html.HtmlNodeRendererContext; +import org.commonmark.renderer.html.HtmlNodeRendererFactory; + +public class MarkdownImageBlockRendererFactory implements HtmlNodeRendererFactory { + + private final String baseUrl; + + public MarkdownImageBlockRendererFactory(String baseUrl) { + this.baseUrl = baseUrl; + } + + @Override + public NodeRenderer create(HtmlNodeRendererContext context) { + return new MarkdownImageBlockRenderer(context, baseUrl); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/MarkdownRelativeToAbsolutePathAttributeProvider.java b/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/MarkdownRelativeToAbsolutePathAttributeProvider.java new file mode 100644 index 000000000000..de95f6cf3af7 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/MarkdownRelativeToAbsolutePathAttributeProvider.java @@ -0,0 +1,32 @@ +package de.tum.cit.aet.artemis.communication.service.notifications; + +import java.util.Map; + +import org.commonmark.node.Node; +import org.commonmark.renderer.html.AttributeProvider; + +public class MarkdownRelativeToAbsolutePathAttributeProvider implements AttributeProvider { + + private final String baseUrl; + + public MarkdownRelativeToAbsolutePathAttributeProvider(String baseUrl) { + this.baseUrl = baseUrl; + } + + /** + * We store images and attachments with relative urls, so when rendering we need to replace them with absolute ones + * + * @param node rendered Node, if Image or Link we try to replace the source + * @param attributes of the Node + * @param tagName of the html element + */ + @Override + public void setAttributes(Node node, String tagName, Map attributes) { + if ("a".equals(tagName)) { + String href = attributes.get("href"); + if (href != null && href.startsWith("/")) { + attributes.put("href", baseUrl + href); + } + } + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/web/ConversationMessageResource.java b/src/main/java/de/tum/cit/aet/artemis/communication/web/ConversationMessageResource.java index bfa04d53cc5f..75c68fbec7a1 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/web/ConversationMessageResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/web/ConversationMessageResource.java @@ -132,6 +132,8 @@ else if (postContextFilter.courseWideChannelIds() != null) { if (post.getConversation() != null) { post.getConversation().hideDetails(); } + + conversationMessagingService.preparePostForBroadcast(post); }); final var headers = PaginationUtil.generatePaginationHttpHeaders(ServletUriComponentsBuilder.fromCurrentRequest(), coursePosts); logDuration(coursePosts.getContent(), principal, timeNanoStart); diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/web/SavedPostResource.java b/src/main/java/de/tum/cit/aet/artemis/communication/web/SavedPostResource.java new file mode 100644 index 000000000000..a7c6ba9c7faa --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/communication/web/SavedPostResource.java @@ -0,0 +1,218 @@ +package de.tum.cit.aet.artemis.communication.web; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import de.tum.cit.aet.artemis.communication.domain.AnswerPost; +import de.tum.cit.aet.artemis.communication.domain.Post; +import de.tum.cit.aet.artemis.communication.domain.Posting; +import de.tum.cit.aet.artemis.communication.domain.PostingType; +import de.tum.cit.aet.artemis.communication.domain.SavedPost; +import de.tum.cit.aet.artemis.communication.domain.SavedPostStatus; +import de.tum.cit.aet.artemis.communication.dto.PostingDTO; +import de.tum.cit.aet.artemis.communication.repository.AnswerPostRepository; +import de.tum.cit.aet.artemis.communication.repository.PostRepository; +import de.tum.cit.aet.artemis.communication.service.SavedPostService; +import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException; +import de.tum.cit.aet.artemis.core.exception.EntityNotFoundException; +import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastStudent; +import de.tum.cit.aet.artemis.core.util.TimeLogUtil; + +/** + * REST controller for managing Message Posts. + */ +@Profile(PROFILE_CORE) +@RestController +@RequestMapping("api/") +public class SavedPostResource { + + private static final Logger log = LoggerFactory.getLogger(SavedPostResource.class); + + public static final String ENTITY_NAME = "savedPost"; + + private final SavedPostService savedPostService; + + private final PostRepository postRepository; + + private final AnswerPostRepository answerPostRepository; + + public SavedPostResource(SavedPostService savedPostService, PostRepository postRepository, AnswerPostRepository answerPostRepository) { + this.savedPostService = savedPostService; + this.postRepository = postRepository; + this.answerPostRepository = answerPostRepository; + } + + /** + * GET /saved-posts/{courseId}/{status} : Get saved posts of course with specific status + * + * @param courseId id of course to filter posts + * @param status saved post status (progress, completed, archived) + * @return ResponseEntity with status 200 (Success) if course id and status are ok, + * or with status 400 (Bad Request) if the checks on type or post validity fail + */ + @GetMapping("saved-posts/{courseId}/{status}") + @EnforceAtLeastStudent + public ResponseEntity> getSavedPosts(@PathVariable Long courseId, @PathVariable short status) { + log.debug("GET getSavedPosts invoked for course {} and status {}", courseId, status); + long start = System.nanoTime(); + + SavedPostStatus savedPostStatus; + try { + savedPostStatus = SavedPostStatus.fromDatabaseKey(status); + } + catch (IllegalArgumentException e) { + throw new BadRequestAlertException("The provided post status could not be found.", ENTITY_NAME, "savedPostStatusDoesNotExist"); + } + + var savedPosts = savedPostService.getSavedPostsForCurrentUserByStatus(savedPostStatus); + + List posts = postRepository.findByIdIn(savedPosts.stream().filter(savedPost -> savedPost.getPostType() == PostingType.POST).map(SavedPost::getPostId).toList()) + .stream().filter(post -> Objects.equals(post.getCoursePostingBelongsTo().getId(), courseId)).toList(); + List answerPosts = answerPostRepository + .findByIdIn(savedPosts.stream().filter(savedPost -> savedPost.getPostType() == PostingType.ANSWER).map(SavedPost::getPostId).toList()).stream() + .filter(post -> Objects.equals(post.getCoursePostingBelongsTo().getId(), courseId)).toList(); + List postingList = new ArrayList<>(); + + for (SavedPost savedPost : savedPosts) { + Optional posting; + if (savedPost.getPostType() == PostingType.ANSWER) { + posting = answerPosts.stream().filter(answerPost -> answerPost.getId().equals(savedPost.getPostId())).findFirst(); + } + else { + posting = posts.stream().filter(post -> post.getId().equals(savedPost.getPostId())).findFirst(); + } + if (posting.isPresent()) { + postingList.add(new PostingDTO((Posting) posting.get(), true, savedPost.getStatus().getDatabaseKey())); + } + } + + log.info("getSavedPosts took {}", TimeLogUtil.formatDurationFrom(start)); + return new ResponseEntity<>(postingList, null, HttpStatus.OK); + } + + /** + * POST /saved-posts/{postId}/{type} : Create a new saved post + * + * @param postId post to save + * @param type post type (post, answer) + * @return ResponseEntity with status 201 (Created) if successfully saved post, + * or with status 400 (Bad Request) if the checks on type or post validity fail + */ + @PostMapping("saved-posts/{postId}/{type}") + @EnforceAtLeastStudent + public ResponseEntity savePost(@PathVariable Long postId, @PathVariable short type) { + log.debug("POST savePost invoked for post {}", postId); + long start = System.nanoTime(); + + if (savedPostService.isMaximumSavedPostsReached()) { + throw new BadRequestAlertException("The maximum amount of saved posts was reached.", ENTITY_NAME, "savedPostMaxReached"); + } + + var post = retrievePostingElseThrow(postId, type); + + this.savedPostService.savePostForCurrentUser(post); + + log.info("savePost took {}", TimeLogUtil.formatDurationFrom(start)); + return new ResponseEntity<>(null, null, HttpStatus.CREATED); + } + + /** + * DELETE /saved-posts/{postId}/{type} : Remove a saved post + * + * @param postId post to save + * @param type post type (post, answer) + * @return ResponseEntity with status 204 (No content) if successfully deleted post, + * or with status 400 (Bad Request) if the checks on type or post validity fail + */ + @DeleteMapping("saved-posts/{postId}/{type}") + @EnforceAtLeastStudent + public ResponseEntity deleteSavedPost(@PathVariable Long postId, @PathVariable short type) { + log.debug("DELETE deletePost invoked for post {}", postId); + long start = System.nanoTime(); + + var post = retrievePostingElseThrow(postId, type); + + if (!this.savedPostService.removeSavedPostForCurrentUser(post)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "You are not allowed to delete this bookmark."); + } + + log.info("deletePost took {}", TimeLogUtil.formatDurationFrom(start)); + return new ResponseEntity<>(null, null, HttpStatus.NO_CONTENT); + } + + /** + * PUT /saved-posts/{postId}/{type} : Update the status of a saved post + * + * @param postId post to save + * @param type post type (post, answer) + * @param status saved post status (progress, answer) + * @return ResponseEntity with status 200 (Success) if successfully updated saved post status, + * or with status 400 (Bad Request) if the checks on type or post validity fail + */ + @PutMapping("saved-posts/{postId}/{type}") + @EnforceAtLeastStudent + public ResponseEntity putSavedPost(@PathVariable Long postId, @PathVariable short type, @RequestParam(name = "status") short status) { + log.debug("DELETE putSavedPost invoked for post {}", postId); + long start = System.nanoTime(); + + var post = retrievePostingElseThrow(postId, type); + + SavedPostStatus savedPostStatus; + try { + savedPostStatus = SavedPostStatus.fromDatabaseKey(status); + } + catch (IllegalArgumentException e) { + throw new BadRequestAlertException("The provided post status could not be found.", ENTITY_NAME, "savedPostStatusDoesNotExist"); + } + + this.savedPostService.updateStatusOfSavedPostForCurrentUser(post, savedPostStatus); + + log.info("putSavedPost took {}", TimeLogUtil.formatDurationFrom(start)); + return new ResponseEntity<>(null, null, HttpStatus.OK); + } + + private Posting retrievePostingElseThrow(long postId, short type) throws BadRequestAlertException { + PostingType postingType; + + try { + postingType = PostingType.fromDatabaseKey(type); + } + catch (IllegalArgumentException e) { + throw new BadRequestAlertException("The provided post type could not be found.", ENTITY_NAME, "savedPostTypeDoesNotExist"); + } + + Posting post; + try { + if (postingType == PostingType.POST) { + post = postRepository.findPostOrMessagePostByIdElseThrow(postId); + } + else { + post = answerPostRepository.findAnswerPostOrMessageByIdElseThrow(postId); + } + } + catch (EntityNotFoundException e) { + throw new BadRequestAlertException("The provided post could not be found.", ENTITY_NAME, "savedPostIdDoesNotExist"); + } + + return post; + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/web/conversation/ChannelResource.java b/src/main/java/de/tum/cit/aet/artemis/communication/web/conversation/ChannelResource.java index 03c7445e02e0..cbb59c4b7e46 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/web/conversation/ChannelResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/web/conversation/ChannelResource.java @@ -35,6 +35,7 @@ import de.tum.cit.aet.artemis.communication.domain.conversation.Channel; import de.tum.cit.aet.artemis.communication.dto.ChannelDTO; import de.tum.cit.aet.artemis.communication.dto.ChannelIdAndNameDTO; +import de.tum.cit.aet.artemis.communication.dto.FeedbackChannelRequestDTO; import de.tum.cit.aet.artemis.communication.repository.ConversationParticipantRepository; import de.tum.cit.aet.artemis.communication.repository.conversation.ChannelRepository; import de.tum.cit.aet.artemis.communication.service.conversation.ChannelService; @@ -42,6 +43,7 @@ import de.tum.cit.aet.artemis.communication.service.conversation.ConversationService; import de.tum.cit.aet.artemis.communication.service.conversation.auth.ChannelAuthorizationService; import de.tum.cit.aet.artemis.communication.service.notifications.SingleUserNotificationService; +import de.tum.cit.aet.artemis.core.domain.Course; import de.tum.cit.aet.artemis.core.domain.User; import de.tum.cit.aet.artemis.core.exception.AccessForbiddenAlertException; import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException; @@ -50,7 +52,9 @@ import de.tum.cit.aet.artemis.core.repository.UserRepository; import de.tum.cit.aet.artemis.core.security.Role; import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastStudent; +import de.tum.cit.aet.artemis.core.security.annotations.enforceRoleInCourse.EnforceAtLeastEditorInCourse; import de.tum.cit.aet.artemis.core.service.AuthorizationCheckService; +import de.tum.cit.aet.artemis.exercise.repository.StudentParticipationRepository; import de.tum.cit.aet.artemis.tutorialgroup.service.TutorialGroupChannelManagementService; @Profile(PROFILE_CORE) @@ -80,10 +84,13 @@ public class ChannelResource extends ConversationManagementResource { private final ConversationParticipantRepository conversationParticipantRepository; + private final StudentParticipationRepository studentParticipationRepository; + public ChannelResource(ConversationParticipantRepository conversationParticipantRepository, SingleUserNotificationService singleUserNotificationService, ChannelService channelService, ChannelRepository channelRepository, ChannelAuthorizationService channelAuthorizationService, AuthorizationCheckService authorizationCheckService, ConversationDTOService conversationDTOService, CourseRepository courseRepository, UserRepository userRepository, - ConversationService conversationService, TutorialGroupChannelManagementService tutorialGroupChannelManagementService) { + ConversationService conversationService, TutorialGroupChannelManagementService tutorialGroupChannelManagementService, + StudentParticipationRepository studentParticipationRepository) { super(courseRepository); this.channelService = channelService; this.channelRepository = channelRepository; @@ -95,6 +102,7 @@ public ChannelResource(ConversationParticipantRepository conversationParticipant this.tutorialGroupChannelManagementService = tutorialGroupChannelManagementService; this.singleUserNotificationService = singleUserNotificationService; this.conversationParticipantRepository = conversationParticipantRepository; + this.studentParticipationRepository = studentParticipationRepository; } /** @@ -460,6 +468,34 @@ public ResponseEntity deregisterUsers(@PathVariable Long courseId, @PathVa return ResponseEntity.ok().build(); } + /** + * POST /api/courses/:courseId/channels/: Creates a new feedback-specific channel in a course. + * + * @param courseId where the channel is being created. + * @param exerciseId for which the feedback channel is being created. + * @param feedbackChannelRequest containing a DTO with the properties of the channel (e.g., name, description, visibility) + * and the feedback detail text used to determine the affected students to be added to the channel. + * @return ResponseEntity with status 201 (Created) and the body containing the details of the created channel. + * @throws URISyntaxException if the URI for the created resource cannot be constructed. + * @throws BadRequestAlertException if the channel name starts with an invalid prefix (e.g., "$"). + */ + @PostMapping("{courseId}/{exerciseId}/feedback-channel") + @EnforceAtLeastEditorInCourse + public ResponseEntity createFeedbackChannel(@PathVariable Long courseId, @PathVariable Long exerciseId, + @RequestBody FeedbackChannelRequestDTO feedbackChannelRequest) throws URISyntaxException { + log.debug("REST request to create feedback channel for course {} and exercise {} with properties: {}", courseId, exerciseId, feedbackChannelRequest); + + ChannelDTO channelDTO = feedbackChannelRequest.channel(); + String feedbackDetailText = feedbackChannelRequest.feedbackDetailText(); + + User requestingUser = userRepository.getUserWithGroupsAndAuthorities(); + Course course = courseRepository.findByIdElseThrow(courseId); + checkCommunicationEnabledElseThrow(course); + Channel createdChannel = channelService.createFeedbackChannel(course, exerciseId, channelDTO, feedbackDetailText, requestingUser); + + return ResponseEntity.created(new URI("/api/channels/" + createdChannel.getId())).body(conversationDTOService.convertChannelToDTO(requestingUser, createdChannel)); + } + private void checkEntityIdMatchesPathIds(Channel channel, Optional courseId, Optional conversationId) { courseId.ifPresent(courseIdValue -> { if (!channel.getCourse().getId().equals(courseIdValue)) { diff --git a/src/main/java/de/tum/cit/aet/artemis/core/api/AbstractApi.java b/src/main/java/de/tum/cit/aet/artemis/core/api/AbstractApi.java new file mode 100644 index 000000000000..8ca26f995aa0 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/core/api/AbstractApi.java @@ -0,0 +1,4 @@ +package de.tum.cit.aet.artemis.core.api; + +public interface AbstractApi { +} diff --git a/src/main/java/de/tum/cit/aet/artemis/core/config/MetricsBean.java b/src/main/java/de/tum/cit/aet/artemis/core/config/MetricsBean.java index 6d2bff114552..3accd0fdd184 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/config/MetricsBean.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/config/MetricsBean.java @@ -290,21 +290,21 @@ private void registerLocalCIMetrics() { } private static int extractRunningBuilds(Optional sharedQueueManagementService) { - return sharedQueueManagementService.map(queueManagementService -> queueManagementService.getBuildAgentInformation().stream() - .map(buildAgentInformation -> buildAgentInformation.runningBuildJobs().size()).reduce(0, Integer::sum)).orElse(0); + return sharedQueueManagementService.map(SharedQueueManagementService::getProcessingJobsSize).orElse(0); } private static int extractQueuedBuilds(Optional sharedQueueManagementService) { - return sharedQueueManagementService.map(queueManagementService -> queueManagementService.getQueuedJobs().size()).orElse(0); + return sharedQueueManagementService.map(SharedQueueManagementService::getQueuedJobsSize).orElse(0); } private static int extractBuildAgents(Optional sharedQueueManagementService) { - return sharedQueueManagementService.map(queueManagementService -> queueManagementService.getBuildAgentInformation().size()).orElse(0); + return sharedQueueManagementService.map(SharedQueueManagementService::getBuildAgentInformationSize).orElse(0); } private static int extractMaxConcurrentBuilds(Optional sharedQueueManagementService) { return sharedQueueManagementService.map(queueManagementService -> queueManagementService.getBuildAgentInformation().stream() - .map(BuildAgentInformation::maxNumberOfConcurrentBuildJobs).reduce(0, Integer::sum)).orElse(0); + .filter(agent -> agent.status() != BuildAgentInformation.BuildAgentStatus.PAUSED).map(BuildAgentInformation::maxNumberOfConcurrentBuildJobs) + .reduce(0, Integer::sum)).orElse(0); } private void registerWebsocketMetrics() { diff --git a/src/main/java/de/tum/cit/aet/artemis/core/config/migration/entries/MigrationEntry20240614_140000.java b/src/main/java/de/tum/cit/aet/artemis/core/config/migration/entries/MigrationEntry20240614_140000.java index 7371408c2df6..b385c3f01053 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/config/migration/entries/MigrationEntry20240614_140000.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/config/migration/entries/MigrationEntry20240614_140000.java @@ -6,9 +6,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import de.tum.cit.aet.artemis.atlas.domain.competency.Competency; -import de.tum.cit.aet.artemis.atlas.repository.CompetencyRepository; -import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyProgressService; +import de.tum.cit.aet.artemis.atlas.api.CompetencyProgressApi; import de.tum.cit.aet.artemis.core.config.migration.MigrationEntry; import de.tum.cit.aet.artemis.core.domain.Course; import de.tum.cit.aet.artemis.core.repository.CourseRepository; @@ -19,14 +17,11 @@ public class MigrationEntry20240614_140000 extends MigrationEntry { private final CourseRepository courseRepository; - private final CompetencyRepository competencyRepository; + private final CompetencyProgressApi competencyProgressApi; - private final CompetencyProgressService competencyProgressService; - - public MigrationEntry20240614_140000(CourseRepository courseRepository, CompetencyRepository competencyRepository, CompetencyProgressService competencyProgressService) { + public MigrationEntry20240614_140000(CourseRepository courseRepository, CompetencyProgressApi competencyProgressApi) { this.courseRepository = courseRepository; - this.competencyRepository = competencyRepository; - this.competencyProgressService = competencyProgressService; + this.competencyProgressApi = competencyProgressApi; } @Override @@ -34,12 +29,7 @@ public void execute() { List activeCourses = courseRepository.findAllActiveWithoutTestCourses(ZonedDateTime.now()); log.info("Updating competency progress for {} active courses", activeCourses.size()); - - activeCourses.forEach(course -> { - List competencies = competencyRepository.findByCourseIdOrderById(course.getId()); - // Asynchronously update the progress for each competency - competencies.forEach(competencyProgressService::updateProgressByCompetencyAsync); - }); + competencyProgressApi.updateProgressForCoursesAsync(activeCourses); } @Override diff --git a/src/main/java/de/tum/cit/aet/artemis/core/domain/User.java b/src/main/java/de/tum/cit/aet/artemis/core/domain/User.java index 6498340f3bc2..c435194cdf40 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/domain/User.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/domain/User.java @@ -40,6 +40,7 @@ import de.tum.cit.aet.artemis.atlas.domain.competency.CompetencyProgress; import de.tum.cit.aet.artemis.atlas.domain.competency.LearningPath; +import de.tum.cit.aet.artemis.communication.domain.SavedPost; import de.tum.cit.aet.artemis.communication.domain.push_notification.PushNotificationDeviceConfiguration; import de.tum.cit.aet.artemis.core.config.Constants; import de.tum.cit.aet.artemis.core.exception.AccessForbiddenException; @@ -155,23 +156,6 @@ public class User extends AbstractAuditingEntity implements Participant { @Column(name = "vcs_access_token_expiry_date") private ZonedDateTime vcsAccessTokenExpiryDate = null; - /** - * The actual full public ssh key of a user used to authenticate git clone and git push operations if available - */ - @Nullable - @JsonIgnore - @Column(name = "ssh_public_key") - private final String sshPublicKey = null; - - /** - * A hash of the public ssh key for fast comparison in the database (with an index) - */ - @Nullable - @Size(max = 100) - @JsonIgnore - @Column(name = "ssh_public_key_hash") - private final String sshPublicKeyHash = null; - @ElementCollection(fetch = FetchType.LAZY) @CollectionTable(name = "user_groups", joinColumns = @JoinColumn(name = "user_id")) @Column(name = "user_groups") @@ -180,6 +164,9 @@ public class User extends AbstractAuditingEntity implements Participant { @OneToMany(mappedBy = "user", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) private Set guidedTourSettings = new HashSet<>(); + @OneToMany(mappedBy = "user", fetch = FetchType.LAZY, cascade = CascadeType.REMOVE, orphanRemoval = true) + private Set savedPosts = new HashSet<>(); + @ManyToMany @JoinTable(name = "jhi_user_authority", joinColumns = { @JoinColumn(name = "user_id", referencedColumnName = "id") }, inverseJoinColumns = { @JoinColumn(name = "authority_name", referencedColumnName = "name") }) @@ -556,14 +543,4 @@ public void hasAcceptedIrisElseThrow() { throw new AccessForbiddenException("The user has not accepted the Iris privacy policy yet."); } } - - @Nullable - public String getSshPublicKey() { - return sshPublicKey; - } - - @Nullable - public @Size(max = 100) String getSshPublicKeyHash() { - return sshPublicKeyHash; - } } diff --git a/src/main/java/de/tum/cit/aet/artemis/core/dto/UserDTO.java b/src/main/java/de/tum/cit/aet/artemis/core/dto/UserDTO.java index 3d627425a8f7..1f5ea1e653b8 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/dto/UserDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/dto/UserDTO.java @@ -76,10 +76,6 @@ public class UserDTO extends AuditingEntityDTO { private ZonedDateTime vcsAccessTokenExpiryDate; - private String sshPublicKey; - - private String sshKeyHash; - private ZonedDateTime irisAccepted; public UserDTO() { @@ -262,14 +258,6 @@ public ZonedDateTime getVcsAccessTokenExpiryDate() { return vcsAccessTokenExpiryDate; } - public String getSshPublicKey() { - return sshPublicKey; - } - - public void setSshPublicKey(String sshPublicKey) { - this.sshPublicKey = sshPublicKey; - } - @Override public String toString() { return "UserDTO{" + "login='" + login + '\'' + ", firstName='" + firstName + '\'' + ", lastName='" + lastName + '\'' + ", email='" + email + '\'' + ", imageUrl='" @@ -293,12 +281,4 @@ public ZonedDateTime getIrisAccepted() { public void setIrisAccepted(ZonedDateTime irisAccepted) { this.irisAccepted = irisAccepted; } - - public String getSshKeyHash() { - return sshKeyHash; - } - - public void setSshKeyHash(String sshKeyHash) { - this.sshKeyHash = sshKeyHash; - } } diff --git a/src/main/java/de/tum/cit/aet/artemis/core/repository/UserRepository.java b/src/main/java/de/tum/cit/aet/artemis/core/repository/UserRepository.java index 5b66b31aee98..0d3280cf5d96 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/repository/UserRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/repository/UserRepository.java @@ -724,16 +724,6 @@ default Page searchAllWithGroupsByLoginOrNameInCourseAndReturnPage(Pageabl """) void updateUserLanguageKey(@Param("userId") long userId, @Param("languageKey") String languageKey); - @Modifying - @Transactional // ok because of modifying query - @Query(""" - UPDATE User user - SET user.sshPublicKeyHash = :sshPublicKeyHash, - user.sshPublicKey = :sshPublicKey - WHERE user.id = :userId - """) - void updateUserSshPublicKeyHash(@Param("userId") long userId, @Param("sshPublicKeyHash") String sshPublicKeyHash, @Param("sshPublicKey") String sshPublicKey); - @Modifying @Transactional // ok because of modifying query @Query(""" @@ -1120,8 +1110,6 @@ default boolean isCurrentUser(String login) { return SecurityUtils.getCurrentUserLogin().map(currentLogin -> currentLogin.equals(login)).orElse(false); } - Optional findBySshPublicKeyHash(String keyString); - /** * Finds all users which a non-null VCS access token that expires before some given date. * diff --git a/src/main/java/de/tum/cit/aet/artemis/core/service/CourseService.java b/src/main/java/de/tum/cit/aet/artemis/core/service/CourseService.java index 81aa6a27f6b0..1b037532e453 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/service/CourseService.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/service/CourseService.java @@ -53,10 +53,11 @@ import de.tum.cit.aet.artemis.assessment.service.ComplaintService; import de.tum.cit.aet.artemis.assessment.service.PresentationPointsCalculationService; import de.tum.cit.aet.artemis.assessment.service.TutorLeaderboardService; -import de.tum.cit.aet.artemis.atlas.repository.CompetencyRelationRepository; -import de.tum.cit.aet.artemis.atlas.repository.CompetencyRepository; -import de.tum.cit.aet.artemis.atlas.repository.PrerequisiteRepository; -import de.tum.cit.aet.artemis.atlas.service.learningpath.LearningPathService; +import de.tum.cit.aet.artemis.atlas.api.CompetencyProgressApi; +import de.tum.cit.aet.artemis.atlas.api.CompetencyRelationApi; +import de.tum.cit.aet.artemis.atlas.api.LearningPathApi; +import de.tum.cit.aet.artemis.atlas.api.PrerequisitesApi; +import de.tum.cit.aet.artemis.communication.domain.FaqState; import de.tum.cit.aet.artemis.communication.domain.NotificationType; import de.tum.cit.aet.artemis.communication.domain.Post; import de.tum.cit.aet.artemis.communication.domain.notification.GroupNotification; @@ -131,7 +132,7 @@ public class CourseService { private final TutorialGroupChannelManagementService tutorialGroupChannelManagementService; - private final CompetencyRelationRepository competencyRelationRepository; + private final CompetencyRelationApi competencyRelationApi; private final ExerciseService exerciseService; @@ -161,9 +162,9 @@ public class CourseService { private final AuditEventRepository auditEventRepository; - private final CompetencyRepository competencyRepository; + private final CompetencyProgressApi competencyProgressApi; - private final PrerequisiteRepository prerequisiteRepository; + private final PrerequisitesApi prerequisitesApi; private final GradingScaleRepository gradingScaleRepository; @@ -199,7 +200,7 @@ public class CourseService { private final ConversationRepository conversationRepository; - private final LearningPathService learningPathService; + private final LearningPathApi learningPathApi; private final Optional irisSettingsService; @@ -216,17 +217,17 @@ public class CourseService { public CourseService(CourseRepository courseRepository, ExerciseService exerciseService, ExerciseDeletionService exerciseDeletionService, AuthorizationCheckService authCheckService, UserRepository userRepository, LectureService lectureService, GroupNotificationRepository groupNotificationRepository, ExerciseGroupRepository exerciseGroupRepository, AuditEventRepository auditEventRepository, UserService userService, ExamDeletionService examDeletionService, - CompetencyRepository competencyRepository, GroupNotificationService groupNotificationService, ExamRepository examRepository, + CompetencyProgressApi competencyProgressApi, GroupNotificationService groupNotificationService, ExamRepository examRepository, CourseExamExportService courseExamExportService, GradingScaleRepository gradingScaleRepository, StatisticsRepository statisticsRepository, StudentParticipationRepository studentParticipationRepository, TutorLeaderboardService tutorLeaderboardService, RatingRepository ratingRepository, ComplaintService complaintService, ComplaintRepository complaintRepository, ResultRepository resultRepository, ComplaintResponseRepository complaintResponseRepository, SubmissionRepository submissionRepository, ProgrammingExerciseRepository programmingExerciseRepository, ExerciseRepository exerciseRepository, ParticipantScoreRepository participantScoreRepository, PresentationPointsCalculationService presentationPointsCalculationService, TutorialGroupRepository tutorialGroupRepository, PlagiarismCaseRepository plagiarismCaseRepository, ConversationRepository conversationRepository, - LearningPathService learningPathService, Optional irisSettingsService, LectureRepository lectureRepository, + LearningPathApi learningPathApi, Optional irisSettingsService, LectureRepository lectureRepository, TutorialGroupNotificationRepository tutorialGroupNotificationRepository, TutorialGroupChannelManagementService tutorialGroupChannelManagementService, - PrerequisiteRepository prerequisiteRepository, CompetencyRelationRepository competencyRelationRepository, PostRepository postRepository, - AnswerPostRepository answerPostRepository, BuildJobRepository buildJobRepository, FaqRepository faqRepository) { + PrerequisitesApi prerequisitesApi, CompetencyRelationApi competencyRelationApi, PostRepository postRepository, AnswerPostRepository answerPostRepository, + BuildJobRepository buildJobRepository, FaqRepository faqRepository) { this.courseRepository = courseRepository; this.exerciseService = exerciseService; this.exerciseDeletionService = exerciseDeletionService; @@ -238,7 +239,7 @@ public CourseService(CourseRepository courseRepository, ExerciseService exercise this.auditEventRepository = auditEventRepository; this.userService = userService; this.examDeletionService = examDeletionService; - this.competencyRepository = competencyRepository; + this.competencyProgressApi = competencyProgressApi; this.groupNotificationService = groupNotificationService; this.examRepository = examRepository; this.courseExamExportService = courseExamExportService; @@ -259,13 +260,13 @@ public CourseService(CourseRepository courseRepository, ExerciseService exercise this.tutorialGroupRepository = tutorialGroupRepository; this.plagiarismCaseRepository = plagiarismCaseRepository; this.conversationRepository = conversationRepository; - this.learningPathService = learningPathService; + this.learningPathApi = learningPathApi; this.irisSettingsService = irisSettingsService; this.lectureRepository = lectureRepository; this.tutorialGroupNotificationRepository = tutorialGroupNotificationRepository; this.tutorialGroupChannelManagementService = tutorialGroupChannelManagementService; - this.prerequisiteRepository = prerequisiteRepository; - this.competencyRelationRepository = competencyRelationRepository; + this.prerequisitesApi = prerequisitesApi; + this.competencyRelationApi = competencyRelationApi; this.buildJobRepository = buildJobRepository; this.postRepository = postRepository; this.answerPostRepository = answerPostRepository; @@ -343,7 +344,7 @@ public void fetchPlagiarismCasesForCourseExercises(Set exercises, Long * @param user the user entity * @return the course including exercises, lectures, exams, competencies and tutorial groups (filtered for given user) */ - public Course findOneWithExercisesAndLecturesAndExamsAndCompetenciesAndTutorialGroupsForUser(Long courseId, User user) { + public Course findOneWithExercisesAndLecturesAndExamsAndCompetenciesAndTutorialGroupsAndFaqForUser(Long courseId, User user) { Course course = courseRepository.findByIdWithLecturesElseThrow(courseId); // Load exercises with categories separately because this is faster than loading them with lectures and exam above (the query would become too complex) course.setExercises(exerciseRepository.findByCourseIdWithCategories(course.getId())); @@ -353,11 +354,14 @@ public Course findOneWithExercisesAndLecturesAndExamsAndCompetenciesAndTutorialG // TODO: in the future, we only want to know if lectures exist, the actual lectures will be loaded when the user navigates into the lecture course.setLectures(lectureService.filterVisibleLecturesWithActiveAttachments(course, course.getLectures(), user)); // NOTE: in this call we only want to know if competencies exist in the course, we will load them when the user navigates into them - course.setNumberOfCompetencies(competencyRepository.countByCourse(course)); + course.setNumberOfCompetencies(competencyProgressApi.countByCourse(course)); // NOTE: in this call we only want to know if prerequisites exist in the course, we will load them when the user navigates into them - course.setNumberOfPrerequisites(prerequisiteRepository.countByCourse(course)); + course.setNumberOfPrerequisites(prerequisitesApi.countByCourse(course)); // NOTE: in this call we only want to know if tutorial groups exist in the course, we will load them when the user navigates into them course.setNumberOfTutorialGroups(tutorialGroupRepository.countByCourse(course)); + if (course.isFaqEnabled()) { + course.setFaqs(faqRepository.findAllByCourseIdAndFaqState(courseId, FaqState.ACCEPTED)); + } if (authCheckService.isOnlyStudentInCourse(course, user)) { course.setExams(examRepository.filterVisibleExams(course.getExams())); } @@ -574,9 +578,9 @@ private void deleteExercisesOfCourse(Course course) { } private void deleteCompetenciesOfCourse(Course course) { - competencyRelationRepository.deleteAllByCourseId(course.getId()); - prerequisiteRepository.deleteAll(course.getPrerequisites()); - competencyRepository.deleteAll(course.getCompetencies()); + competencyRelationApi.deleteAllByCourseId(course.getId()); + prerequisitesApi.deleteAll(course.getPrerequisites()); + competencyProgressApi.deleteAll(course.getCompetencies()); } private void deleteFaqsOfCourse(Course course) { @@ -614,7 +618,7 @@ public void enrollUserForCourseOrThrow(User user, Course course) { authCheckService.checkUserAllowedToEnrollInCourseElseThrow(user, course); userService.addUserToGroup(user, course.getStudentGroupName()); if (course.getLearningPathsEnabled()) { - learningPathService.generateLearningPathForUser(course, user); + learningPathApi.generateLearningPathForUser(course, user); } final var auditEvent = new AuditEvent(user.getLogin(), Constants.ENROLL_IN_COURSE, "course=" + course.getTitle()); auditEventRepository.add(auditEvent); @@ -647,7 +651,7 @@ public List registerUsersForCourseGroup(Long courseId, List ldapUserService, GuidedTourSettingsRepository guidedTourSettingsRepository, PasswordService passwordService, Optional optionalVcsUserManagementService, Optional optionalCIUserManagementService, - InstanceMessageSendService instanceMessageSendService, FileService fileService, ScienceEventRepository scienceEventRepository, - ParticipationVcsAccessTokenService participationVCSAccessTokenService) { + InstanceMessageSendService instanceMessageSendService, FileService fileService, ScienceEventApi scienceEventApi, + ParticipationVcsAccessTokenService participationVCSAccessTokenService, SavedPostRepository savedPostRepository) { this.userCreationService = userCreationService; this.userRepository = userRepository; this.authorityService = authorityService; @@ -130,8 +134,9 @@ public UserService(UserCreationService userCreationService, UserRepository userR this.optionalCIUserManagementService = optionalCIUserManagementService; this.instanceMessageSendService = instanceMessageSendService; this.fileService = fileService; - this.scienceEventRepository = scienceEventRepository; + this.scienceEventApi = scienceEventApi; this.participationVCSAccessTokenService = participationVCSAccessTokenService; + this.savedPostRepository = savedPostRepository; } /** @@ -493,11 +498,17 @@ protected void anonymizeUser(User user) { user.setActivated(false); user.setGroups(Collections.emptySet()); + List savedPostsOfUser = savedPostRepository.findSavedPostsByUserId(user.getId()); + + if (!savedPostsOfUser.isEmpty()) { + savedPostRepository.deleteAll(savedPostsOfUser); + } + userRepository.save(user); clearUserCaches(user); userRepository.flush(); - scienceEventRepository.renameIdentity(originalLogin, anonymizedLogin); + scienceEventApi.renameIdentity(originalLogin, anonymizedLogin); if (userImageString != null) { fileService.schedulePathForDeletion(FilePathService.actualPathForPublicPath(URI.create(userImageString)), 0); diff --git a/src/main/java/de/tum/cit/aet/artemis/core/web/AccountResource.java b/src/main/java/de/tum/cit/aet/artemis/core/web/AccountResource.java index 997574c76da7..16c94629047c 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/web/AccountResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/web/AccountResource.java @@ -2,19 +2,15 @@ import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; -import java.io.IOException; import java.net.URI; import java.net.URISyntaxException; import java.nio.file.Path; -import java.security.GeneralSecurityException; -import java.security.PublicKey; import java.time.ZonedDateTime; import java.util.Optional; import jakarta.validation.Valid; import jakarta.ws.rs.BadRequestException; -import org.apache.sshd.common.config.keys.AuthorizedKeyEntry; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Profile; @@ -45,7 +41,6 @@ import de.tum.cit.aet.artemis.core.service.user.UserCreationService; import de.tum.cit.aet.artemis.core.service.user.UserService; import de.tum.cit.aet.artemis.programming.service.localvc.LocalVCPersonalAccessTokenManagementService; -import de.tum.cit.aet.artemis.programming.service.localvc.ssh.HashUtils; /** * REST controller for managing the current user's account. @@ -128,50 +123,6 @@ public ResponseEntity changePassword(@RequestBody PasswordChangeDTO passwo return ResponseEntity.ok().build(); } - /** - * PUT account/ssh-public-key : sets the ssh public key - * - * @param sshPublicKey the ssh public key to set - * - * @return the ResponseEntity with status 200 (OK), with status 404 (Not Found), or with status 400 (Bad Request) - */ - @PutMapping("account/ssh-public-key") - @EnforceAtLeastStudent - public ResponseEntity addSshPublicKey(@RequestBody String sshPublicKey) throws GeneralSecurityException, IOException { - - User user = userRepository.getUser(); - log.debug("REST request to add SSH key to user {}", user.getLogin()); - // Parse the public key string - AuthorizedKeyEntry keyEntry; - try { - keyEntry = AuthorizedKeyEntry.parseAuthorizedKeyEntry(sshPublicKey); - } - catch (IllegalArgumentException e) { - throw new BadRequestAlertException("Invalid SSH key format", "SSH key", "invalidKeyFormat", true); - } - // Extract the PublicKey object - PublicKey publicKey = keyEntry.resolvePublicKey(null, null, null); - String keyHash = HashUtils.getSha512Fingerprint(publicKey); - userRepository.updateUserSshPublicKeyHash(user.getId(), keyHash, sshPublicKey); - return ResponseEntity.ok().build(); - } - - /** - * PUT account/ssh-public-key : sets the ssh public key - * - * @return the ResponseEntity with status 200 (OK), with status 404 (Not Found), or with status 400 (Bad Request) - */ - @DeleteMapping("account/ssh-public-key") - @EnforceAtLeastStudent - public ResponseEntity deleteSshPublicKey() { - User user = userRepository.getUser(); - log.debug("REST request to remove SSH key of user {}", user.getLogin()); - userRepository.updateUserSshPublicKeyHash(user.getId(), null, null); - - log.debug("Successfully deleted SSH key of user {}", user.getLogin()); - return ResponseEntity.ok().build(); - } - /** * PUT account/user-vcs-access-token : creates a vcsAccessToken for a user * diff --git a/src/main/java/de/tum/cit/aet/artemis/core/web/CourseResource.java b/src/main/java/de/tum/cit/aet/artemis/core/web/CourseResource.java index ba364d0c4fb5..2545a6ffb192 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/web/CourseResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/web/CourseResource.java @@ -66,7 +66,7 @@ import de.tum.cit.aet.artemis.assessment.service.CourseScoreCalculationService; import de.tum.cit.aet.artemis.assessment.service.GradingScaleService; import de.tum.cit.aet.artemis.athena.service.AthenaModuleService; -import de.tum.cit.aet.artemis.atlas.service.learningpath.LearningPathService; +import de.tum.cit.aet.artemis.atlas.api.LearningPathApi; import de.tum.cit.aet.artemis.communication.service.ConductAgreementService; import de.tum.cit.aet.artemis.core.config.Constants; import de.tum.cit.aet.artemis.core.domain.Course; @@ -180,7 +180,7 @@ public class CourseResource { @Value("${artemis.course-archives-path}") private String courseArchivesDirPath; - private final LearningPathService learningPathService; + private final LearningPathApi learningPathApi; private final ExamRepository examRepository; @@ -193,7 +193,7 @@ public CourseResource(UserRepository userRepository, CourseService courseService TutorParticipationRepository tutorParticipationRepository, SubmissionService submissionService, Optional optionalVcsUserManagementService, AssessmentDashboardService assessmentDashboardService, ExerciseRepository exerciseRepository, Optional optionalCiUserManagementService, FileService fileService, TutorialGroupsConfigurationService tutorialGroupsConfigurationService, GradingScaleService gradingScaleService, - CourseScoreCalculationService courseScoreCalculationService, GradingScaleRepository gradingScaleRepository, LearningPathService learningPathService, + CourseScoreCalculationService courseScoreCalculationService, GradingScaleRepository gradingScaleRepository, LearningPathApi learningPathApi, ConductAgreementService conductAgreementService, Optional athenaModuleService, ExamRepository examRepository, ComplaintService complaintService, TeamRepository teamRepository) { this.courseService = courseService; @@ -213,7 +213,7 @@ public CourseResource(UserRepository userRepository, CourseService courseService this.gradingScaleService = gradingScaleService; this.courseScoreCalculationService = courseScoreCalculationService; this.gradingScaleRepository = gradingScaleRepository; - this.learningPathService = learningPathService; + this.learningPathApi = learningPathApi; this.conductAgreementService = conductAgreementService; this.athenaModuleService = athenaModuleService; this.examRepository = examRepository; @@ -335,7 +335,7 @@ else if (courseUpdate.getCourseIcon() == null && existingCourse.getCourseIcon() // if learning paths got enabled, generate learning paths for students if (existingCourse.getLearningPathsEnabled() != courseUpdate.getLearningPathsEnabled() && courseUpdate.getLearningPathsEnabled()) { Course courseWithCompetencies = courseRepository.findWithEagerCompetenciesAndPrerequisitesByIdElseThrow(result.getId()); - learningPathService.generateLearningPaths(courseWithCompetencies); + learningPathApi.generateLearningPaths(courseWithCompetencies); } // if access to restricted athena modules got disabled for the course, we need to set all exercises that use restricted modules to null @@ -589,7 +589,7 @@ public ResponseEntity getCourseForDashboard(@PathVariable log.debug("REST request to get one course {} with exams, lectures, exercises, participations, submissions and results, etc.", courseId); User user = userRepository.getUserWithGroupsAndAuthorities(); - Course course = courseService.findOneWithExercisesAndLecturesAndExamsAndCompetenciesAndTutorialGroupsForUser(courseId, user); + Course course = courseService.findOneWithExercisesAndLecturesAndExamsAndCompetenciesAndTutorialGroupsAndFaqForUser(courseId, user); log.debug("courseService.findOneWithExercisesAndLecturesAndExamsAndCompetenciesAndTutorialGroupsForUser done"); if (!authCheckService.isAtLeastStudentInCourse(course, user)) { // user might be allowed to enroll in the course @@ -1244,7 +1244,7 @@ public ResponseEntity addUserToCourseGroup(String userLogin, User instruct courseService.addUserToGroup(userToAddToGroup.get(), group); if (role == Role.STUDENT && course.getLearningPathsEnabled()) { Course courseWithCompetencies = courseRepository.findWithEagerCompetenciesAndPrerequisitesByIdElseThrow(course.getId()); - learningPathService.generateLearningPathForUser(courseWithCompetencies, userToAddToGroup.get()); + learningPathApi.generateLearningPathForUser(courseWithCompetencies, userToAddToGroup.get()); } return ResponseEntity.ok().body(null); } diff --git a/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminBuildJobQueueResource.java b/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminBuildJobQueueResource.java index 27cd50a6e4b9..07c7579751ca 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminBuildJobQueueResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminBuildJobQueueResource.java @@ -195,7 +195,7 @@ public ResponseEntity getBuildJobStatistics(@RequestPara } /** - * {@code PUT /api/admin/agent/{agentName}/pause} : Pause the specified build agent. + * {@code PUT /api/admin/agents/{agentName}/pause} : Pause the specified build agent. * This endpoint allows administrators to pause a specific build agent by its name. * Pausing a build agent will prevent it from picking up any new build jobs until it is resumed. * @@ -207,7 +207,7 @@ public ResponseEntity getBuildJobStatistics(@RequestPara * @return {@link ResponseEntity} with status code 204 (No Content) if the agent was successfully paused * or an appropriate error response if something went wrong */ - @PutMapping("agent/{agentName}/pause") + @PutMapping("agents/{agentName}/pause") public ResponseEntity pauseBuildAgent(@PathVariable String agentName) { log.debug("REST request to pause agent {}", agentName); localCIBuildJobQueueService.pauseBuildAgent(agentName); @@ -215,7 +215,26 @@ public ResponseEntity pauseBuildAgent(@PathVariable String agentName) { } /** - * {@code PUT /api/admin/agent/{agentName}/resume} : Resume the specified build agent. + * {@code PUT /api/admin/agents/pause-all} : Pause all build agents. + * This endpoint allows administrators to pause all build agents. + * Pausing all build agents will prevent them from picking up any new build jobs until they are resumed. + * + *

+ * Authorization: This operation requires admin privileges, enforced by {@code @EnforceAdmin}. + *

+ * + * @return {@link ResponseEntity} with status code 204 (No Content) if all agents were successfully paused + * or an appropriate error response if something went wrong + */ + @PutMapping("agents/pause-all") + public ResponseEntity pauseAllBuildAgents() { + log.debug("REST request to pause all agents"); + localCIBuildJobQueueService.pauseAllBuildAgents(); + return ResponseEntity.noContent().build(); + } + + /** + * {@code PUT /api/admin/agents/{agentName}/resume} : Resume the specified build agent. * This endpoint allows administrators to resume a specific build agent by its name. * Resuming a build agent will allow it to pick up new build jobs again. * @@ -227,10 +246,29 @@ public ResponseEntity pauseBuildAgent(@PathVariable String agentName) { * @return {@link ResponseEntity} with status code 204 (No Content) if the agent was successfully resumed * or an appropriate error response if something went wrong */ - @PutMapping("agent/{agentName}/resume") + @PutMapping("agents/{agentName}/resume") public ResponseEntity resumeBuildAgent(@PathVariable String agentName) { log.debug("REST request to resume agent {}", agentName); localCIBuildJobQueueService.resumeBuildAgent(agentName); return ResponseEntity.noContent().build(); } + + /** + * {@code PUT /api/admin/agents/resume-all} : Resume all build agents. + * This endpoint allows administrators to resume all build agents. + * Resuming all build agents will allow them to pick up new build jobs again. + * + *

+ * Authorization: This operation requires admin privileges, enforced by {@code @EnforceAdmin}. + *

+ * + * @return {@link ResponseEntity} with status code 204 (No Content) if all agents were successfully resumed + * or an appropriate error response if something went wrong + */ + @PutMapping("agents/resume-all") + public ResponseEntity resumeAllBuildAgents() { + log.debug("REST request to resume all agents"); + localCIBuildJobQueueService.resumeAllBuildAgents(); + return ResponseEntity.noContent().build(); + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/core/web/open/PublicAccountResource.java b/src/main/java/de/tum/cit/aet/artemis/core/web/open/PublicAccountResource.java index bd673ece51d4..543ad85964da 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/web/open/PublicAccountResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/web/open/PublicAccountResource.java @@ -166,8 +166,6 @@ public ResponseEntity getAccount() { // we set this value on purpose here: the user can only fetch their own information, make the token available for constructing the token-based clone-URL userDTO.setVcsAccessToken(user.getVcsAccessToken()); userDTO.setVcsAccessTokenExpiryDate(user.getVcsAccessTokenExpiryDate()); - userDTO.setSshPublicKey(user.getSshPublicKey()); - userDTO.setSshKeyHash(user.getSshPublicKeyHash()); log.info("GET /account {} took {}ms", user.getLogin(), System.currentTimeMillis() - start); return ResponseEntity.ok(userDTO); } diff --git a/src/main/java/de/tum/cit/aet/artemis/exercise/domain/participation/Participation.java b/src/main/java/de/tum/cit/aet/artemis/exercise/domain/participation/Participation.java index ce891aeb9093..9d8e8627635a 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exercise/domain/participation/Participation.java +++ b/src/main/java/de/tum/cit/aet/artemis/exercise/domain/participation/Participation.java @@ -96,14 +96,19 @@ public abstract class Participation extends DomainObject implements Participatio protected Exercise exercise; /** - * Results are not cascaded through the participation because ideally we want the relationship between participations, submissions and results as follows: each participation - * has multiple submissions. For each submission there can be a result. Therefore, the result is persisted with the submission. Refer to Submission.result for cascading - * settings. + * @deprecated: Will be removed for 8.0, please use submissions.results instead + * + * Results are not cascaded through the participation because ideally we want the relationship between participations, submissions and results as follows: each + * participation + * has multiple submissions. For each submission there can be a result. Therefore, the result is persisted with the submission. Refer to Submission.result for + * cascading + * settings. */ @OneToMany(mappedBy = "participation") @JsonIgnoreProperties(value = "participation", allowSetters = true) @Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) @JsonView(QuizView.Before.class) + @Deprecated(since = "7.7", forRemoval = true) private Set results = new HashSet<>(); /** @@ -201,26 +206,52 @@ public void setPracticeMode(boolean practiceMode) { this.testRun = practiceMode; } + /** + * @deprecated: Will be removed for 8.0, please use submissions.results instead + * @return the results + */ + @Deprecated(since = "7.7", forRemoval = true) public Set getResults() { return results; } + /** + * @deprecated: Will be removed for 8.0, please use submissions.results instead + * @param results the results + * @return the results + */ + @Deprecated(since = "7.7", forRemoval = true) public Participation results(Set results) { this.results = results; return this; } + /** + * @deprecated: Will be removed for 8.0, please use submissions.results instead + * @param result the result + */ + @Deprecated(since = "7.7", forRemoval = true) @Override public void addResult(Result result) { this.results.add(result); result.setParticipation(this); } + /** + * @deprecated: Will be removed for 8.0, please use submissions.results instead + * @param result the result + */ + @Deprecated(since = "7.7", forRemoval = true) public void removeResult(Result result) { this.results.remove(result); result.setParticipation(null); } + /** + * @deprecated: Will be removed for 8.0, please use submissions.results instead + * @param results the results + */ + @Deprecated(since = "7.7", forRemoval = true) public void setResults(Set results) { this.results = results; } diff --git a/src/main/java/de/tum/cit/aet/artemis/exercise/repository/StudentParticipationRepository.java b/src/main/java/de/tum/cit/aet/artemis/exercise/repository/StudentParticipationRepository.java index 2c8e4e02eb7c..d9cb4a6e205a 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exercise/repository/StudentParticipationRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/exercise/repository/StudentParticipationRepository.java @@ -1387,4 +1387,54 @@ SELECT MAX(pr.id) ORDER BY p.student.firstName ASC """) Page findAffectedStudentsByFeedbackId(@Param("exerciseId") long exerciseId, @Param("feedbackIds") List feedbackIds, Pageable pageable); + + /** + * Retrieves the logins of students affected by a specific feedback detail text in a given exercise. + * + * @param exerciseId The ID of the exercise for which affected students are requested. + * @param detailText The feedback detail text to filter by. + * @return A list of student logins affected by the given feedback detail text in the specified exercise. + */ + @Query(""" + SELECT DISTINCT p.student.login + FROM ProgrammingExerciseStudentParticipation p + INNER JOIN p.submissions s + INNER JOIN s.results r ON r.id = ( + SELECT MAX(pr.id) + FROM s.results pr + WHERE pr.participation.id = p.id + ) + INNER JOIN r.feedbacks f + WHERE p.exercise.id = :exerciseId + AND f.detailText = :detailText + AND p.testRun = FALSE + """) + List findAffectedLoginsByFeedbackDetailText(@Param("exerciseId") long exerciseId, @Param("detailText") String detailText); + + /** + * Counts the number of distinct students affected by a specific feedback detail text for a given programming exercise. + *

+ * This query identifies students whose submissions were impacted by feedback entries matching the provided detail text + * within the specified exercise. Only students with non-test run submissions and negative feedback entries are considered. + *

+ * + * @param exerciseId the ID of the programming exercise for which the count is calculated. + * @param detailText the feedback detail text used to filter the affected students. + * @return the total number of distinct students affected by the feedback detail text. + */ + @Query(""" + SELECT COUNT(DISTINCT p.student.id) + FROM ProgrammingExerciseStudentParticipation p + INNER JOIN p.submissions s + INNER JOIN s.results r ON r.id = ( + SELECT MAX(pr.id) + FROM s.results pr + WHERE pr.participation.id = p.id + ) + INNER JOIN r.feedbacks f + WHERE p.exercise.id = :exerciseId + AND f.detailText = :detailText + AND p.testRun = FALSE + """) + long countAffectedStudentsByFeedbackDetailText(@Param("exerciseId") long exerciseId, @Param("detailText") String detailText); } diff --git a/src/main/java/de/tum/cit/aet/artemis/exercise/service/ExerciseDeletionService.java b/src/main/java/de/tum/cit/aet/artemis/exercise/service/ExerciseDeletionService.java index 36b7e99fedd0..fd80e0a00fb5 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exercise/service/ExerciseDeletionService.java +++ b/src/main/java/de/tum/cit/aet/artemis/exercise/service/ExerciseDeletionService.java @@ -16,8 +16,8 @@ import de.tum.cit.aet.artemis.assessment.repository.TutorParticipationRepository; import de.tum.cit.aet.artemis.assessment.service.ExampleSubmissionService; +import de.tum.cit.aet.artemis.atlas.api.CompetencyProgressApi; import de.tum.cit.aet.artemis.atlas.domain.competency.CompetencyExerciseLink; -import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyProgressService; import de.tum.cit.aet.artemis.communication.domain.conversation.Channel; import de.tum.cit.aet.artemis.communication.repository.conversation.ChannelRepository; import de.tum.cit.aet.artemis.communication.service.conversation.ChannelService; @@ -78,7 +78,7 @@ public class ExerciseDeletionService { private final ChannelService channelService; - private final CompetencyProgressService competencyProgressService; + private final CompetencyProgressApi competencyProgressApi; private final Optional irisSettingsService; @@ -86,8 +86,7 @@ public ExerciseDeletionService(ExerciseRepository exerciseRepository, ExerciseUn ProgrammingExerciseService programmingExerciseService, ModelingExerciseService modelingExerciseService, QuizExerciseService quizExerciseService, TutorParticipationRepository tutorParticipationRepository, ExampleSubmissionService exampleSubmissionService, StudentExamRepository studentExamRepository, LectureUnitService lectureUnitService, PlagiarismResultRepository plagiarismResultRepository, TextExerciseService textExerciseService, - ChannelRepository channelRepository, ChannelService channelService, CompetencyProgressService competencyProgressService, - Optional irisSettingsService) { + ChannelRepository channelRepository, ChannelService channelService, CompetencyProgressApi competencyProgressApi, Optional irisSettingsService) { this.exerciseRepository = exerciseRepository; this.participationService = participationService; this.programmingExerciseService = programmingExerciseService; @@ -102,7 +101,7 @@ public ExerciseDeletionService(ExerciseRepository exerciseRepository, ExerciseUn this.textExerciseService = textExerciseService; this.channelRepository = channelRepository; this.channelService = channelService; - this.competencyProgressService = competencyProgressService; + this.competencyProgressApi = competencyProgressApi; this.irisSettingsService = irisSettingsService; } @@ -215,7 +214,7 @@ public void delete(long exerciseId, boolean deleteStudentReposBuildPlans, boolea exerciseRepository.delete(exercise); } - competencyLinks.stream().map(CompetencyExerciseLink::getCompetency).forEach(competencyProgressService::updateProgressByCompetencyAsync); + competencyLinks.stream().map(CompetencyExerciseLink::getCompetency).forEach(competencyProgressApi::updateProgressByCompetencyAsync); } /** diff --git a/src/main/java/de/tum/cit/aet/artemis/exercise/service/ExerciseService.java b/src/main/java/de/tum/cit/aet/artemis/exercise/service/ExerciseService.java index 0a355a870620..73fce3b0f9f1 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exercise/service/ExerciseService.java +++ b/src/main/java/de/tum/cit/aet/artemis/exercise/service/ExerciseService.java @@ -45,9 +45,9 @@ import de.tum.cit.aet.artemis.assessment.repository.ResultRepository; import de.tum.cit.aet.artemis.assessment.service.RatingService; import de.tum.cit.aet.artemis.assessment.service.TutorLeaderboardService; +import de.tum.cit.aet.artemis.atlas.api.CompetencyRelationApi; import de.tum.cit.aet.artemis.atlas.domain.competency.CompetencyExerciseLink; import de.tum.cit.aet.artemis.atlas.domain.competency.CourseCompetency; -import de.tum.cit.aet.artemis.atlas.repository.CompetencyExerciseLinkRepository; import de.tum.cit.aet.artemis.communication.service.notifications.GroupNotificationScheduleService; import de.tum.cit.aet.artemis.core.config.Constants; import de.tum.cit.aet.artemis.core.domain.Course; @@ -130,7 +130,7 @@ public class ExerciseService { private final GroupNotificationScheduleService groupNotificationScheduleService; - private final CompetencyExerciseLinkRepository competencyExerciseLinkRepository; + private final CompetencyRelationApi competencyRelationApi; public ExerciseService(ExerciseRepository exerciseRepository, AuthorizationCheckService authCheckService, AuditEventRepository auditEventRepository, TeamRepository teamRepository, ProgrammingExerciseRepository programmingExerciseRepository, Optional lti13ResourceLaunchRepository, @@ -139,7 +139,7 @@ public ExerciseService(ExerciseRepository exerciseRepository, AuthorizationCheck TutorLeaderboardService tutorLeaderboardService, ComplaintResponseRepository complaintResponseRepository, GradingCriterionRepository gradingCriterionRepository, FeedbackRepository feedbackRepository, RatingService ratingService, ExerciseDateService exerciseDateService, ExampleSubmissionRepository exampleSubmissionRepository, QuizBatchService quizBatchService, ExamLiveEventsService examLiveEventsService, GroupNotificationScheduleService groupNotificationScheduleService, - CompetencyExerciseLinkRepository competencyExerciseLinkRepository) { + CompetencyRelationApi competencyRelationApi) { this.exerciseRepository = exerciseRepository; this.resultRepository = resultRepository; this.authCheckService = authCheckService; @@ -162,7 +162,7 @@ public ExerciseService(ExerciseRepository exerciseRepository, AuthorizationCheck this.quizBatchService = quizBatchService; this.examLiveEventsService = examLiveEventsService; this.groupNotificationScheduleService = groupNotificationScheduleService; - this.competencyExerciseLinkRepository = competencyExerciseLinkRepository; + this.competencyRelationApi = competencyRelationApi; } /** @@ -773,7 +773,7 @@ public List getFeedbackToBeDeletedAfterGradingInstructionUpdate(boolea * @param competency competency to remove */ public void removeCompetency(@NotNull Set exerciseLinks, @NotNull CourseCompetency competency) { - competencyExerciseLinkRepository.deleteAll(exerciseLinks); + competencyRelationApi.deleteAllExerciseLinks(exerciseLinks); competency.getExerciseLinks().removeAll(exerciseLinks); } @@ -815,7 +815,7 @@ public T saveWithCompetencyLinks(T exercise, Function if (Hibernate.isInitialized(links) && !links.isEmpty()) { savedExercise.setCompetencyLinks(links); reconnectCompetencyExerciseLinks(savedExercise); - savedExercise.setCompetencyLinks(new HashSet<>(competencyExerciseLinkRepository.saveAll(links))); + savedExercise.setCompetencyLinks(new HashSet<>(competencyRelationApi.saveAllExerciseLinks(links))); } return savedExercise; diff --git a/src/main/java/de/tum/cit/aet/artemis/exercise/service/ParticipationService.java b/src/main/java/de/tum/cit/aet/artemis/exercise/service/ParticipationService.java index 9074ad8ec1f8..a8c4d3507813 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exercise/service/ParticipationService.java +++ b/src/main/java/de/tum/cit/aet/artemis/exercise/service/ParticipationService.java @@ -24,7 +24,7 @@ import de.tum.cit.aet.artemis.assessment.repository.StudentScoreRepository; import de.tum.cit.aet.artemis.assessment.repository.TeamScoreRepository; import de.tum.cit.aet.artemis.assessment.service.ResultService; -import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyProgressService; +import de.tum.cit.aet.artemis.atlas.api.CompetencyProgressApi; import de.tum.cit.aet.artemis.core.domain.User; import de.tum.cit.aet.artemis.core.exception.ContinuousIntegrationException; import de.tum.cit.aet.artemis.core.exception.EntityNotFoundException; @@ -109,7 +109,7 @@ public class ParticipationService { private final ParticipationVcsAccessTokenService participationVCSAccessTokenService; - private final CompetencyProgressService competencyProgressService; + private final CompetencyProgressApi competencyProgressApi; public ParticipationService(GitService gitService, Optional continuousIntegrationService, Optional versionControlService, BuildLogEntryService buildLogEntryService, ParticipationRepository participationRepository, StudentParticipationRepository studentParticipationRepository, @@ -118,7 +118,7 @@ public ParticipationService(GitService gitService, Optional localCISharedBuildJobQueueService, ProfileService profileService, - ParticipationVcsAccessTokenService participationVCSAccessTokenService, CompetencyProgressService competencyProgressService) { + ParticipationVcsAccessTokenService participationVCSAccessTokenService, CompetencyProgressApi competencyProgressApi) { this.gitService = gitService; this.continuousIntegrationService = continuousIntegrationService; this.versionControlService = versionControlService; @@ -139,7 +139,7 @@ public ParticipationService(GitService gitService, Optional createFileUploadExercise(@RequestBody channelService.createExerciseChannel(result, Optional.ofNullable(fileUploadExercise.getChannelName())); groupNotificationScheduleService.checkNotificationsForNewExerciseAsync(fileUploadExercise); - competencyProgressService.updateProgressByLearningObjectAsync(result); + competencyProgressApi.updateProgressByLearningObjectAsync(result); return ResponseEntity.created(new URI("/api/file-upload-exercises/" + result.getId())).body(result); } @@ -287,7 +287,7 @@ public ResponseEntity updateFileUploadExercise(@RequestBody participationRepository.removeIndividualDueDatesIfBeforeDueDate(updatedExercise, fileUploadExerciseBeforeUpdate.getDueDate()); exerciseService.notifyAboutExerciseChanges(fileUploadExerciseBeforeUpdate, updatedExercise, notificationText); - competencyProgressService.updateProgressForUpdatedLearningObjectAsync(fileUploadExerciseBeforeUpdate, Optional.of(fileUploadExercise)); + competencyProgressApi.updateProgressForUpdatedLearningObjectAsync(fileUploadExerciseBeforeUpdate, Optional.of(fileUploadExercise)); return ResponseEntity.ok(updatedExercise); } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/dto/IngestionState.java b/src/main/java/de/tum/cit/aet/artemis/iris/dto/IngestionState.java new file mode 100644 index 000000000000..6ed226ce4400 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/dto/IngestionState.java @@ -0,0 +1,5 @@ +package de.tum.cit.aet.artemis.iris.dto; + +public enum IngestionState { + NOT_STARTED, IN_PROGRESS, PARTIALLY_INGESTED, DONE, ERROR +} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/dto/IngestionStateResponseDTO.java b/src/main/java/de/tum/cit/aet/artemis/iris/dto/IngestionStateResponseDTO.java new file mode 100644 index 000000000000..54d5a04bd3cf --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/dto/IngestionStateResponseDTO.java @@ -0,0 +1,4 @@ +package de.tum.cit.aet.artemis.iris.dto; + +public record IngestionStateResponseDTO(IngestionState state) { +} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisConnectorService.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisConnectorService.java index f41de6b6c97d..911428b5b176 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisConnectorService.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisConnectorService.java @@ -2,6 +2,8 @@ import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_IRIS; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.List; @@ -20,11 +22,15 @@ import com.fasterxml.jackson.databind.ObjectMapper; import de.tum.cit.aet.artemis.iris.domain.settings.IrisSubSettingsType; +import de.tum.cit.aet.artemis.iris.dto.IngestionState; +import de.tum.cit.aet.artemis.iris.dto.IngestionStateResponseDTO; import de.tum.cit.aet.artemis.iris.exception.IrisException; import de.tum.cit.aet.artemis.iris.exception.IrisForbiddenException; import de.tum.cit.aet.artemis.iris.exception.IrisInternalPyrisErrorException; import de.tum.cit.aet.artemis.iris.service.pyris.dto.PyrisVariantDTO; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.lectureingestionwebhook.PyrisWebhookLectureDeletionExecutionDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.lectureingestionwebhook.PyrisWebhookLectureIngestionExecutionDTO; +import de.tum.cit.aet.artemis.iris.service.pyris.job.IngestionWebhookJob; import de.tum.cit.aet.artemis.iris.web.open.PublicPyrisStatusUpdateResource; /** @@ -42,12 +48,19 @@ public class PyrisConnectorService { private final ObjectMapper objectMapper; + private final PyrisJobService pyrisJobService; + + @Value("${server.url}") + private String artemisBaseUrl; + @Value("${artemis.iris.url}") private String pyrisUrl; - public PyrisConnectorService(@Qualifier("pyrisRestTemplate") RestTemplate restTemplate, MappingJackson2HttpMessageConverter springMvcJacksonConverter) { + public PyrisConnectorService(@Qualifier("pyrisRestTemplate") RestTemplate restTemplate, MappingJackson2HttpMessageConverter springMvcJacksonConverter, + PyrisJobService pyrisJobService) { this.restTemplate = restTemplate; this.objectMapper = springMvcJacksonConverter.getObjectMapper(); + this.pyrisJobService = pyrisJobService; } /** @@ -97,15 +110,63 @@ public void executePipeline(String feature, String variant, Object executionDTO) * @param variant The variant of the feature to execute * @param executionDTO The DTO sent as a body for the execution */ - public void executeLectureWebhook(String variant, PyrisWebhookLectureIngestionExecutionDTO executionDTO) { + public void executeLectureAddtionWebhook(String variant, PyrisWebhookLectureIngestionExecutionDTO executionDTO) { var endpoint = "/api/v1/webhooks/lectures/" + variant; try { restTemplate.postForEntity(pyrisUrl + endpoint, objectMapper.valueToTree(executionDTO), Void.class); } + catch (HttpStatusCodeException e) { + log.error("Failed to send lecture unit {} to Pyris: {}", executionDTO.pyrisLectureUnit().lectureUnitId(), e.getMessage()); + throw toIrisException(e); + } + catch (RestClientException | IllegalArgumentException e) { + log.error("Failed to send lecture unit {} to Pyris: {}", executionDTO.pyrisLectureUnit().lectureUnitId(), e.getMessage()); + throw new PyrisConnectorException("Could not fetch response from Pyris"); + } + } + + /** + * Retrieves the ingestion state of the lecture unit specified by retrieving the ingestion state from the vector database in Pyris. + * + * @param courseId id of the course + * @param lectureId id of the lecture + * @param lectureUnitId id of the lectureUnit to check in the Pyris vector database + * @return The ingestion state of the lecture Unit + * + */ + IngestionState getLectureUnitIngestionState(long courseId, long lectureId, long lectureUnitId) { + try { + String encodedBaseUrl = URLEncoder.encode(artemisBaseUrl, StandardCharsets.UTF_8); + String url = pyrisUrl + "/api/v1/courses/" + courseId + "/lectures/" + lectureId + "/lectureUnits/" + lectureUnitId + "/ingestion-state?base_url=" + encodedBaseUrl; + IngestionStateResponseDTO response = restTemplate.getForObject(url, IngestionStateResponseDTO.class); + IngestionState state = response.state(); + if (state != IngestionState.DONE) { + if (pyrisJobService.currentJobs().stream().filter(job -> job instanceof IngestionWebhookJob).map(job -> (IngestionWebhookJob) job) + .anyMatch(ingestionJob -> ingestionJob.courseId() == courseId && ingestionJob.lectureId() == lectureId && ingestionJob.lectureUnitId() == lectureUnitId)) { + return IngestionState.IN_PROGRESS; + } + } + return state; + } + catch (RestClientException | IllegalArgumentException e) { + log.error("Error fetching ingestion state for lecture {}, lecture unit {}", lectureId, lectureUnitId, e); + throw new PyrisConnectorException("Error fetching ingestion state for lecture unit" + lectureUnitId); + } + } + + /** + * Executes a webhook and send lectures to the webhook with the given variant + * + * @param executionDTO The DTO sent as a body for the execution + */ + public void executeLectureDeletionWebhook(PyrisWebhookLectureDeletionExecutionDTO executionDTO) { + var endpoint = "/api/v1/webhooks/lectures/delete"; + try { + restTemplate.postForEntity(pyrisUrl + endpoint, objectMapper.valueToTree(executionDTO), Void.class); + } catch (HttpStatusCodeException e) { log.error("Failed to send lectures to Pyris", e); throw toIrisException(e); - // TODO : add error ingestion UI. } catch (RestClientException | IllegalArgumentException e) { log.error("Failed to send lectures to Pyris", e); diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisJobService.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisJobService.java index 16e8969bc463..46c0dd7547cd 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisJobService.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisJobService.java @@ -3,6 +3,7 @@ import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_IRIS; import java.security.SecureRandom; +import java.util.Collection; import java.util.concurrent.TimeUnit; import java.util.function.Function; @@ -93,11 +94,14 @@ public String addCourseChatJob(Long courseId, Long sessionId) { /** * Adds a new ingestion webhook job to the job map with a timeout. * + * @param courseId the ID of the course associated with the webhook job + * @param lectureId the ID of the lecture associated with the webhook job + * @param lectureUnitId the ID of the lecture unit associated with the webhook job * @return a unique token identifying the created webhook job */ - public String addIngestionWebhookJob() { + public String addIngestionWebhookJob(long courseId, long lectureId, long lectureUnitId) { var token = generateJobIdToken(); - var job = new IngestionWebhookJob(token); + var job = new IngestionWebhookJob(token, courseId, lectureId, lectureUnitId); long timeoutWebhookJob = 60; TimeUnit unitWebhookJob = TimeUnit.MINUTES; jobMap.put(token, job, timeoutWebhookJob, unitWebhookJob); @@ -122,6 +126,15 @@ public void updateJob(PyrisJob job) { jobMap.put(job.jobId(), job); } + /** + * Get all current jobs. + * + * @return the all current jobs + */ + public Collection currentJobs() { + return jobMap.values(); + } + /** * Get the job of a token. * diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisPipelineService.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisPipelineService.java index 08861c06a773..c8851341e29e 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisPipelineService.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisPipelineService.java @@ -17,13 +17,13 @@ import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; +import de.tum.cit.aet.artemis.atlas.api.LearningMetricsApi; import de.tum.cit.aet.artemis.atlas.domain.competency.CompetencyJol; import de.tum.cit.aet.artemis.atlas.dto.CompetencyJolDTO; import de.tum.cit.aet.artemis.core.domain.Course; import de.tum.cit.aet.artemis.core.repository.CourseRepository; import de.tum.cit.aet.artemis.exercise.domain.participation.StudentParticipation; import de.tum.cit.aet.artemis.exercise.repository.StudentParticipationRepository; -import de.tum.cit.aet.artemis.exercise.service.LearningMetricsService; import de.tum.cit.aet.artemis.iris.domain.session.IrisCourseChatSession; import de.tum.cit.aet.artemis.iris.domain.session.IrisExerciseChatSession; import de.tum.cit.aet.artemis.iris.exception.IrisException; @@ -61,20 +61,20 @@ public class PyrisPipelineService { private final StudentParticipationRepository studentParticipationRepository; - private final LearningMetricsService learningMetricsService; + private final LearningMetricsApi learningMetricsApi; @Value("${server.url}") private String artemisBaseUrl; public PyrisPipelineService(PyrisConnectorService pyrisConnectorService, PyrisJobService pyrisJobService, PyrisDTOService pyrisDTOService, - IrisChatWebsocketService irisChatWebsocketService, CourseRepository courseRepository, LearningMetricsService learningMetricsService, + IrisChatWebsocketService irisChatWebsocketService, CourseRepository courseRepository, LearningMetricsApi learningMetricsApi, StudentParticipationRepository studentParticipationRepository) { this.pyrisConnectorService = pyrisConnectorService; this.pyrisJobService = pyrisJobService; this.pyrisDTOService = pyrisDTOService; this.irisChatWebsocketService = irisChatWebsocketService; this.courseRepository = courseRepository; - this.learningMetricsService = learningMetricsService; + this.learningMetricsApi = learningMetricsApi; this.studentParticipationRepository = studentParticipationRepository; } @@ -186,7 +186,7 @@ public void executeCourseChatPipeline(String variant, IrisCourseChatSession sess var fullCourse = loadCourseWithParticipationOfStudent(courseId, studentId); return new PyrisCourseChatPipelineExecutionDTO( PyrisExtendedCourseDTO.of(fullCourse), - learningMetricsService.getStudentCourseMetrics(session.getUser().getId(), courseId), + learningMetricsApi.getStudentCourseMetrics(session.getUser().getId(), courseId), competencyJol == null ? null : CompetencyJolDTO.of(competencyJol), pyrisDTOService.toPyrisMessageDTOList(session.getMessages()), new PyrisUserDTO(session.getUser()), diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisStatusUpdateService.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisStatusUpdateService.java index cdd398e5c683..ad1a63e60c58 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisStatusUpdateService.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisStatusUpdateService.java @@ -126,14 +126,12 @@ private void removeJobIfTerminatedElseUpdate(List stages, PyrisJo } /** - * Handles the status update of a lecture ingestion job and logs the results for now => will change later - * TODO: Update this method to handle changes beyond logging + * Handles the status update of a lecture ingestion job. * * @param job the job that is updated * @param statusUpdate the status update */ public void handleStatusUpdate(IngestionWebhookJob job, PyrisLectureIngestionStatusUpdateDTO statusUpdate) { - statusUpdate.stages().forEach(stage -> log.info(stage.name() + ":" + stage.message())); removeJobIfTerminatedElseUpdate(statusUpdate.stages(), job); } } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisWebhookService.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisWebhookService.java index 03f58ad5ba14..2138b8789b0b 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisWebhookService.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisWebhookService.java @@ -9,6 +9,9 @@ import java.util.ArrayList; import java.util.Base64; import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -17,16 +20,23 @@ import org.springframework.stereotype.Service; import de.tum.cit.aet.artemis.core.domain.Course; +import de.tum.cit.aet.artemis.core.domain.DomainObject; import de.tum.cit.aet.artemis.core.service.FilePathService; import de.tum.cit.aet.artemis.iris.domain.settings.IrisCourseSettings; +import de.tum.cit.aet.artemis.iris.dto.IngestionState; import de.tum.cit.aet.artemis.iris.exception.IrisInternalPyrisErrorException; import de.tum.cit.aet.artemis.iris.repository.IrisSettingsRepository; import de.tum.cit.aet.artemis.iris.service.pyris.dto.PyrisPipelineExecutionSettingsDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.lectureingestionwebhook.PyrisLectureUnitWebhookDTO; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.lectureingestionwebhook.PyrisWebhookLectureDeletionExecutionDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.lectureingestionwebhook.PyrisWebhookLectureIngestionExecutionDTO; import de.tum.cit.aet.artemis.iris.service.settings.IrisSettingsService; import de.tum.cit.aet.artemis.lecture.domain.AttachmentType; import de.tum.cit.aet.artemis.lecture.domain.AttachmentUnit; +import de.tum.cit.aet.artemis.lecture.domain.Lecture; +import de.tum.cit.aet.artemis.lecture.domain.LectureUnit; +import de.tum.cit.aet.artemis.lecture.repository.LectureRepository; +import de.tum.cit.aet.artemis.lecture.repository.LectureUnitRepository; @Service @Profile(PROFILE_IRIS) @@ -42,15 +52,21 @@ public class PyrisWebhookService { private final IrisSettingsRepository irisSettingsRepository; + private final LectureUnitRepository lectureUnitRepository; + + private final LectureRepository lectureRepository; + @Value("${server.url}") private String artemisBaseUrl; public PyrisWebhookService(PyrisConnectorService pyrisConnectorService, PyrisJobService pyrisJobService, IrisSettingsService irisSettingsService, - IrisSettingsRepository irisSettingsRepository) { + IrisSettingsRepository irisSettingsRepository, LectureUnitRepository lectureUnitRepository, LectureRepository lectureRepository) { this.pyrisConnectorService = pyrisConnectorService; this.pyrisJobService = pyrisJobService; this.irisSettingsService = irisSettingsService; this.irisSettingsRepository = irisSettingsRepository; + this.lectureUnitRepository = lectureUnitRepository; + this.lectureRepository = lectureRepository; } private boolean lectureIngestionEnabled(Course course) { @@ -78,15 +94,15 @@ private PyrisLectureUnitWebhookDTO processAttachmentForUpdate(AttachmentUnit att String courseTitle = attachmentUnit.getLecture().getCourse().getTitle(); String courseDescription = attachmentUnit.getLecture().getCourse().getDescription() == null ? "" : attachmentUnit.getLecture().getCourse().getDescription(); String base64EncodedPdf = attachmentToBase64(attachmentUnit); - return new PyrisLectureUnitWebhookDTO(true, artemisBaseUrl, base64EncodedPdf, lectureUnitId, lectureUnitName, lectureId, lectureTitle, courseId, courseTitle, - courseDescription); + lectureUnitRepository.save(attachmentUnit); + return new PyrisLectureUnitWebhookDTO(base64EncodedPdf, lectureUnitId, lectureUnitName, lectureId, lectureTitle, courseId, courseTitle, courseDescription); } private PyrisLectureUnitWebhookDTO processAttachmentForDeletion(AttachmentUnit attachmentUnit) { Long lectureUnitId = attachmentUnit.getId(); Long lectureId = attachmentUnit.getLecture().getId(); Long courseId = attachmentUnit.getLecture().getCourse().getId(); - return new PyrisLectureUnitWebhookDTO(false, artemisBaseUrl, "", lectureUnitId, "", lectureId, "", courseId, "", ""); + return new PyrisLectureUnitWebhookDTO("", lectureUnitId, "", lectureId, "", courseId, "", ""); } /** @@ -94,15 +110,15 @@ private PyrisLectureUnitWebhookDTO processAttachmentForDeletion(AttachmentUnit a * * @param courseId Id of the course where the attachment is added * @param newAttachmentUnits the new attachment Units to be sent to pyris for ingestion - * @return true if the units were sent to pyris */ - public boolean autoUpdateAttachmentUnitsInPyris(Long courseId, List newAttachmentUnits) { + public void autoUpdateAttachmentUnitsInPyris(Long courseId, List newAttachmentUnits) { IrisCourseSettings courseSettings = irisSettingsRepository.findCourseSettings(courseId).isPresent() ? irisSettingsRepository.findCourseSettings(courseId).get() : null; if (courseSettings != null && courseSettings.getIrisLectureIngestionSettings() != null && courseSettings.getIrisLectureIngestionSettings().isEnabled() && courseSettings.getIrisLectureIngestionSettings().getAutoIngestOnLectureAttachmentUpload()) { - return addLectureUnitsToPyrisDB(newAttachmentUnits) != null; + for (AttachmentUnit attachmentUnit : newAttachmentUnits) { + addLectureUnitToPyrisDB(attachmentUnit); + } } - return false; } /** @@ -112,44 +128,29 @@ public boolean autoUpdateAttachmentUnitsInPyris(Long courseId, List attachmentUnits) { - try { - List toUpdateAttachmentUnits = new ArrayList<>(); - attachmentUnits.stream().filter(unit -> unit.getAttachment().getAttachmentType() == AttachmentType.FILE && unit.getAttachment().getLink().endsWith(".pdf")) - .forEach(unit -> { - toUpdateAttachmentUnits.add(processAttachmentForDeletion(unit)); - }); - if (!toUpdateAttachmentUnits.isEmpty()) { - return executeLectureWebhook(toUpdateAttachmentUnits); - } - } - catch (Exception e) { - log.error(e.getMessage()); + List toUpdateAttachmentUnits = new ArrayList<>(); + attachmentUnits.stream().filter(unit -> unit.getAttachment().getAttachmentType() == AttachmentType.FILE && unit.getAttachment().getLink().endsWith(".pdf")) + .forEach(unit -> { + toUpdateAttachmentUnits.add(processAttachmentForDeletion(unit)); + }); + if (!toUpdateAttachmentUnits.isEmpty()) { + return executeLectureDeletionWebhook(toUpdateAttachmentUnits); } return null; } /** - * adds the lectures to the vector database on pyris + * adds the lectures to the vector database in Pyris * - * @param attachmentUnits The attachmentUnit that got Updated / erased - * @return jobToken if the job was created + * @param attachmentUnit The attachmentUnit that got Updated + * @return jobToken if the job was created else null */ - public String addLectureUnitsToPyrisDB(List attachmentUnits) { - if (!lectureIngestionEnabled(attachmentUnits.getFirst().getLecture().getCourse())) { - return null; - } - try { - List toUpdateAttachmentUnits = new ArrayList<>(); - attachmentUnits.stream().filter(unit -> unit.getAttachment().getAttachmentType() == AttachmentType.FILE && unit.getAttachment().getLink().endsWith(".pdf")) - .forEach(unit -> { - toUpdateAttachmentUnits.add(processAttachmentForUpdate(unit)); - }); - if (!toUpdateAttachmentUnits.isEmpty()) { - return executeLectureWebhook(toUpdateAttachmentUnits); + public String addLectureUnitToPyrisDB(AttachmentUnit attachmentUnit) { + if (lectureIngestionEnabled(attachmentUnit.getLecture().getCourse())) { + if (attachmentUnit.getAttachment().getAttachmentType() == AttachmentType.FILE && attachmentUnit.getAttachment().getLink().endsWith(".pdf")) { + return executeLectureAdditionWebhook(processAttachmentForUpdate(attachmentUnit)); } - } - catch (Exception e) { - log.error(e.getMessage()); + log.error("Attachment {} is not a file or is not of type pdf thus it will not be sent to Pyris", attachmentUnit.getId()); } return null; } @@ -157,15 +158,85 @@ public String addLectureUnitsToPyrisDB(List attachmentUnits) { /** * executes executeLectureWebhook add or delete lectures from to the vector database on pyris * - * @param toUpdateAttachmentUnits The attachmentUnit that are goin to be Updated / deleted + * @param toUpdateAttachmentUnits The attachmentUnit that are goin to be deleted * @return jobToken if the job was created */ - private String executeLectureWebhook(List toUpdateAttachmentUnits) { - String jobToken = pyrisJobService.addIngestionWebhookJob(); + private String executeLectureDeletionWebhook(List toUpdateAttachmentUnits) { + String jobToken = pyrisJobService.addIngestionWebhookJob(0, 0, 0); PyrisPipelineExecutionSettingsDTO settingsDTO = new PyrisPipelineExecutionSettingsDTO(jobToken, List.of(), artemisBaseUrl); - PyrisWebhookLectureIngestionExecutionDTO executionDTO = new PyrisWebhookLectureIngestionExecutionDTO(toUpdateAttachmentUnits, settingsDTO, List.of()); - pyrisConnectorService.executeLectureWebhook("fullIngestion", executionDTO); + PyrisWebhookLectureDeletionExecutionDTO executionDTO = new PyrisWebhookLectureDeletionExecutionDTO(toUpdateAttachmentUnits, settingsDTO, List.of()); + pyrisConnectorService.executeLectureDeletionWebhook(executionDTO); return jobToken; } + /** + * executes executeLectureAdditionWebhook add lecture from to the vector database on pyris + * + * @param toUpdateAttachmentUnit The attachmentUnit that are going to be Updated + * @return jobToken if the job was created + */ + private String executeLectureAdditionWebhook(PyrisLectureUnitWebhookDTO toUpdateAttachmentUnit) { + String jobToken = pyrisJobService.addIngestionWebhookJob(toUpdateAttachmentUnit.courseId(), toUpdateAttachmentUnit.lectureId(), toUpdateAttachmentUnit.lectureUnitId()); + PyrisPipelineExecutionSettingsDTO settingsDTO = new PyrisPipelineExecutionSettingsDTO(jobToken, List.of(), artemisBaseUrl); + PyrisWebhookLectureIngestionExecutionDTO executionDTO = new PyrisWebhookLectureIngestionExecutionDTO(toUpdateAttachmentUnit, settingsDTO, List.of()); + pyrisConnectorService.executeLectureAddtionWebhook("fullIngestion", executionDTO); + return jobToken; + } + + /** + * uses getLectureUnitIngestionState for all lecture units and then determines the IngestionState of the lecture + * + * @param courseId id of the course + * @return The ingestion state of the lecture + * + */ + public Map getLecturesIngestionState(long courseId) { + Set lectures = lectureRepository.findAllByCourseId(courseId); + return lectures.stream().collect(Collectors.toMap(DomainObject::getId, lecture -> getLectureIngestionState(courseId, lecture.getId()))); + + } + + /** + * uses getLectureUnitIngestionState for all lecture units and then determines the IngestionState of the lecture + * + * @param courseId id of the course + * @param lectureId id of the lecture + * @return The ingestion state of the lecture + * + */ + private IngestionState getLectureIngestionState(long courseId, long lectureId) { + Map states = getLectureUnitsIngestionState(courseId, lectureId); + + if (states.values().stream().allMatch(state -> state == IngestionState.DONE)) { + return IngestionState.DONE; + } + + if (states.values().stream().allMatch(state -> state == IngestionState.NOT_STARTED)) { + return IngestionState.NOT_STARTED; + } + + if (states.values().stream().allMatch(state -> state == IngestionState.ERROR)) { + return IngestionState.ERROR; + } + + if (states.containsValue(IngestionState.DONE) || states.containsValue(IngestionState.IN_PROGRESS)) { + return IngestionState.PARTIALLY_INGESTED; + } + + return IngestionState.NOT_STARTED; + } + + /** + * uses send an api call to get all the ingestion states of the lecture units of one lecture in Pyris + * + * @param courseId id of the course + * @param lectureId id of the lecture + * @return The ingestion state of the lecture Unit + */ + public Map getLectureUnitsIngestionState(long courseId, long lectureId) { + List lectureunits = lectureRepository.findByIdWithLectureUnitsElseThrow(lectureId).getLectureUnits(); + return lectureunits.stream().filter(lectureUnit -> lectureUnit instanceof AttachmentUnit) + .collect(Collectors.toMap(DomainObject::getId, unit -> pyrisConnectorService.getLectureUnitIngestionState(courseId, lectureId, unit.getId()))); + } + } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/lectureingestionwebhook/PyrisLectureIngestionStatusUpdateDTO.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/lectureingestionwebhook/PyrisLectureIngestionStatusUpdateDTO.java index b49aafefac79..56582b2e1ac2 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/lectureingestionwebhook/PyrisLectureIngestionStatusUpdateDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/lectureingestionwebhook/PyrisLectureIngestionStatusUpdateDTO.java @@ -7,5 +7,5 @@ import de.tum.cit.aet.artemis.iris.service.pyris.dto.status.PyrisStageDTO; @JsonInclude(JsonInclude.Include.NON_EMPTY) -public record PyrisLectureIngestionStatusUpdateDTO(String result, List stages) { +public record PyrisLectureIngestionStatusUpdateDTO(String result, List stages, long jobId) { } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/lectureingestionwebhook/PyrisLectureUnitWebhookDTO.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/lectureingestionwebhook/PyrisLectureUnitWebhookDTO.java index 1bef48d26c2e..b2f2cde1019d 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/lectureingestionwebhook/PyrisLectureUnitWebhookDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/lectureingestionwebhook/PyrisLectureUnitWebhookDTO.java @@ -8,6 +8,6 @@ * providing necessary details such as lecture and course identifiers, names, and descriptions. */ @JsonInclude(JsonInclude.Include.NON_EMPTY) -public record PyrisLectureUnitWebhookDTO(Boolean toUpdate, String artemisBaseUrl, String pdfFile, Long lectureUnitId, String lectureUnitName, Long lectureId, String lectureName, - Long courseId, String courseName, String courseDescription) { +public record PyrisLectureUnitWebhookDTO(String pdfFile, long lectureUnitId, String lectureUnitName, long lectureId, String lectureName, long courseId, String courseName, + String courseDescription) { } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/lectureingestionwebhook/PyrisWebhookLectureDeletionExecutionDTO.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/lectureingestionwebhook/PyrisWebhookLectureDeletionExecutionDTO.java new file mode 100644 index 000000000000..45f3bb809f01 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/lectureingestionwebhook/PyrisWebhookLectureDeletionExecutionDTO.java @@ -0,0 +1,13 @@ +package de.tum.cit.aet.artemis.iris.service.pyris.dto.lectureingestionwebhook; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.cit.aet.artemis.iris.service.pyris.dto.PyrisPipelineExecutionSettingsDTO; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.status.PyrisStageDTO; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record PyrisWebhookLectureDeletionExecutionDTO(List pyrisLectureUnits, PyrisPipelineExecutionSettingsDTO settings, + List initialStages) { +} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/lectureingestionwebhook/PyrisWebhookLectureIngestionExecutionDTO.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/lectureingestionwebhook/PyrisWebhookLectureIngestionExecutionDTO.java index 4ef052e91de3..d1eb256e27b0 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/lectureingestionwebhook/PyrisWebhookLectureIngestionExecutionDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/lectureingestionwebhook/PyrisWebhookLectureIngestionExecutionDTO.java @@ -8,6 +8,5 @@ import de.tum.cit.aet.artemis.iris.service.pyris.dto.status.PyrisStageDTO; @JsonInclude(JsonInclude.Include.NON_EMPTY) -public record PyrisWebhookLectureIngestionExecutionDTO(List pyrisLectureUnitWebhookDTOS, PyrisPipelineExecutionSettingsDTO settings, - List initialStages) { +public record PyrisWebhookLectureIngestionExecutionDTO(PyrisLectureUnitWebhookDTO pyrisLectureUnit, PyrisPipelineExecutionSettingsDTO settings, List initialStages) { } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/job/IngestionWebhookJob.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/job/IngestionWebhookJob.java index e29bb966520f..5dbfe0955520 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/job/IngestionWebhookJob.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/job/IngestionWebhookJob.java @@ -7,7 +7,7 @@ * An implementation of a PyrisJob for Lecture Ingestion in Pyris. * This job is used to reference the details of then Ingestion when Pyris sends a status update. */ -public record IngestionWebhookJob(String jobId) implements PyrisJob { +public record IngestionWebhookJob(String jobId, long courseId, long lectureId, long lectureUnitId) implements PyrisJob { @Override public boolean canAccess(Course course) { diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/web/IrisResource.java b/src/main/java/de/tum/cit/aet/artemis/iris/web/IrisResource.java index 58f40981a861..37e2983e39e0 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/web/IrisResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/web/IrisResource.java @@ -2,34 +2,60 @@ import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_IRIS; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.boot.actuate.health.Status; import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import de.tum.cit.aet.artemis.core.domain.Course; +import de.tum.cit.aet.artemis.core.repository.CourseRepository; import de.tum.cit.aet.artemis.core.repository.UserRepository; +import de.tum.cit.aet.artemis.core.security.Role; import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastStudent; +import de.tum.cit.aet.artemis.core.security.annotations.enforceRoleInCourse.EnforceAtLeastInstructorInCourse; +import de.tum.cit.aet.artemis.core.service.AuthorizationCheckService; +import de.tum.cit.aet.artemis.iris.dto.IngestionState; import de.tum.cit.aet.artemis.iris.dto.IrisStatusDTO; import de.tum.cit.aet.artemis.iris.service.IrisRateLimitService; +import de.tum.cit.aet.artemis.iris.service.pyris.PyrisConnectorException; import de.tum.cit.aet.artemis.iris.service.pyris.PyrisHealthIndicator; +import de.tum.cit.aet.artemis.iris.service.pyris.PyrisWebhookService; @Profile(PROFILE_IRIS) @RestController @RequestMapping("api/iris/") public class IrisResource { + private static final Logger log = LoggerFactory.getLogger(IrisResource.class); + protected final UserRepository userRepository; protected final IrisRateLimitService irisRateLimitService; protected final PyrisHealthIndicator pyrisHealthIndicator; - public IrisResource(UserRepository userRepository, PyrisHealthIndicator pyrisHealthIndicator, IrisRateLimitService irisRateLimitService) { + private final AuthorizationCheckService authorizationCheckService; + + private final CourseRepository courseRepository; + + private final PyrisWebhookService pyrisWebhookService; + + public IrisResource(UserRepository userRepository, PyrisHealthIndicator pyrisHealthIndicator, IrisRateLimitService irisRateLimitService, + AuthorizationCheckService authorizationCheckService, CourseRepository courseRepository, PyrisWebhookService pyrisWebhookService) { this.userRepository = userRepository; this.pyrisHealthIndicator = pyrisHealthIndicator; this.irisRateLimitService = irisRateLimitService; + this.authorizationCheckService = authorizationCheckService; + this.courseRepository = courseRepository; + this.pyrisWebhookService = pyrisWebhookService; } /** @@ -47,4 +73,58 @@ public ResponseEntity getStatus() { return ResponseEntity.ok(new IrisStatusDTO(health.getStatus() == Status.UP, rateLimitInfo)); } + /** + * Retrieves the overall ingestion state of a lecture by communicating with Pyris. + * + *

+ * This method sends a GET request to the external Pyris service to fetch the current ingestion + * state of all lectures in a course, identified by its `lectureId`. The ingestion state can be aggregated from + * multiple lecture units or can reflect the overall status of the lecture ingestion process. + *

+ * + * @param courseId the ID of the lecture for which the ingestion state is being requested + * @return a {@link ResponseEntity} containing the {@link IngestionState} of the lecture, + */ + @GetMapping("courses/{courseId}/lectures/ingestion-state") + @EnforceAtLeastInstructorInCourse + public ResponseEntity> getStatusOfLectureIngestion(@PathVariable long courseId) { + try { + Course course = courseRepository.findByIdElseThrow(courseId); + authorizationCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.INSTRUCTOR, course, null); + return ResponseEntity.ok(pyrisWebhookService.getLecturesIngestionState(courseId)); + } + catch (PyrisConnectorException e) { + log.error("Error fetching ingestion state for course {}", courseId, e); + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).build(); + } + } + + /** + * Retrieves the ingestion state of all lecture unit in a lecture by communicating with Pyris. + * + *

+ * This method sends a GET request to the external Pyris service to fetch the current ingestion + * state of a lecture unit, identified by its ID. It constructs a request using the provided + * `lectureId` and `lectureUnitId` and returns the state of the ingestion process (e.g., NOT_STARTED, + * IN_PROGRESS, DONE, ERROR). + *

+ * + * @param courseId the ID of the lecture the unit belongs to + * @param lectureId the ID of the lecture the unit belongs to + * @return a {@link ResponseEntity} containing the {@link IngestionState} of the lecture unit, + */ + @GetMapping("courses/{courseId}/lectures/{lectureId}/lecture-units/ingestion-state") + @EnforceAtLeastInstructorInCourse + public ResponseEntity> getStatusOfLectureUnitsIngestion(@PathVariable long courseId, @PathVariable long lectureId) { + try { + Course course = courseRepository.findByIdElseThrow(courseId); + authorizationCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.INSTRUCTOR, course, null); + return ResponseEntity.ok(pyrisWebhookService.getLectureUnitsIngestionState(courseId, lectureId)); + } + catch (PyrisConnectorException e) { + log.error("Error fetching ingestion state for lecture {}", lectureId, e); + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).build(); + } + } + } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/web/open/PublicPyrisStatusUpdateResource.java b/src/main/java/de/tum/cit/aet/artemis/iris/web/open/PublicPyrisStatusUpdateResource.java index 025a6ce4e897..73e4520c32a6 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/web/open/PublicPyrisStatusUpdateResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/web/open/PublicPyrisStatusUpdateResource.java @@ -171,7 +171,6 @@ public ResponseEntity setStatusOfIngestionJob(@PathVariable String runId, } pyrisStatusUpdateService.handleStatusUpdate(ingestionWebhookJob, statusUpdateDTO); - return ResponseEntity.ok().build(); } } diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/repository/LectureRepository.java b/src/main/java/de/tum/cit/aet/artemis/lecture/repository/LectureRepository.java index d0fd1afbf8a6..33ce07e7b6fb 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/repository/LectureRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/repository/LectureRepository.java @@ -28,6 +28,13 @@ @Repository public interface LectureRepository extends ArtemisJpaRepository { + @Query(""" + SELECT lecture + FROM Lecture lecture + WHERE lecture.course.id = :courseId + """) + Set findAllByCourseId(@Param("courseId") Long courseId); + @Query(""" SELECT lecture FROM Lecture lecture @@ -85,6 +92,14 @@ public interface LectureRepository extends ArtemisJpaRepository { """) Optional findByIdWithLectureUnitsAndCompetencies(@Param("lectureId") Long lectureId); + @Query(""" + SELECT lecture + FROM Lecture lecture + LEFT JOIN FETCH lecture.lectureUnits + WHERE lecture.id = :lectureId + """) + Optional findByIdWithLectureUnits(@Param("lectureId") Long lectureId); + @Query(""" SELECT lecture FROM Lecture lecture @@ -165,6 +180,11 @@ Page findByTitleInLectureOrCourseAndUserHasAccessToCourse(@Param("parti @Cacheable(cacheNames = "lectureTitle", key = "#lectureId", unless = "#result == null") String getLectureTitle(@Param("lectureId") Long lectureId); + @NotNull + default Lecture findByIdWithLectureUnitsElseThrow(Long lectureId) { + return getValueElseThrow(findByIdWithLectureUnits(lectureId), lectureId); + } + @NotNull default Lecture findByIdWithLectureUnitsAndCompetenciesElseThrow(Long lectureId) { return getValueElseThrow(findByIdWithLectureUnitsAndCompetencies(lectureId), lectureId); diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/repository/LectureUnitRepository.java b/src/main/java/de/tum/cit/aet/artemis/lecture/repository/LectureUnitRepository.java index 031fdc300e9d..42807551d308 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/repository/LectureUnitRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/repository/LectureUnitRepository.java @@ -21,6 +21,13 @@ @Repository public interface LectureUnitRepository extends ArtemisJpaRepository { + @Query(""" + SELECT lu + FROM LectureUnit lu + WHERE lu.id = :lectureUnitId + """) + Optional findById(@Param("lectureUnitId") long lectureUnitId); + @Query(""" SELECT lu FROM LectureUnit lu @@ -104,4 +111,7 @@ default LectureUnit findByIdWithCompetenciesAndSlidesElseThrow(long lectureUnitI return getValueElseThrow(findWithCompetenciesAndSlidesById(lectureUnitId), lectureUnitId); } + default LectureUnit findByIdElseThrow(long lectureUnitId) { + return getValueElseThrow(findById(lectureUnitId), lectureUnitId); + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/service/AttachmentUnitService.java b/src/main/java/de/tum/cit/aet/artemis/lecture/service/AttachmentUnitService.java index 83d3ffdf6751..342e463b5650 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/service/AttachmentUnitService.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/service/AttachmentUnitService.java @@ -16,8 +16,8 @@ import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; +import de.tum.cit.aet.artemis.atlas.api.CompetencyProgressApi; import de.tum.cit.aet.artemis.atlas.domain.competency.CompetencyLectureUnitLink; -import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyProgressService; import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException; import de.tum.cit.aet.artemis.core.service.FilePathService; import de.tum.cit.aet.artemis.core.service.FileService; @@ -49,13 +49,13 @@ public class AttachmentUnitService { private final Optional irisSettingsRepository; - private final CompetencyProgressService competencyProgressService; + private final CompetencyProgressApi competencyProgressService; private final LectureUnitService lectureUnitService; public AttachmentUnitService(SlideRepository slideRepository, SlideSplitterService slideSplitterService, AttachmentUnitRepository attachmentUnitRepository, AttachmentRepository attachmentRepository, FileService fileService, Optional pyrisWebhookService, - Optional irisSettingsRepository, CompetencyProgressService competencyProgressService, LectureUnitService lectureUnitService) { + Optional irisSettingsRepository, CompetencyProgressApi competencyProgressService, LectureUnitService lectureUnitService) { this.attachmentUnitRepository = attachmentUnitRepository; this.attachmentRepository = attachmentRepository; this.fileService = fileService; diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/service/LectureService.java b/src/main/java/de/tum/cit/aet/artemis/lecture/service/LectureService.java index 3095605139a1..9bf8edba5bea 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/service/LectureService.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/service/LectureService.java @@ -13,8 +13,8 @@ import org.springframework.data.domain.Page; import org.springframework.stereotype.Service; -import de.tum.cit.aet.artemis.atlas.repository.CompetencyLectureUnitLinkRepository; -import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyProgressService; +import de.tum.cit.aet.artemis.atlas.api.CompetencyProgressApi; +import de.tum.cit.aet.artemis.atlas.api.CompetencyRelationApi; import de.tum.cit.aet.artemis.communication.domain.conversation.Channel; import de.tum.cit.aet.artemis.communication.repository.conversation.ChannelRepository; import de.tum.cit.aet.artemis.communication.service.conversation.ChannelService; @@ -46,20 +46,19 @@ public class LectureService { private final Optional pyrisWebhookService; - private final CompetencyProgressService competencyProgressService; + private final CompetencyProgressApi competencyProgressApi; - private final CompetencyLectureUnitLinkRepository competencyLectureUnitLinkRepository; + private final CompetencyRelationApi competencyRelationApi; public LectureService(LectureRepository lectureRepository, AuthorizationCheckService authCheckService, ChannelRepository channelRepository, ChannelService channelService, - Optional pyrisWebhookService, CompetencyProgressService competencyProgressService, - CompetencyLectureUnitLinkRepository competencyLectureUnitLinkRepository) { + Optional pyrisWebhookService, CompetencyProgressApi competencyProgressApi, CompetencyRelationApi competencyRelationApi) { this.lectureRepository = lectureRepository; this.authCheckService = authCheckService; this.channelRepository = channelRepository; this.channelService = channelService; this.pyrisWebhookService = pyrisWebhookService; - this.competencyProgressService = competencyProgressService; - this.competencyLectureUnitLinkRepository = competencyLectureUnitLinkRepository; + this.competencyProgressApi = competencyProgressApi; + this.competencyRelationApi = competencyRelationApi; } /** @@ -162,13 +161,13 @@ public void delete(Lecture lecture, boolean updateCompetencyProgress) { if (updateCompetencyProgress) { lecture.getLectureUnits().stream().filter(lectureUnit -> !(lectureUnit instanceof ExerciseUnit)) - .forEach(lectureUnit -> competencyProgressService.updateProgressForUpdatedLearningObjectAsync(lectureUnit, Optional.empty())); + .forEach(lectureUnit -> competencyProgressApi.updateProgressForUpdatedLearningObjectAsync(lectureUnit, Optional.empty())); } Channel lectureChannel = channelRepository.findChannelByLectureId(lecture.getId()); channelService.deleteChannel(lectureChannel); - competencyLectureUnitLinkRepository.deleteAllByLectureId(lecture.getId()); + competencyRelationApi.deleteAllLectureUnitLinksByLectureId(lecture.getId()); lectureRepository.deleteById(lecture.getId()); } @@ -177,16 +176,14 @@ public void delete(Lecture lecture, boolean updateCompetencyProgress) { * Ingest the lectures when triggered by the ingest lectures button * * @param lectures set of lectures to be ingested - * @return returns the job token if the operation is successful else it returns null */ - public boolean ingestLecturesInPyris(Set lectures) { + public void ingestLecturesInPyris(Set lectures) { if (pyrisWebhookService.isPresent()) { List attachmentUnitList = lectures.stream().flatMap(lec -> lec.getLectureUnits().stream()).filter(unit -> unit instanceof AttachmentUnit) .map(unit -> (AttachmentUnit) unit).toList(); - if (!attachmentUnitList.isEmpty()) { - return pyrisWebhookService.get().addLectureUnitsToPyrisDB(attachmentUnitList) != null; + for (AttachmentUnit attachmentUnit : attachmentUnitList) { + pyrisWebhookService.get().addLectureUnitToPyrisDB(attachmentUnit); } } - return false; } } diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/service/LectureUnitService.java b/src/main/java/de/tum/cit/aet/artemis/lecture/service/LectureUnitService.java index bda282499738..b3fba2a6c829 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/service/LectureUnitService.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/service/LectureUnitService.java @@ -19,15 +19,19 @@ import jakarta.ws.rs.BadRequestException; import org.hibernate.Hibernate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Profile; import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; +import de.tum.cit.aet.artemis.atlas.api.CompetencyProgressApi; +import de.tum.cit.aet.artemis.atlas.api.CompetencyRelationApi; +import de.tum.cit.aet.artemis.atlas.api.CourseCompetencyApi; import de.tum.cit.aet.artemis.atlas.domain.competency.CompetencyLectureUnitLink; import de.tum.cit.aet.artemis.atlas.domain.competency.CourseCompetency; -import de.tum.cit.aet.artemis.atlas.repository.CompetencyLectureUnitLinkRepository; -import de.tum.cit.aet.artemis.atlas.repository.CourseCompetencyRepository; -import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyProgressService; import de.tum.cit.aet.artemis.core.domain.User; import de.tum.cit.aet.artemis.core.service.FilePathService; import de.tum.cit.aet.artemis.core.service.FileService; @@ -47,6 +51,8 @@ @Service public class LectureUnitService { + private static final Logger log = LoggerFactory.getLogger(LectureUnitService.class); + private final LectureUnitRepository lectureUnitRepository; private final LectureRepository lectureRepository; @@ -59,24 +65,24 @@ public class LectureUnitService { private final Optional pyrisWebhookService; - private final CompetencyProgressService competencyProgressService; + private final CompetencyProgressApi competencyProgressApi; - private final CourseCompetencyRepository courseCompetencyRepository; + private final CourseCompetencyApi courseCompetencyApi; - private final CompetencyLectureUnitLinkRepository competencyLectureUnitLinkRepository; + private final CompetencyRelationApi competencyRelationApi; public LectureUnitService(LectureUnitRepository lectureUnitRepository, LectureRepository lectureRepository, LectureUnitCompletionRepository lectureUnitCompletionRepository, - FileService fileService, SlideRepository slideRepository, Optional pyrisWebhookService, CompetencyProgressService competencyProgressService, - CourseCompetencyRepository courseCompetencyRepository, CompetencyLectureUnitLinkRepository competencyLectureUnitLinkRepository) { + FileService fileService, SlideRepository slideRepository, Optional pyrisWebhookService, CompetencyProgressApi competencyProgressApi, + CourseCompetencyApi courseCompetencyApi, CompetencyRelationApi competencyRelationApi) { this.lectureUnitRepository = lectureUnitRepository; this.lectureRepository = lectureRepository; this.lectureUnitCompletionRepository = lectureUnitCompletionRepository; this.fileService = fileService; this.slideRepository = slideRepository; this.pyrisWebhookService = pyrisWebhookService; - this.courseCompetencyRepository = courseCompetencyRepository; - this.competencyProgressService = competencyProgressService; - this.competencyLectureUnitLinkRepository = competencyLectureUnitLinkRepository; + this.courseCompetencyApi = courseCompetencyApi; + this.competencyProgressApi = competencyProgressApi; + this.competencyRelationApi = competencyRelationApi; } /** @@ -177,7 +183,7 @@ public void removeLectureUnit(@NotNull LectureUnit lectureUnit) { if (!(lectureUnitToDelete instanceof ExerciseUnit)) { // update associated competency progress objects - competencyProgressService.updateProgressForUpdatedLearningObjectAsync(lectureUnitToDelete, Optional.empty()); + competencyProgressApi.updateProgressForUpdatedLearningObjectAsync(lectureUnitToDelete, Optional.empty()); } } @@ -190,7 +196,7 @@ public void removeLectureUnit(@NotNull LectureUnit lectureUnit) { public void linkLectureUnitsToCompetency(CourseCompetency competency, Set lectureUnitLinks) { lectureUnitLinks.forEach(link -> link.setCompetency(competency)); competency.setLectureUnitLinks(lectureUnitLinks); - courseCompetencyRepository.save(competency); + courseCompetencyApi.save(competency); } /** @@ -200,7 +206,7 @@ public void linkLectureUnitsToCompetency(CourseCompetency competency, Set lectureUnitLinks, CourseCompetency competency) { - competencyLectureUnitLinkRepository.deleteAll(lectureUnitLinks); + competencyRelationApi.deleteAllLectureUnitLinks(lectureUnitLinks); competency.getLectureUnitLinks().removeAll(lectureUnitLinks); } @@ -220,6 +226,30 @@ public URL validateUrlStringAndReturnUrl(String urlString) { } } + /** + * This method is responsible for ingesting a specific `LectureUnit` into Pyris, but only if it is an instance of + * `AttachmentUnit`. If the Pyris webhook service is available, it attempts to add the `LectureUnit` to the Pyris + * database. + * The method responds with different HTTP status codes based on the result: + * Returns {OK} if the ingestion is successful. + * Returns {SERVICE_UNAVAILABLE} if the Pyris webhook service is unavailable or if the ingestion fails. + * Returns {400 BAD_REQUEST} if the provided lecture unit is not of type {AttachmentUnit}. + * + * @param lectureUnit the lecture unit to be ingested, which must be an instance of AttachmentUnit. + * @return ResponseEntity representing the outcome of the operation with the appropriate HTTP status. + */ + public ResponseEntity ingestLectureUnitInPyris(LectureUnit lectureUnit) { + if (!(lectureUnit instanceof AttachmentUnit)) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).build(); + } + if (pyrisWebhookService.isEmpty()) { + log.error("Could not send Lecture Unit to Pyris: Pyris webhook service is not available, check if IRIS is enabled."); + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).build(); + } + boolean isIngested = pyrisWebhookService.get().addLectureUnitToPyrisDB((AttachmentUnit) lectureUnit) != null; + return ResponseEntity.status(isIngested ? HttpStatus.OK : HttpStatus.BAD_REQUEST).build(); + } + /** * Disconnects the competency exercise links from the exercise before the cycle is broken by the deserialization. * @@ -256,7 +286,7 @@ public T saveWithCompetencyLinks(T lectureUnit, Function if (Hibernate.isInitialized(links) && links != null && !links.isEmpty()) { savedLectureUnit.setCompetencyLinks(links); reconnectCompetencyLectureUnitLinks(savedLectureUnit); - savedLectureUnit.setCompetencyLinks(new HashSet<>(competencyLectureUnitLinkRepository.saveAll(links))); + savedLectureUnit.setCompetencyLinks(new HashSet<>(competencyRelationApi.saveAllLectureUnitLinks(links))); } return savedLectureUnit; diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/web/AttachmentUnitResource.java b/src/main/java/de/tum/cit/aet/artemis/lecture/web/AttachmentUnitResource.java index 83aa9f8ebefb..ec1514ca668b 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/web/AttachmentUnitResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/web/AttachmentUnitResource.java @@ -29,7 +29,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; -import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyProgressService; +import de.tum.cit.aet.artemis.atlas.api.CompetencyProgressApi; import de.tum.cit.aet.artemis.communication.service.notifications.GroupNotificationService; import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException; import de.tum.cit.aet.artemis.core.exception.EntityNotFoundException; @@ -69,7 +69,7 @@ public class AttachmentUnitResource { private final LectureUnitProcessingService lectureUnitProcessingService; - private final CompetencyProgressService competencyProgressService; + private final CompetencyProgressApi competencyProgressApi; private final SlideSplitterService slideSplitterService; @@ -77,14 +77,14 @@ public class AttachmentUnitResource { public AttachmentUnitResource(AttachmentUnitRepository attachmentUnitRepository, LectureRepository lectureRepository, LectureUnitProcessingService lectureUnitProcessingService, AuthorizationCheckService authorizationCheckService, GroupNotificationService groupNotificationService, AttachmentUnitService attachmentUnitService, - CompetencyProgressService competencyProgressService, SlideSplitterService slideSplitterService, FileService fileService) { + CompetencyProgressApi competencyProgressApi, SlideSplitterService slideSplitterService, FileService fileService) { this.attachmentUnitRepository = attachmentUnitRepository; this.lectureUnitProcessingService = lectureUnitProcessingService; this.lectureRepository = lectureRepository; this.authorizationCheckService = authorizationCheckService; this.groupNotificationService = groupNotificationService; this.attachmentUnitService = attachmentUnitService; - this.competencyProgressService = competencyProgressService; + this.competencyProgressApi = competencyProgressApi; this.slideSplitterService = slideSplitterService; this.fileService = fileService; } @@ -173,7 +173,7 @@ public ResponseEntity createAttachmentUnit(@PathVariable Long le slideSplitterService.splitAttachmentUnitIntoSingleSlides(savedAttachmentUnit); } attachmentUnitService.prepareAttachmentUnitForClient(savedAttachmentUnit); - competencyProgressService.updateProgressByLearningObjectAsync(savedAttachmentUnit); + competencyProgressApi.updateProgressByLearningObjectAsync(savedAttachmentUnit); return ResponseEntity.created(new URI("/api/attachment-units/" + savedAttachmentUnit.getId())).body(savedAttachmentUnit); } @@ -228,7 +228,7 @@ public ResponseEntity> createAttachmentUnits(@PathVariable List savedAttachmentUnits = lectureUnitProcessingService.splitAndSaveUnits(lectureUnitSplitInformationDTO, fileBytes, lectureRepository.findByIdWithLectureUnitsAndAttachmentsElseThrow(lectureId)); savedAttachmentUnits.forEach(attachmentUnitService::prepareAttachmentUnitForClient); - savedAttachmentUnits.forEach(competencyProgressService::updateProgressByLearningObjectAsync); + savedAttachmentUnits.forEach(competencyProgressApi::updateProgressByLearningObjectAsync); return ResponseEntity.ok().body(savedAttachmentUnits); } catch (IOException e) { diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/web/LectureResource.java b/src/main/java/de/tum/cit/aet/artemis/lecture/web/LectureResource.java index baecd6cd7c57..deeb3ec3861c 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/web/LectureResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/web/LectureResource.java @@ -27,7 +27,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyService; +import de.tum.cit.aet.artemis.atlas.api.CompetencyApi; import de.tum.cit.aet.artemis.communication.domain.conversation.Channel; import de.tum.cit.aet.artemis.communication.repository.conversation.ChannelRepository; import de.tum.cit.aet.artemis.communication.service.conversation.ChannelService; @@ -67,7 +67,7 @@ public class LectureResource { private static final String ENTITY_NAME = "lecture"; - private final CompetencyService competencyService; + private final CompetencyApi competencyApi; @Value("${jhipster.clientApp.name}") private String applicationName; @@ -92,7 +92,7 @@ public class LectureResource { public LectureResource(LectureRepository lectureRepository, LectureService lectureService, LectureImportService lectureImportService, CourseRepository courseRepository, UserRepository userRepository, AuthorizationCheckService authCheckService, ExerciseService exerciseService, ChannelService channelService, - ChannelRepository channelRepository, CompetencyService competencyService) { + ChannelRepository channelRepository, CompetencyApi competencyApi) { this.lectureRepository = lectureRepository; this.lectureService = lectureService; this.lectureImportService = lectureImportService; @@ -102,7 +102,7 @@ public LectureResource(LectureRepository lectureRepository, LectureService lectu this.exerciseService = exerciseService; this.channelService = channelService; this.channelRepository = channelRepository; - this.competencyService = competencyService; + this.competencyApi = competencyApi; } /** @@ -276,21 +276,21 @@ public ResponseEntity importLecture(@PathVariable long sourceLectureId, @Profile(PROFILE_IRIS) @PostMapping("courses/{courseId}/ingest") @EnforceAtLeastInstructorInCourse - public ResponseEntity ingestLectures(@PathVariable Long courseId, @RequestParam(required = false) Optional lectureId) { - log.debug("REST request to ingest lectures of course : {}", courseId); + public ResponseEntity ingestLectures(@PathVariable Long courseId, @RequestParam(required = false) Optional lectureId) { Course course = courseRepository.findByIdWithLecturesAndLectureUnitsElseThrow(courseId); + authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.INSTRUCTOR, course, null); if (lectureId.isPresent()) { Optional lectureToIngest = course.getLectures().stream().filter(lecture -> lecture.getId().equals(lectureId.get())).findFirst(); if (lectureToIngest.isPresent()) { Set lecturesToIngest = new HashSet<>(); lecturesToIngest.add(lectureToIngest.get()); - return ResponseEntity.ok().body(lectureService.ingestLecturesInPyris(lecturesToIngest)); + lectureService.ingestLecturesInPyris(lecturesToIngest); + return ResponseEntity.ok().build(); } - return ResponseEntity.badRequest() - .headers(HeaderUtil.createAlert(applicationName, "Could not send lecture to Iris, no lecture found with the provided id.", "idExists")).body(null); - + return ResponseEntity.badRequest().headers(HeaderUtil.createAlert(applicationName, "artemisApp.iris.ingestionAlert.allLecturesError", "idExists")).body(null); } - return ResponseEntity.ok().body(lectureService.ingestLecturesInPyris(course.getLectures())); + lectureService.ingestLecturesInPyris(course.getLectures()); + return ResponseEntity.ok().build(); } /** @@ -304,7 +304,7 @@ public ResponseEntity ingestLectures(@PathVariable Long courseId, @Requ public ResponseEntity getLectureWithDetails(@PathVariable Long lectureId) { log.debug("REST request to get lecture {} with details", lectureId); Lecture lecture = lectureRepository.findByIdWithAttachmentsAndPostsAndLectureUnitsAndCompetenciesAndCompletionsElseThrow(lectureId); - competencyService.addCompetencyLinksToExerciseUnits(lecture); + competencyApi.addCompetencyLinksToExerciseUnits(lecture); Course course = lecture.getCourse(); if (course == null) { return ResponseEntity.badRequest().build(); @@ -334,7 +334,7 @@ public ResponseEntity getLectureWithDetailsAndSlides(@PathVariable long User user = userRepository.getUserWithGroupsAndAuthorities(); authCheckService.checkIsAllowedToSeeLectureElseThrow(lecture, user); - competencyService.addCompetencyLinksToExerciseUnits(lecture); + competencyApi.addCompetencyLinksToExerciseUnits(lecture); lectureService.filterActiveAttachmentUnits(lecture); lectureService.filterActiveAttachments(lecture, user); return ResponseEntity.ok(lecture); diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/web/LectureUnitResource.java b/src/main/java/de/tum/cit/aet/artemis/lecture/web/LectureUnitResource.java index b14667346674..2faca61e3854 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/web/LectureUnitResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/web/LectureUnitResource.java @@ -4,6 +4,7 @@ import java.util.Comparator; import java.util.List; +import java.util.Optional; import jakarta.validation.Valid; @@ -11,6 +12,7 @@ import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -22,7 +24,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyProgressService; +import de.tum.cit.aet.artemis.atlas.api.CompetencyProgressApi; import de.tum.cit.aet.artemis.core.domain.User; import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException; import de.tum.cit.aet.artemis.core.exception.EntityNotFoundException; @@ -63,16 +65,16 @@ public class LectureUnitResource { private final LectureUnitService lectureUnitService; - private final CompetencyProgressService competencyProgressService; + private final CompetencyProgressApi competencyProgressApi; public LectureUnitResource(AuthorizationCheckService authorizationCheckService, UserRepository userRepository, LectureRepository lectureRepository, - LectureUnitRepository lectureUnitRepository, LectureUnitService lectureUnitService, CompetencyProgressService competencyProgressService, UserService userService) { + LectureUnitRepository lectureUnitRepository, LectureUnitService lectureUnitService, CompetencyProgressApi competencyProgressApi, UserService userService) { this.authorizationCheckService = authorizationCheckService; this.userRepository = userRepository; this.lectureUnitRepository = lectureUnitRepository; this.lectureRepository = lectureRepository; this.lectureUnitService = lectureUnitService; - this.competencyProgressService = competencyProgressService; + this.competencyProgressApi = competencyProgressApi; } /** @@ -142,7 +144,7 @@ public ResponseEntity completeLectureUnit(@PathVariable Long lectureUnitId authorizationCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.STUDENT, lectureUnit.getLecture().getCourse(), user); lectureUnitService.setLectureUnitCompletion(lectureUnit, user, completed); - competencyProgressService.updateProgressByLearningObjectForParticipantAsync(lectureUnit, user); + competencyProgressApi.updateProgressByLearningObjectForParticipantAsync(lectureUnit, user); return ResponseEntity.ok().build(); } @@ -156,7 +158,7 @@ public ResponseEntity completeLectureUnit(@PathVariable Long lectureUnitId */ @DeleteMapping("lectures/{lectureId}/lecture-units/{lectureUnitId}") @EnforceAtLeastInstructor - public ResponseEntity deleteLectureUnit(@PathVariable Long lectureUnitId, @PathVariable Long lectureId) { + public ResponseEntity deleteLectureUnit(@PathVariable long lectureUnitId, @PathVariable Long lectureId) { log.info("REST request to delete lecture unit: {}", lectureUnitId); LectureUnit lectureUnit = lectureUnitRepository.findByIdWithCompetenciesBidirectionalElseThrow(lectureUnitId); if (lectureUnit.getLecture() == null || lectureUnit.getLecture().getCourse() == null) { @@ -200,11 +202,35 @@ public ResponseEntity getLectureUnitFo */ @GetMapping("lecture-units/{lectureUnitId}") @EnforceAtLeastStudent - public ResponseEntity getLectureUnitById(@PathVariable @Valid Long lectureUnitId) { + public ResponseEntity getLectureUnitById(@PathVariable @Valid long lectureUnitId) { log.debug("REST request to get lecture unit with id: {}", lectureUnitId); var lectureUnit = lectureUnitRepository.findByIdWithCompletedUsersElseThrow(lectureUnitId); authorizationCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.STUDENT, lectureUnit.getLecture().getCourse(), null); lectureUnit.setCompleted(lectureUnit.isCompletedFor(userRepository.getUser())); return ResponseEntity.ok(lectureUnit); } + + /** + * This endpoint triggers the ingestion process for a specified lecture unit into Pyris. + * + * @param lectureId the ID of the lecture to which the lecture unit belongs + * @param lectureUnitId the ID of the lecture unit to be ingested + * @return ResponseEntity with the status of the ingestion operation. + * Returns 200 OK if the ingestion is successfully started. + * Returns 400 BAD_REQUEST if the lecture unit cannot be ingested. + * Returns SERVICE_UNAVAILABLE if the Pyris service is unavailable or + * ingestion fails for another reason. + */ + @PostMapping("lectures/{lectureId}/lecture-units/{lectureUnitId}/ingest") + @EnforceAtLeastInstructor + public ResponseEntity ingestLectureUnit(@PathVariable long lectureId, @PathVariable long lectureUnitId) { + Lecture lecture = this.lectureRepository.findByIdWithLectureUnitsElseThrow(lectureId); + Optional lectureUnitOptional = lecture.getLectureUnits().stream().filter(lu -> lu.getId() == lectureUnitId).findFirst(); + if (lectureUnitOptional.isEmpty()) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).build(); + } + LectureUnit lectureUnit = lectureUnitOptional.get(); + authorizationCheckService.checkHasAtLeastRoleForLectureElseThrow(Role.INSTRUCTOR, lectureUnit.getLecture(), null); + return lectureUnitService.ingestLectureUnitInPyris(lectureUnit); + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/web/OnlineUnitResource.java b/src/main/java/de/tum/cit/aet/artemis/lecture/web/OnlineUnitResource.java index c5e8bfa600d1..891701b00afc 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/web/OnlineUnitResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/web/OnlineUnitResource.java @@ -28,7 +28,7 @@ import com.google.common.net.InternetDomainName; -import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyProgressService; +import de.tum.cit.aet.artemis.atlas.api.CompetencyProgressApi; import de.tum.cit.aet.artemis.core.dto.OnlineResourceDTO; import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException; import de.tum.cit.aet.artemis.core.exception.InternalServerErrorException; @@ -56,16 +56,16 @@ public class OnlineUnitResource { private final AuthorizationCheckService authorizationCheckService; - private final CompetencyProgressService competencyProgressService; + private final CompetencyProgressApi competencyProgressApi; private final LectureUnitService lectureUnitService; public OnlineUnitResource(LectureRepository lectureRepository, AuthorizationCheckService authorizationCheckService, OnlineUnitRepository onlineUnitRepository, - CompetencyProgressService competencyProgressService, LectureUnitService lectureUnitService) { + CompetencyProgressApi competencyProgressApi, LectureUnitService lectureUnitService) { this.lectureRepository = lectureRepository; this.authorizationCheckService = authorizationCheckService; this.onlineUnitRepository = onlineUnitRepository; - this.competencyProgressService = competencyProgressService; + this.competencyProgressApi = competencyProgressApi; this.lectureUnitService = lectureUnitService; } @@ -110,7 +110,7 @@ public ResponseEntity updateOnlineUnit(@PathVariable Long lectureId, OnlineUnit result = lectureUnitService.saveWithCompetencyLinks(onlineUnit, onlineUnitRepository::save); - competencyProgressService.updateProgressForUpdatedLearningObjectAsync(existingOnlineUnit, Optional.of(onlineUnit)); + competencyProgressApi.updateProgressForUpdatedLearningObjectAsync(existingOnlineUnit, Optional.of(onlineUnit)); return ResponseEntity.ok(result); } @@ -148,7 +148,7 @@ public ResponseEntity createOnlineUnit(@PathVariable Long lectureId, lecture.addLectureUnit(persistedOnlineUnit); lectureRepository.save(lecture); - competencyProgressService.updateProgressByLearningObjectAsync(persistedOnlineUnit); + competencyProgressApi.updateProgressByLearningObjectAsync(persistedOnlineUnit); return ResponseEntity.created(new URI("/api/online-units/" + persistedOnlineUnit.getId())).body(persistedOnlineUnit); } diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/web/TextUnitResource.java b/src/main/java/de/tum/cit/aet/artemis/lecture/web/TextUnitResource.java index b48d11ca87d4..e7c15d7e786f 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/web/TextUnitResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/web/TextUnitResource.java @@ -18,7 +18,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyProgressService; +import de.tum.cit.aet.artemis.atlas.api.CompetencyProgressApi; import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException; import de.tum.cit.aet.artemis.core.exception.EntityNotFoundException; import de.tum.cit.aet.artemis.core.security.Role; @@ -45,16 +45,16 @@ public class TextUnitResource { private final AuthorizationCheckService authorizationCheckService; - private final CompetencyProgressService competencyProgressService; + private final CompetencyProgressApi competencyProgressApi; private final LectureUnitService lectureUnitService; public TextUnitResource(LectureRepository lectureRepository, TextUnitRepository textUnitRepository, AuthorizationCheckService authorizationCheckService, - CompetencyProgressService competencyProgressService, LectureUnitService lectureUnitService) { + CompetencyProgressApi competencyProgressApi, LectureUnitService lectureUnitService) { this.lectureRepository = lectureRepository; this.textUnitRepository = textUnitRepository; this.authorizationCheckService = authorizationCheckService; - this.competencyProgressService = competencyProgressService; + this.competencyProgressApi = competencyProgressApi; this.lectureUnitService = lectureUnitService; } @@ -108,7 +108,7 @@ public ResponseEntity updateTextUnit(@PathVariable Long lectureId, @Re TextUnit result = lectureUnitService.saveWithCompetencyLinks(textUnitForm, textUnitRepository::save); - competencyProgressService.updateProgressForUpdatedLearningObjectAsync(existingTextUnit, Optional.of(textUnitForm)); + competencyProgressApi.updateProgressForUpdatedLearningObjectAsync(existingTextUnit, Optional.of(textUnitForm)); return ResponseEntity.ok(result); } @@ -146,7 +146,7 @@ public ResponseEntity createTextUnit(@PathVariable Long lectureId, @Re Lecture updatedLecture = lectureRepository.save(lecture); TextUnit persistedTextUnit = (TextUnit) updatedLecture.getLectureUnits().getLast(); - competencyProgressService.updateProgressByLearningObjectAsync(persistedTextUnit); + competencyProgressApi.updateProgressByLearningObjectAsync(persistedTextUnit); lectureUnitService.disconnectCompetencyLectureUnitLinks(persistedTextUnit); return ResponseEntity.created(new URI("/api/text-units/" + persistedTextUnit.getId())).body(persistedTextUnit); diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/web/VideoUnitResource.java b/src/main/java/de/tum/cit/aet/artemis/lecture/web/VideoUnitResource.java index e38d2580f83e..ad4e2d75b163 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/web/VideoUnitResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/web/VideoUnitResource.java @@ -20,7 +20,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyProgressService; +import de.tum.cit.aet.artemis.atlas.api.CompetencyProgressApi; import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException; import de.tum.cit.aet.artemis.core.security.Role; import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastEditor; @@ -46,16 +46,16 @@ public class VideoUnitResource { private final AuthorizationCheckService authorizationCheckService; - private final CompetencyProgressService competencyProgressService; + private final CompetencyProgressApi competencyProgressApi; private final LectureUnitService lectureUnitService; public VideoUnitResource(LectureRepository lectureRepository, AuthorizationCheckService authorizationCheckService, VideoUnitRepository videoUnitRepository, - CompetencyProgressService competencyProgressService, LectureUnitService lectureUnitService) { + CompetencyProgressApi competencyProgressApi, LectureUnitService lectureUnitService) { this.lectureRepository = lectureRepository; this.authorizationCheckService = authorizationCheckService; this.videoUnitRepository = videoUnitRepository; - this.competencyProgressService = competencyProgressService; + this.competencyProgressApi = competencyProgressApi; this.lectureUnitService = lectureUnitService; } @@ -99,7 +99,7 @@ public ResponseEntity updateVideoUnit(@PathVariable Long lectureId, @ VideoUnit result = lectureUnitService.saveWithCompetencyLinks(videoUnit, videoUnitRepository::save); - competencyProgressService.updateProgressForUpdatedLearningObjectAsync(existingVideoUnit, Optional.of(videoUnit)); + competencyProgressApi.updateProgressForUpdatedLearningObjectAsync(existingVideoUnit, Optional.of(videoUnit)); return ResponseEntity.ok(result); } @@ -139,7 +139,7 @@ public ResponseEntity createVideoUnit(@PathVariable Long lectureId, @ Lecture updatedLecture = lectureRepository.save(lecture); VideoUnit persistedVideoUnit = (VideoUnit) updatedLecture.getLectureUnits().getLast(); - competencyProgressService.updateProgressByLearningObjectAsync(persistedVideoUnit); + competencyProgressApi.updateProgressByLearningObjectAsync(persistedVideoUnit); lectureUnitService.disconnectCompetencyLectureUnitLinks(persistedVideoUnit); return ResponseEntity.created(new URI("/api/video-units/" + persistedVideoUnit.getId())).body(persistedVideoUnit); diff --git a/src/main/java/de/tum/cit/aet/artemis/modeling/service/ModelingExerciseImportService.java b/src/main/java/de/tum/cit/aet/artemis/modeling/service/ModelingExerciseImportService.java index 3c877ce69465..c418596c3a49 100644 --- a/src/main/java/de/tum/cit/aet/artemis/modeling/service/ModelingExerciseImportService.java +++ b/src/main/java/de/tum/cit/aet/artemis/modeling/service/ModelingExerciseImportService.java @@ -21,7 +21,7 @@ import de.tum.cit.aet.artemis.assessment.repository.ExampleSubmissionRepository; import de.tum.cit.aet.artemis.assessment.repository.ResultRepository; import de.tum.cit.aet.artemis.assessment.service.FeedbackService; -import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyProgressService; +import de.tum.cit.aet.artemis.atlas.api.CompetencyProgressApi; import de.tum.cit.aet.artemis.communication.service.conversation.ChannelService; import de.tum.cit.aet.artemis.exercise.domain.Exercise; import de.tum.cit.aet.artemis.exercise.domain.Submission; @@ -42,17 +42,17 @@ public class ModelingExerciseImportService extends ExerciseImportService { private final ChannelService channelService; - private final CompetencyProgressService competencyProgressService; + private final CompetencyProgressApi competencyProgressApi; private final ExerciseService exerciseService; public ModelingExerciseImportService(ModelingExerciseRepository modelingExerciseRepository, ExampleSubmissionRepository exampleSubmissionRepository, SubmissionRepository submissionRepository, ResultRepository resultRepository, ChannelService channelService, FeedbackService feedbackService, - CompetencyProgressService competencyProgressService, ExerciseService exerciseService) { + CompetencyProgressApi competencyProgressApi, ExerciseService exerciseService) { super(exampleSubmissionRepository, submissionRepository, resultRepository, feedbackService); this.modelingExerciseRepository = modelingExerciseRepository; this.channelService = channelService; - this.competencyProgressService = competencyProgressService; + this.competencyProgressApi = competencyProgressApi; this.exerciseService = exerciseService; } @@ -77,7 +77,7 @@ public ModelingExercise importModelingExercise(ModelingExercise templateExercise channelService.createExerciseChannel(newModelingExercise, Optional.ofNullable(importedExercise.getChannelName())); newModelingExercise.setExampleSubmissions(copyExampleSubmission(templateExercise, newExercise, gradingInstructionCopyTracker)); - competencyProgressService.updateProgressByLearningObjectAsync(newModelingExercise); + competencyProgressApi.updateProgressByLearningObjectAsync(newModelingExercise); return newModelingExercise; } diff --git a/src/main/java/de/tum/cit/aet/artemis/modeling/web/ModelingExerciseResource.java b/src/main/java/de/tum/cit/aet/artemis/modeling/web/ModelingExerciseResource.java index a2739ed28c76..899e16a32127 100644 --- a/src/main/java/de/tum/cit/aet/artemis/modeling/web/ModelingExerciseResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/modeling/web/ModelingExerciseResource.java @@ -29,7 +29,7 @@ import de.tum.cit.aet.artemis.assessment.domain.GradingCriterion; import de.tum.cit.aet.artemis.assessment.repository.GradingCriterionRepository; -import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyProgressService; +import de.tum.cit.aet.artemis.atlas.api.CompetencyProgressApi; import de.tum.cit.aet.artemis.communication.domain.conversation.Channel; import de.tum.cit.aet.artemis.communication.repository.conversation.ChannelRepository; import de.tum.cit.aet.artemis.communication.service.conversation.ChannelService; @@ -81,7 +81,7 @@ public class ModelingExerciseResource { private static final String ENTITY_NAME = "modelingExercise"; - private final CompetencyProgressService competencyProgressService; + private final CompetencyProgressApi competencyProgressApi; @Value("${jhipster.clientApp.name}") private String applicationName; @@ -126,7 +126,7 @@ public ModelingExerciseResource(ModelingExerciseRepository modelingExerciseRepos ModelingExerciseImportService modelingExerciseImportService, SubmissionExportService modelingSubmissionExportService, ExerciseService exerciseService, GroupNotificationScheduleService groupNotificationScheduleService, GradingCriterionRepository gradingCriterionRepository, PlagiarismDetectionService plagiarismDetectionService, ChannelService channelService, ChannelRepository channelRepository, - CompetencyProgressService competencyProgressService) { + CompetencyProgressApi competencyProgressApi) { this.modelingExerciseRepository = modelingExerciseRepository; this.courseService = courseService; this.modelingExerciseService = modelingExerciseService; @@ -144,7 +144,7 @@ public ModelingExerciseResource(ModelingExerciseRepository modelingExerciseRepos this.plagiarismDetectionService = plagiarismDetectionService; this.channelService = channelService; this.channelRepository = channelRepository; - this.competencyProgressService = competencyProgressService; + this.competencyProgressApi = competencyProgressApi; } // TODO: most of these calls should be done in the context of a course @@ -182,7 +182,7 @@ public ResponseEntity createModelingExercise(@RequestBody Mode channelService.createExerciseChannel(result, Optional.ofNullable(modelingExercise.getChannelName())); modelingExerciseService.scheduleOperations(result.getId()); groupNotificationScheduleService.checkNotificationsForNewExerciseAsync(modelingExercise); - competencyProgressService.updateProgressByLearningObjectAsync(result); + competencyProgressApi.updateProgressByLearningObjectAsync(result); return ResponseEntity.created(new URI("/api/modeling-exercises/" + result.getId())).body(result); } @@ -253,7 +253,7 @@ public ResponseEntity updateModelingExercise(@RequestBody Mode exerciseService.notifyAboutExerciseChanges(modelingExerciseBeforeUpdate, updatedModelingExercise, notificationText); - competencyProgressService.updateProgressForUpdatedLearningObjectAsync(modelingExerciseBeforeUpdate, Optional.of(modelingExercise)); + competencyProgressApi.updateProgressForUpdatedLearningObjectAsync(modelingExerciseBeforeUpdate, Optional.of(modelingExercise)); return ResponseEntity.ok(updatedModelingExercise); } diff --git a/src/main/java/de/tum/cit/aet/artemis/plagiarism/service/PlagiarismAnswerPostService.java b/src/main/java/de/tum/cit/aet/artemis/plagiarism/service/PlagiarismAnswerPostService.java index 0c3ff1beb754..ec2f97befb23 100644 --- a/src/main/java/de/tum/cit/aet/artemis/plagiarism/service/PlagiarismAnswerPostService.java +++ b/src/main/java/de/tum/cit/aet/artemis/plagiarism/service/PlagiarismAnswerPostService.java @@ -15,6 +15,7 @@ import de.tum.cit.aet.artemis.communication.repository.AnswerPostRepository; import de.tum.cit.aet.artemis.communication.repository.ConversationParticipantRepository; import de.tum.cit.aet.artemis.communication.repository.PostRepository; +import de.tum.cit.aet.artemis.communication.repository.SavedPostRepository; import de.tum.cit.aet.artemis.communication.service.PostingService; import de.tum.cit.aet.artemis.communication.service.WebsocketMessagingService; import de.tum.cit.aet.artemis.core.domain.Course; @@ -40,8 +41,9 @@ public class PlagiarismAnswerPostService extends PostingService { protected PlagiarismAnswerPostService(CourseRepository courseRepository, AuthorizationCheckService authorizationCheckService, UserRepository userRepository, AnswerPostRepository answerPostRepository, PostRepository postRepository, ExerciseRepository exerciseRepository, LectureRepository lectureRepository, - WebsocketMessagingService websocketMessagingService, ConversationParticipantRepository conversationParticipantRepository) { - super(courseRepository, userRepository, exerciseRepository, lectureRepository, authorizationCheckService, websocketMessagingService, conversationParticipantRepository); + WebsocketMessagingService websocketMessagingService, ConversationParticipantRepository conversationParticipantRepository, SavedPostRepository savedPostRepository) { + super(courseRepository, userRepository, exerciseRepository, lectureRepository, authorizationCheckService, websocketMessagingService, conversationParticipantRepository, + savedPostRepository); this.answerPostRepository = answerPostRepository; this.postRepository = postRepository; } @@ -164,7 +166,7 @@ public void deleteAnswerPostById(Long courseId, Long answerPostId) { // delete answerPostRepository.deleteById(answerPostId); - + preparePostForBroadcast(post); broadcastForPost(new PostDTO(post, MetisCrudAction.UPDATE), course.getId(), null, null); } diff --git a/src/main/java/de/tum/cit/aet/artemis/plagiarism/service/PlagiarismPostService.java b/src/main/java/de/tum/cit/aet/artemis/plagiarism/service/PlagiarismPostService.java index fc5bda5882c3..a1034b96e335 100644 --- a/src/main/java/de/tum/cit/aet/artemis/plagiarism/service/PlagiarismPostService.java +++ b/src/main/java/de/tum/cit/aet/artemis/plagiarism/service/PlagiarismPostService.java @@ -16,6 +16,7 @@ import de.tum.cit.aet.artemis.communication.dto.PostDTO; import de.tum.cit.aet.artemis.communication.repository.ConversationParticipantRepository; import de.tum.cit.aet.artemis.communication.repository.PostRepository; +import de.tum.cit.aet.artemis.communication.repository.SavedPostRepository; import de.tum.cit.aet.artemis.communication.service.PostingService; import de.tum.cit.aet.artemis.communication.service.WebsocketMessagingService; import de.tum.cit.aet.artemis.core.domain.Course; @@ -42,9 +43,11 @@ public class PlagiarismPostService extends PostingService { private final PlagiarismCaseService plagiarismCaseService; protected PlagiarismPostService(CourseRepository courseRepository, AuthorizationCheckService authorizationCheckService, UserRepository userRepository, - PostRepository postRepository, ExerciseRepository exerciseRepository, LectureRepository lectureRepository, WebsocketMessagingService websocketMessagingService, - PlagiarismCaseService plagiarismCaseService, PlagiarismCaseRepository plagiarismCaseRepository, ConversationParticipantRepository conversationParticipantRepository) { - super(courseRepository, userRepository, exerciseRepository, lectureRepository, authorizationCheckService, websocketMessagingService, conversationParticipantRepository); + SavedPostRepository savedPostRepository, PostRepository postRepository, ExerciseRepository exerciseRepository, LectureRepository lectureRepository, + WebsocketMessagingService websocketMessagingService, PlagiarismCaseService plagiarismCaseService, PlagiarismCaseRepository plagiarismCaseRepository, + ConversationParticipantRepository conversationParticipantRepository) { + super(courseRepository, userRepository, exerciseRepository, lectureRepository, authorizationCheckService, websocketMessagingService, conversationParticipantRepository, + savedPostRepository); this.postRepository = postRepository; this.plagiarismCaseRepository = plagiarismCaseRepository; this.plagiarismCaseService = plagiarismCaseService; @@ -132,6 +135,7 @@ public Post updatePost(Long courseId, Long postId, Post post) { Post updatedPost = postRepository.save(existingPost); + preparePostForBroadcast(updatedPost); broadcastForPost(new PostDTO(updatedPost, MetisCrudAction.UPDATE), course.getId(), null, null); return updatedPost; } @@ -184,6 +188,7 @@ public void deletePostById(Long courseId, Long postId) { // delete postRepository.deleteById(postId); + preparePostForBroadcast(post); broadcastForPost(new PostDTO(post, MetisCrudAction.DELETE), course.getId(), null, null); } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/domain/StaticCodeAnalysisTool.java b/src/main/java/de/tum/cit/aet/artemis/programming/domain/StaticCodeAnalysisTool.java index 9068d7fadd9c..dd3ffcbea67a 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/domain/StaticCodeAnalysisTool.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/domain/StaticCodeAnalysisTool.java @@ -1,7 +1,8 @@ package de.tum.cit.aet.artemis.programming.domain; -import java.util.ArrayList; +import java.util.EnumMap; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Optional; @@ -10,28 +11,29 @@ */ public enum StaticCodeAnalysisTool { - SPOTBUGS(ProgrammingLanguage.JAVA, "spotbugs:spotbugs", "spotbugsXml.xml"), CHECKSTYLE(ProgrammingLanguage.JAVA, "checkstyle:checkstyle", "checkstyle-result.xml"), - PMD(ProgrammingLanguage.JAVA, "pmd:pmd", "pmd.xml"), PMD_CPD(ProgrammingLanguage.JAVA, "pmd:cpd", "cpd.xml"), SWIFTLINT(ProgrammingLanguage.SWIFT, "", "swiftlint-result.xml"), - GCC(ProgrammingLanguage.C, "", "gcc.xml"); + // @formatter:off + SPOTBUGS("spotbugsXml.xml"), + CHECKSTYLE("checkstyle-result.xml"), + PMD("pmd.xml"), + PMD_CPD("cpd.xml"), + SWIFTLINT("swiftlint-result.xml"), + GCC("gcc.xml"), + OTHER(null), + ; + // @formatter:on - private final ProgrammingLanguage language; + // @formatter:off + private static final Map> TOOLS_OF_PROGRAMMING_LANGUAGE = new EnumMap<>(Map.of( + ProgrammingLanguage.JAVA, List.of(SPOTBUGS, CHECKSTYLE, PMD, PMD_CPD), + ProgrammingLanguage.SWIFT, List.of(SWIFTLINT), + ProgrammingLanguage.C, List.of(GCC) + )); + // @formatter:on - private final String command; + private final String fileName; - private final String filePattern; - - StaticCodeAnalysisTool(ProgrammingLanguage language, String command, String filePattern) { - this.language = language; - this.command = command; - this.filePattern = filePattern; - } - - public String getTask() { - return this.command; - } - - public String getFilePattern() { - return this.filePattern; + StaticCodeAnalysisTool(String fileName) { + this.fileName = fileName; } /** @@ -41,13 +43,7 @@ public String getFilePattern() { * @return List of static code analysis */ public static List getToolsForProgrammingLanguage(ProgrammingLanguage language) { - List tools = new ArrayList<>(); - for (var tool : StaticCodeAnalysisTool.values()) { - if (tool.language == language) { - tools.add(tool); - } - } - return tools; + return TOOLS_OF_PROGRAMMING_LANGUAGE.getOrDefault(language, List.of()); } /** @@ -58,7 +54,7 @@ public static List getToolsForProgrammingLanguage(Progra */ public static Optional getToolByFilePattern(String fileName) { for (StaticCodeAnalysisTool tool : StaticCodeAnalysisTool.values()) { - if (Objects.equals(fileName, tool.filePattern)) { + if (Objects.equals(fileName, tool.fileName)) { return Optional.of(tool); } } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/domain/UserSshPublicKey.java b/src/main/java/de/tum/cit/aet/artemis/programming/domain/UserSshPublicKey.java new file mode 100644 index 000000000000..8348c7eb656c --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/domain/UserSshPublicKey.java @@ -0,0 +1,131 @@ +package de.tum.cit.aet.artemis.programming.domain; + +import java.time.ZonedDateTime; + +import jakarta.annotation.Nullable; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import org.hibernate.annotations.Cache; +import org.hibernate.annotations.CacheConcurrencyStrategy; + +import de.tum.cit.aet.artemis.core.domain.DomainObject; + +/** + * A public SSH key of a user. + */ +@Entity +@Table(name = "user_public_ssh_key") +@Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) +public class UserSshPublicKey extends DomainObject { + + /** + * The user who is owner of the public key + */ + @NotNull + @Column(name = "user_id") + private long userId; + + /** + * The label of the SSH key shwon in the UI + */ + @Size(max = 50) + @Column(name = "label", length = 50) + private String label; + + /** + * The actual full public ssh key of a user used to authenticate git clone and git push operations if available + */ + @NotNull + @Column(name = "public_key") + private String publicKey; + + /** + * A hash of the public ssh key for fast comparison in the database (with an index) + */ + @Size(max = 100) + @Column(name = "key_hash") + private String keyHash; + + /** + * The creation date of the public SSH key + */ + @Column(name = "creation_date") + private ZonedDateTime creationDate = null; + + /** + * The last used date of the public SSH key + */ + @Nullable + @Column(name = "last_used_date") + private ZonedDateTime lastUsedDate = null; + + /** + * The expiry date of the public SSH key + */ + @Nullable + @Column(name = "expiry_date") + private ZonedDateTime expiryDate = null; + + public @NotNull long getUserId() { + return userId; + } + + public void setUserId(@NotNull long userId) { + this.userId = userId; + } + + public @Size(max = 50) String getLabel() { + return label; + } + + public void setLabel(@Size(max = 50) String label) { + this.label = label; + } + + public String getPublicKey() { + return publicKey; + } + + public void setPublicKey(String publicKey) { + this.publicKey = publicKey; + } + + @Nullable + public @Size(max = 100) String getKeyHash() { + return keyHash; + } + + public void setKeyHash(@Nullable @Size(max = 100) String keyHash) { + this.keyHash = keyHash; + } + + public ZonedDateTime getCreationDate() { + return creationDate; + } + + public void setCreationDate(ZonedDateTime creationDate) { + this.creationDate = creationDate; + } + + @Nullable + public ZonedDateTime getLastUsedDate() { + return lastUsedDate; + } + + public void setLastUsedDate(@Nullable ZonedDateTime lastUsedDate) { + this.lastUsedDate = lastUsedDate; + } + + @Nullable + public ZonedDateTime getExpiryDate() { + return expiryDate; + } + + public void setExpiryDate(@Nullable ZonedDateTime expiryDate) { + this.expiryDate = expiryDate; + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/dto/UserSshPublicKeyDTO.java b/src/main/java/de/tum/cit/aet/artemis/programming/dto/UserSshPublicKeyDTO.java new file mode 100644 index 000000000000..ecfeedab8126 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/dto/UserSshPublicKeyDTO.java @@ -0,0 +1,16 @@ +package de.tum.cit.aet.artemis.programming.dto; + +import java.time.ZonedDateTime; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.cit.aet.artemis.programming.domain.UserSshPublicKey; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record UserSshPublicKeyDTO(Long id, String label, String publicKey, String keyHash, ZonedDateTime creationDate, ZonedDateTime lastUsedDate, ZonedDateTime expiryDate) { + + public static UserSshPublicKeyDTO of(UserSshPublicKey userSshPublicKey) { + return new UserSshPublicKeyDTO(userSshPublicKey.getId(), userSshPublicKey.getLabel(), userSshPublicKey.getPublicKey(), userSshPublicKey.getKeyHash(), + userSshPublicKey.getCreationDate(), userSshPublicKey.getLastUsedDate(), userSshPublicKey.getExpiryDate()); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/repository/UserSshPublicKeyRepository.java b/src/main/java/de/tum/cit/aet/artemis/programming/repository/UserSshPublicKeyRepository.java new file mode 100644 index 000000000000..b177b7b72089 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/repository/UserSshPublicKeyRepository.java @@ -0,0 +1,27 @@ +package de.tum.cit.aet.artemis.programming.repository; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import java.util.List; +import java.util.Optional; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Repository; + +import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository; +import de.tum.cit.aet.artemis.programming.domain.UserSshPublicKey; + +@Profile(PROFILE_CORE) +@Repository +public interface UserSshPublicKeyRepository extends ArtemisJpaRepository { + + List findAllByUserId(Long userId); + + Optional findByKeyHash(String keyHash); + + Optional findByIdAndUserId(Long keyId, Long userId); + + boolean existsByIdAndUserId(Long id, Long userId); + + boolean existsByUserId(Long userId); +} diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/BuildLogEntryService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/BuildLogEntryService.java index f6143a43561c..039fce2c76bc 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/BuildLogEntryService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/BuildLogEntryService.java @@ -23,6 +23,7 @@ import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; +import de.tum.cit.aet.artemis.buildagent.dto.BuildLogDTO; import de.tum.cit.aet.artemis.core.service.ProfileService; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; import de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage; @@ -300,14 +301,14 @@ public void deleteBuildLogEntriesForProgrammingSubmission(ProgrammingSubmission * and the build job ID. If the directory structure for the logs does not already exist, it is created. * Each log entry is written to the log file in the format of "time\tlog message". * - * @param buildLogEntries A list of {@link BuildLogEntry} objects containing the build log information to be saved. + * @param buildLogEntries A list of {@link BuildLogDTO} objects containing the build log information to be saved. * @param buildJobId The unique identifier of the build job whose logs are being saved. * @param programmingExercise The programming exercise associated with the build job, used to * retrieve the course and exercise short names. * @throws IllegalStateException If the directory for storing the logs could not be created. * @throws RuntimeException If an I/O error occurs while writing the log file. */ - public void saveBuildLogsToFile(List buildLogEntries, String buildJobId, ProgrammingExercise programmingExercise) { + public void saveBuildLogsToFile(List buildLogEntries, String buildJobId, ProgrammingExercise programmingExercise) { String courseShortName = programmingExercise.getCourseViaExerciseGroupOrCourseMember().getShortName(); String exerciseShortName = programmingExercise.getShortName(); Path exerciseLogsPath = buildLogsPath.resolve(courseShortName).resolve(exerciseShortName); @@ -323,8 +324,8 @@ public void saveBuildLogsToFile(List buildLogEntries, String buil Path logPath = exerciseLogsPath.resolve(buildJobId + ".log"); StringBuilder logsStringBuilder = new StringBuilder(); - for (BuildLogEntry buildLogEntry : buildLogEntries) { - logsStringBuilder.append(buildLogEntry.getTime()).append("\t").append(buildLogEntry.getLog()); + for (BuildLogDTO buildLogEntry : buildLogEntries) { + logsStringBuilder.append(buildLogEntry.time()).append("\t").append(buildLogEntry.log()); } try { diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingAssessmentService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingAssessmentService.java index 03ec7f527429..f62bde6cc957 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingAssessmentService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingAssessmentService.java @@ -52,7 +52,7 @@ public ProgrammingAssessmentService(ComplaintResponseService complaintResponseSe ProgrammingExerciseParticipationService programmingExerciseParticipationService, Optional athenaFeedbackSendingService, LongFeedbackTextRepository longFeedbackTextRepository) { super(complaintResponseService, complaintRepository, feedbackRepository, resultRepository, studentParticipationRepository, resultService, submissionService, - submissionRepository, examDateService, userRepository, ltiNewResultService, singleUserNotificationService, resultWebsocketService, longFeedbackTextRepository); + submissionRepository, examDateService, userRepository, ltiNewResultService, singleUserNotificationService, resultWebsocketService); this.programmingExerciseParticipationService = programmingExerciseParticipationService; this.athenaFeedbackSendingService = athenaFeedbackSendingService; } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseService.java index 65a12b87be77..20b546ad4a48 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseService.java @@ -45,7 +45,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import de.tum.cit.aet.artemis.assessment.repository.ResultRepository; -import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyProgressService; +import de.tum.cit.aet.artemis.atlas.api.CompetencyProgressApi; import de.tum.cit.aet.artemis.communication.service.conversation.ChannelService; import de.tum.cit.aet.artemis.communication.service.notifications.GroupNotificationScheduleService; import de.tum.cit.aet.artemis.core.domain.Course; @@ -183,7 +183,7 @@ public class ProgrammingExerciseService { private final ExerciseService exerciseService; - private final CompetencyProgressService competencyProgressService; + private final CompetencyProgressApi competencyProgressApi; public ProgrammingExerciseService(ProgrammingExerciseRepository programmingExerciseRepository, GitService gitService, Optional versionControlService, Optional continuousIntegrationService, Optional continuousIntegrationTriggerService, @@ -199,7 +199,7 @@ public ProgrammingExerciseService(ProgrammingExerciseRepository programmingExerc ProgrammingSubmissionService programmingSubmissionService, Optional irisSettingsService, Optional aeolusTemplateService, Optional buildScriptGenerationService, ProgrammingExerciseStudentParticipationRepository programmingExerciseStudentParticipationRepository, ProfileService profileService, ExerciseService exerciseService, - ProgrammingExerciseBuildConfigRepository programmingExerciseBuildConfigRepository, CompetencyProgressService competencyProgressService) { + ProgrammingExerciseBuildConfigRepository programmingExerciseBuildConfigRepository, CompetencyProgressApi competencyProgressApi) { this.programmingExerciseRepository = programmingExerciseRepository; this.gitService = gitService; this.versionControlService = versionControlService; @@ -232,7 +232,7 @@ public ProgrammingExerciseService(ProgrammingExerciseRepository programmingExerc this.profileService = profileService; this.exerciseService = exerciseService; this.programmingExerciseBuildConfigRepository = programmingExerciseBuildConfigRepository; - this.competencyProgressService = competencyProgressService; + this.competencyProgressApi = competencyProgressApi; } /** @@ -339,7 +339,7 @@ public ProgrammingExercise createProgrammingExercise(ProgrammingExercise program // Step 12c: Check notifications for new exercise groupNotificationScheduleService.checkNotificationsForNewExerciseAsync(savedProgrammingExercise); // Step 12d: Update student competency progress - competencyProgressService.updateProgressByLearningObjectAsync(savedProgrammingExercise); + competencyProgressApi.updateProgressByLearningObjectAsync(savedProgrammingExercise); // Step 13: Set Iris settings if (irisSettingsService.isPresent()) { @@ -627,7 +627,7 @@ public ProgrammingExercise updateProgrammingExercise(ProgrammingExercise program exerciseService.notifyAboutExerciseChanges(programmingExerciseBeforeUpdate, updatedProgrammingExercise, notificationText); - competencyProgressService.updateProgressForUpdatedLearningObjectAsync(programmingExerciseBeforeUpdate, Optional.of(updatedProgrammingExercise)); + competencyProgressApi.updateProgressForUpdatedLearningObjectAsync(programmingExerciseBeforeUpdate, Optional.of(updatedProgrammingExercise)); irisSettingsService .ifPresent(settingsService -> settingsService.setEnabledForExerciseByCategories(savedProgrammingExercise, programmingExerciseBeforeUpdate.getCategories())); diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/UserSshPublicKeyService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/UserSshPublicKeyService.java new file mode 100644 index 000000000000..232afd327663 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/UserSshPublicKeyService.java @@ -0,0 +1,139 @@ +package de.tum.cit.aet.artemis.programming.service; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.security.PublicKey; +import java.time.ZonedDateTime; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +import org.apache.commons.lang3.StringUtils; +import org.apache.sshd.common.config.keys.AuthorizedKeyEntry; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import de.tum.cit.aet.artemis.core.domain.User; +import de.tum.cit.aet.artemis.core.exception.AccessForbiddenException; +import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException; +import de.tum.cit.aet.artemis.programming.domain.UserSshPublicKey; +import de.tum.cit.aet.artemis.programming.dto.UserSshPublicKeyDTO; +import de.tum.cit.aet.artemis.programming.repository.UserSshPublicKeyRepository; +import de.tum.cit.aet.artemis.programming.service.localvc.ssh.HashUtils; + +@Profile(PROFILE_CORE) +@Service +public class UserSshPublicKeyService { + + private final UserSshPublicKeyRepository userSshPublicKeyRepository; + + public UserSshPublicKeyService(UserSshPublicKeyRepository userSshPublicKeyRepository) { + this.userSshPublicKeyRepository = userSshPublicKeyRepository; + } + + /** + * Creates a new SSH public key for the specified user, ensuring that the key is unique + * based on its SHA-512 hash fingerprint. If the key already exists, an exception is thrown. + * + * @param user the {@link User} for whom the SSH key is being created. + * @param keyEntry the {@link AuthorizedKeyEntry} containing the SSH public key details, used to resolve the {@link PublicKey}. + * @param sshPublicKey the {@link UserSshPublicKey} object containing metadata about the SSH key such as the key itself, label, and expiry date. + */ + public void createSshKeyForUser(User user, AuthorizedKeyEntry keyEntry, UserSshPublicKeyDTO sshPublicKey) throws GeneralSecurityException, IOException { + PublicKey publicKey = keyEntry.resolvePublicKey(null, null, null); + String keyHash = HashUtils.getSha512Fingerprint(publicKey); + + if (userSshPublicKeyRepository.findByKeyHash(keyHash).isPresent()) { + throw new BadRequestAlertException("Key already exists", "SSH key", "keyAlreadyExists", true); + } + + UserSshPublicKey newUserSshPublicKey = new UserSshPublicKey(); + newUserSshPublicKey.setUserId(user.getId()); + newUserSshPublicKey.setPublicKey(sshPublicKey.publicKey()); + newUserSshPublicKey.setKeyHash(keyHash); + setLabelForKey(newUserSshPublicKey, sshPublicKey.label()); + newUserSshPublicKey.setCreationDate(ZonedDateTime.now()); + newUserSshPublicKey.setExpiryDate(sshPublicKey.expiryDate()); + userSshPublicKeyRepository.save(newUserSshPublicKey); + } + + /** + * Sets the label for the provided SSH public key. If the given label is null or empty, + * the label is extracted from the public key or defaults to a predefined value. + * + * @param newSshPublicKey the {@link UserSshPublicKey} for which the label is being set. + * @param label the label to assign to the SSH key, or null/empty to use the default logic. + * @throws BadRequestAlertException if the key label is longer than 50 characters + */ + private void setLabelForKey(UserSshPublicKey newSshPublicKey, String label) { + if (StringUtils.isBlank(label)) { + String[] parts = newSshPublicKey.getPublicKey().split("\\s+"); + + // we are only interested in the comment of the key. A typical key looks like this, the key prefix, the actual key and then the comment: + // ssh-rsa AAAAB3NzaC1yc2EAAAADAYVTLQ== comment + if (parts.length >= 3) { + label = String.join(" ", Arrays.copyOfRange(parts, 2, parts.length)); + } + else { + label = "Key " + (userSshPublicKeyRepository.findAllByUserId(newSshPublicKey.getUserId()).size() + 1); + } + } + if (label.length() <= 50) { + newSshPublicKey.setLabel(label); + } + else { + throw new BadRequestAlertException("Key label is too long", "SSH key", "keyLabelTooLong", true); + } + } + + /** + * Retrieves the SSH public key for the specified user by key ID. + * + * @param user the {@link User} to whom the SSH key belongs. + * @param keyId the ID of the SSH key. + * @return the {@link UserSshPublicKey} if found and belongs to the user. + * @throws AccessForbiddenException if the key does not belong to the user, or does not exist + */ + public UserSshPublicKey getSshKeyForUser(User user, Long keyId) { + Optional userSshPublicKey = userSshPublicKeyRepository.findByIdAndUserId(keyId, user.getId()); + return userSshPublicKey.orElseThrow(() -> new AccessForbiddenException("SSH key", keyId)); + } + + /** + * Retrieves all SSH public keys associated with the specified user. + * + * @param user the {@link User} whose SSH keys are to be retrieved. + * @return a list of {@link UserSshPublicKey} objects for the user. + */ + public List getAllSshKeysForUser(User user) { + return userSshPublicKeyRepository.findAllByUserId(user.getId()).stream().map(UserSshPublicKeyDTO::of).toList(); + } + + /** + * Deletes the specified SSH public key for the given user ID. + * + * @param userId the ID of the user. + * @param keyId the ID of the SSH key to delete. + * @throws AccessForbiddenException if the key does not belong to the user. + */ + public void deleteUserSshPublicKey(Long userId, Long keyId) { + if (userSshPublicKeyRepository.existsByIdAndUserId(keyId, userId)) { + userSshPublicKeyRepository.deleteById(keyId); + } + else { + throw new AccessForbiddenException("SSH key", keyId); + } + } + + /** + * Returns whether the user of the specified id has stored SSH keys + * + * @param userId the ID of the user. + * @return true if the user has SSH keys, false if not + */ + public boolean hasUserSSHkeys(Long userId) { + return userSshPublicKeyRepository.existsByUserId(userId); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIQueueWebsocketService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIQueueWebsocketService.java index 5a805ff54d03..d25304141c24 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIQueueWebsocketService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIQueueWebsocketService.java @@ -1,5 +1,6 @@ package de.tum.cit.aet.artemis.programming.service.localci; +import java.util.ArrayList; import java.util.List; import jakarta.annotation.PostConstruct; @@ -20,7 +21,9 @@ import com.hazelcast.map.listener.EntryUpdatedListener; import de.tum.cit.aet.artemis.buildagent.dto.BuildAgentInformation; +import de.tum.cit.aet.artemis.buildagent.dto.BuildConfig; import de.tum.cit.aet.artemis.buildagent.dto.BuildJobQueueItem; +import de.tum.cit.aet.artemis.buildagent.dto.RepositoryInfo; /** * This service is responsible for sending build job queue information over websockets. @@ -68,18 +71,21 @@ public void init() { } private void sendQueuedJobsOverWebsocket(long courseId) { - localCIWebsocketMessagingService.sendQueuedBuildJobs(sharedQueueManagementService.getQueuedJobs()); - localCIWebsocketMessagingService.sendQueuedBuildJobsForCourse(courseId, sharedQueueManagementService.getQueuedJobsForCourse(courseId)); + var queuedJobs = removeUnnecessaryInformation(sharedQueueManagementService.getQueuedJobs()); + var queuedJobsForCourse = queuedJobs.stream().filter(job -> job.courseId() == courseId).toList(); + localCIWebsocketMessagingService.sendQueuedBuildJobs(queuedJobs); + localCIWebsocketMessagingService.sendQueuedBuildJobsForCourse(courseId, queuedJobsForCourse); } private void sendProcessingJobsOverWebsocket(long courseId) { - localCIWebsocketMessagingService.sendRunningBuildJobs(sharedQueueManagementService.getProcessingJobs()); - localCIWebsocketMessagingService.sendRunningBuildJobsForCourse(courseId, sharedQueueManagementService.getProcessingJobsForCourse(courseId)); + var processingJobs = removeUnnecessaryInformation(sharedQueueManagementService.getProcessingJobs()); + var processingJobsForCourse = processingJobs.stream().filter(job -> job.courseId() == courseId).toList(); + localCIWebsocketMessagingService.sendRunningBuildJobs(processingJobs); + localCIWebsocketMessagingService.sendRunningBuildJobsForCourse(courseId, processingJobsForCourse); } private void sendBuildAgentSummaryOverWebsocket() { - // remove the recentBuildJobs from the build agent information before sending it over the websocket - List buildAgentSummary = sharedQueueManagementService.getBuildAgentInformationWithoutRecentBuildJobs(); + var buildAgentSummary = removeUnnecessaryInformationFromBuildAgentInformation(sharedQueueManagementService.getBuildAgentInformationWithoutRecentBuildJobs()); localCIWebsocketMessagingService.sendBuildAgentSummary(buildAgentSummary); } @@ -142,4 +148,57 @@ public void entryUpdated(com.hazelcast.core.EntryEvent removeUnnecessaryInformation(List queuedJobs) { + var filteredQueuedJobs = new ArrayList(); // make list mutable in case it is not + for (BuildJobQueueItem job : queuedJobs) { + var buildConfig = removeUnnecessaryInformationFromBuildConfig(job.buildConfig()); + var repositoryInfo = removeUnnecessaryInformationFromRepositoryInfo(job.repositoryInfo()); + filteredQueuedJobs.add(new BuildJobQueueItem(job.id(), job.name(), job.buildAgent(), job.participationId(), job.courseId(), job.exerciseId(), job.retryCount(), + job.priority(), job.status(), repositoryInfo, job.jobTimingInfo(), buildConfig, null)); + + } + return filteredQueuedJobs; + } + + /** + * Removes unnecessary information (e.g. build script, docker image) from the build config before sending it over the websocket. + * + * @param buildConfig the build config + */ + private static BuildConfig removeUnnecessaryInformationFromBuildConfig(BuildConfig buildConfig) { + // We pass "" instead of null strings to avoid errors when serializing to JSON + return new BuildConfig("", "", buildConfig.commitHashToBuild(), "", "", "", null, null, buildConfig.scaEnabled(), buildConfig.sequentialTestRunsEnabled(), + buildConfig.testwiseCoverageEnabled(), null, buildConfig.timeoutSeconds(), "", "", ""); + } + + /** + * Removes unnecessary information (RepositoryUris) from the repository info before sending it over the websocket. + * + * @param repositoryInfo the repository info + */ + private static RepositoryInfo removeUnnecessaryInformationFromRepositoryInfo(RepositoryInfo repositoryInfo) { + // We pass "" instead of null strings to avoid errors when serializing to JSON + return new RepositoryInfo(repositoryInfo.repositoryName(), repositoryInfo.repositoryType(), repositoryInfo.triggeredByPushTo(), "", "", "", null, null); + } + + /** + * Removes unnecessary information (e.g. recent build jobs, public ssh key, result) from the running jobs before sending them over the websocket. + * + * @param buildAgentSummary the build agent summary + */ + private static List removeUnnecessaryInformationFromBuildAgentInformation(List buildAgentSummary) { + var filteredBuildAgentSummary = new ArrayList(); // make list mutable in case it is not + for (BuildAgentInformation agent : buildAgentSummary) { + var runningJobs = removeUnnecessaryInformation(agent.runningBuildJobs()); + filteredBuildAgentSummary.add(new BuildAgentInformation(agent.buildAgent(), agent.maxNumberOfConcurrentBuildJobs(), agent.numberOfCurrentBuildJobs(), runningJobs, + agent.status(), null, null)); + } + return filteredBuildAgentSummary; + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIResultProcessingService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIResultProcessingService.java index 71edf64a3fa8..417ee5bdc630 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIResultProcessingService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIResultProcessingService.java @@ -27,6 +27,7 @@ import de.tum.cit.aet.artemis.assessment.domain.Result; import de.tum.cit.aet.artemis.buildagent.dto.BuildAgentInformation; import de.tum.cit.aet.artemis.buildagent.dto.BuildJobQueueItem; +import de.tum.cit.aet.artemis.buildagent.dto.BuildLogDTO; import de.tum.cit.aet.artemis.buildagent.dto.BuildResult; import de.tum.cit.aet.artemis.buildagent.dto.ResultQueueItem; import de.tum.cit.aet.artemis.core.exception.EntityNotFoundException; @@ -37,7 +38,6 @@ import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseParticipation; import de.tum.cit.aet.artemis.programming.domain.RepositoryType; import de.tum.cit.aet.artemis.programming.domain.build.BuildJob; -import de.tum.cit.aet.artemis.programming.domain.build.BuildLogEntry; import de.tum.cit.aet.artemis.programming.domain.build.BuildStatus; import de.tum.cit.aet.artemis.programming.dto.ResultDTO; import de.tum.cit.aet.artemis.programming.exception.BuildTriggerWebsocketError; @@ -133,7 +133,7 @@ public void processResult() { BuildJobQueueItem buildJob = resultQueueItem.buildJobQueueItem(); BuildResult buildResult = resultQueueItem.buildResult(); - List buildLogs = resultQueueItem.buildLogs(); + List buildLogs = resultQueueItem.buildLogs(); Throwable ex = resultQueueItem.exception(); BuildJob savedBuildJob; diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIWebsocketMessagingService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIWebsocketMessagingService.java index e27ec440d5aa..f17ef965750c 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIWebsocketMessagingService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIWebsocketMessagingService.java @@ -95,7 +95,6 @@ public void sendRunningBuildJobs(List buildJobQueue) { public void sendBuildAgentSummary(List buildAgentInfo) { String channel = "/topic/admin/build-agents"; log.debug("Sending message on topic {}: {}", channel, buildAgentInfo); - // TODO: convert into a proper DTO and strip unnecessary information, e.g. build config, because it's not shown in the client and contains too much information websocketMessagingService.sendMessage(channel, buildAgentInfo); } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/SharedQueueManagementService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/SharedQueueManagementService.java index 87b44d4872ba..f03325c503d8 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/SharedQueueManagementService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/SharedQueueManagementService.java @@ -108,36 +108,57 @@ public void pushDockerImageCleanupInfo() { } } + /** + * @return a copy of the queued build jobs as ArrayList + */ public List getQueuedJobs() { - return queue.stream().toList(); + // NOTE: we should not use streams with IQueue directly, because it can be unstable, when many items are added at the same time and there is a slow network condition + return new ArrayList<>(queue); + } + + public int getQueuedJobsSize() { + return queue.size(); } + /** + * @return a copy of the processing jobs as ArrayList + */ public List getProcessingJobs() { - return processingJobs.values().stream().toList(); + // NOTE: we should not use streams with IMap, because it can be unstable, when many items are added at the same time and there is a slow network condition + return new ArrayList<>(processingJobs.values()); + } + + public int getProcessingJobsSize() { + return processingJobs.size(); } public List getQueuedJobsForCourse(long courseId) { - return queue.stream().filter(job -> job.courseId() == courseId).toList(); + return getQueuedJobs().stream().filter(job -> job.courseId() == courseId).toList(); } public List getProcessingJobsForCourse(long courseId) { - return processingJobs.values().stream().filter(job -> job.courseId() == courseId).toList(); + return getProcessingJobs().stream().filter(job -> job.courseId() == courseId).toList(); } public List getQueuedJobsForParticipation(long participationId) { - return queue.stream().filter(job -> job.participationId() == participationId).toList(); + return getQueuedJobs().stream().filter(job -> job.participationId() == participationId).toList(); } public List getProcessingJobsForParticipation(long participationId) { - return processingJobs.values().stream().filter(job -> job.participationId() == participationId).toList(); + return getProcessingJobs().stream().filter(job -> job.participationId() == participationId).toList(); } public List getBuildAgentInformation() { - return buildAgentInformation.values().stream().toList(); + // NOTE: we should not use streams with IMap, because it can be unstable, when many items are added at the same time and there is a slow network condition + return new ArrayList<>(buildAgentInformation.values()); + } + + public int getBuildAgentInformationSize() { + return buildAgentInformation.size(); } public List getBuildAgentInformationWithoutRecentBuildJobs() { - return buildAgentInformation.values().stream().map(agent -> new BuildAgentInformation(agent.buildAgent(), agent.maxNumberOfConcurrentBuildJobs(), + return getBuildAgentInformation().stream().map(agent -> new BuildAgentInformation(agent.buildAgent(), agent.maxNumberOfConcurrentBuildJobs(), agent.numberOfCurrentBuildJobs(), agent.runningBuildJobs(), agent.status(), null, null)).toList(); } @@ -145,10 +166,18 @@ public void pauseBuildAgent(String agent) { pauseBuildAgentTopic.publish(agent); } + public void pauseAllBuildAgents() { + getBuildAgentInformation().forEach(agent -> pauseBuildAgent(agent.buildAgent().name())); + } + public void resumeBuildAgent(String agent) { resumeBuildAgentTopic.publish(agent); } + public void resumeAllBuildAgents() { + getBuildAgentInformation().forEach(agent -> resumeBuildAgent(agent.buildAgent().name())); + } + /** * Cancel a build job by removing it from the queue or stopping the build process. * @@ -156,9 +185,10 @@ public void resumeBuildAgent(String agent) { */ public void cancelBuildJob(String buildJobId) { // Remove build job if it is queued - if (queue.stream().anyMatch(job -> Objects.equals(job.id(), buildJobId))) { + List queuedJobs = getQueuedJobs(); + if (queuedJobs.stream().anyMatch(job -> Objects.equals(job.id(), buildJobId))) { List toRemove = new ArrayList<>(); - for (BuildJobQueueItem job : queue) { + for (BuildJobQueueItem job : queuedJobs) { if (Objects.equals(job.id(), buildJobId)) { toRemove.add(job); } @@ -197,7 +227,8 @@ public void cancelAllQueuedBuildJobs() { * Cancel all running build jobs. */ public void cancelAllRunningBuildJobs() { - for (BuildJobQueueItem buildJob : processingJobs.values()) { + List runningJobs = getProcessingJobs(); + for (BuildJobQueueItem buildJob : runningJobs) { cancelBuildJob(buildJob.id()); } } @@ -208,7 +239,7 @@ public void cancelAllRunningBuildJobs() { * @param agentName name of the agent */ public void cancelAllRunningBuildJobsForAgent(String agentName) { - processingJobs.values().stream().filter(job -> Objects.equals(job.buildAgent().name(), agentName)).forEach(job -> cancelBuildJob(job.id())); + getProcessingJobs().stream().filter(job -> Objects.equals(job.buildAgent().name(), agentName)).forEach(job -> cancelBuildJob(job.id())); } /** @@ -217,8 +248,9 @@ public void cancelAllRunningBuildJobsForAgent(String agentName) { * @param courseId id of the course */ public void cancelAllQueuedBuildJobsForCourse(long courseId) { + List queuedJobs = getQueuedJobs(); List toRemove = new ArrayList<>(); - for (BuildJobQueueItem job : queue) { + for (BuildJobQueueItem job : queuedJobs) { if (job.courseId() == courseId) { toRemove.add(job); } @@ -232,7 +264,8 @@ public void cancelAllQueuedBuildJobsForCourse(long courseId) { * @param courseId id of the course */ public void cancelAllRunningBuildJobsForCourse(long courseId) { - for (BuildJobQueueItem buildJob : processingJobs.values()) { + List runningJobs = getProcessingJobs(); + for (BuildJobQueueItem buildJob : runningJobs) { if (buildJob.courseId() == courseId) { cancelBuildJob(buildJob.id()); } @@ -246,14 +279,16 @@ public void cancelAllRunningBuildJobsForCourse(long courseId) { */ public void cancelAllJobsForParticipation(long participationId) { List toRemove = new ArrayList<>(); - for (BuildJobQueueItem queuedJob : queue) { + List queuedJobs = getQueuedJobs(); + for (BuildJobQueueItem queuedJob : queuedJobs) { if (queuedJob.participationId() == participationId) { toRemove.add(queuedJob); } } queue.removeAll(toRemove); - for (BuildJobQueueItem runningJob : processingJobs.values()) { + List runningJobs = getProcessingJobs(); + for (BuildJobQueueItem runningJob : runningJobs) { if (runningJob.participationId() == participationId) { cancelBuildJob(runningJob.id()); } @@ -288,5 +323,4 @@ public Page getFilteredFinishedBuildJobs(FinishedBuildJobPageableSearc return new PageImpl<>(orderedBuildJobs, buildJobIdsPage.getPageable(), buildJobIdsPage.getTotalElements()); } - } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/ReportParser.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/ReportParser.java index 9b8f59766ac2..f31cbcd0c63e 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/ReportParser.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/ReportParser.java @@ -1,94 +1,27 @@ package de.tum.cit.aet.artemis.programming.service.localci.scaparser; -import static de.tum.cit.aet.artemis.programming.service.localci.scaparser.utils.ReportUtils.createErrorReport; -import static de.tum.cit.aet.artemis.programming.service.localci.scaparser.utils.ReportUtils.createFileTooLargeReport; - -import java.io.File; -import java.io.IOException; -import java.nio.file.Files; - -import com.fasterxml.jackson.databind.ObjectMapper; - import de.tum.cit.aet.artemis.programming.dto.StaticCodeAnalysisReportDTO; -import de.tum.cit.aet.artemis.programming.service.localci.scaparser.exception.ParserException; import de.tum.cit.aet.artemis.programming.service.localci.scaparser.exception.UnsupportedToolException; import de.tum.cit.aet.artemis.programming.service.localci.scaparser.strategy.ParserPolicy; import de.tum.cit.aet.artemis.programming.service.localci.scaparser.strategy.ParserStrategy; -import de.tum.cit.aet.artemis.programming.service.localci.scaparser.utils.FileUtils; /** * Public API for parsing of static code analysis reports */ public class ReportParser { - private final ObjectMapper mapper = new ObjectMapper(); - - // Reports that are bigger then the threshold will not be parsed - // and an issue will be generated. The unit is in megabytes. - private static final int STATIC_CODE_ANALYSIS_REPORT_FILESIZE_LIMIT_IN_MB = 1; - - /** - * Transform a given static code analysis report into a JSON representation. - * All supported tools share the same JSON format. - * - * @param file Reference to the static code analysis report - * @return Static code analysis report represented as a JSON String - * @throws ParserException - If an exception occurs that is not already handled by the parser itself, e.g. caused by the json-parsing - */ - public String transformToJSONReport(File file) throws ParserException { - try { - StaticCodeAnalysisReportDTO report = transformToReport(file); - return mapper.writeValueAsString(report); - } - catch (Exception e) { - throw new ParserException(e.getMessage(), e); - } - } - - /** - * Transform a given static code analysis report given as a file into a plain Java object. - * - * @param file Reference to the static code analysis report - * @return Static code analysis report represented as a plain Java object - */ - public StaticCodeAnalysisReportDTO transformToReport(File file) { - if (file == null) { - throw new IllegalArgumentException("File must not be null"); - } - - // The static code analysis parser only supports xml files. - if (!FileUtils.getExtension(file).equals("xml")) { - throw new IllegalArgumentException("File must be xml format"); - } - try { - // Reject any file larger than the given threshold - if (FileUtils.isFilesizeGreaterThan(file, STATIC_CODE_ANALYSIS_REPORT_FILESIZE_LIMIT_IN_MB)) { - return createFileTooLargeReport(file.getName()); - } - - return getReport(file); - } - catch (Exception e) { - return createErrorReport(file.getName(), e); - } - } + private static final ParserPolicy parserPolicy = new ParserPolicy(); /** - * Builds the document using the provided file and parses it to a Report object using ObjectMapper. + * Builds the document using the provided string and parses it to a Report object. * - * @param file File referencing the static code analysis report + * @param reportContent String containing the static code analysis report + * @param fileName filename of the report used for configuring a parser * @return Report containing the static code analysis issues * @throws UnsupportedToolException if the static code analysis tool which created the report is not supported - * @throws IOException if the file could not be read */ - public static StaticCodeAnalysisReportDTO getReport(File file) throws IOException { - String xmlContent = Files.readString(file.toPath()); - return getReport(xmlContent, file.getName()); - } - - public static StaticCodeAnalysisReportDTO getReport(String xmlContent, String fileName) { - ParserPolicy parserPolicy = new ParserPolicy(); + public static StaticCodeAnalysisReportDTO getReport(String reportContent, String fileName) { ParserStrategy parserStrategy = parserPolicy.configure(fileName); - return parserStrategy.parse(xmlContent); + return parserStrategy.parse(reportContent); } } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/exception/ParserException.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/exception/ParserException.java deleted file mode 100644 index f0934ad041f3..000000000000 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/exception/ParserException.java +++ /dev/null @@ -1,16 +0,0 @@ -package de.tum.cit.aet.artemis.programming.service.localci.scaparser.exception; - -/** - * Exception thrown when an error occurs during parsing. - */ -public class ParserException extends Exception { - - /** - * Creates a new ParserException. - * - * @param message the detail message. - */ - public ParserException(String message, Throwable cause) { - super(message, cause); - } -} diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/ArtifactLocation.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/ArtifactLocation.java new file mode 100644 index 000000000000..ab497fc272d9 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/ArtifactLocation.java @@ -0,0 +1,22 @@ +package de.tum.cit.aet.artemis.programming.service.localci.scaparser.format.sarif; + +import java.util.Optional; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** + * Specifies the location of an artifact. + * + * @param uri A string containing a valid relative or absolute URI. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record ArtifactLocation(String uri) { + + /** + * A string containing a valid relative or absolute URI. + */ + public Optional getOptionalUri() { + return Optional.ofNullable(uri); + } + +} diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/GlobalMessageStrings.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/GlobalMessageStrings.java new file mode 100644 index 000000000000..04293695820b --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/GlobalMessageStrings.java @@ -0,0 +1,14 @@ +package de.tum.cit.aet.artemis.programming.service.localci.scaparser.format.sarif; + +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonAnySetter; + +/** + * A dictionary, each of whose keys is a resource identifier and each of whose values is a multiformatMessageString object, which holds message strings in plain text and + * (optionally) Markdown format. The strings can include placeholders, which can be used to construct a message in combination with an arbitrary number of additional string + * arguments. + */ +public record GlobalMessageStrings(@JsonAnySetter Map additionalProperties) { + +} diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/Location.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/Location.java new file mode 100644 index 000000000000..20dbd72ef99d --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/Location.java @@ -0,0 +1,23 @@ +package de.tum.cit.aet.artemis.programming.service.localci.scaparser.format.sarif; + +import java.util.Optional; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** + * A location within a programming artifact. + * + * @param physicalLocation A physical location relevant to a result. Specifies a reference to a programming artifact together with a range of bytes or characters within that + * artifact. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record Location(PhysicalLocation physicalLocation) { + + /** + * A physical location relevant to a result. Specifies a reference to a programming artifact together with a range of bytes or characters within that artifact. + */ + public Optional getOptionalPhysicalLocation() { + return Optional.ofNullable(physicalLocation); + } + +} diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/Message.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/Message.java new file mode 100644 index 000000000000..d353df2e203e --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/Message.java @@ -0,0 +1,30 @@ +package de.tum.cit.aet.artemis.programming.service.localci.scaparser.format.sarif; + +import java.util.Optional; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** + * Encapsulates a message intended to be read by the end user. + * + * @param text A plain text message string. + * @param id The identifier for this message. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record Message(String text, String id) { + + /** + * A plain text message string. + */ + public Optional getOptionalText() { + return Optional.ofNullable(text); + } + + /** + * The identifier for this message. + */ + public Optional getOptionalId() { + return Optional.ofNullable(id); + } + +} diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/MessageStrings.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/MessageStrings.java new file mode 100644 index 000000000000..a888c306bcee --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/MessageStrings.java @@ -0,0 +1,13 @@ +package de.tum.cit.aet.artemis.programming.service.localci.scaparser.format.sarif; + +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonAnySetter; + +/** + * A set of name/value pairs with arbitrary names. Each value is a multiformatMessageString object, which holds message strings in plain text and (optionally) Markdown format. The + * strings can include placeholders, which can be used to construct a message in combination with an arbitrary number of additional string arguments. + */ +public record MessageStrings(@JsonAnySetter Map additionalProperties) { + +} diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/MultiformatMessageString.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/MultiformatMessageString.java new file mode 100644 index 000000000000..a08afe804d02 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/MultiformatMessageString.java @@ -0,0 +1,13 @@ +package de.tum.cit.aet.artemis.programming.service.localci.scaparser.format.sarif; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** + * A message string or message format string rendered in multiple formats. + * + * @param text A plain text message string or format string. + * (Required) + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record MultiformatMessageString(String text) { +} diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/PhysicalLocation.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/PhysicalLocation.java new file mode 100644 index 000000000000..b122875e94be --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/PhysicalLocation.java @@ -0,0 +1,30 @@ +package de.tum.cit.aet.artemis.programming.service.localci.scaparser.format.sarif; + +import java.util.Optional; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** + * A physical location relevant to a result. Specifies a reference to a programming artifact together with a range of bytes or characters within that artifact. + * + * @param artifactLocation Specifies the location of an artifact. + * @param region A region within an artifact where a result was detected. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record PhysicalLocation(ArtifactLocation artifactLocation, Region region) { + + /** + * Specifies the location of an artifact. + */ + public Optional getOptionalArtifactLocation() { + return Optional.ofNullable(artifactLocation); + } + + /** + * A region within an artifact where a result was detected. + */ + public Optional getOptionalRegion() { + return Optional.ofNullable(region); + } + +} diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/PropertyBag.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/PropertyBag.java new file mode 100644 index 000000000000..ba3b0bb208fb --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/PropertyBag.java @@ -0,0 +1,14 @@ +package de.tum.cit.aet.artemis.programming.service.localci.scaparser.format.sarif; + +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** + * Key/value pairs that provide additional information about the object. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record PropertyBag(@JsonAnySetter Map additionalProperties) { + +} diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/Region.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/Region.java new file mode 100644 index 000000000000..f9ac3391df87 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/Region.java @@ -0,0 +1,46 @@ +package de.tum.cit.aet.artemis.programming.service.localci.scaparser.format.sarif; + +import java.util.Optional; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** + * A region within an artifact where a result was detected. + * + * @param startLine The line number of the first character in the region. + * @param startColumn The column number of the first character in the region. + * @param endLine The line number of the last character in the region. + * @param endColumn The column number of the character following the end of the region. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record Region(Integer startLine, Integer startColumn, Integer endLine, Integer endColumn) { + + /** + * The line number of the first character in the region. + */ + public Optional getOptionalStartLine() { + return Optional.ofNullable(startLine); + } + + /** + * The column number of the first character in the region. + */ + public Optional getOptionalStartColumn() { + return Optional.ofNullable(startColumn); + } + + /** + * The line number of the last character in the region. + */ + public Optional getOptionalEndLine() { + return Optional.ofNullable(endLine); + } + + /** + * The column number of the character following the end of the region. + */ + public Optional getOptionalEndColumn() { + return Optional.ofNullable(endColumn); + } + +} diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/ReportingDescriptor.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/ReportingDescriptor.java new file mode 100644 index 000000000000..75552ee32d69 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/ReportingDescriptor.java @@ -0,0 +1,112 @@ +package de.tum.cit.aet.artemis.programming.service.localci.scaparser.format.sarif; + +import java.net.URI; +import java.util.Optional; +import java.util.Set; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** + * Metadata that describes a specific report produced by the tool, as part of the analysis it provides or its runtime reporting. + * + * @param id A stable, opaque identifier for the report. + * (Required) + * @param deprecatedIds An array of stable, opaque identifiers by which this report was known in some previous version of the analysis tool. + * @param guid A unique identifier for the reporting descriptor in the form of a GUID. + * @param deprecatedGuids An array of unique identifies in the form of a GUID by which this report was known in some previous version of the analysis tool. + * @param name A report identifier that is understandable to an end user. + * @param deprecatedNames An array of readable identifiers by which this report was known in some previous version of the analysis tool. + * @param shortDescription A message string or message format string rendered in multiple formats. + * @param fullDescription A message string or message format string rendered in multiple formats. + * @param messageStrings A set of name/value pairs with arbitrary names. Each value is a multiformatMessageString object, which holds message strings in plain text and + * (optionally) Markdown format. + * The strings can include placeholders, which can be used to construct a message in combination with an arbitrary number of additional string + * arguments. + * @param helpUri A URI where the primary documentation for the report can be found. + * @param help A message string or message format string rendered in multiple formats. + * @param properties Key/value pairs that provide additional information about the object. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record ReportingDescriptor(String id, Set deprecatedIds, String guid, Set deprecatedGuids, String name, Set deprecatedNames, + MultiformatMessageString shortDescription, MultiformatMessageString fullDescription, MessageStrings messageStrings, URI helpUri, MultiformatMessageString help, + PropertyBag properties) { + + /** + * An array of stable, opaque identifiers by which this report was known in some previous version of the analysis tool. + */ + public Optional> getOptionalDeprecatedIds() { + return Optional.ofNullable(deprecatedIds); + } + + /** + * A unique identifier for the reporting descriptor in the form of a GUID. + */ + public Optional getOptionalGuid() { + return Optional.ofNullable(guid); + } + + /** + * An array of unique identifies in the form of a GUID by which this report was known in some previous version of the analysis tool. + */ + public Optional> getOptionalDeprecatedGuids() { + return Optional.ofNullable(deprecatedGuids); + } + + /** + * A report identifier that is understandable to an end user. + */ + public Optional getOptionalName() { + return Optional.ofNullable(name); + } + + /** + * An array of readable identifiers by which this report was known in some previous version of the analysis tool. + */ + public Optional> getOptionalDeprecatedNames() { + return Optional.ofNullable(deprecatedNames); + } + + /** + * A message string or message format string rendered in multiple formats. + */ + public Optional getOptionalShortDescription() { + return Optional.ofNullable(shortDescription); + } + + /** + * A message string or message format string rendered in multiple formats. + */ + public Optional getOptionalFullDescription() { + return Optional.ofNullable(fullDescription); + } + + /** + * A set of name/value pairs with arbitrary names. Each value is a multiformatMessageString object, which holds message strings in plain text and (optionally) Markdown format. + * The strings can include placeholders, which can be used to construct a message in combination with an arbitrary number of additional string arguments. + */ + public Optional getOptionalMessageStrings() { + return Optional.ofNullable(messageStrings); + } + + /** + * A URI where the primary documentation for the report can be found. + */ + public Optional getOptionalHelpUri() { + return Optional.ofNullable(helpUri); + } + + /** + * A message string or message format string rendered in multiple formats. + */ + public Optional getOptionalHelp() { + return Optional.ofNullable(help); + } + + /** + * Key/value pairs that provide additional information about the object. + */ + public Optional getOptionalProperties() { + return Optional.ofNullable(properties); + } + +} diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/ReportingDescriptorReference.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/ReportingDescriptorReference.java new file mode 100644 index 000000000000..f46e722e6b1b --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/ReportingDescriptorReference.java @@ -0,0 +1,37 @@ +package de.tum.cit.aet.artemis.programming.service.localci.scaparser.format.sarif; + +import java.util.Objects; +import java.util.Optional; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** + * Information about how to locate a relevant reporting descriptor. + * + * @param id The id of the descriptor. + * @param index The index into an array of descriptors in toolComponent.ruleDescriptors, toolComponent.notificationDescriptors, or toolComponent.taxonomyDescriptors, depending on + * context. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record ReportingDescriptorReference(String id, Integer index) { + + public ReportingDescriptorReference(String id, Integer index) { + this.id = id; + this.index = Objects.requireNonNullElse(index, -1); + } + + /** + * The id of the descriptor. + */ + public Optional getOptionalId() { + return Optional.ofNullable(id); + } + + /** + * The index into an array of descriptors in toolComponent.ruleDescriptors, toolComponent.notificationDescriptors, or toolComponent.taxonomyDescriptors, depending on context. + */ + public Optional getOptionalIndex() { + return Optional.ofNullable(index); + } + +} diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/Result.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/Result.java new file mode 100644 index 000000000000..5b280e65e9c0 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/Result.java @@ -0,0 +1,185 @@ +package de.tum.cit.aet.artemis.programming.service.localci.scaparser.format.sarif; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonValue; + +/** + * A result produced by an analysis tool. + * + * @param ruleId The stable, unique identifier of the rule, if any, to which this result is relevant. + * @param ruleIndex The index within the tool component rules array of the rule object associated with this result. + * @param rule Information about how to locate a relevant reporting descriptor. + * @param kind A value that categorizes results by evaluation state. + * @param level A value specifying the severity level of the result. + * @param message A message that describes the result. The first sentence of the message only will be displayed when visible space is limited. + * @param locations The set of locations where the result was detected. Specify only one location unless the problem indicated by the result can only be corrected by making a + * change at every specified location. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record Result(String ruleId, Integer ruleIndex, ReportingDescriptorReference rule, + de.tum.cit.aet.artemis.programming.service.localci.scaparser.format.sarif.Result.Kind kind, + de.tum.cit.aet.artemis.programming.service.localci.scaparser.format.sarif.Result.Level level, Message message, List locations) { + + public Result(String ruleId, Integer ruleIndex, ReportingDescriptorReference rule, Kind kind, Level level, Message message, List locations) { + this.ruleId = ruleId; + this.ruleIndex = Objects.requireNonNullElse(ruleIndex, -1); + this.rule = rule; + this.kind = Objects.requireNonNullElse(kind, Kind.FAIL); + this.level = Objects.requireNonNullElse(level, Level.WARNING); + this.message = message; + this.locations = locations; + } + + /** + * The stable, unique identifier of the rule, if any, to which this result is relevant. + */ + public Optional getOptionalRuleId() { + return Optional.ofNullable(ruleId); + } + + /** + * The index within the tool component rules array of the rule object associated with this result. + */ + public Optional getOptionalRuleIndex() { + return Optional.ofNullable(ruleIndex); + } + + /** + * Information about how to locate a relevant reporting descriptor. + */ + public Optional getOptionalRule() { + return Optional.ofNullable(rule); + } + + /** + * A value that categorizes results by evaluation state. + */ + public Optional getOptionalKind() { + return Optional.ofNullable(kind); + } + + /** + * A value specifying the severity level of the result. + */ + public Optional getOptionalLevel() { + return Optional.ofNullable(level); + } + + /** + * The set of locations where the result was detected. Specify only one location unless the problem indicated by the result can only be corrected by making a change at every + * specified location. + */ + public Optional> getOptionalLocations() { + return Optional.ofNullable(locations); + } + + /** + * A value that categorizes results by evaluation state. + */ + public enum Kind { + + NOT_APPLICABLE("notApplicable"), PASS("pass"), FAIL("fail"), REVIEW("review"), OPEN("open"), INFORMATIONAL("informational"); + + private final String value; + + private static final Map CONSTANTS = new HashMap<>(); + + static { + for (Kind c : values()) { + CONSTANTS.put(c.value, c); + } + } + + Kind(String value) { + this.value = value; + } + + @Override + public String toString() { + return this.value; + } + + @JsonValue + public String value() { + return this.value; + } + + /** + * Creates a {@link Kind} instance from a given string value. + *

+ * + * @param value the string representation of the {@link Kind} + * @return the matching {@link Kind} instance + * @throws IllegalArgumentException if the provided value does not correspond to any defined {@link Kind} + */ + @JsonCreator + public static Kind fromValue(String value) { + Kind constant = CONSTANTS.get(value); + if (constant == null) { + throw new IllegalArgumentException(value); + } + else { + return constant; + } + } + } + + /** + * A value specifying the severity level of the result. + */ + public enum Level { + + NONE("none"), NOTE("note"), WARNING("warning"), ERROR("error"); + + private final String value; + + private static final Map CONSTANTS = new HashMap<>(); + + static { + for (Level c : values()) { + CONSTANTS.put(c.value, c); + } + } + + Level(String value) { + this.value = value; + } + + @Override + public String toString() { + return this.value; + } + + @JsonValue + public String value() { + return this.value; + } + + /** + * Creates a {@link Level} instance from a given string value. + * + * @param value the string representation of the {@link Level} + * @return the matching {@link Level} instance + * @throws IllegalArgumentException if the provided value does not correspond to any defined {@link Level} + */ + @JsonCreator + public static Level fromValue(String value) { + Level constant = CONSTANTS.get(value); + if (constant == null) { + throw new IllegalArgumentException(value); + } + else { + return constant; + } + } + + } + +} diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/Run.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/Run.java new file mode 100644 index 000000000000..c77d9ccaee4b --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/Run.java @@ -0,0 +1,27 @@ +package de.tum.cit.aet.artemis.programming.service.localci.scaparser.format.sarif; + +import java.util.List; +import java.util.Optional; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** + * Describes a single run of an analysis tool, and contains the reported output of that run. + * + * @param tool The analysis tool that was run. + * (Required) + * @param results The set of results contained in an SARIF log. The results array can be omitted when a run is solely exporting rules metadata. It must be present (but may be + * empty) if a log file represents an actual scan. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record Run(Tool tool, List results) { + + /** + * The set of results contained in an SARIF log. The results array can be omitted when a run is solely exporting rules metadata. It must be present (but may be empty) if a log + * file represents an actual scan. + */ + public Optional> getOptionalResults() { + return Optional.ofNullable(results); + } + +} diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/SarifLog.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/SarifLog.java new file mode 100644 index 000000000000..28efb3370c53 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/SarifLog.java @@ -0,0 +1,18 @@ +package de.tum.cit.aet.artemis.programming.service.localci.scaparser.format.sarif; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** + * Static Analysis Results Format (SARIF) Version 2.1.0 JSON Schema + *

+ * Static Analysis Results Format (SARIF) Version 2.1.0 JSON Schema: a standard format for the output of static analysis tools. + * + * @param runs The set of runs contained in this log file. + * (Required) + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record SarifLog(List runs) { + +} diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/Tool.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/Tool.java new file mode 100644 index 000000000000..d6af4c944ba3 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/Tool.java @@ -0,0 +1,13 @@ +package de.tum.cit.aet.artemis.programming.service.localci.scaparser.format.sarif; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** + * The analysis tool that was run. + * + * @param driver A component, such as a plug-in or the driver, of the analysis tool that was run. + * (Required) + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record Tool(ToolComponent driver) { +} diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/ToolComponent.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/ToolComponent.java new file mode 100644 index 000000000000..b7fb4cf33d3e --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/ToolComponent.java @@ -0,0 +1,37 @@ +package de.tum.cit.aet.artemis.programming.service.localci.scaparser.format.sarif; + +import java.util.List; +import java.util.Optional; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** + * A component, such as a plug-in or the driver, of the analysis tool that was run. + * + * @param name The name of the tool component. + * (Required) + * @param globalMessageStrings A dictionary, each of whose keys is a resource identifier and each of whose values is a multiformatMessageString object, which holds message strings + * in plain text and (optionally) Markdown format. The strings can include placeholders, which can be used to construct a message in combination + * with an arbitrary number of additional string arguments. + * @param rules An array of reportingDescriptor objects relevant to the analysis performed by the tool component. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record ToolComponent(String name, GlobalMessageStrings globalMessageStrings, List rules) { + + /** + * A dictionary, each of whose keys is a resource identifier and each of whose values is a multiformatMessageString object, which holds message strings in plain text and + * (optionally) Markdown format. The strings can include placeholders, which can be used to construct a message in combination with an arbitrary number of additional string + * arguments. + */ + public Optional getOptionalGlobalMessageStrings() { + return Optional.ofNullable(globalMessageStrings); + } + + /** + * An array of reportingDescriptor objects relevant to the analysis performed by the tool component. + */ + public Optional> getOptionalRules() { + return Optional.ofNullable(rules); + } + +} diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/strategy/CheckstyleParser.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/strategy/CheckstyleParser.java index e50be8d57997..f9fe1b9d90ba 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/strategy/CheckstyleParser.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/strategy/CheckstyleParser.java @@ -40,9 +40,9 @@ public class CheckstyleParser implements ParserStrategy { private final XmlMapper xmlMapper = new XmlMapper(); @Override - public StaticCodeAnalysisReportDTO parse(String xmlContent) { + public StaticCodeAnalysisReportDTO parse(String reportContent) { try { - List files = xmlMapper.readValue(xmlContent, new com.fasterxml.jackson.core.type.TypeReference>() { + List files = xmlMapper.readValue(reportContent, new com.fasterxml.jackson.core.type.TypeReference>() { }); return createReportFromFiles(files); } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/strategy/PMDCPDParser.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/strategy/PMDCPDParser.java index 11fbab145fd4..b5da7a65fdf0 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/strategy/PMDCPDParser.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/strategy/PMDCPDParser.java @@ -43,9 +43,9 @@ class PMDCPDParser implements ParserStrategy { private final XmlMapper xmlMapper = new XmlMapper(); @Override - public StaticCodeAnalysisReportDTO parse(String xmlContent) { + public StaticCodeAnalysisReportDTO parse(String reportContent) { try { - PmdCpc duplication = xmlMapper.readValue(xmlContent, PmdCpc.class); + PmdCpc duplication = xmlMapper.readValue(reportContent, PmdCpc.class); return createReportFromDuplication(duplication); } catch (IOException e) { diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/strategy/PMDParser.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/strategy/PMDParser.java index ee7ce091b37b..7c7951630dc3 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/strategy/PMDParser.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/strategy/PMDParser.java @@ -45,9 +45,9 @@ class PMDParser implements ParserStrategy { private final XmlMapper xmlMapper = new XmlMapper(); @Override - public StaticCodeAnalysisReportDTO parse(String xmlContent) { + public StaticCodeAnalysisReportDTO parse(String reportContent) { try { - PMDReport pmdReport = xmlMapper.readValue(xmlContent, PMDReport.class); + PMDReport pmdReport = xmlMapper.readValue(reportContent, PMDReport.class); return createReportFromPMDReport(pmdReport); } catch (IOException e) { diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/strategy/ParserStrategy.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/strategy/ParserStrategy.java index ea7e8e356179..cee6ca946062 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/strategy/ParserStrategy.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/strategy/ParserStrategy.java @@ -12,10 +12,10 @@ static String transformToUnixPath(String path) { } /** - * Parse a static code analysis report from an XML string into a common Java representation. + * Parse a static code analysis report from a serialized string into a common Java representation. * - * @param xmlContent The XML content as a String + * @param reportContent The serialized content as a String * @return Report object containing the parsed report information */ - StaticCodeAnalysisReportDTO parse(String xmlContent); + StaticCodeAnalysisReportDTO parse(String reportContent); } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/strategy/SpotbugsParser.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/strategy/SpotbugsParser.java index f4db7cbb351b..51e5e3f2cee5 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/strategy/SpotbugsParser.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/strategy/SpotbugsParser.java @@ -49,9 +49,9 @@ class SpotbugsParser implements ParserStrategy { private final XmlMapper xmlMapper = new XmlMapper(); @Override - public StaticCodeAnalysisReportDTO parse(String xmlContent) { + public StaticCodeAnalysisReportDTO parse(String reportContent) { try { - BugCollection bugCollection = xmlMapper.readValue(xmlContent, BugCollection.class); + BugCollection bugCollection = xmlMapper.readValue(reportContent, BugCollection.class); return createReportFromBugCollection(bugCollection); } catch (IOException e) { diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/strategy/sarif/IdCategorizer.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/strategy/sarif/IdCategorizer.java new file mode 100644 index 000000000000..646780fee2f5 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/strategy/sarif/IdCategorizer.java @@ -0,0 +1,11 @@ +package de.tum.cit.aet.artemis.programming.service.localci.scaparser.strategy.sarif; + +import de.tum.cit.aet.artemis.programming.service.localci.scaparser.format.sarif.ReportingDescriptor; + +class IdCategorizer implements RuleCategorizer { + + @Override + public String categorizeRule(ReportingDescriptor rule) { + return rule.id(); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/strategy/sarif/RuleCategorizer.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/strategy/sarif/RuleCategorizer.java new file mode 100644 index 000000000000..46898ec5d9c4 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/strategy/sarif/RuleCategorizer.java @@ -0,0 +1,14 @@ +package de.tum.cit.aet.artemis.programming.service.localci.scaparser.strategy.sarif; + +import de.tum.cit.aet.artemis.programming.service.localci.scaparser.format.sarif.ReportingDescriptor; + +public interface RuleCategorizer { + + /** + * Categorizes a SARIF rule using a tool specific strategy. + * + * @param rule The reporting descriptor containing the rule details + * @return The identifier of the resulting category + */ + String categorizeRule(ReportingDescriptor rule); +} diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/strategy/sarif/SarifParser.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/strategy/sarif/SarifParser.java new file mode 100644 index 000000000000..46905fa6ed01 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/strategy/sarif/SarifParser.java @@ -0,0 +1,185 @@ +package de.tum.cit.aet.artemis.programming.service.localci.scaparser.strategy.sarif; + +import java.net.URI; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import de.tum.cit.aet.artemis.programming.domain.StaticCodeAnalysisTool; +import de.tum.cit.aet.artemis.programming.dto.StaticCodeAnalysisIssue; +import de.tum.cit.aet.artemis.programming.dto.StaticCodeAnalysisReportDTO; +import de.tum.cit.aet.artemis.programming.service.localci.scaparser.format.sarif.ArtifactLocation; +import de.tum.cit.aet.artemis.programming.service.localci.scaparser.format.sarif.GlobalMessageStrings; +import de.tum.cit.aet.artemis.programming.service.localci.scaparser.format.sarif.Location; +import de.tum.cit.aet.artemis.programming.service.localci.scaparser.format.sarif.MessageStrings; +import de.tum.cit.aet.artemis.programming.service.localci.scaparser.format.sarif.PhysicalLocation; +import de.tum.cit.aet.artemis.programming.service.localci.scaparser.format.sarif.Region; +import de.tum.cit.aet.artemis.programming.service.localci.scaparser.format.sarif.ReportingDescriptor; +import de.tum.cit.aet.artemis.programming.service.localci.scaparser.format.sarif.ReportingDescriptorReference; +import de.tum.cit.aet.artemis.programming.service.localci.scaparser.format.sarif.Result; +import de.tum.cit.aet.artemis.programming.service.localci.scaparser.format.sarif.Run; +import de.tum.cit.aet.artemis.programming.service.localci.scaparser.format.sarif.SarifLog; +import de.tum.cit.aet.artemis.programming.service.localci.scaparser.format.sarif.ToolComponent; +import de.tum.cit.aet.artemis.programming.service.localci.scaparser.strategy.ParserStrategy; + +/** + * Implements parts of the SARIF OASIS standard version 2.1.0. + * + * @see SARIF specification + */ +public class SarifParser implements ParserStrategy { + + private static final Logger log = LoggerFactory.getLogger(SarifParser.class); + + private static class SarifFormatException extends RuntimeException { + + private SarifFormatException(String message) { + super(message); + } + } + + private static class InformationMissingException extends RuntimeException { + + private InformationMissingException(String message) { + super(message); + } + } + + private record FileLocation(String path, int startLine, int endLine, int startColumn, int endColumn) { + } + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private final StaticCodeAnalysisTool tool; + + private final RuleCategorizer ruleCategorizer; + + public SarifParser(StaticCodeAnalysisTool tool, RuleCategorizer ruleCategorizer) { + this.tool = tool; + this.ruleCategorizer = ruleCategorizer; + } + + @Override + public StaticCodeAnalysisReportDTO parse(String reportContent) { + SarifLog sarifLog; + try { + sarifLog = objectMapper.readValue(reportContent, SarifLog.class); + } + catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + + Run run = sarifLog.runs().getFirst(); + ToolComponent driver = run.tool().driver(); + + List rules = driver.getOptionalRules().orElse(List.of()); + + // Rule ids are not guaranteed to be unique. Use the first occurring for rule lookup. + Map ruleOfId = rules.stream().collect(Collectors.toMap(ReportingDescriptor::id, Function.identity(), (first, next) -> first)); + + List results = run.getOptionalResults().orElse(List.of()); + List issues = results.stream().map(result -> tryProcessResult(result, driver, ruleOfId)).filter(Objects::nonNull).toList(); + + return new StaticCodeAnalysisReportDTO(tool, issues); + } + + private StaticCodeAnalysisIssue tryProcessResult(Result result, ToolComponent driver, Map ruleOfId) { + try { + return processResult(result, driver, ruleOfId); + } + catch (SarifFormatException | NullPointerException e) { + log.error("The result is malformed", e); + return null; + } + catch (InformationMissingException e) { + log.warn("The result does not contain required information", e); + return null; + } + } + + private StaticCodeAnalysisIssue processResult(Result result, ToolComponent driver, Map ruleOfId) throws SarifFormatException { + FileLocation fileLocation = result.getOptionalLocations().flatMap(locations -> locations.stream().findFirst()).flatMap(Location::getOptionalPhysicalLocation) + .map(this::extractLocation).orElseThrow(() -> new InformationMissingException("Location needed")); + + String ruleId = getRuleId(result); + + Optional ruleIndex = getRuleIndex(result); + + Optional ruleByIndex = driver.getOptionalRules().flatMap(rules -> ruleIndex.map(rules::get)); + Optional rule = ruleByIndex.or(() -> lookupRuleById(ruleId, ruleOfId)); + + // Fallback to the rule identifier for the category + String category = rule.map(ruleCategorizer::categorizeRule).orElse(ruleId); + + Result.Level level = result.getOptionalLevel().orElse(Result.Level.WARNING); + + String message = findMessage(result, driver, rule); + + return new StaticCodeAnalysisIssue(fileLocation.path(), fileLocation.startLine(), fileLocation.endLine(), fileLocation.startColumn(), fileLocation.endColumn(), ruleId, + category, message, level.toString(), null); + } + + private FileLocation extractLocation(PhysicalLocation location) { + URI uri = URI + .create(location.getOptionalArtifactLocation().flatMap(ArtifactLocation::getOptionalUri).orElseThrow(() -> new InformationMissingException("File path needed"))); + + Region region = location.getOptionalRegion().orElseThrow(() -> new SarifFormatException("Region must be present")); + + int startLine = region.getOptionalStartLine().orElseThrow(() -> new InformationMissingException("Text region needed")); + int startColumn = region.getOptionalStartColumn().orElse(1); + int endLine = region.getOptionalEndLine().orElse(startLine); + int endColumn = region.getOptionalEndColumn().orElse(startColumn + 1); + + return new FileLocation(uri.getPath(), startLine, endLine, startColumn, endColumn); + } + + private static String getRuleId(Result result) throws SarifFormatException { + return result.getOptionalRuleId().orElseGet(() -> result.getOptionalRule().flatMap(ReportingDescriptorReference::getOptionalId) + .orElseThrow(() -> new SarifFormatException("Either ruleId or rule.id must be present"))); + } + + private static Optional getRuleIndex(Result result) { + // ruleIndex can use -1 to indicate a missing value + Optional ruleIndexOrMinusOne = result.getOptionalRuleIndex().or(() -> result.getOptionalRule().flatMap(ReportingDescriptorReference::getOptionalIndex)); + return ruleIndexOrMinusOne.flatMap(index -> index != -1 ? Optional.of(index) : Optional.empty()); + } + + @SuppressWarnings("OptionalUsedAsFieldOrParameterType") + private static String findMessage(Result result, ToolComponent driver, Optional rule) throws SarifFormatException { + return result.message().getOptionalText().orElseGet(() -> lookupMessageById(result, driver, rule)); + } + + @SuppressWarnings("OptionalUsedAsFieldOrParameterType") + private static String lookupMessageById(Result result, ToolComponent driver, Optional rule) throws SarifFormatException { + String messageId = result.message().getOptionalId().orElseThrow(() -> new SarifFormatException("Either text or id must be present")); + + var ruleMessageString = rule.flatMap(ReportingDescriptor::getOptionalMessageStrings).map(MessageStrings::additionalProperties).map(strings -> strings.get(messageId)); + var globalMessageString = driver.getOptionalGlobalMessageStrings().map(GlobalMessageStrings::additionalProperties).map(strings -> strings.get(messageId)); + + var messageString = ruleMessageString.or(() -> globalMessageString).orElseThrow(() -> new SarifFormatException("Message lookup failed")); + return messageString.text(); + } + + private static Optional lookupRuleById(String ruleId, Map ruleOfId) { + return Optional.ofNullable(ruleOfId.get(ruleId)).or(() -> getBaseRuleId(ruleId).map(ruleOfId::get)); + } + + private static Optional getBaseRuleId(String ruleId) { + int hierarchySeperatorIndex = ruleId.lastIndexOf('/'); + if (hierarchySeperatorIndex == -1) { + return Optional.empty(); + } + String baseRuleId = ruleId.substring(0, hierarchySeperatorIndex); + return Optional.of(baseRuleId); + } + +} diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/utils/ReportUtils.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/utils/ReportUtils.java deleted file mode 100644 index 3b38d2597a4f..000000000000 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/utils/ReportUtils.java +++ /dev/null @@ -1,48 +0,0 @@ -package de.tum.cit.aet.artemis.programming.service.localci.scaparser.utils; - -import java.util.List; - -import de.tum.cit.aet.artemis.programming.domain.StaticCodeAnalysisTool; -import de.tum.cit.aet.artemis.programming.dto.StaticCodeAnalysisIssue; -import de.tum.cit.aet.artemis.programming.dto.StaticCodeAnalysisReportDTO; - -public final class ReportUtils { - - private ReportUtils() { - } - - /** - * Creates a report which states that the specified file is too large - * to be parsed by the parser. - * - * @param filename name of the parsed file - * @return report with the issue about the filesize - */ - public static StaticCodeAnalysisReportDTO createFileTooLargeReport(String filename) { - StaticCodeAnalysisTool tool = StaticCodeAnalysisTool.getToolByFilePattern(filename).orElse(null); - List issues = List.of(new StaticCodeAnalysisIssue(filename, 1, 1, 0, 0, // Assuming there are no column details - "TooManyIssues", "miscellaneous", String.format("There are too many issues found in the %s tool.", tool), null, // No priority for this issue - null // No penalty for this issue - )); - - return new StaticCodeAnalysisReportDTO(tool, issues); - } - - /** - * Creates a report wrapping an exception; Used to inform the client about any exception during parsing - * - * @param filename name of the parsed file - * @param exception exception to wrap - * @return a report for the file with an issue wrapping the exception - */ - public static StaticCodeAnalysisReportDTO createErrorReport(String filename, Exception exception) { - StaticCodeAnalysisTool tool = StaticCodeAnalysisTool.getToolByFilePattern(filename).orElse(null); - List issues = List.of(new StaticCodeAnalysisIssue(filename, 1, 1, 0, 0, // Assuming there are no column details - "ExceptionDuringParsing", "miscellaneous", - String.format("An exception occurred during parsing the report for %s. Exception: %s", tool != null ? tool : "file " + filename, exception), - // No priority and no penalty for this issue - null, null)); - - return new StaticCodeAnalysisReportDTO(tool, issues); - } -} diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/GitPublickeyAuthenticatorService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/GitPublickeyAuthenticatorService.java index 4f0cad0aa4e9..3c9bc85f83a4 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/GitPublickeyAuthenticatorService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/GitPublickeyAuthenticatorService.java @@ -5,6 +5,7 @@ import java.io.IOException; import java.security.GeneralSecurityException; import java.security.PublicKey; +import java.time.ZonedDateTime; import java.util.Objects; import java.util.Optional; @@ -13,11 +14,14 @@ import org.apache.sshd.server.session.ServerSession; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; import de.tum.cit.aet.artemis.buildagent.dto.BuildAgentInformation; import de.tum.cit.aet.artemis.core.repository.UserRepository; +import de.tum.cit.aet.artemis.programming.domain.UserSshPublicKey; +import de.tum.cit.aet.artemis.programming.repository.UserSshPublicKeyRepository; import de.tum.cit.aet.artemis.programming.service.localci.SharedQueueManagementService; import de.tum.cit.aet.artemis.programming.service.localvc.ssh.HashUtils; import de.tum.cit.aet.artemis.programming.service.localvc.ssh.SshConstants; @@ -32,41 +36,85 @@ public class GitPublickeyAuthenticatorService implements PublickeyAuthenticator private final Optional localCIBuildJobQueueService; - public GitPublickeyAuthenticatorService(UserRepository userRepository, Optional localCIBuildJobQueueService) { + private final UserSshPublicKeyRepository userSshPublicKeyRepository; + + private final int AUTHENTICATION_FAILED_CODE = 10; + + @Value("${server.url}") + private String artemisServerUrl; + + public GitPublickeyAuthenticatorService(UserRepository userRepository, Optional localCIBuildJobQueueService, + UserSshPublicKeyRepository userSshPublicKeyRepository) { this.userRepository = userRepository; this.localCIBuildJobQueueService = localCIBuildJobQueueService; + this.userSshPublicKeyRepository = userSshPublicKeyRepository; } @Override public boolean authenticate(String username, PublicKey publicKey, ServerSession session) { String keyHash = HashUtils.getSha512Fingerprint(publicKey); - var user = userRepository.findBySshPublicKeyHash(keyHash); - if (user.isPresent()) { - try { - // Retrieve the stored public key string - String storedPublicKeyString = user.get().getSshPublicKey(); - - // Parse the stored public key string - AuthorizedKeyEntry keyEntry = AuthorizedKeyEntry.parseAuthorizedKeyEntry(storedPublicKeyString); - PublicKey storedPublicKey = keyEntry.resolvePublicKey(null, null, null); - - // Compare the stored public key with the provided public key - if (Objects.equals(storedPublicKey, publicKey)) { - log.debug("Found user {} for public key authentication", user.get().getLogin()); - session.setAttribute(SshConstants.USER_KEY, user.get()); - session.setAttribute(SshConstants.IS_BUILD_AGENT_KEY, false); - return true; - } - else { - log.warn("Public key mismatch for user {}", user.get().getLogin()); - } + var userSshPublicKey = userSshPublicKeyRepository.findByKeyHash(keyHash); + return userSshPublicKey.map(sshPublicKey -> { + ZonedDateTime expiryDate = sshPublicKey.getExpiryDate(); + if (expiryDate == null || expiryDate.isAfter(ZonedDateTime.now())) { + return authenticateUser(sshPublicKey, publicKey, session); + } + else { + disconnectBecauseKeyHasExpired(session); + } + + return false; + }).orElseGet(() -> authenticateBuildAgent(publicKey, session)); + } + + /** + * Tries to authenticate a user by the provided key + * + * @param storedKey The key stored in the Artemis database + * @param providedKey The key provided by the user for authentication + * @param session The SSH server session + * + * @return true if the authentication succeeds, and false if it doesn't + */ + private boolean authenticateUser(UserSshPublicKey storedKey, PublicKey providedKey, ServerSession session) { + try { + var user = userRepository.findById(storedKey.getUserId()); + if (user.isEmpty()) { + return false; + } + // Retrieve and parse the stored public key string + AuthorizedKeyEntry keyEntry = AuthorizedKeyEntry.parseAuthorizedKeyEntry(storedKey.getPublicKey()); + PublicKey storedPublicKey = keyEntry.resolvePublicKey(null, null, null); + + // Compare the stored public key with the provided public key + if (Objects.equals(storedPublicKey, providedKey)) { + log.debug("Found user {} for public key authentication", user.get().getLogin()); + session.setAttribute(SshConstants.USER_KEY, user.get()); + session.setAttribute(SshConstants.IS_BUILD_AGENT_KEY, false); + return true; } - catch (Exception e) { - log.error("Failed to convert stored public key string to PublicKey object", e); + else { + log.warn("Public key mismatch for user {}", user.get().getLogin()); } } - else if (localCIBuildJobQueueService.isPresent() - && localCIBuildJobQueueService.get().getBuildAgentInformation().stream().anyMatch(agent -> checkPublicKeyMatchesBuildAgentPublicKey(agent, publicKey))) { + catch (Exception e) { + log.error("Failed to convert stored public key string to PublicKey object", e); + } + return false; + } + + /** + * Tries to authenticate a build agent by the provided key + * + * @param providedKey The key provided by the user for authentication + * @param session The SSH server session + * + * @return true if the authentication succeeds, and false if it doesn't + */ + private boolean authenticateBuildAgent(PublicKey providedKey, ServerSession session) { + if (localCIBuildJobQueueService.isPresent() + && localCIBuildJobQueueService.get().getBuildAgentInformation().stream().anyMatch(agent -> checkPublicKeyMatchesBuildAgentPublicKey(agent, providedKey))) { + log.info("Authenticating as build agent"); session.setAttribute(SshConstants.IS_BUILD_AGENT_KEY, true); return true; @@ -74,6 +122,14 @@ else if (localCIBuildJobQueueService.isPresent() return false; } + /** + * Checks whether a provided key matches the build agents public key + * + * @param agent The build agent which tires to be authenticated by Artemis + * @param publicKey The provided public key + * + * @return true if the build agents has this public key, and false if it doesn't + */ private boolean checkPublicKeyMatchesBuildAgentPublicKey(BuildAgentInformation agent, PublicKey publicKey) { if (agent.publicSshKey() == null) { return false; @@ -90,4 +146,25 @@ private boolean checkPublicKeyMatchesBuildAgentPublicKey(BuildAgentInformation a return agentPublicKey.equals(publicKey); } + + /** + * Disconnects the client from the session and informs that the key used to authenticate with has expired + * + * @param session the session with the client + */ + private void disconnectBecauseKeyHasExpired(ServerSession session) { + try { + var keyExpiredErrorMessage = String.format(""" + Keys expired. + + One of your SSH keys has expired. Renew it in the Artemis settings: + %s/user-settings/ssh + """, artemisServerUrl); + + session.disconnect(AUTHENTICATION_FAILED_CODE, keyExpiredErrorMessage); + } + catch (IOException e) { + log.info("Failed to disconnect SSH client session {}", e.getMessage()); + } + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseExportImportResource.java b/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseExportImportResource.java index 1659956ac1f8..748645568dc6 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseExportImportResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseExportImportResource.java @@ -42,7 +42,7 @@ import de.tum.cit.aet.artemis.assessment.domain.Visibility; import de.tum.cit.aet.artemis.athena.service.AthenaModuleService; -import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyProgressService; +import de.tum.cit.aet.artemis.atlas.api.CompetencyProgressApi; import de.tum.cit.aet.artemis.core.domain.Course; import de.tum.cit.aet.artemis.core.domain.User; import de.tum.cit.aet.artemis.core.dto.RepositoryExportOptionsDTO; @@ -95,7 +95,7 @@ public class ProgrammingExerciseExportImportResource { private static final String ENTITY_NAME = "programmingExercise"; - private final CompetencyProgressService competencyProgressService; + private final CompetencyProgressApi competencyProgressApi; @Value("${jhipster.clientApp.name}") private String applicationName; @@ -136,7 +136,7 @@ public ProgrammingExerciseExportImportResource(ProgrammingExerciseRepository pro AuxiliaryRepositoryRepository auxiliaryRepositoryRepository, SubmissionPolicyService submissionPolicyService, ProgrammingExerciseTaskRepository programmingExerciseTaskRepository, ExamAccessService examAccessService, CourseRepository courseRepository, ProgrammingExerciseImportFromFileService programmingExerciseImportFromFileService, ConsistencyCheckService consistencyCheckService, - Optional athenaModuleService, CompetencyProgressService competencyProgressService) { + Optional athenaModuleService, CompetencyProgressApi competencyProgressApi) { this.programmingExerciseRepository = programmingExerciseRepository; this.userRepository = userRepository; this.courseService = courseService; @@ -152,7 +152,7 @@ public ProgrammingExerciseExportImportResource(ProgrammingExerciseRepository pro this.programmingExerciseImportFromFileService = programmingExerciseImportFromFileService; this.consistencyCheckService = consistencyCheckService; this.athenaModuleService = athenaModuleService; - this.competencyProgressService = competencyProgressService; + this.competencyProgressApi = competencyProgressApi; } /** @@ -259,7 +259,7 @@ public ResponseEntity importProgrammingExercise(@PathVariab importedProgrammingExercise.setExerciseHints(null); importedProgrammingExercise.setTasks(null); - competencyProgressService.updateProgressByLearningObjectAsync(importedProgrammingExercise); + competencyProgressApi.updateProgressByLearningObjectAsync(importedProgrammingExercise); return ResponseEntity.ok().headers(HeaderUtil.createEntityCreationAlert(applicationName, true, ENTITY_NAME, importedProgrammingExercise.getTitle())) .body(importedProgrammingExercise); diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/web/localvc/ssh/SshPublicKeysResource.java b/src/main/java/de/tum/cit/aet/artemis/programming/web/localvc/ssh/SshPublicKeysResource.java new file mode 100644 index 000000000000..8eb38fdfd263 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/web/localvc/ssh/SshPublicKeysResource.java @@ -0,0 +1,117 @@ +package de.tum.cit.aet.artemis.programming.web.localvc.ssh; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_LOCALVC; + +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.util.List; + +import org.apache.sshd.common.config.keys.AuthorizedKeyEntry; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import de.tum.cit.aet.artemis.core.domain.User; +import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException; +import de.tum.cit.aet.artemis.core.repository.UserRepository; +import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastStudent; +import de.tum.cit.aet.artemis.programming.domain.UserSshPublicKey; +import de.tum.cit.aet.artemis.programming.dto.UserSshPublicKeyDTO; +import de.tum.cit.aet.artemis.programming.service.UserSshPublicKeyService; + +@Profile(PROFILE_LOCALVC) +@RestController +@RequestMapping("api/ssh-settings/") +public class SshPublicKeysResource { + + private static final Logger log = LoggerFactory.getLogger(SshPublicKeysResource.class); + + private final UserSshPublicKeyService userSshPublicKeyService; + + private final UserRepository userRepository; + + public SshPublicKeysResource(UserSshPublicKeyService userSshPublicKeyService, UserRepository userRepository) { + this.userSshPublicKeyService = userSshPublicKeyService; + this.userRepository = userRepository; + } + + /** + * GET public-keys : retrieves all SSH keys of a user + * + * @return the ResponseEntity containing all public SSH keys of a user with status 200 (OK) + */ + @GetMapping("public-keys") + @EnforceAtLeastStudent + public ResponseEntity> getSshPublicKeys() { + User user = userRepository.getUser(); + List keys = userSshPublicKeyService.getAllSshKeysForUser(user); + return ResponseEntity.ok(keys); + } + + /** + * GET public-key : gets the ssh public key + * + * @param keyId The id of the key that should be fetched + * + * @return the ResponseEntity containing the requested public SSH key of a user with status 200 (OK), or with status 403 (Access Forbidden) if the key does not exist or is not + * owned by the requesting user + */ + @GetMapping("public-key/{keyId}") + @EnforceAtLeastStudent + public ResponseEntity getSshPublicKey(@PathVariable Long keyId) { + User user = userRepository.getUser(); + UserSshPublicKey key = userSshPublicKeyService.getSshKeyForUser(user, keyId); + return ResponseEntity.ok(UserSshPublicKeyDTO.of(key)); + } + + /** + * POST public-key : creates a new ssh public key for a user + * + * @param sshPublicKey the ssh public key to create + * + * @return the ResponseEntity with status 200 (OK), or with status 400 (Bad Request) when the SSH key is malformed, the label is too long, or when a key with the same hash + * already exists + */ + @PostMapping("public-key") + @EnforceAtLeastStudent + public ResponseEntity addSshPublicKey(@RequestBody UserSshPublicKeyDTO sshPublicKey) throws GeneralSecurityException, IOException { + User user = userRepository.getUser(); + log.debug("REST request to add SSH key to user {}", user.getLogin()); + AuthorizedKeyEntry keyEntry; + try { + keyEntry = AuthorizedKeyEntry.parseAuthorizedKeyEntry(sshPublicKey.publicKey()); + } + catch (IllegalArgumentException e) { + throw new BadRequestAlertException("Invalid SSH key format", "SSH key", "invalidKeyFormat", true); + } + + userSshPublicKeyService.createSshKeyForUser(user, keyEntry, sshPublicKey); + return ResponseEntity.ok().build(); + } + + /** + * Delete - public-key : deletes the ssh public key by its keyId + * + * @param keyId The id of the key that should be deleted + * + * @return the ResponseEntity with status 200 (OK) when the deletion succeeded, or with status 403 (Access Forbidden) if the key does not belong to the user, or does not exist + */ + @DeleteMapping("public-key/{keyId}") + @EnforceAtLeastStudent + public ResponseEntity deleteSshPublicKey(@PathVariable Long keyId) { + User user = userRepository.getUser(); + log.debug("REST request to remove SSH key of user {}", user.getLogin()); + userSshPublicKeyService.deleteUserSshPublicKey(user.getId(), keyId); + + log.debug("Successfully deleted SSH key with id {} of user {}", keyId, user.getLogin()); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/quiz/service/QuizExerciseImportService.java b/src/main/java/de/tum/cit/aet/artemis/quiz/service/QuizExerciseImportService.java index 1b26865c641b..b76d15598f91 100644 --- a/src/main/java/de/tum/cit/aet/artemis/quiz/service/QuizExerciseImportService.java +++ b/src/main/java/de/tum/cit/aet/artemis/quiz/service/QuizExerciseImportService.java @@ -24,7 +24,7 @@ import de.tum.cit.aet.artemis.assessment.repository.ExampleSubmissionRepository; import de.tum.cit.aet.artemis.assessment.repository.ResultRepository; import de.tum.cit.aet.artemis.assessment.service.FeedbackService; -import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyProgressService; +import de.tum.cit.aet.artemis.atlas.api.CompetencyProgressApi; import de.tum.cit.aet.artemis.communication.service.conversation.ChannelService; import de.tum.cit.aet.artemis.core.service.FilePathService; import de.tum.cit.aet.artemis.core.service.FileService; @@ -56,16 +56,16 @@ public class QuizExerciseImportService extends ExerciseImportService { private final ChannelService channelService; - private final CompetencyProgressService competencyProgressService; + private final CompetencyProgressApi competencyProgressApi; public QuizExerciseImportService(QuizExerciseService quizExerciseService, FileService fileService, ExampleSubmissionRepository exampleSubmissionRepository, SubmissionRepository submissionRepository, ResultRepository resultRepository, ChannelService channelService, FeedbackService feedbackService, - CompetencyProgressService competencyProgressService) { + CompetencyProgressApi competencyProgressApi) { super(exampleSubmissionRepository, submissionRepository, resultRepository, feedbackService); this.quizExerciseService = quizExerciseService; this.fileService = fileService; this.channelService = channelService; - this.competencyProgressService = competencyProgressService; + this.competencyProgressApi = competencyProgressApi; } /** @@ -90,7 +90,7 @@ public QuizExercise importQuizExercise(final QuizExercise templateExercise, Quiz channelService.createExerciseChannel(newQuizExercise, Optional.ofNullable(importedExercise.getChannelName())); - competencyProgressService.updateProgressByLearningObjectAsync(newQuizExercise); + competencyProgressApi.updateProgressByLearningObjectAsync(newQuizExercise); if (files != null) { newQuizExercise = quizExerciseService.save(quizExerciseService.uploadNewFilesToNewImportedQuiz(newQuizExercise, files)); } diff --git a/src/main/java/de/tum/cit/aet/artemis/quiz/service/QuizResultService.java b/src/main/java/de/tum/cit/aet/artemis/quiz/service/QuizResultService.java index b540fdc5f9bd..4d461ceb1a12 100644 --- a/src/main/java/de/tum/cit/aet/artemis/quiz/service/QuizResultService.java +++ b/src/main/java/de/tum/cit/aet/artemis/quiz/service/QuizResultService.java @@ -6,6 +6,7 @@ import java.util.Comparator; import java.util.HashSet; import java.util.List; +import java.util.Optional; import java.util.Set; import jakarta.validation.constraints.NotNull; @@ -88,9 +89,12 @@ public void evaluateQuizAndUpdateStatistics(@NotNull Long quizExerciseId) { * Evaluate the given quiz exercise by performing the following actions for each participation: * 1. Get the submission for each participation (there should be only one as in exam mode, the submission gets created upfront and will be updated) * - If no submission is found, print a warning and continue as we cannot evaluate that submission - * - If more than one submission is found, select one of them + * - Filter out submissions that are not submitted before the quiz deadline (practice mode) + * - If more than one submission is found, select one with the highest ID * 2. mark submission and participation as evaluated * 3. Create a new result for the selected submission and calculate scores + * - If a rated result already exists, skip the evaluation + * - If no rated result exists, create a new one and evaluate the submission * 4. Save the updated submission & participation and the newly created result *

* After processing all participations, the created results will be returned for further processing @@ -104,6 +108,7 @@ private Set evaluateSubmissions(@NotNull QuizExercise quizExercise) { Set createdResults = new HashSet<>(); List studentParticipations = studentParticipationRepository.findAllWithEagerLegalSubmissionsAndEagerResultsByExerciseId(quizExercise.getId()); submittedAnswerRepository.loadQuizSubmissionsSubmittedAnswers(studentParticipations); + ZonedDateTime quizDeadline = quizExercise.getDueDate(); for (var participation : studentParticipations) { if (participation.isTestRun()) { @@ -122,8 +127,19 @@ private Set evaluateSubmissions(@NotNull QuizExercise quizExercise) { else if (submissions.size() > 1) { log.warn("Found multiple ({}) submissions for participation {} (Participant {}) in quiz {}, taking the one with highest id", submissions.size(), participation.getId(), participation.getParticipant().getName(), quizExercise.getId()); - // Load submission with highest id - quizSubmission = (QuizSubmission) submissions.stream().max(Comparator.comparing(Submission::getId)).get(); + // Filter submissions to only include those submitted before the quiz deadline if the due date is not null, otherwise select the one with the highest ID + Optional validSubmission = submissions.stream() + .filter(submission -> quizExercise.getDueDate() == null + || (submission.getSubmissionDate() != null && !submission.getSubmissionDate().isAfter(quizExercise.getDueDate()))) + .max(Comparator.comparing(Submission::getId)); + if (validSubmission.isPresent()) { + quizSubmission = (QuizSubmission) validSubmission.get(); + } + else { + log.warn("No valid submissions found for participation {} (Participant {}) in quiz {}", participation.getId(), participation.getParticipant().getName(), + quizExercise.getId()); + continue; + } } else { quizSubmission = (QuizSubmission) submissions.iterator().next(); @@ -131,48 +147,41 @@ else if (submissions.size() > 1) { participation.setInitializationState(InitializationState.FINISHED); - boolean resultExisting = false; - // create new result if none is existing - Result result; - if (participation.getResults().isEmpty()) { - result = new Result().participation(participation); + Optional existingRatedResult = participation.getResults().stream().filter(result -> Boolean.TRUE.equals(result.isRated())).findFirst(); + + if (existingRatedResult.isPresent()) { + // A rated result already exists; no need to create a new one + log.debug("A rated result already exists for participation {} (Participant {}), skipping evaluation.", participation.getId(), + participation.getParticipant().getName()); } else { - resultExisting = true; - result = participation.getResults().iterator().next(); - } - // Only create Results once after the first evaluation - if (!resultExisting) { - // delete result from quizSubmission, to be able to set a new one - if (quizSubmission.getLatestResult() != null) { - resultService.deleteResult(quizSubmission.getLatestResult(), true); - } - result.setRated(true); - result.setAssessmentType(AssessmentType.AUTOMATIC); - result.setCompletionDate(ZonedDateTime.now()); + // No rated result exists; create a new one + Result result = new Result().participation(participation).rated(true).assessmentType(AssessmentType.AUTOMATIC).completionDate(ZonedDateTime.now()); - // set submission to calculate scores + // Associate submission with result result.setSubmission(quizSubmission); - // calculate scores and update result and submission accordingly + + // Calculate and update scores quizSubmission.calculateAndUpdateScores(quizExercise.getQuizQuestions()); result.evaluateQuizSubmission(quizExercise); - // remove submission to follow save order for ordered collections + + // Detach submission to maintain proper save order result.setSubmission(null); - // NOTE: we save participation, submission and result here individually so that one exception (e.g. duplicated key) cannot destroy multiple student answers + // Save entities individually submissionRepository.save(quizSubmission); result = resultRepository.save(result); - // add result to participation + // Update participation with new result participation.addResult(result); studentParticipationRepository.save(participation); - // add result to submission + // Re-associate result with submission and save result.setSubmission(quizSubmission); quizSubmission.addResult(result); submissionRepository.save(quizSubmission); - // Add result so that it can be returned (and processed later) + // Add result to the set of created results createdResults.add(result); } } diff --git a/src/main/java/de/tum/cit/aet/artemis/quiz/web/QuizExerciseResource.java b/src/main/java/de/tum/cit/aet/artemis/quiz/web/QuizExerciseResource.java index d9d276bb3bd4..ecb693e49250 100644 --- a/src/main/java/de/tum/cit/aet/artemis/quiz/web/QuizExerciseResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/quiz/web/QuizExerciseResource.java @@ -38,7 +38,7 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; -import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyProgressService; +import de.tum.cit.aet.artemis.atlas.api.CompetencyProgressApi; import de.tum.cit.aet.artemis.communication.domain.conversation.Channel; import de.tum.cit.aet.artemis.communication.repository.conversation.ChannelRepository; import de.tum.cit.aet.artemis.communication.service.conversation.ChannelService; @@ -148,7 +148,7 @@ public class QuizExerciseResource { private final ChannelRepository channelRepository; - private final CompetencyProgressService competencyProgressService; + private final CompetencyProgressApi competencyProgressApi; public QuizExerciseResource(QuizExerciseService quizExerciseService, QuizMessagingService quizMessagingService, QuizExerciseRepository quizExerciseRepository, UserRepository userRepository, CourseService courseService, ExerciseService exerciseService, ExerciseDeletionService exerciseDeletionService, @@ -156,7 +156,7 @@ public QuizExerciseResource(QuizExerciseService quizExerciseService, QuizMessagi QuizExerciseImportService quizExerciseImportService, AuthorizationCheckService authCheckService, GroupNotificationService groupNotificationService, GroupNotificationScheduleService groupNotificationScheduleService, StudentParticipationRepository studentParticipationRepository, QuizBatchService quizBatchService, QuizBatchRepository quizBatchRepository, FileService fileService, ChannelService channelService, ChannelRepository channelRepository, - QuizSubmissionService quizSubmissionService, QuizResultService quizResultService, CompetencyProgressService competencyProgressService) { + QuizSubmissionService quizSubmissionService, QuizResultService quizResultService, CompetencyProgressApi competencyProgressApi) { this.quizExerciseService = quizExerciseService; this.quizMessagingService = quizMessagingService; this.quizExerciseRepository = quizExerciseRepository; @@ -179,7 +179,7 @@ public QuizExerciseResource(QuizExerciseService quizExerciseService, QuizMessagi this.channelRepository = channelRepository; this.quizSubmissionService = quizSubmissionService; this.quizResultService = quizResultService; - this.competencyProgressService = competencyProgressService; + this.competencyProgressApi = competencyProgressApi; } /** @@ -241,7 +241,7 @@ public ResponseEntity createQuizExercise(@RequestPart("exercise") channelService.createExerciseChannel(result, Optional.ofNullable(quizExercise.getChannelName())); - competencyProgressService.updateProgressByLearningObjectAsync(result); + competencyProgressApi.updateProgressByLearningObjectAsync(result); return ResponseEntity.created(new URI("/api/quiz-exercises/" + result.getId())) .headers(HeaderUtil.createEntityCreationAlert(applicationName, true, ENTITY_NAME, result.getId().toString())).body(result); @@ -308,7 +308,7 @@ public ResponseEntity updateQuizExercise(@PathVariable Long exerci if (updatedChannel != null) { quizExercise.setChannelName(updatedChannel.getName()); } - competencyProgressService.updateProgressForUpdatedLearningObjectAsync(originalQuiz, Optional.of(quizExercise)); + competencyProgressApi.updateProgressForUpdatedLearningObjectAsync(originalQuiz, Optional.of(quizExercise)); return ResponseEntity.ok(quizExercise); } diff --git a/src/main/java/de/tum/cit/aet/artemis/text/service/TextAssessmentService.java b/src/main/java/de/tum/cit/aet/artemis/text/service/TextAssessmentService.java index ad1e367c2735..774e3655e752 100644 --- a/src/main/java/de/tum/cit/aet/artemis/text/service/TextAssessmentService.java +++ b/src/main/java/de/tum/cit/aet/artemis/text/service/TextAssessmentService.java @@ -44,7 +44,7 @@ public TextAssessmentService(UserRepository userRepository, ComplaintResponseSer SubmissionService submissionService, Optional ltiNewResultService, SingleUserNotificationService singleUserNotificationService, ResultWebsocketService resultWebsocketService, LongFeedbackTextRepository longFeedbackTextRepository) { super(complaintResponseService, complaintRepository, feedbackRepository, resultRepository, studentParticipationRepository, resultService, submissionService, - submissionRepository, examDateService, userRepository, ltiNewResultService, singleUserNotificationService, resultWebsocketService, longFeedbackTextRepository); + submissionRepository, examDateService, userRepository, ltiNewResultService, singleUserNotificationService, resultWebsocketService); this.textBlockService = textBlockService; } diff --git a/src/main/java/de/tum/cit/aet/artemis/text/service/TextExerciseImportService.java b/src/main/java/de/tum/cit/aet/artemis/text/service/TextExerciseImportService.java index 161c6426589b..f45cadcd7ddd 100644 --- a/src/main/java/de/tum/cit/aet/artemis/text/service/TextExerciseImportService.java +++ b/src/main/java/de/tum/cit/aet/artemis/text/service/TextExerciseImportService.java @@ -27,7 +27,7 @@ import de.tum.cit.aet.artemis.assessment.repository.ResultRepository; import de.tum.cit.aet.artemis.assessment.repository.TextBlockRepository; import de.tum.cit.aet.artemis.assessment.service.FeedbackService; -import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyProgressService; +import de.tum.cit.aet.artemis.atlas.api.CompetencyProgressApi; import de.tum.cit.aet.artemis.communication.service.conversation.ChannelService; import de.tum.cit.aet.artemis.exercise.domain.Exercise; import de.tum.cit.aet.artemis.exercise.domain.Submission; @@ -57,13 +57,13 @@ public class TextExerciseImportService extends ExerciseImportService { private final ChannelService channelService; - private final CompetencyProgressService competencyProgressService; + private final CompetencyProgressApi competencyProgressApi; private final ExerciseService exerciseService; public TextExerciseImportService(TextExerciseRepository textExerciseRepository, ExampleSubmissionRepository exampleSubmissionRepository, SubmissionRepository submissionRepository, ResultRepository resultRepository, TextBlockRepository textBlockRepository, FeedbackRepository feedbackRepository, - TextSubmissionRepository textSubmissionRepository, ChannelService channelService, FeedbackService feedbackService, CompetencyProgressService competencyProgressService, + TextSubmissionRepository textSubmissionRepository, ChannelService channelService, FeedbackService feedbackService, CompetencyProgressApi competencyProgressApi, ExerciseService exerciseService) { super(exampleSubmissionRepository, submissionRepository, resultRepository, feedbackService); this.textBlockRepository = textBlockRepository; @@ -71,7 +71,7 @@ public TextExerciseImportService(TextExerciseRepository textExerciseRepository, this.feedbackRepository = feedbackRepository; this.textSubmissionRepository = textSubmissionRepository; this.channelService = channelService; - this.competencyProgressService = competencyProgressService; + this.competencyProgressApi = competencyProgressApi; this.exerciseService = exerciseService; } @@ -100,7 +100,7 @@ public TextExercise importTextExercise(final TextExercise templateExercise, Text channelService.createExerciseChannel(newTextExercise, Optional.ofNullable(importedExercise.getChannelName())); newExercise.setExampleSubmissions(copyExampleSubmission(templateExercise, newExercise, gradingInstructionCopyTracker)); - competencyProgressService.updateProgressByLearningObjectAsync(newTextExercise); + competencyProgressApi.updateProgressByLearningObjectAsync(newTextExercise); return newExercise; } diff --git a/src/main/java/de/tum/cit/aet/artemis/text/web/TextExerciseResource.java b/src/main/java/de/tum/cit/aet/artemis/text/web/TextExerciseResource.java index 0040e183e1e8..0b72f23ba0db 100644 --- a/src/main/java/de/tum/cit/aet/artemis/text/web/TextExerciseResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/text/web/TextExerciseResource.java @@ -41,7 +41,7 @@ import de.tum.cit.aet.artemis.assessment.repository.ResultRepository; import de.tum.cit.aet.artemis.assessment.repository.TextBlockRepository; import de.tum.cit.aet.artemis.athena.service.AthenaModuleService; -import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyProgressService; +import de.tum.cit.aet.artemis.atlas.api.CompetencyProgressApi; import de.tum.cit.aet.artemis.communication.domain.conversation.Channel; import de.tum.cit.aet.artemis.communication.repository.conversation.ChannelRepository; import de.tum.cit.aet.artemis.communication.service.conversation.ChannelService; @@ -154,7 +154,7 @@ public class TextExerciseResource { private final Optional athenaModuleService; - private final CompetencyProgressService competencyProgressService; + private final CompetencyProgressApi competencyProgressApi; private final Optional irisSettingsService; @@ -165,8 +165,8 @@ public TextExerciseResource(TextExerciseRepository textExerciseRepository, TextE TextSubmissionExportService textSubmissionExportService, ExampleSubmissionRepository exampleSubmissionRepository, ExerciseService exerciseService, GradingCriterionRepository gradingCriterionRepository, TextBlockRepository textBlockRepository, GroupNotificationScheduleService groupNotificationScheduleService, InstanceMessageSendService instanceMessageSendService, PlagiarismDetectionService plagiarismDetectionService, CourseRepository courseRepository, - ChannelService channelService, ChannelRepository channelRepository, Optional athenaModuleService, - CompetencyProgressService competencyProgressService, Optional irisSettingsService) { + ChannelService channelService, ChannelRepository channelRepository, Optional athenaModuleService, CompetencyProgressApi competencyProgressApi, + Optional irisSettingsService) { this.feedbackRepository = feedbackRepository; this.exerciseDeletionService = exerciseDeletionService; this.plagiarismResultRepository = plagiarismResultRepository; @@ -191,7 +191,7 @@ public TextExerciseResource(TextExerciseRepository textExerciseRepository, TextE this.channelService = channelService; this.channelRepository = channelRepository; this.athenaModuleService = athenaModuleService; - this.competencyProgressService = competencyProgressService; + this.competencyProgressApi = competencyProgressApi; this.irisSettingsService = irisSettingsService; } @@ -232,7 +232,7 @@ public ResponseEntity createTextExercise(@RequestBody TextExercise channelService.createExerciseChannel(result, Optional.ofNullable(textExercise.getChannelName())); instanceMessageSendService.sendTextExerciseSchedule(result.getId()); groupNotificationScheduleService.checkNotificationsForNewExerciseAsync(textExercise); - competencyProgressService.updateProgressByLearningObjectAsync(result); + competencyProgressApi.updateProgressByLearningObjectAsync(result); irisSettingsService.ifPresent(iss -> iss.setEnabledForExerciseByCategories(result, new HashSet<>())); @@ -294,7 +294,7 @@ public ResponseEntity updateTextExercise(@RequestBody TextExercise exerciseService.checkExampleSubmissions(updatedTextExercise); exerciseService.notifyAboutExerciseChanges(textExerciseBeforeUpdate, updatedTextExercise, notificationText); - competencyProgressService.updateProgressForUpdatedLearningObjectAsync(textExerciseBeforeUpdate, Optional.of(textExercise)); + competencyProgressApi.updateProgressForUpdatedLearningObjectAsync(textExerciseBeforeUpdate, Optional.of(textExercise)); irisSettingsService.ifPresent(iss -> iss.setEnabledForExerciseByCategories(textExercise, textExerciseBeforeUpdate.getCategories())); diff --git a/src/main/resources/config/application-buildagent.yml b/src/main/resources/config/application-buildagent.yml index 2872d91575cc..e4c1d3013357 100644 --- a/src/main/resources/config/application-buildagent.yml +++ b/src/main/resources/config/application-buildagent.yml @@ -34,9 +34,12 @@ artemis: cleanup-schedule-minutes: 60 pause-grace-period-seconds: 60 build-timeout-seconds: - min: 10 - default: 120 - max: 240 + min: 10 + default: 120 + max: 240 + build-logs: + max-lines-per-job: 10000 + max-chars-per-line: 1024 git: name: Artemis email: artemis@xcit.tum.de diff --git a/src/main/resources/config/liquibase/changelog/20241101121000_changelog.xml b/src/main/resources/config/liquibase/changelog/20241101121000_changelog.xml new file mode 100644 index 000000000000..b8e12b77c118 --- /dev/null +++ b/src/main/resources/config/liquibase/changelog/20241101121000_changelog.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/config/liquibase/changelog/20241105150000_changelog.xml b/src/main/resources/config/liquibase/changelog/20241105150000_changelog.xml new file mode 100644 index 000000000000..0e7e5e68aac6 --- /dev/null +++ b/src/main/resources/config/liquibase/changelog/20241105150000_changelog.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO user_public_ssh_key (user_id, label, public_key, key_hash, creation_date, last_used_date, expiry_date) + SELECT id, 'Key 1', ssh_public_key, ssh_public_key_hash, CURRENT_TIMESTAMP, NULL, NULL + FROM jhi_user + WHERE ssh_public_key IS NOT NULL; + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/config/liquibase/master.xml b/src/main/resources/config/liquibase/master.xml index 6a06b398b783..d331337ceef4 100644 --- a/src/main/resources/config/liquibase/master.xml +++ b/src/main/resources/config/liquibase/master.xml @@ -29,10 +29,12 @@ - + + + diff --git a/src/main/resources/templates/jenkins/c_plus_plus/regularRuns/pipeline.groovy b/src/main/resources/templates/jenkins/c_plus_plus/regularRuns/pipeline.groovy index d9bc3bbf4a27..3b0ad271b3ae 100644 --- a/src/main/resources/templates/jenkins/c_plus_plus/regularRuns/pipeline.groovy +++ b/src/main/resources/templates/jenkins/c_plus_plus/regularRuns/pipeline.groovy @@ -69,7 +69,6 @@ void postBuildTasks() { sed -i 's/[^[:print:]\t]/�/g' test-reports/tests-results.xml sed -i 's//<\\/error>/g' test-reports/tests-results.xml - sed -i 's/]*>//g ; s/<\\/testsuites>/<\\/testsuite>/g' test-reports/tests-results.xml fi rm -rf results mv test-reports results diff --git a/src/main/resources/templates/jenkins/c_sharp/regularRuns/pipeline.groovy b/src/main/resources/templates/jenkins/c_sharp/regularRuns/pipeline.groovy index b8a48c3a6393..871d3db4aaa0 100644 --- a/src/main/resources/templates/jenkins/c_sharp/regularRuns/pipeline.groovy +++ b/src/main/resources/templates/jenkins/c_sharp/regularRuns/pipeline.groovy @@ -45,10 +45,6 @@ void postBuildTasks() { sh ''' rm -rf results mkdir results - if [ -e tests/TestResults/TestResults.xml ] - then - sed -i 's/]*>//g ; s/<\\/testsuites>/<\\/testsuite>/g' tests/TestResults/TestResults.xml - fi cp tests/TestResults/TestResults.xml $WORKSPACE/results/ || true sed -i 's/[^[:print:]\t]/�/g' $WORKSPACE/results/*.xml || true ''' diff --git a/src/main/resources/templates/jenkins/haskell/regularRuns/pipeline.groovy b/src/main/resources/templates/jenkins/haskell/regularRuns/pipeline.groovy index eacc732bd58d..9b93985e9622 100644 --- a/src/main/resources/templates/jenkins/haskell/regularRuns/pipeline.groovy +++ b/src/main/resources/templates/jenkins/haskell/regularRuns/pipeline.groovy @@ -48,10 +48,6 @@ void postBuildTasks() { sh ''' rm -rf results mkdir results - if [ -e test-reports/results.xml ] - then - sed -i 's/]*>//g ; s/<\\/testsuites>//g' test-reports/results.xml - fi cp test-reports/*.xml $WORKSPACE/results/ || true sed -i 's/[^[:print:]\t]/�/g' $WORKSPACE/results/*.xml || true ''' diff --git a/src/main/resources/templates/jenkins/javascript/regularRuns/pipeline.groovy b/src/main/resources/templates/jenkins/javascript/regularRuns/pipeline.groovy index bc37c762097a..f65df123e726 100644 --- a/src/main/resources/templates/jenkins/javascript/regularRuns/pipeline.groovy +++ b/src/main/resources/templates/jenkins/javascript/regularRuns/pipeline.groovy @@ -45,10 +45,6 @@ void postBuildTasks() { sh ''' rm -rf results mkdir results - if [ -e junit.xml ] - then - sed -i 's/]*>//g ; s/<\\/testsuites>/<\\/testsuite>/g' junit.xml - fi cp junit.xml $WORKSPACE/results/ || true sed -i 's/[^[:print:]\t]/�/g' $WORKSPACE/results/*.xml || true ''' diff --git a/src/main/resources/templates/jenkins/python/regularRuns/pipeline.groovy b/src/main/resources/templates/jenkins/python/regularRuns/pipeline.groovy index 594497262ce2..ead76945175e 100644 --- a/src/main/resources/templates/jenkins/python/regularRuns/pipeline.groovy +++ b/src/main/resources/templates/jenkins/python/regularRuns/pipeline.groovy @@ -50,10 +50,6 @@ void postBuildTasks() { sh ''' rm -rf results mkdir results - if [ -e test-reports/results.xml ] - then - sed -i 's///g ; s/<\\/testsuites>//g' test-reports/results.xml - fi cp test-reports/*.xml $WORKSPACE/results/ || true sed -i 's/[^[:print:]\t]/�/g' $WORKSPACE/results/*.xml || true ''' diff --git a/src/main/resources/templates/jenkins/r/regularRuns/pipeline.groovy b/src/main/resources/templates/jenkins/r/regularRuns/pipeline.groovy index 9a2ec97b5843..7c1441f0cabe 100644 --- a/src/main/resources/templates/jenkins/r/regularRuns/pipeline.groovy +++ b/src/main/resources/templates/jenkins/r/regularRuns/pipeline.groovy @@ -45,10 +45,6 @@ void postBuildTasks() { sh ''' rm -rf results mkdir results - if [ -e tests/testthat/junit.xml ] - then - sed -i 's/]*>//g ; s/<\\/testsuites>/<\\/testsuite>/g' tests/testthat/junit.xml - fi cp tests/testthat/junit.xml $WORKSPACE/results/ || true sed -i 's/[^[:print:]\t]/�/g' $WORKSPACE/results/*.xml || true ''' diff --git a/src/main/resources/templates/jenkins/rust/regularRuns/pipeline.groovy b/src/main/resources/templates/jenkins/rust/regularRuns/pipeline.groovy index 965d7423ce7c..58d6d570d6d2 100644 --- a/src/main/resources/templates/jenkins/rust/regularRuns/pipeline.groovy +++ b/src/main/resources/templates/jenkins/rust/regularRuns/pipeline.groovy @@ -42,10 +42,6 @@ void postBuildTasks() { sh ''' rm -rf results mkdir results - if [ -e target/nextest/ci/junit.xml ] - then - sed -i 's/]*>//g ; s/<\\/testsuites>/<\\/testsuite>/g' target/nextest/ci/junit.xml - fi cp target/nextest/ci/junit.xml $WORKSPACE/results/ || true sed -i 's/[^[:print:]\t]/�/g' $WORKSPACE/results/*.xml || true ''' diff --git a/src/main/resources/templates/jenkins/swift/regularRuns/pipeline.groovy b/src/main/resources/templates/jenkins/swift/regularRuns/pipeline.groovy index e204451deb56..b2c775762be7 100644 --- a/src/main/resources/templates/jenkins/swift/regularRuns/pipeline.groovy +++ b/src/main/resources/templates/jenkins/swift/regularRuns/pipeline.groovy @@ -81,7 +81,6 @@ void postBuildTasks() { mkdir results if [ -e assignment/tests.xml ] then - sed -i 's///g ; s/<\\/testsuites>/<\\/testsuite>/g' assignment/tests.xml cp assignment/tests.xml $WORKSPACE/results/ || true sed -i 's/[^[:print:]\t]/�/g' $WORKSPACE/results/*.xml || true fi diff --git a/src/main/resources/templates/jenkins/typescript/regularRuns/pipeline.groovy b/src/main/resources/templates/jenkins/typescript/regularRuns/pipeline.groovy index 1ba259ab3553..f25114413156 100644 --- a/src/main/resources/templates/jenkins/typescript/regularRuns/pipeline.groovy +++ b/src/main/resources/templates/jenkins/typescript/regularRuns/pipeline.groovy @@ -48,10 +48,6 @@ void postBuildTasks() { sh ''' rm -rf results mkdir results - if [ -e junit.xml ] - then - sed -i 's/]*>//g ; s/<\\/testsuites>/<\\/testsuite>/g' junit.xml - fi cp junit.xml $WORKSPACE/results/ || true sed -i 's/[^[:print:]\t]/�/g' $WORKSPACE/results/*.xml || true ''' diff --git a/src/main/webapp/app/core/auth/account.service.ts b/src/main/webapp/app/core/auth/account.service.ts index a89944954c05..8745ae5357b9 100644 --- a/src/main/webapp/app/core/auth/account.service.ts +++ b/src/main/webapp/app/core/auth/account.service.ts @@ -30,7 +30,6 @@ export interface IAccountService { isAuthenticated: () => boolean; getAuthenticationState: () => Observable; getImageUrl: () => string | undefined; - addSshPublicKey: (sshPublicKey: string) => Observable; } @Injectable({ providedIn: 'root' }) @@ -325,28 +324,6 @@ export class AccountService implements IAccountService { this.prefilledUsernameValue = prefilledUsername; } - /** - * Sends the added SSH key to the server - * - * @param sshPublicKey - */ - addSshPublicKey(sshPublicKey: string): Observable { - if (this.userIdentity) { - this.userIdentity.sshPublicKey = sshPublicKey; - } - return this.http.put('api/account/ssh-public-key', sshPublicKey); - } - - /** - * Sends a request to the server to delete the user's current SSH key - */ - deleteSshPublicKey(): Observable { - if (this.userIdentity) { - this.userIdentity.sshPublicKey = undefined; - } - return this.http.delete('api/account/ssh-public-key'); - } - /** * Sends a request to the server to delete the user's current vcsAccessToken */ diff --git a/src/main/webapp/app/core/posthog/analytics.service.ts b/src/main/webapp/app/core/posthog/analytics.service.ts deleted file mode 100644 index 3971f5748b0d..000000000000 --- a/src/main/webapp/app/core/posthog/analytics.service.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Injectable } from '@angular/core'; -import { ProfileInfo } from 'app/shared/layouts/profiles/profile-info.model'; -import { posthog } from 'posthog-js'; - -@Injectable({ providedIn: 'root' }) -export class AnalyticsService { - /** - * Initialize Analytics with profile information. - * @param profileInfo - */ - public async initAnalytics(profileInfo: ProfileInfo): Promise { - if (!profileInfo || !profileInfo.postHog) { - return; - } - - posthog.init(profileInfo.postHog.token, { - api_host: profileInfo.postHog.host, - }); - } -} diff --git a/src/main/webapp/app/core/user/account.model.ts b/src/main/webapp/app/core/user/account.model.ts index ad1d36a2a18b..37f98ded0405 100644 --- a/src/main/webapp/app/core/user/account.model.ts +++ b/src/main/webapp/app/core/user/account.model.ts @@ -11,7 +11,7 @@ export class Account { public lastName?: string; public langKey?: string; public imageUrl?: string; - public guidedTourSettings: GuidedTourSetting[]; + public guidedTourSettings?: GuidedTourSetting[]; constructor( activated?: boolean, diff --git a/src/main/webapp/app/core/user/user.model.ts b/src/main/webapp/app/core/user/user.model.ts index f793581b21a3..52fa28f56ab9 100644 --- a/src/main/webapp/app/core/user/user.model.ts +++ b/src/main/webapp/app/core/user/user.model.ts @@ -15,8 +15,6 @@ export class User extends Account { public password?: string; public vcsAccessToken?: string; public vcsAccessTokenExpiryDate?: string; - public sshPublicKey?: string; - public sshKeyHash?: string; public irisAccepted?: dayjs.Dayjs; constructor( @@ -38,7 +36,6 @@ export class User extends Account { imageUrl?: string, vcsAccessToken?: string, vcsAccessTokenExpiryDate?: string, - sshPublicKey?: string, irisAccepted?: dayjs.Dayjs, ) { super(activated, authorities, email, firstName, langKey, lastName, login, imageUrl); @@ -52,7 +49,6 @@ export class User extends Account { this.password = password; this.vcsAccessToken = vcsAccessToken; this.vcsAccessTokenExpiryDate = vcsAccessTokenExpiryDate; - this.sshPublicKey = sshPublicKey; this.irisAccepted = irisAccepted; } } diff --git a/src/main/webapp/app/course/dashboards/assessment-dashboard/assessment-dashboard.component.html b/src/main/webapp/app/course/dashboards/assessment-dashboard/assessment-dashboard.component.html index 6d95da823bb4..3e5157787dbc 100644 --- a/src/main/webapp/app/course/dashboards/assessment-dashboard/assessment-dashboard.component.html +++ b/src/main/webapp/app/course/dashboards/assessment-dashboard/assessment-dashboard.component.html @@ -222,6 +222,7 @@

{{ exerciseGroup.title }} @for (exercise of exerciseGroup.exercises; track exercise) { - + @if (course.isAtLeastEditor) { + + + + + diff --git a/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/confirm-feedback-channel-creation-modal.component.ts b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/confirm-feedback-channel-creation-modal.component.ts new file mode 100644 index 000000000000..4a31192b6299 --- /dev/null +++ b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/confirm-feedback-channel-creation-modal.component.ts @@ -0,0 +1,23 @@ +import { Component, inject, input } from '@angular/core'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; + +@Component({ + selector: 'jhi-confirm-feedback-channel-creation-modal', + templateUrl: './confirm-feedback-channel-creation-modal.component.html', + imports: [ArtemisSharedCommonModule], + standalone: true, +}) +export class ConfirmFeedbackChannelCreationModalComponent { + protected readonly TRANSLATION_BASE = 'artemisApp.programmingExercise.configureGrading.feedbackAnalysis.feedbackDetailChannel.confirmationModal'; + affectedStudentsCount = input.required(); + private activeModal = inject(NgbActiveModal); + + confirm(): void { + this.activeModal.close(true); + } + + dismiss(): void { + this.activeModal.dismiss(); + } +} diff --git a/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-detail-channel-modal.component.html b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-detail-channel-modal.component.html new file mode 100644 index 000000000000..36d5b08ed5b9 --- /dev/null +++ b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-detail-channel-modal.component.html @@ -0,0 +1,87 @@ + + diff --git a/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-detail-channel-modal.component.ts b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-detail-channel-modal.component.ts new file mode 100644 index 000000000000..e9b7963f0e78 --- /dev/null +++ b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-detail-channel-modal.component.ts @@ -0,0 +1,71 @@ +import { Component, inject, input, output, signal } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { ChannelDTO } from 'app/entities/metis/conversation/channel.model'; +import { FeedbackDetail } from 'app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.service'; +import { ConfirmFeedbackChannelCreationModalComponent } from 'app/exercises/programming/manage/grading/feedback-analysis/Modal/confirm-feedback-channel-creation-modal.component'; +import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; +import { AlertService } from 'app/core/util/alert.service'; + +@Component({ + selector: 'jhi-feedback-detail-channel-modal', + templateUrl: './feedback-detail-channel-modal.component.html', + imports: [ArtemisSharedCommonModule], + standalone: true, +}) +export class FeedbackDetailChannelModalComponent { + protected readonly TRANSLATION_BASE = 'artemisApp.programmingExercise.configureGrading.feedbackAnalysis.feedbackDetailChannel'; + affectedStudentsCount = input.required(); + feedbackDetail = input.required(); + formSubmitted = output<{ channelDto: ChannelDTO; navigate: boolean }>(); + + isConfirmModalOpen = signal(false); + + private alertService = inject(AlertService); + private readonly formBuilder = inject(FormBuilder); + private readonly activeModal = inject(NgbActiveModal); + private readonly modalService = inject(NgbModal); + form: FormGroup = this.formBuilder.group({ + name: ['', [Validators.required, Validators.maxLength(30), Validators.pattern('^[a-z0-9-]{1}[a-z0-9-]{0,30}$')]], + description: ['', [Validators.required, Validators.maxLength(250)]], + isPublic: [true, Validators.required], + isAnnouncementChannel: [false, Validators.required], + }); + + async submitForm(navigate: boolean): Promise { + if (this.form.valid && !this.isConfirmModalOpen()) { + this.isConfirmModalOpen.set(true); + const result = await this.handleModal(); + if (result) { + const channelDTO = new ChannelDTO(); + channelDTO.name = this.form.get('name')?.value; + channelDTO.description = this.form.get('description')?.value; + channelDTO.isPublic = this.form.get('isPublic')?.value; + channelDTO.isAnnouncementChannel = this.form.get('isAnnouncementChannel')?.value; + + this.formSubmitted.emit({ channelDto: channelDTO, navigate }); + this.closeModal(); + } + this.isConfirmModalOpen.set(false); + } + } + + async handleModal(): Promise { + try { + const modalRef = this.modalService.open(ConfirmFeedbackChannelCreationModalComponent, { centered: true }); + modalRef.componentInstance.affectedStudentsCount = this.affectedStudentsCount; + return await modalRef.result; + } catch (error) { + this.alertService.error(error); + return false; + } + } + + closeModal(): void { + this.activeModal.close(); + } + + dismissModal(): void { + this.activeModal.dismiss(); + } +} diff --git a/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.component.html b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.component.html index 20206a2c4ae3..2c1d01040253 100644 --- a/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.component.html +++ b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.component.html @@ -60,8 +60,11 @@

{{ item.testCaseName }} {{ item.errorCategory }} - + + @if (isCommunicationEnabled()) { + + } } diff --git a/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.component.ts b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.component.ts index 9026d7cbb1ec..14da1cbe4cce 100644 --- a/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.component.ts +++ b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.component.ts @@ -1,8 +1,9 @@ import { Component, computed, effect, inject, input, signal, untracked } from '@angular/core'; -import { FeedbackAnalysisService, FeedbackDetail } from './feedback-analysis.service'; +import { FeedbackAnalysisService, FeedbackChannelRequestDTO, FeedbackDetail } from './feedback-analysis.service'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { AlertService } from 'app/core/util/alert.service'; -import { faFilter, faSort, faSortDown, faSortUp, faUpRightAndDownLeftFromCenter, faUsers } from '@fortawesome/free-solid-svg-icons'; +import { faFilter, faMessage, faSort, faSortDown, faSortUp, faUsers } from '@fortawesome/free-solid-svg-icons'; +import { facDetails } from '../../../../../../content/icons/icons'; import { SearchResult, SortingOrder } from 'app/shared/table/pageable-table'; import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; import { FeedbackModalComponent } from 'app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-modal.component'; @@ -11,6 +12,9 @@ import { LocalStorageService } from 'ngx-webstorage'; import { BaseApiHttpService } from 'app/course/learning-paths/services/base-api-http.service'; import { SortIconComponent } from 'app/shared/sort/sort-icon.component'; import { AffectedStudentsModalComponent } from 'app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-affected-students-modal.component'; +import { FeedbackDetailChannelModalComponent } from 'app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-detail-channel-modal.component'; +import { ChannelDTO } from 'app/entities/metis/conversation/channel.model'; +import { Router } from '@angular/router'; @Component({ selector: 'jhi-feedback-analysis', @@ -23,11 +27,14 @@ import { AffectedStudentsModalComponent } from 'app/exercises/programming/manage export class FeedbackAnalysisComponent { exerciseTitle = input.required(); exerciseId = input.required(); + courseId = input.required(); + isCommunicationEnabled = input.required(); private feedbackAnalysisService = inject(FeedbackAnalysisService); private alertService = inject(AlertService); private modalService = inject(NgbModal); private localStorage = inject(LocalStorageService); + private router = inject(Router); readonly page = signal(1); readonly pageSize = signal(25); @@ -44,8 +51,9 @@ export class FeedbackAnalysisComponent { readonly faSortUp = faSortUp; readonly faSortDown = faSortDown; readonly faFilter = faFilter; - readonly faUpRightAndDownLeftFromCenter = faUpRightAndDownLeftFromCenter; + readonly facDetails = facDetails; readonly faUsers = faUsers; + readonly faMessage = faMessage; readonly SortingOrder = SortingOrder; readonly MAX_FEEDBACK_DETAIL_TEXT_LENGTH = 200; @@ -60,6 +68,8 @@ export class FeedbackAnalysisComponent { readonly maxCount = signal(0); readonly errorCategories = signal([]); + private isFeedbackDetailChannelModalOpen = false; + private readonly debounceLoadData = BaseApiHttpService.debounce(this.loadData.bind(this), 300); constructor() { @@ -117,7 +127,7 @@ export class FeedbackAnalysisComponent { } openFeedbackModal(feedbackDetail: FeedbackDetail): void { - const modalRef = this.modalService.open(FeedbackModalComponent, { centered: true }); + const modalRef = this.modalService.open(FeedbackModalComponent, { centered: true, size: 'lg' }); modalRef.componentInstance.feedbackDetail = signal(feedbackDetail); } @@ -191,4 +201,40 @@ export class FeedbackAnalysisComponent { modalRef.componentInstance.exerciseId = this.exerciseId; modalRef.componentInstance.feedbackDetail = signal(feedbackDetail); } + + async openFeedbackDetailChannelModal(feedbackDetail: FeedbackDetail): Promise { + if (this.isFeedbackDetailChannelModalOpen) { + return; + } + this.isFeedbackDetailChannelModalOpen = true; + const modalRef = this.modalService.open(FeedbackDetailChannelModalComponent, { centered: true, size: 'lg' }); + modalRef.componentInstance.affectedStudentsCount = await this.feedbackAnalysisService.getAffectedStudentCount(this.exerciseId(), feedbackDetail.detailText); + modalRef.componentInstance.feedbackDetail = signal(feedbackDetail); + modalRef.componentInstance.formSubmitted.subscribe(async ({ channelDto, navigate }: { channelDto: ChannelDTO; navigate: boolean }) => { + try { + const feedbackChannelRequest: FeedbackChannelRequestDTO = { + channel: channelDto, + feedbackDetailText: feedbackDetail.detailText, + }; + const createdChannel = await this.feedbackAnalysisService.createChannel(this.courseId(), this.exerciseId(), feedbackChannelRequest); + const channelName = createdChannel.name; + this.alertService.success(this.TRANSLATION_BASE + '.channelSuccess', { channelName }); + if (navigate) { + const urlTree = this.router.createUrlTree(['courses', this.courseId(), 'communication'], { + queryParams: { conversationId: createdChannel.id }, + }); + await this.router.navigateByUrl(urlTree); + } + } catch (error) { + this.alertService.error(error); + } + }); + try { + await modalRef.result; + } catch { + // modal dismissed + } finally { + this.isFeedbackDetailChannelModalOpen = false; + } + } } diff --git a/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.service.ts b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.service.ts index 214c9a4e4f4c..d034cc56a506 100644 --- a/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.service.ts +++ b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.service.ts @@ -3,6 +3,7 @@ import { PageableResult, PageableSearch, SearchResult, SearchTermPageableSearch import { BaseApiHttpService } from 'app/course/learning-paths/services/base-api-http.service'; import { HttpHeaders, HttpParams } from '@angular/common/http'; import { FilterData } from 'app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-filter-modal.component'; +import { ChannelDTO } from 'app/entities/metis/conversation/channel.model'; export interface FeedbackAnalysisResponse { feedbackDetails: SearchResult; @@ -28,6 +29,10 @@ export interface FeedbackAffectedStudentDTO { login: string; repositoryURI: string; } +export interface FeedbackChannelRequestDTO { + channel: ChannelDTO; + feedbackDetailText: string; +} @Injectable() export class FeedbackAnalysisService extends BaseApiHttpService { search(pageable: SearchTermPageableSearch, options: { exerciseId: number; filters: FilterData }): Promise { @@ -62,4 +67,13 @@ export class FeedbackAnalysisService extends BaseApiHttpService { return this.get>(`exercises/${exerciseId}/feedback-details-participation`, { params, headers }); } + + createChannel(courseId: number, exerciseId: number, feedbackChannelRequest: FeedbackChannelRequestDTO): Promise { + return this.post(`courses/${courseId}/${exerciseId}/feedback-channel`, feedbackChannelRequest); + } + + getAffectedStudentCount(exerciseId: number, feedbackDetailText: string): Promise { + const params = new HttpParams().set('detailText', feedbackDetailText); + return this.get(`exercises/${exerciseId}/feedback-detail/affected-students`, { params }); + } } diff --git a/src/main/webapp/app/exercises/programming/manage/grading/programming-exercise-configure-grading.component.html b/src/main/webapp/app/exercises/programming/manage/grading/programming-exercise-configure-grading.component.html index 147c35adea2f..39014c0f5657 100644 --- a/src/main/webapp/app/exercises/programming/manage/grading/programming-exercise-configure-grading.component.html +++ b/src/main/webapp/app/exercises/programming/manage/grading/programming-exercise-configure-grading.component.html @@ -270,8 +270,13 @@

}
- @if (programmingExercise.isAtLeastEditor && activeTab === 'feedback-analysis') { - + @if (programmingExercise.isAtLeastEditor && activeTab === 'feedback-analysis' && programmingExercise.title && programmingExercise.id && course.id) { + }
} diff --git a/src/main/webapp/app/exercises/programming/manage/grading/programming-exercise-configure-grading.component.ts b/src/main/webapp/app/exercises/programming/manage/grading/programming-exercise-configure-grading.component.ts index c5ef675338f5..13ce8237d382 100644 --- a/src/main/webapp/app/exercises/programming/manage/grading/programming-exercise-configure-grading.component.ts +++ b/src/main/webapp/app/exercises/programming/manage/grading/programming-exercise-configure-grading.component.ts @@ -6,7 +6,7 @@ import { TranslateService } from '@ngx-translate/core'; import { AccountService } from 'app/core/auth/account.service'; import { AlertService } from 'app/core/util/alert.service'; import { CourseManagementService } from 'app/course/manage/course-management.service'; -import { Course } from 'app/entities/course.model'; +import { Course, isCommunicationEnabled } from 'app/entities/course.model'; import { IssuesMap, ProgrammingExerciseGradingStatistics } from 'app/entities/programming/programming-exercise-test-case-statistics.model'; import { ProgrammingExerciseTestCase, Visibility } from 'app/entities/programming/programming-exercise-test-case.model'; import { ProgrammingExercise, ProgrammingLanguage } from 'app/entities/programming/programming-exercise.model'; @@ -92,6 +92,7 @@ export class ProgrammingExerciseConfigureGradingComponent implements OnInit, OnD readonly RESET_TABLE = ProgrammingGradingChartsDirective.RESET_TABLE; readonly chartFilterType = ChartFilterType; readonly ProgrammingLanguage = ProgrammingLanguage; + protected readonly isCommunicationEnabled = isCommunicationEnabled; // We have to separate these test cases in order to separate the table and chart presentation if the table is filtered by the chart staticCodeAnalysisCategoriesForTable: StaticCodeAnalysisCategory[] = []; diff --git a/src/main/webapp/app/exercises/programming/manage/programming-exercise-management-routing.module.ts b/src/main/webapp/app/exercises/programming/manage/programming-exercise-management-routing.module.ts index 583c85ec8b49..19c51d8dff55 100644 --- a/src/main/webapp/app/exercises/programming/manage/programming-exercise-management-routing.module.ts +++ b/src/main/webapp/app/exercises/programming/manage/programming-exercise-management-routing.module.ts @@ -166,9 +166,6 @@ export const routes: Routes = [ data: { authorities: [Authority.ADMIN, Authority.INSTRUCTOR, Authority.EDITOR, Authority.TA], pageTitle: 'artemisApp.repository.title', - flushRepositoryCacheAfter: 900000, // 15 min - participationCache: {}, - repositoryCache: {}, }, canActivate: [UserRouteAccessService, LocalVCGuard], }, @@ -178,9 +175,6 @@ export const routes: Routes = [ data: { authorities: [Authority.ADMIN, Authority.INSTRUCTOR, Authority.EDITOR, Authority.TA], pageTitle: 'artemisApp.repository.title', - flushRepositoryCacheAfter: 900000, // 15 min - participationCache: {}, - repositoryCache: {}, }, canActivate: [UserRouteAccessService, LocalVCGuard], }, @@ -190,9 +184,6 @@ export const routes: Routes = [ data: { authorities: [Authority.ADMIN, Authority.INSTRUCTOR, Authority.EDITOR], pageTitle: 'artemisApp.repository.title', - flushRepositoryCacheAfter: 900000, // 15 min - participationCache: {}, - repositoryCache: {}, }, canActivate: [LocalVCGuard], }, @@ -202,9 +193,6 @@ export const routes: Routes = [ data: { authorities: [Authority.ADMIN, Authority.INSTRUCTOR, Authority.EDITOR], pageTitle: 'artemisApp.repository.title', - flushRepositoryCacheAfter: 900000, // 15 min - participationCache: {}, - repositoryCache: {}, }, canActivate: [LocalVCGuard], }, @@ -214,9 +202,6 @@ export const routes: Routes = [ data: { authorities: [Authority.ADMIN, Authority.INSTRUCTOR], pageTitle: 'artemisApp.repository.title', - flushRepositoryCacheAfter: 900000, // 15 min - participationCache: {}, - repositoryCache: {}, }, canActivate: [LocalVCGuard], }, @@ -226,9 +211,6 @@ export const routes: Routes = [ data: { authorities: [Authority.ADMIN, Authority.INSTRUCTOR], pageTitle: 'artemisApp.repository.title', - flushRepositoryCacheAfter: 900000, // 15 min - participationCache: {}, - repositoryCache: {}, }, canActivate: [LocalVCGuard], }, @@ -238,9 +220,6 @@ export const routes: Routes = [ data: { authorities: [Authority.ADMIN, Authority.INSTRUCTOR, Authority.EDITOR], pageTitle: 'artemisApp.repository.title', - flushRepositoryCacheAfter: 900000, // 15 min - participationCache: {}, - repositoryCache: {}, }, canActivate: [LocalVCGuard], }, @@ -250,9 +229,6 @@ export const routes: Routes = [ data: { authorities: [Authority.ADMIN, Authority.INSTRUCTOR, Authority.EDITOR, Authority.TA], pageTitle: 'artemisApp.repository.title', - flushRepositoryCacheAfter: 900000, // 15 min - participationCache: {}, - repositoryCache: {}, }, canActivate: [UserRouteAccessService, LocalVCGuard], }, @@ -262,9 +238,6 @@ export const routes: Routes = [ data: { authorities: [Authority.ADMIN, Authority.INSTRUCTOR, Authority.EDITOR, Authority.TA], pageTitle: 'artemisApp.repository.title', - flushRepositoryCacheAfter: 900000, // 15 min - participationCache: {}, - repositoryCache: {}, }, canActivate: [UserRouteAccessService, LocalVCGuard], }, @@ -274,9 +247,6 @@ export const routes: Routes = [ data: { authorities: [Authority.ADMIN, Authority.INSTRUCTOR], pageTitle: 'artemisApp.repository.title', - flushRepositoryCacheAfter: 900000, // 15 min - participationCache: {}, - repositoryCache: {}, }, canActivate: [UserRouteAccessService, LocalVCGuard], }, @@ -286,9 +256,6 @@ export const routes: Routes = [ data: { authorities: [Authority.ADMIN, Authority.INSTRUCTOR, Authority.EDITOR, Authority.TA], pageTitle: 'artemisApp.repository.title', - flushRepositoryCacheAfter: 900000, // 15 min - participationCache: {}, - repositoryCache: {}, }, canActivate: [UserRouteAccessService, LocalVCGuard], }, diff --git a/src/main/webapp/app/exercises/programming/participate/programming-participation-routing.module.ts b/src/main/webapp/app/exercises/programming/participate/programming-participation-routing.module.ts index e5ddddb415ab..55f0d5bd5a17 100644 --- a/src/main/webapp/app/exercises/programming/participate/programming-participation-routing.module.ts +++ b/src/main/webapp/app/exercises/programming/participate/programming-participation-routing.module.ts @@ -11,9 +11,6 @@ const routes: Routes = [ data: { authorities: [Authority.USER], pageTitle: 'artemisApp.editor.home.title', - flushRepositoryCacheAfter: 900000, // 15 min - participationCache: {}, - repositoryCache: {}, }, canActivate: [UserRouteAccessService], }, diff --git a/src/main/webapp/app/exercises/programming/participate/programming-repository-routing.module.ts b/src/main/webapp/app/exercises/programming/participate/programming-repository-routing.module.ts index 76697a6cd7d9..155f8ac14580 100644 --- a/src/main/webapp/app/exercises/programming/participate/programming-repository-routing.module.ts +++ b/src/main/webapp/app/exercises/programming/participate/programming-repository-routing.module.ts @@ -13,9 +13,6 @@ const routes: Routes = [ data: { authorities: [Authority.USER], pageTitle: 'artemisApp.repository.title', - flushRepositoryCacheAfter: 900000, // 15 min - participationCache: {}, - repositoryCache: {}, }, canActivate: [UserRouteAccessService, LocalVCGuard], }, @@ -25,9 +22,6 @@ const routes: Routes = [ data: { authorities: [Authority.USER], pageTitle: 'artemisApp.repository.commitHistory.title', - flushRepositoryCacheAfter: 900000, // 15 min - participationCache: {}, - repositoryCache: {}, }, canActivate: [UserRouteAccessService, LocalVCGuard], }, @@ -37,9 +31,6 @@ const routes: Routes = [ data: { authorities: [Authority.USER], pageTitle: 'artemisApp.repository.commitHistory.commitDetails.title', - flushRepositoryCacheAfter: 900000, // 15 min - participationCache: {}, - repositoryCache: {}, }, canActivate: [UserRouteAccessService, LocalVCGuard], }, @@ -49,9 +40,6 @@ const routes: Routes = [ data: { authorities: [Authority.ADMIN, Authority.INSTRUCTOR, Authority.TA], pageTitle: 'artemisApp.repository.title', - flushRepositoryCacheAfter: 900000, // 15 min - participationCache: {}, - repositoryCache: {}, }, canActivate: [UserRouteAccessService, LocalVCGuard], }, diff --git a/src/main/webapp/app/exercises/shared/dashboards/tutor/exercise-assessment-dashboard.component.html b/src/main/webapp/app/exercises/shared/dashboards/tutor/exercise-assessment-dashboard.component.html index 14c1ad054fb1..2ac0bbee41af 100644 --- a/src/main/webapp/app/exercises/shared/dashboards/tutor/exercise-assessment-dashboard.component.html +++ b/src/main/webapp/app/exercises/shared/dashboards/tutor/exercise-assessment-dashboard.component.html @@ -38,6 +38,7 @@ @if (exercise.isAtLeastInstructor && exam?.numberOfCorrectionRoundsInExam && exam?.numberOfCorrectionRoundsInExam! > 1 && isExamMode && !isTestRun) {
@if (messages?.length) {
- @if (suggestions?.length) { + @if ( + suggestions?.length && + userAccepted && + !this.isLoading && + this.active && + (!this.rateLimitInfo?.rateLimit || this.rateLimitInfo?.currentMessageCount !== this.rateLimitInfo?.rateLimit) && + !this.hasActiveStage + ) {
@for (suggestion of suggestions; track suggestion) { diff --git a/src/main/webapp/app/iris/exercise-chatbot/widget/chatbot-widget.component.html b/src/main/webapp/app/iris/exercise-chatbot/widget/chatbot-widget.component.html index 427b8bc2184a..9b19aeec3384 100644 --- a/src/main/webapp/app/iris/exercise-chatbot/widget/chatbot-widget.component.html +++ b/src/main/webapp/app/iris/exercise-chatbot/widget/chatbot-widget.component.html @@ -2,5 +2,6 @@
+
diff --git a/src/main/webapp/app/iris/exercise-chatbot/widget/chatbot-widget.component.scss b/src/main/webapp/app/iris/exercise-chatbot/widget/chatbot-widget.component.scss index 23ff98fcc30b..e27dec60427a 100644 --- a/src/main/webapp/app/iris/exercise-chatbot/widget/chatbot-widget.component.scss +++ b/src/main/webapp/app/iris/exercise-chatbot/widget/chatbot-widget.component.scss @@ -15,6 +15,13 @@ flex-direction: column; } +.chat-widget-top-resize-area { + position: absolute; + height: 5px; + width: 100%; + z-index: 10; +} + .ng-draggable { cursor: grab; } diff --git a/src/main/webapp/app/iris/exercise-chatbot/widget/chatbot-widget.component.ts b/src/main/webapp/app/iris/exercise-chatbot/widget/chatbot-widget.component.ts index be84a8d62c97..11d27fdd8d12 100644 --- a/src/main/webapp/app/iris/exercise-chatbot/widget/chatbot-widget.component.ts +++ b/src/main/webapp/app/iris/exercise-chatbot/widget/chatbot-widget.component.ts @@ -44,7 +44,7 @@ export class IrisChatbotWidgetComponent implements OnDestroy, AfterViewInit { interact('.chat-widget') .resizable({ // resize from all edges and corners - edges: { left: true, right: true, bottom: true, top: true }, + edges: { left: true, right: true, bottom: true, top: '.chat-widget-top-resize-area' }, listeners: { move: (event) => { @@ -85,6 +85,7 @@ export class IrisChatbotWidgetComponent implements OnDestroy, AfterViewInit { inertia: true, }) .draggable({ + allowFrom: '.chat-header', listeners: { move: (event: any) => { const target = event.target, diff --git a/src/main/webapp/app/lecture/lecture-attachments.component.html b/src/main/webapp/app/lecture/lecture-attachments.component.html index 6192f270d64e..82050a7c3efe 100644 --- a/src/main/webapp/app/lecture/lecture-attachments.component.html +++ b/src/main/webapp/app/lecture/lecture-attachments.component.html @@ -44,7 +44,6 @@

- @@ -53,25 +52,25 @@

@for (attachment of attachments; track trackId($index, attachment)) { - + {{ attachment.id }} - {{ attachment.name }} - {{ attachment.attachmentType }} @if (!isDownloadingAttachmentLink) {
{{ attachment.name }} - } - @if (isDownloadingAttachmentLink === attachment.link) { + } @else if (isDownloadingAttachmentLink === attachment.link) { {{ 'artemisApp.courseOverview.lectureDetails.isDownloading' | artemisTranslate }} + } @else { + {{ attachment.name }} } + {{ attachment.attachmentType }} {{ attachment.releaseDate | artemisDate }} {{ attachment.uploadDate | artemisDate }} @@ -91,7 +90,7 @@

}

@if (lecture().isAtLeastInstructor) {

} - @if (attachmentToBeCreated?.id === attachment?.id) { + @if (attachmentToBeUpdatedOrCreated()?.id === attachment?.id) {
} @@ -125,11 +124,11 @@

} - @if (attachmentToBeCreated) { -
+ @if (attachmentToBeUpdatedOrCreated()) { +
- @if (!attachmentToBeCreated.id) { + @if (!attachmentToBeUpdatedOrCreated()!.id) {

} @else {

@@ -140,11 +139,11 @@

- +
@@ -175,22 +177,17 @@

}
- +
- @if (attachmentToBeCreated.id) { + @if (attachmentToBeUpdatedOrCreated()!.id) {
} @@ -198,7 +195,7 @@

- @@ -207,9 +204,8 @@

-
- } - @if (!attachmentToBeCreated) { + + } @else {
-
- @if (lecture.course?.id && showCompetencies) { - - } - @if (viewButtonAvailable[lectureUnit.id!]) { - + @if (lecture.course?.id && showCompetencies) { + + } +
- + + +
+ } @else { +
+ @if (lecture.course?.id && showCompetencies) { + + } +
+ } + @if (viewButtonAvailable[lectureUnit.id!]) { + + + + }
@if (this.emitEditEvents) { @if (editButtonAvailable(lectureUnit)) { diff --git a/src/main/webapp/app/lecture/lecture-unit/lecture-unit-management/lecture-unit-management.component.ts b/src/main/webapp/app/lecture/lecture-unit/lecture-unit-management/lecture-unit-management.component.ts index dd3897bf0318..6ca545a7881c 100644 --- a/src/main/webapp/app/lecture/lecture-unit/lecture-unit-management/lecture-unit-management.component.ts +++ b/src/main/webapp/app/lecture/lecture-unit/lecture-unit-management/lecture-unit-management.component.ts @@ -10,10 +10,13 @@ import { onError } from 'app/shared/util/global.utils'; import { Subject, Subscription } from 'rxjs'; import { LectureUnitService } from 'app/lecture/lecture-unit/lecture-unit-management/lectureUnit.service'; import { ActionType } from 'app/shared/delete-dialog/delete-dialog.model'; -import { AttachmentUnit } from 'app/entities/lecture-unit/attachmentUnit.model'; +import { AttachmentUnit, IngestionState } from 'app/entities/lecture-unit/attachmentUnit.model'; import { ExerciseUnit } from 'app/entities/lecture-unit/exerciseUnit.model'; -import { faEye, faPencilAlt, faTrash } from '@fortawesome/free-solid-svg-icons'; +import { IconDefinition, faCheckCircle, faEye, faFileExport, faPencilAlt, faRepeat, faSpinner, faTrash } from '@fortawesome/free-solid-svg-icons'; import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'; +import { PROFILE_IRIS } from 'app/app.constants'; +import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; +import { IrisSettingsService } from 'app/iris/settings/shared/iris-settings.service'; @Component({ selector: 'jhi-lecture-unit-management', @@ -43,7 +46,9 @@ export class LectureUnitManagementComponent implements OnInit, OnDestroy { readonly ActionType = ActionType; private dialogErrorSource = new Subject(); dialogError$ = this.dialogErrorSource.asObservable(); - + private profileInfoSubscription: Subscription; + irisEnabled = false; + lectureIngestionEnabled = false; routerEditLinksBase: { [key: string]: string } = { [LectureUnitType.ATTACHMENT]: 'attachment-units', [LectureUnitType.VIDEO]: 'video-units', @@ -52,9 +57,13 @@ export class LectureUnitManagementComponent implements OnInit, OnDestroy { }; // Icons - faTrash = faTrash; - faPencilAlt = faPencilAlt; - faEye = faEye; + readonly faTrash = faTrash; + readonly faPencilAlt = faPencilAlt; + readonly faEye = faEye; + readonly faFileExport = faFileExport; + readonly faRepeat = faRepeat; + readonly faCheckCircle = faCheckCircle; + readonly faSpinner = faSpinner; constructor( private activatedRoute: ActivatedRoute, @@ -62,6 +71,8 @@ export class LectureUnitManagementComponent implements OnInit, OnDestroy { private lectureService: LectureService, private alertService: AlertService, public lectureUnitService: LectureUnitService, + private profileService: ProfileService, + private irisSettingsService: IrisSettingsService, ) {} ngOnDestroy(): void { @@ -69,6 +80,7 @@ export class LectureUnitManagementComponent implements OnInit, OnDestroy { this.updateOrderSubjectSubscription.unsubscribe(); this.dialogErrorSource.unsubscribe(); this.navigationEndSubscription.unsubscribe(); + this.profileInfoSubscription?.unsubscribe(); } ngOnInit(): void { @@ -111,6 +123,10 @@ export class LectureUnitManagementComponent implements OnInit, OnDestroy { this.lectureUnits.forEach((lectureUnit) => { this.viewButtonAvailable[lectureUnit.id!] = this.isViewButtonAvailable(lectureUnit); }); + this.initializeProfileInfo(); + if (this.lectureIngestionEnabled) { + this.updateIngestionStates(); + } } else { this.lectureUnits = []; } @@ -132,6 +148,17 @@ export class LectureUnitManagementComponent implements OnInit, OnDestroy { }); } + initializeProfileInfo() { + this.profileInfoSubscription = this.profileService.getProfileInfo().subscribe(async (profileInfo) => { + this.irisEnabled = profileInfo.activeProfiles.includes(PROFILE_IRIS); + if (this.irisEnabled && this.lecture.course && this.lecture.course.id) { + this.irisSettingsService.getCombinedCourseSettings(this.lecture.course.id).subscribe((settings) => { + this.lectureIngestionEnabled = settings?.irisLectureIngestionSettings?.enabled || false; + }); + } + }); + } + drop(event: CdkDragDrop) { moveItemInArray(this.lectureUnits, event.previousIndex, event.currentIndex); this.updateOrderSubject.next(''); @@ -239,4 +266,66 @@ export class LectureUnitManagementComponent implements OnInit, OnDestroy { return undefined; } } + + /** + * Fetches the ingestion state for each lecture unit asynchronously and updates the lecture unit object. + */ + updateIngestionStates() { + this.lectureUnitService.getIngestionState(this.lecture.course!.id!, this.lecture.id!).subscribe({ + next: (res: HttpResponse>) => { + if (res.body) { + const ingestionStatesMap = res.body; + this.lectureUnits.forEach((lectureUnit) => { + if (lectureUnit.id) { + const ingestionState = ingestionStatesMap[lectureUnit.id]; + if (ingestionState !== undefined) { + (lectureUnit).pyrisIngestionState = ingestionState; + } + } + }); + } + }, + error: (err: HttpErrorResponse) => { + console.error(`Error fetching ingestion states for lecture ${this.lecture.id}`, err); + this.alertService.error('artemisApp.iris.ingestionAlert.pyrisError'); + }, + }); + } + + onIngestButtonClicked(lectureUnitId: number) { + const unitIndex: number = this.lectureUnits.findIndex((unit) => unit.id === lectureUnitId); + if (unitIndex > -1) { + const unit: AttachmentUnit = this.lectureUnits[unitIndex]; + unit.pyrisIngestionState = IngestionState.IN_PROGRESS; + this.lectureUnits[unitIndex] = unit; + } + this.lectureUnitService.ingestLectureUnitInPyris(lectureUnitId, this.lecture.id!).subscribe({ + next: () => this.alertService.success('artemisApp.iris.ingestionAlert.lectureUnitSuccess'), + error: (error) => { + if (error.status === 400) { + this.alertService.error('artemisApp.iris.ingestionAlert.lectureUnitError'); + } else if (error.status === 503) { + this.alertService.error('artemisApp.iris.ingestionAlert.pyrisUnavailable'); + } else { + this.alertService.error('artemisApp.iris.ingestionAlert.pyrisError'); + } + console.error('Failed to send lecture unit ingestion request', error); + }, + }); + } + + getIcon(attachmentUnit: AttachmentUnit): IconDefinition { + switch (attachmentUnit.pyrisIngestionState) { + case IngestionState.NOT_STARTED: + return this.faFileExport; + case IngestionState.IN_PROGRESS: + return this.faSpinner; + case IngestionState.DONE: + return this.faCheckCircle; + case IngestionState.ERROR: + return this.faRepeat; + default: + return this.faFileExport; + } + } } diff --git a/src/main/webapp/app/lecture/lecture-unit/lecture-unit-management/lectureUnit.service.ts b/src/main/webapp/app/lecture/lecture-unit/lecture-unit-management/lectureUnit.service.ts index 2825fed950e2..053b29704369 100644 --- a/src/main/webapp/app/lecture/lecture-unit/lecture-unit-management/lectureUnit.service.ts +++ b/src/main/webapp/app/lecture/lecture-unit/lecture-unit-management/lectureUnit.service.ts @@ -7,7 +7,7 @@ import { onError } from 'app/shared/util/global.utils'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { Injectable } from '@angular/core'; -import { AttachmentUnit } from 'app/entities/lecture-unit/attachmentUnit.model'; +import { AttachmentUnit, IngestionState } from 'app/entities/lecture-unit/attachmentUnit.model'; import { AttachmentService } from 'app/lecture/attachment.service'; import { ExerciseUnit } from 'app/entities/lecture-unit/exerciseUnit.model'; import { ExerciseService } from 'app/exercises/shared/exercise/exercise.service'; @@ -167,4 +167,28 @@ export class LectureUnitService { getLectureUnitById(lectureUnitId: number): Observable { return this.httpClient.get(`${this.resourceURL}/lecture-units/${lectureUnitId}`); } + /** + * Fetch the actual ingestion state for all lecture units from an external service (e.g., Pyris). + * @param courseId + * @param lectureId ID of the lecture + * @returns Observable with the ingestion state + */ + getIngestionState(courseId: number, lectureId: number): Observable>> { + return this.httpClient.get>(`${this.resourceURL}/iris/courses/${courseId}/lectures/${lectureId}/lecture-units/ingestion-state`, { + observe: 'response', + }); + } + + /** + * Triggers the ingestion of one lecture unit. + * + * @param lectureUnitId - The ID of the lecture unit to be ingested. + * @param lectureId - The ID of the lecture to which the unit belongs. + * @returns An Observable with an HttpResponse 200 if the request was successful . + */ + ingestLectureUnitInPyris(lectureUnitId: number, lectureId: number): Observable> { + return this.httpClient.post(`${this.resourceURL}/lectures/${lectureId}/lecture-units/${lectureUnitId}/ingest`, null, { + observe: 'response', + }); + } } diff --git a/src/main/webapp/app/lecture/lecture.component.html b/src/main/webapp/app/lecture/lecture.component.html index 68a82448b85e..51e581861e0f 100644 --- a/src/main/webapp/app/lecture/lecture.component.html +++ b/src/main/webapp/app/lecture/lecture.component.html @@ -118,6 +118,12 @@

+ @if (lectureIngestionEnabled) { + + + + + } @@ -134,6 +140,30 @@

{{ lecture.visibleDate | artemisDate }} {{ lecture.startDate | artemisDate }} {{ lecture.endDate | artemisDate }} + @if (lectureIngestionEnabled) { + + @switch (lecture.ingested) { + @case (IngestionState.NOT_STARTED) { + + } + @case (IngestionState.IN_PROGRESS) { + + } + @case (IngestionState.PARTIALLY_INGESTED) { + + } + @case (IngestionState.DONE) { + + } + @case (IngestionState.ERROR) { + + } + @default { + + } + } + + }
diff --git a/src/main/webapp/app/lecture/lecture.component.ts b/src/main/webapp/app/lecture/lecture.component.ts index 6f4d832c5167..19c4dc417eb1 100644 --- a/src/main/webapp/app/lecture/lecture.component.ts +++ b/src/main/webapp/app/lecture/lecture.component.ts @@ -16,6 +16,7 @@ import { Subject, Subscription } from 'rxjs'; import { DocumentationType } from 'app/shared/components/documentation-button/documentation-button.component'; import { SortService } from 'app/shared/service/sort.service'; import { IrisSettingsService } from 'app/iris/settings/shared/iris-settings.service'; +import { IngestionState } from 'app/entities/lecture-unit/attachmentUnit.model'; export enum LectureDateFilter { PAST = 'filterPast', @@ -44,6 +45,7 @@ export class LectureComponent implements OnInit, OnDestroy { readonly filterType = LectureDateFilter; readonly documentationType: DocumentationType = 'Lecture'; + readonly ingestionState: IngestionState; // Icons faPlus = faPlus; @@ -57,6 +59,8 @@ export class LectureComponent implements OnInit, OnDestroy { faSort = faSort; lectureIngestionEnabled = false; + protected readonly IngestionState = IngestionState; + private profileInfoSubscription: Subscription; constructor( @@ -75,7 +79,6 @@ export class LectureComponent implements OnInit, OnDestroy { ngOnInit() { this.courseId = Number(this.route.snapshot.paramMap.get('courseId')); - this.loadAll(); this.profileInfoSubscription = this.profileService.getProfileInfo().subscribe(async (profileInfo) => { this.irisEnabled = profileInfo.activeProfiles.includes(PROFILE_IRIS); if (this.irisEnabled) { @@ -84,6 +87,7 @@ export class LectureComponent implements OnInit, OnDestroy { }); } }); + this.loadAll(); } ngOnDestroy(): void { @@ -150,14 +154,21 @@ export class LectureComponent implements OnInit, OnDestroy { private loadAll() { this.lectureService - .findAllByCourseId(this.courseId) + .findAllByCourseIdWithSlides(this.courseId) .pipe( filter((res: HttpResponse) => res.ok), map((res: HttpResponse) => res.body), ) .subscribe({ next: (res: Lecture[]) => { - this.lectures = res; + this.lectures = res.map((lectureData) => { + const lecture = new Lecture(); + Object.assign(lecture, lectureData); + return lecture; + }); + if (this.lectureIngestionEnabled) { + this.updateIngestionStates(); + } this.applyFilters(); }, error: (res: HttpErrorResponse) => onError(this.alertService, res), @@ -208,8 +219,37 @@ export class LectureComponent implements OnInit, OnDestroy { ingestLecturesInPyris() { if (this.lectures.first()) { this.lectureService.ingestLecturesInPyris(this.lectures.first()!.course!.id!).subscribe({ - error: (error) => console.error('Failed to send Ingestion request', error), + next: () => this.alertService.success('artemisApp.iris.ingestionAlert.allLecturesSuccess'), + error: (error) => { + this.alertService.error('artemisApp.iris.ingestionAlert.allLecturesError'); + console.error('Failed to send Lectures Ingestion request', error); + }, }); } } + + /** + * Fetches the ingestion state for all lecture asynchronously and updates all the lectures ingestion state. + */ + updateIngestionStates() { + this.lectureService.getIngestionState(this.courseId).subscribe({ + next: (res: HttpResponse>) => { + if (res.body) { + const ingestionStatesMap = res.body; + this.lectures.forEach((lecture) => { + if (lecture.id) { + const ingestionState = ingestionStatesMap[lecture.id]; + if (ingestionState !== undefined) { + lecture.ingested = ingestionState; + } + } + }); + } + }, + error: (err: HttpErrorResponse) => { + console.error(`Error fetching ingestion state for lecture in course ${this.courseId}`, err); + this.alertService.error('artemisApp.iris.ingestionAlert.pyrisError'); + }, + }); + } } diff --git a/src/main/webapp/app/lecture/lecture.service.ts b/src/main/webapp/app/lecture/lecture.service.ts index 7e1259860fa3..e6b508e6695a 100644 --- a/src/main/webapp/app/lecture/lecture.service.ts +++ b/src/main/webapp/app/lecture/lecture.service.ts @@ -8,6 +8,7 @@ import { AccountService } from 'app/core/auth/account.service'; import { LectureUnitService } from 'app/lecture/lecture-unit/lecture-unit-management/lectureUnit.service'; import { convertDateFromClient, convertDateFromServer } from 'app/utils/date.utils'; import { EntityTitleService, EntityType } from 'app/shared/layouts/navbar/entity-title.service'; +import { IngestionState } from 'app/entities/lecture-unit/attachmentUnit.model'; type EntityResponseType = HttpResponse; type EntityArrayResponseType = HttpResponse; @@ -117,17 +118,23 @@ export class LectureService { * @param courseId Course containing the lecture(s) * @param lectureId The lecture to be ingested in pyris */ - ingestLecturesInPyris(courseId: number, lectureId?: number): Observable> { + ingestLecturesInPyris(courseId: number, lectureId?: number): Observable> { let params = new HttpParams(); if (lectureId !== undefined) { params = params.set('lectureId', lectureId.toString()); } - - return this.http.post(`api/courses/${courseId}/ingest`, null, { + return this.http.post(`api/courses/${courseId}/ingest`, null, { params: params, observe: 'response', }); } + /** + * Fetch the ingestion state of all the lectures inside the course specified + * @param courseId + */ + getIngestionState(courseId: number): Observable>> { + return this.http.get>(`api/iris/courses/${courseId}/lectures/ingestion-state`, { observe: 'response' }); + } /** * Clones and imports the lecture to the course * diff --git a/src/main/webapp/app/lecture/pdf-preview/pdf-preview-enlarged-canvas/pdf-preview-enlarged-canvas.component.html b/src/main/webapp/app/lecture/pdf-preview/pdf-preview-enlarged-canvas/pdf-preview-enlarged-canvas.component.html new file mode 100644 index 000000000000..f5dd4f631994 --- /dev/null +++ b/src/main/webapp/app/lecture/pdf-preview/pdf-preview-enlarged-canvas/pdf-preview-enlarged-canvas.component.html @@ -0,0 +1,18 @@ +
+ @if (isEnlargedCanvasLoading()) { +
+
+ +
+
+ } + + + @if (currentPage() !== 1) { + + } + @if (currentPage() !== totalPages()) { + + } +
{{ currentPage() }}
+
diff --git a/src/main/webapp/app/lecture/pdf-preview/pdf-preview-enlarged-canvas/pdf-preview-enlarged-canvas.component.scss b/src/main/webapp/app/lecture/pdf-preview/pdf-preview-enlarged-canvas/pdf-preview-enlarged-canvas.component.scss new file mode 100644 index 000000000000..333c12462b49 --- /dev/null +++ b/src/main/webapp/app/lecture/pdf-preview/pdf-preview-enlarged-canvas/pdf-preview-enlarged-canvas.component.scss @@ -0,0 +1,62 @@ +.enlarged-container { + position: absolute; + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + background-color: var(--pdf-preview-enlarged-container-overlay); + z-index: 5; + + .btn-close { + position: absolute; + top: 10px; + right: 10px; + cursor: pointer; + color: var(--bs-body-color); + } +} + +.nav-button { + position: absolute; + transform: translateY(-50%); + cursor: pointer; + border-radius: 50%; + width: 30px; + height: 30px; + display: flex; + justify-content: center; + align-items: center; + font-size: 20px; + z-index: 3; +} + +.nav-button.left { + left: calc(5% + 10px); + + @media (max-width: 1200px) { + left: 10px; + } +} + +.nav-button.right { + right: calc(5% + 10px); + + @media (max-width: 1200px) { + right: 10px; + } +} + +.page-number-display { + position: absolute; + bottom: 10px; + right: calc(5% + 10px); + font-size: 18px; + color: var(--bs-body-color); + z-index: 2; + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + + @media (max-width: 1200px) { + right: 10px; + } +} diff --git a/src/main/webapp/app/lecture/pdf-preview/pdf-preview-enlarged-canvas/pdf-preview-enlarged-canvas.component.ts b/src/main/webapp/app/lecture/pdf-preview/pdf-preview-enlarged-canvas/pdf-preview-enlarged-canvas.component.ts new file mode 100644 index 000000000000..789aba78aefb --- /dev/null +++ b/src/main/webapp/app/lecture/pdf-preview/pdf-preview-enlarged-canvas/pdf-preview-enlarged-canvas.component.ts @@ -0,0 +1,225 @@ +import { Component, ElementRef, HostListener, effect, input, output, signal, viewChild } from '@angular/core'; +import { ArtemisSharedModule } from 'app/shared/shared.module'; + +type NavigationDirection = 'next' | 'prev'; + +@Component({ + selector: 'jhi-pdf-preview-enlarged-canvas-component', + templateUrl: './pdf-preview-enlarged-canvas.component.html', + styleUrls: ['./pdf-preview-enlarged-canvas.component.scss'], + standalone: true, + imports: [ArtemisSharedModule], +}) +export class PdfPreviewEnlargedCanvasComponent { + enlargedContainer = viewChild.required>('enlargedContainer'); + enlargedCanvas = viewChild.required>('enlargedCanvas'); + + readonly DEFAULT_ENLARGED_SLIDE_HEIGHT = 800; + + // Inputs + pdfContainer = input.required(); + originalCanvas = input(); + totalPages = input(0); + + // Signals + currentPage = signal(1); + isEnlargedCanvasLoading = signal(false); + + //Outputs + isEnlargedViewOutput = output(); + + constructor() { + effect( + () => { + this.enlargedContainer().nativeElement.style.top = `${this.pdfContainer().scrollTop}px`; + this.displayEnlargedCanvas(this.originalCanvas()!); + }, + { allowSignalWrites: true }, + ); + } + + /** + * Handles navigation within the PDF viewer using keyboard arrow keys. + * @param event - The keyboard event captured for navigation. + */ + @HostListener('document:keydown', ['$event']) + handleKeyboardEvents(event: KeyboardEvent) { + if (event.key === 'ArrowRight' && this.currentPage() < this.totalPages()) { + this.navigatePages('next'); + } else if (event.key === 'ArrowLeft' && this.currentPage() > 1) { + this.navigatePages('prev'); + } + } + + /** + * Adjusts the canvas size based on the window resize event to ensure proper display. + */ + @HostListener('window:resize') + resizeCanvasBasedOnContainer() { + this.adjustCanvasSize(); + } + + /** + * Dynamically updates the canvas size within an enlarged view based on the viewport. + */ + adjustCanvasSize = () => { + const canvasElements = this.pdfContainer().querySelectorAll('.pdf-canvas-container canvas'); + if (this.currentPage() - 1 < canvasElements.length) { + const canvas = canvasElements[this.currentPage() - 1] as HTMLCanvasElement; + this.updateEnlargedCanvas(canvas); + } + }; + + displayEnlargedCanvas(originalCanvas: HTMLCanvasElement) { + this.isEnlargedCanvasLoading.set(true); + this.currentPage.set(Number(originalCanvas.id)); + this.toggleBodyScroll(true); + setTimeout(() => { + this.updateEnlargedCanvas(originalCanvas); + }, 500); + } + + /** + * Updates the enlarged canvas dimensions to optimize PDF page display within the current viewport. + * This method dynamically adjusts the size, position, and scale of the canvas to maintain the aspect ratio, + * ensuring the content is centered and displayed appropriately within the available space. + * It is called within an animation frame to synchronize updates with the browser's render cycle for smooth visuals. + * + * @param originalCanvas - The source canvas element used to extract image data for resizing and redrawing. + */ + updateEnlargedCanvas(originalCanvas: HTMLCanvasElement) { + requestAnimationFrame(() => { + const isVertical = originalCanvas.height > originalCanvas.width; + this.adjustPdfContainerSize(isVertical); + + const scaleFactor = this.calculateScaleFactor(originalCanvas); + this.resizeCanvas(originalCanvas, scaleFactor); + this.redrawCanvas(originalCanvas); + this.isEnlargedCanvasLoading.set(false); + }); + } + + /** + * Calculates the scaling factor to adjust the canvas size based on the dimensions of the container. + * This method ensures that the canvas is scaled to fit within the container without altering the aspect ratio. + * + * @param originalCanvas - The original canvas element representing the PDF page. + * @returns The scaling factor used to resize the original canvas to fit within the container dimensions. + */ + calculateScaleFactor(originalCanvas: HTMLCanvasElement): number { + const containerWidth = this.pdfContainer().clientWidth; + const containerHeight = this.pdfContainer().clientHeight; + + let scaleX, scaleY; + + if (originalCanvas.height > originalCanvas.width) { + // Vertical slide + const fixedHeight = this.DEFAULT_ENLARGED_SLIDE_HEIGHT; + scaleY = fixedHeight / originalCanvas.height; + scaleX = containerWidth / originalCanvas.width; + } else { + // Horizontal slide + scaleX = containerWidth / originalCanvas.width; + scaleY = containerHeight / originalCanvas.height; + } + + return Math.min(scaleX, scaleY); + } + + /** + * Resizes the canvas according to the computed scale factor. + * This method updates the dimensions of the enlarged canvas element to ensure that the entire PDF page + * is visible and properly scaled within the viewer. + * + * @param originalCanvas - The canvas element from which the image is scaled. + * @param scaleFactor - The factor by which the canvas is resized. + */ + resizeCanvas(originalCanvas: HTMLCanvasElement, scaleFactor: number): void { + const enlargedCanvas = this.enlargedCanvas().nativeElement; + enlargedCanvas.width = originalCanvas.width * scaleFactor; + enlargedCanvas.height = originalCanvas.height * scaleFactor; + } + + /** + * Redraws the original canvas content onto the enlarged canvas at the updated scale. + * This method ensures that the image is rendered clearly and correctly positioned on the enlarged canvas. + * + * @param originalCanvas - The original canvas containing the image to be redrawn. + */ + redrawCanvas(originalCanvas: HTMLCanvasElement): void { + const enlargedCanvas = this.enlargedCanvas().nativeElement; + const context = enlargedCanvas.getContext('2d'); + context!.clearRect(0, 0, enlargedCanvas.width, enlargedCanvas.height); + context!.drawImage(originalCanvas, 0, 0, enlargedCanvas.width, enlargedCanvas.height); + } + + /** + * Adjusts the size of the PDF container based on whether the enlarged view is active or not. + * If the enlarged view is active, the container's size is reduced to focus on the enlarged content. + * If the enlarged view is closed, the container returns to its original size. + * + * @param isVertical A boolean flag indicating whether to enlarge or reset the container size. + */ + adjustPdfContainerSize(isVertical: boolean): void { + const pdfContainer = this.pdfContainer(); + if (isVertical) { + pdfContainer.style.height = `${this.DEFAULT_ENLARGED_SLIDE_HEIGHT}px`; + } else { + pdfContainer.style.height = '60vh'; + } + } + + /** + * Toggles the ability to scroll through the PDF container. + * @param disable A boolean flag indicating whether scrolling should be disabled (`true`) or enabled (`false`). + */ + toggleBodyScroll(disable: boolean): void { + this.pdfContainer().style.overflow = disable ? 'hidden' : 'auto'; + } + + /** + * Closes the enlarged view of the PDF and re-enables scrolling in the PDF container. + */ + closeEnlargedView(event: MouseEvent) { + this.isEnlargedViewOutput.emit(false); + this.adjustPdfContainerSize(false); + this.toggleBodyScroll(false); + event.stopPropagation(); + } + + /** + * Closes the enlarged view if a click event occurs outside the actual canvas area but within the enlarged container. + * @param event The mouse event captured, used to determine the location of the click. + */ + closeIfOutside(event: MouseEvent): void { + const target = event.target as HTMLElement; + const enlargedCanvas = this.enlargedCanvas().nativeElement; + + if (target.classList.contains('enlarged-container') && target !== enlargedCanvas) { + this.closeEnlargedView(event); + } + } + + /** + * Handles navigation between PDF pages and stops event propagation to prevent unwanted side effects. + * @param direction The direction to navigate. + * @param event The MouseEvent to be stopped. + */ + handleNavigation(direction: NavigationDirection, event: MouseEvent): void { + event.stopPropagation(); + this.navigatePages(direction); + } + + /** + * Navigates to a specific page in the PDF based on the direction relative to the current page. + * @param direction The navigation direction (next or previous). + */ + navigatePages(direction: NavigationDirection) { + const nextPageIndex = direction === 'next' ? this.currentPage() + 1 : this.currentPage() - 1; + if (nextPageIndex > 0 && nextPageIndex <= this.totalPages()) { + this.currentPage.set(nextPageIndex); + const canvas = this.pdfContainer().querySelectorAll('.pdf-canvas-container canvas')[this.currentPage() - 1] as HTMLCanvasElement; + this.updateEnlargedCanvas(canvas); + } + } +} diff --git a/src/main/webapp/app/lecture/pdf-preview/pdf-preview-thumbnail-grid/pdf-preview-thumbnail-grid.component.html b/src/main/webapp/app/lecture/pdf-preview/pdf-preview-thumbnail-grid/pdf-preview-thumbnail-grid.component.html new file mode 100644 index 000000000000..01ca1b9617a2 --- /dev/null +++ b/src/main/webapp/app/lecture/pdf-preview/pdf-preview-thumbnail-grid/pdf-preview-thumbnail-grid.component.html @@ -0,0 +1,11 @@ +
+ @if (isEnlargedView()) { + + } +
diff --git a/src/main/webapp/app/lecture/pdf-preview/pdf-preview-thumbnail-grid/pdf-preview-thumbnail-grid.component.scss b/src/main/webapp/app/lecture/pdf-preview/pdf-preview-thumbnail-grid/pdf-preview-thumbnail-grid.component.scss new file mode 100644 index 000000000000..cbe9131ec39c --- /dev/null +++ b/src/main/webapp/app/lecture/pdf-preview/pdf-preview-thumbnail-grid/pdf-preview-thumbnail-grid.component.scss @@ -0,0 +1,26 @@ +.pdf-container { + position: relative; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); + gap: 10px; + height: 60vh; + overflow-y: auto; + border: 1px solid var(--border-color); + padding: 10px; + margin: 10px 0; + width: 100%; + box-shadow: 0 2px 5px var(--pdf-preview-pdf-container-shadow); + z-index: 0; + + @media (max-width: 800px) { + grid-template-columns: repeat(auto-fit, minmax(270px, 1fr)); + } + + @media (max-width: 500px) { + grid-template-columns: 1fr; + } +} + +.enlarged-canvas { + display: contents; +} diff --git a/src/main/webapp/app/lecture/pdf-preview/pdf-preview-thumbnail-grid/pdf-preview-thumbnail-grid.component.ts b/src/main/webapp/app/lecture/pdf-preview/pdf-preview-thumbnail-grid/pdf-preview-thumbnail-grid.component.ts new file mode 100644 index 000000000000..84a220ad8cba --- /dev/null +++ b/src/main/webapp/app/lecture/pdf-preview/pdf-preview-thumbnail-grid/pdf-preview-thumbnail-grid.component.ts @@ -0,0 +1,194 @@ +import { Component, ElementRef, effect, inject, input, output, signal, viewChild } from '@angular/core'; +import * as PDFJS from 'pdfjs-dist'; +import 'pdfjs-dist/build/pdf.worker'; +import { ArtemisSharedModule } from 'app/shared/shared.module'; +import { onError } from 'app/shared/util/global.utils'; +import { AlertService } from 'app/core/util/alert.service'; +import { PdfPreviewEnlargedCanvasComponent } from 'app/lecture/pdf-preview/pdf-preview-enlarged-canvas/pdf-preview-enlarged-canvas.component'; + +@Component({ + selector: 'jhi-pdf-preview-thumbnail-grid-component', + templateUrl: './pdf-preview-thumbnail-grid.component.html', + styleUrls: ['./pdf-preview-thumbnail-grid.component.scss'], + standalone: true, + imports: [ArtemisSharedModule, PdfPreviewEnlargedCanvasComponent], +}) +export class PdfPreviewThumbnailGridComponent { + pdfContainer = viewChild.required>('pdfContainer'); + + readonly DEFAULT_SLIDE_WIDTH = 250; + + // Inputs + currentPdfUrl = input(); + appendFile = input(); + + // Signals + isEnlargedView = signal(false); + totalPages = signal(0); + selectedPages = signal>(new Set()); + originalCanvas = signal(undefined); + + // Outputs + isPdfLoading = output(); + totalPagesOutput = output(); + selectedPagesOutput = output>(); + + // Injected services + private readonly alertService = inject(AlertService); + + constructor() { + effect( + () => { + this.loadOrAppendPdf(this.currentPdfUrl()!, this.appendFile()); + }, + { allowSignalWrites: true }, + ); + } + + /** + * Loads or appends a PDF from a provided URL. + * @param fileUrl The URL of the file to load or append. + * @param append Whether the document should be appended to the existing one. + * @returns A promise that resolves when the PDF is loaded. + */ + async loadOrAppendPdf(fileUrl: string, append = false): Promise { + this.pdfContainer() + .nativeElement.querySelectorAll('.pdf-canvas-container') + .forEach((canvas) => canvas.remove()); + this.totalPages.set(0); + this.isPdfLoading.emit(true); + try { + const loadingTask = PDFJS.getDocument(fileUrl); + const pdf = await loadingTask.promise; + this.totalPages.set(pdf.numPages); + + for (let i = 1; i <= this.totalPages(); i++) { + const page = await pdf.getPage(i); + const viewport = page.getViewport({ scale: 2 }); + const canvas = this.createCanvas(viewport, i); + const context = canvas.getContext('2d'); + await page.render({ canvasContext: context!, viewport }).promise; + + const canvasContainer = this.createCanvasContainer(canvas, i); + this.pdfContainer().nativeElement.appendChild(canvasContainer); + } + + if (append) { + this.scrollToBottom(); + } + } catch (error) { + onError(this.alertService, error); + } finally { + this.totalPagesOutput.emit(this.totalPages()); + this.isPdfLoading.emit(false); + } + } + + /** + * Scrolls the PDF container to the bottom after appending new pages. + */ + scrollToBottom(): void { + const scrollOptions: ScrollToOptions = { + top: this.pdfContainer().nativeElement.scrollHeight, + left: 0, + behavior: 'smooth' as ScrollBehavior, + }; + this.pdfContainer().nativeElement.scrollTo(scrollOptions); + } + + /** + * Creates a canvas for each page of the PDF to allow for individual page rendering. + * @param viewport The viewport settings used for rendering the page. + * @param pageIndex The index of the page within the PDF document. + * @returns A new HTMLCanvasElement configured for the PDF page. + */ + createCanvas(viewport: PDFJS.PageViewport, pageIndex: number): HTMLCanvasElement { + const canvas = document.createElement('canvas'); + canvas.id = `${pageIndex}`; + /* Canvas styling is predefined because Canvas tags do not support CSS classes + * as they are not HTML elements but rather a bitmap drawing surface. + * See: https://stackoverflow.com/a/29675448 + * */ + canvas.height = viewport.height; + canvas.width = viewport.width; + const fixedWidth = this.DEFAULT_SLIDE_WIDTH; + const scaleFactor = fixedWidth / viewport.width; + canvas.style.width = `${fixedWidth}px`; + canvas.style.height = `${viewport.height * scaleFactor}px`; + return canvas; + } + + /** + * Creates a container div for each canvas, facilitating layering and interaction. + * @param canvas The canvas element that displays a PDF page. + * @param pageIndex The index of the page within the PDF document. + * @returns A configured div element that includes the canvas and interactive overlays. + */ + createCanvasContainer(canvas: HTMLCanvasElement, pageIndex: number): HTMLDivElement { + const container = document.createElement('div'); + /* Dynamically created elements are not detected by DOM, that is why we need to set the styles manually. + * See: https://stackoverflow.com/a/70911189 + */ + container.id = `pdf-page-${pageIndex}`; + container.classList.add('pdf-canvas-container'); + container.style.cssText = `position: relative; display: inline-block; width: ${canvas.style.width}; height: ${canvas.style.height}; margin: 20px; box-shadow: 0 2px 6px var(--pdf-preview-canvas-shadow);`; + + const overlay = this.createOverlay(pageIndex); + const checkbox = this.createCheckbox(pageIndex); + container.appendChild(canvas); + container.appendChild(overlay); + container.appendChild(checkbox); + + container.addEventListener('mouseenter', () => { + overlay.style.opacity = '1'; + }); + container.addEventListener('mouseleave', () => { + overlay.style.opacity = '0'; + }); + overlay.addEventListener('click', () => this.displayEnlargedCanvas(canvas)); + + return container; + } + + /** + * Generates an interactive overlay for each PDF page to allow for user interactions. + * @param pageIndex The index of the page. + * @returns A div element styled as an overlay. + */ + private createOverlay(pageIndex: number): HTMLDivElement { + const overlay = document.createElement('div'); + overlay.innerHTML = `${pageIndex}`; + /* Dynamically created elements are not detected by DOM, that is why we need to set the styles manually. + * See: https://stackoverflow.com/a/70911189 + */ + overlay.style.cssText = `position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; justify-content: center; align-items: center; font-size: 24px; color: white; z-index: 1; transition: opacity 0.3s ease; opacity: 0; cursor: pointer; background-color: var(--pdf-preview-container-overlay)`; + return overlay; + } + + private createCheckbox(pageIndex: number): HTMLDivElement { + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.id = String(pageIndex); + checkbox.style.cssText = `position: absolute; top: -5px; right: -5px; z-index: 4;`; + checkbox.checked = this.selectedPages().has(pageIndex); + checkbox.addEventListener('change', () => { + if (checkbox.checked) { + this.selectedPages().add(Number(checkbox.id)); + this.selectedPagesOutput.emit(this.selectedPages()); + } else { + this.selectedPages().delete(Number(checkbox.id)); + this.selectedPagesOutput.emit(this.selectedPages()); + } + }); + return checkbox; + } + + /** + * Displays the selected PDF page in an enlarged view for detailed examination. + * @param originalCanvas - The original canvas element of the PDF page to be enlarged. + * */ + displayEnlargedCanvas(originalCanvas: HTMLCanvasElement) { + this.originalCanvas.set(originalCanvas); + this.isEnlargedView.set(true); + } +} diff --git a/src/main/webapp/app/lecture/pdf-preview/pdf-preview.component.html b/src/main/webapp/app/lecture/pdf-preview/pdf-preview.component.html index 8883da0aa551..ace60a5014e2 100644 --- a/src/main/webapp/app/lecture/pdf-preview/pdf-preview.component.html +++ b/src/main/webapp/app/lecture/pdf-preview/pdf-preview.component.html @@ -11,7 +11,7 @@

}

@if (isPdfLoading()) { -
+
} @@ -32,28 +32,24 @@

- -

-
- @if (isEnlargedView()) { -
- - - @if (currentPage() !== 1) { - - } - @if (currentPage() !== totalPages()) { - - } -
{{ currentPage() }}
-
- } -
+ @if (currentPdfUrl()) { + + } @else { +
+ }
>('pdfContainer'); - enlargedCanvas = viewChild.required>('enlargedCanvas'); fileInput = viewChild.required>('fileInput'); attachmentSub: Subscription; attachmentUnitSub: Subscription; - readonly DEFAULT_SLIDE_WIDTH = 250; - readonly DEFAULT_SLIDE_HEIGHT = 800; + // Signals course = signal(undefined); attachment = signal(undefined); attachmentUnit = signal(undefined); - isEnlargedView = signal(false); - isFileChanged = signal(false); - currentPage = signal(1); - totalPages = signal(0); - selectedPages = signal>(new Set()); isPdfLoading = signal(false); attachmentToBeEdited = signal(undefined); - currentPdfBlob = signal(null); + currentPdfBlob = signal(undefined); + currentPdfUrl = signal(undefined); + totalPages = signal(0); + appendFile = signal(false); + isFileChanged = signal(false); + selectedPages = signal>(new Set()); + allPagesSelected = computed(() => this.selectedPages().size === this.totalPages()); // Injected services private readonly route = inject(ActivatedRoute); @@ -74,386 +69,78 @@ export class PdfPreviewComponent implements OnInit, OnDestroy { if ('attachment' in data) { this.attachment.set(data.attachment); this.attachmentSub = this.attachmentService.getAttachmentFile(this.course()!.id!, this.attachment()!.id!).subscribe({ - next: (blob: Blob) => this.handleBlob(blob), + next: (blob: Blob) => { + this.currentPdfBlob.set(blob); + this.currentPdfUrl.set(URL.createObjectURL(blob)); + }, error: (error: HttpErrorResponse) => onError(this.alertService, error), }); } else if ('attachmentUnit' in data) { this.attachmentUnit.set(data.attachmentUnit); this.attachmentUnitSub = this.attachmentUnitService.getAttachmentFile(this.course()!.id!, this.attachmentUnit()!.id!).subscribe({ - next: (blob: Blob) => this.handleBlob(blob), + next: (blob: Blob) => { + this.currentPdfBlob.set(blob); + this.currentPdfUrl.set(URL.createObjectURL(blob)); + }, error: (error: HttpErrorResponse) => onError(this.alertService, error), }); } }); } - handleBlob(blob: Blob): void { - this.currentPdfBlob.set(blob); - const objectUrl = URL.createObjectURL(blob); - this.loadOrAppendPdf(objectUrl).then(() => URL.revokeObjectURL(objectUrl)); - } - ngOnDestroy() { this.attachmentSub?.unsubscribe(); this.attachmentUnitSub?.unsubscribe(); } /** - * Checks if all pages are selected. - * @returns True if the number of selected pages equals the total number of pages, otherwise false. - */ - allPagesSelected() { - return this.selectedPages().size === this.totalPages(); - } - - /** - * Handles navigation within the PDF viewer using keyboard arrow keys. - * @param event - The keyboard event captured for navigation. - */ - @HostListener('document:keydown', ['$event']) - handleKeyboardEvents(event: KeyboardEvent) { - if (this.isEnlargedView()) { - if (event.key === 'ArrowRight' && this.currentPage() < this.totalPages()) { - this.navigatePages('next'); - } else if (event.key === 'ArrowLeft' && this.currentPage() > 1) { - this.navigatePages('prev'); - } - } - } - - /** - * Adjusts the canvas size based on the window resize event to ensure proper display. - */ - @HostListener('window:resize') - resizeCanvasBasedOnContainer() { - this.adjustCanvasSize(); - } - - /** - * Loads or appends a PDF from a provided URL. - * @param fileUrl The URL of the file to load or append. - * @param append Whether the document should be appended to the existing one. - * @returns A promise that resolves when the PDF is loaded. - */ - async loadOrAppendPdf(fileUrl: string, append = false): Promise { - this.pdfContainer() - .nativeElement.querySelectorAll('.pdf-canvas-container') - .forEach((canvas) => canvas.remove()); - this.totalPages.set(0); - this.isPdfLoading.set(true); - try { - const loadingTask = PDFJS.getDocument(fileUrl); - const pdf = await loadingTask.promise; - this.totalPages.set(pdf.numPages); - - for (let i = 1; i <= this.totalPages(); i++) { - const page = await pdf.getPage(i); - const viewport = page.getViewport({ scale: 2 }); - const canvas = this.createCanvas(viewport, i); - const context = canvas.getContext('2d'); - await page.render({ canvasContext: context!, viewport }).promise; - - const canvasContainer = this.createCanvasContainer(canvas, i); - this.pdfContainer().nativeElement.appendChild(canvasContainer); - } - - if (append) { - this.scrollToBottom(); - } - } catch (error) { - onError(this.alertService, error); - } finally { - this.isPdfLoading.set(false); - if (append) { - this.fileInput().nativeElement.value = ''; - } - } - } - - /** - * Scrolls the PDF container to the bottom after appending new pages. - */ - scrollToBottom(): void { - const scrollOptions: ScrollToOptions = { - top: this.pdfContainer().nativeElement.scrollHeight, - left: 0, - behavior: 'smooth' as ScrollBehavior, - }; - this.pdfContainer().nativeElement.scrollTo(scrollOptions); - } - - /** - * Creates a canvas for each page of the PDF to allow for individual page rendering. - * @param viewport The viewport settings used for rendering the page. - * @param pageIndex The index of the page within the PDF document. - * @returns A new HTMLCanvasElement configured for the PDF page. - */ - createCanvas(viewport: PDFJS.PageViewport, pageIndex: number): HTMLCanvasElement { - const canvas = document.createElement('canvas'); - canvas.id = `${pageIndex}`; - /* Canvas styling is predefined because Canvas tags do not support CSS classes - * as they are not HTML elements but rather a bitmap drawing surface. - * See: https://stackoverflow.com/a/29675448 - * */ - canvas.height = viewport.height; - canvas.width = viewport.width; - const fixedWidth = this.DEFAULT_SLIDE_WIDTH; - const scaleFactor = fixedWidth / viewport.width; - canvas.style.width = `${fixedWidth}px`; - canvas.style.height = `${viewport.height * scaleFactor}px`; - return canvas; - } - - /** - * Creates a container div for each canvas, facilitating layering and interaction. - * @param canvas The canvas element that displays a PDF page. - * @param pageIndex The index of the page within the PDF document. - * @returns A configured div element that includes the canvas and interactive overlays. - */ - createCanvasContainer(canvas: HTMLCanvasElement, pageIndex: number): HTMLDivElement { - const container = document.createElement('div'); - /* Dynamically created elements are not detected by DOM, that is why we need to set the styles manually. - * See: https://stackoverflow.com/a/70911189 - */ - container.id = `pdf-page-${pageIndex}`; - container.classList.add('pdf-canvas-container'); - container.style.cssText = `position: relative; display: inline-block; width: ${canvas.style.width}; height: ${canvas.style.height}; margin: 20px; box-shadow: 0 2px 6px var(--pdf-preview-canvas-shadow);`; - - const overlay = this.createOverlay(pageIndex); - const checkbox = this.createCheckbox(pageIndex); - container.appendChild(canvas); - container.appendChild(overlay); - container.appendChild(checkbox); - - container.addEventListener('mouseenter', () => { - overlay.style.opacity = '1'; - }); - container.addEventListener('mouseleave', () => { - overlay.style.opacity = '0'; - }); - overlay.addEventListener('click', () => this.displayEnlargedCanvas(canvas)); - - return container; - } - - /** - * Generates an interactive overlay for each PDF page to allow for user interactions. - * @param pageIndex The index of the page. - * @returns A div element styled as an overlay. - */ - private createOverlay(pageIndex: number): HTMLDivElement { - const overlay = document.createElement('div'); - overlay.innerHTML = `${pageIndex}`; - /* Dynamically created elements are not detected by DOM, that is why we need to set the styles manually. - * See: https://stackoverflow.com/a/70911189 - */ - overlay.style.cssText = `position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; justify-content: center; align-items: center; font-size: 24px; color: white; z-index: 1; transition: opacity 0.3s ease; opacity: 0; cursor: pointer; background-color: var(--pdf-preview-container-overlay)`; - return overlay; - } - - private createCheckbox(pageIndex: number): HTMLDivElement { - const checkbox = document.createElement('input'); - checkbox.type = 'checkbox'; - checkbox.id = String(pageIndex); - checkbox.style.cssText = `position: absolute; top: -5px; right: -5px; z-index: 4;`; - checkbox.checked = this.selectedPages().has(pageIndex); - checkbox.addEventListener('change', () => { - if (checkbox.checked) { - this.selectedPages().add(Number(checkbox.id)); - } else { - this.selectedPages().delete(Number(checkbox.id)); - } - }); - return checkbox; - } - - /** - * Dynamically updates the canvas size within an enlarged view based on the viewport. - */ - adjustCanvasSize = () => { - if (this.isEnlargedView()) { - const canvasElements = this.pdfContainer().nativeElement.querySelectorAll('.pdf-canvas-container canvas'); - if (this.currentPage() - 1 < canvasElements.length) { - const canvas = canvasElements[this.currentPage() - 1] as HTMLCanvasElement; - this.updateEnlargedCanvas(canvas); - } - } - }; - - /** - * Adjusts the size of the PDF container based on whether the enlarged view is active or not. - * If the enlarged view is active, the container's size is reduced to focus on the enlarged content. - * If the enlarged view is closed, the container returns to its original size. - * - * @param isVertical A boolean flag indicating whether to enlarge or reset the container size. - */ - adjustPdfContainerSize(isVertical: boolean): void { - const pdfContainer = this.pdfContainer().nativeElement; - if (isVertical) { - pdfContainer.style.height = `${this.DEFAULT_SLIDE_HEIGHT}px`; - } else { - pdfContainer.style.height = '60vh'; - } - } - - /** - * Displays the selected PDF page in an enlarged view for detailed examination. - * @param originalCanvas - The original canvas element of the PDF page to be enlarged. - * */ - displayEnlargedCanvas(originalCanvas: HTMLCanvasElement) { - const isVertical = originalCanvas.height > originalCanvas.width; - this.adjustPdfContainerSize(isVertical); - this.isEnlargedView.set(true); - this.currentPage.set(Number(originalCanvas.id)); - this.toggleBodyScroll(true); - setTimeout(() => { - this.updateEnlargedCanvas(originalCanvas); - }, 50); - } - - /** - * Updates the enlarged canvas dimensions to optimize PDF page display within the current viewport. - * This method dynamically adjusts the size, position, and scale of the canvas to maintain the aspect ratio, - * ensuring the content is centered and displayed appropriately within the available space. - * It is called within an animation frame to synchronize updates with the browser's render cycle for smooth visuals. - * - * @param originalCanvas - The source canvas element used to extract image data for resizing and redrawing. + * Triggers the file input to select files. */ - updateEnlargedCanvas(originalCanvas: HTMLCanvasElement) { - requestAnimationFrame(() => { - if (!this.isEnlargedView) return; - - const isVertical = originalCanvas.height > originalCanvas.width; - this.adjustPdfContainerSize(isVertical); - - const scaleFactor = this.calculateScaleFactor(originalCanvas); - this.resizeCanvas(originalCanvas, scaleFactor); - this.redrawCanvas(originalCanvas); - this.positionCanvas(); - }); + triggerFileInput(): void { + this.fileInput().nativeElement.click(); } - /** - * Calculates the scaling factor to adjust the canvas size based on the dimensions of the container. - * This method ensures that the canvas is scaled to fit within the container without altering the aspect ratio. - * - * @param originalCanvas - The original canvas element representing the PDF page. - * @returns The scaling factor used to resize the original canvas to fit within the container dimensions. - */ - calculateScaleFactor(originalCanvas: HTMLCanvasElement): number { - const containerWidth = this.pdfContainer().nativeElement.clientWidth; - const containerHeight = this.pdfContainer().nativeElement.clientHeight; - - let scaleX, scaleY; + updateAttachmentWithFile(): void { + const pdfFile = new File([this.currentPdfBlob()!], 'updatedAttachment.pdf', { type: 'application/pdf' }); - if (originalCanvas.height > originalCanvas.width) { - // Vertical slide - const fixedHeight = this.DEFAULT_SLIDE_HEIGHT; - scaleY = fixedHeight / originalCanvas.height; - scaleX = containerWidth / originalCanvas.width; - } else { - // Horizontal slide - scaleX = containerWidth / originalCanvas.width; - scaleY = containerHeight / originalCanvas.height; + if (pdfFile.size > MAX_FILE_SIZE) { + this.alertService.error('artemisApp.attachment.pdfPreview.fileSizeError'); + return; } - return Math.min(scaleX, scaleY); - } - - /** - * Resizes the canvas according to the computed scale factor. - * This method updates the dimensions of the enlarged canvas element to ensure that the entire PDF page - * is visible and properly scaled within the viewer. - * - * @param originalCanvas - The canvas element from which the image is scaled. - * @param scaleFactor - The factor by which the canvas is resized. - */ - resizeCanvas(originalCanvas: HTMLCanvasElement, scaleFactor: number): void { - const enlargedCanvas = this.enlargedCanvas().nativeElement; - enlargedCanvas.width = originalCanvas.width * scaleFactor; - enlargedCanvas.height = originalCanvas.height * scaleFactor; - } - - /** - * Redraws the original canvas content onto the enlarged canvas at the updated scale. - * This method ensures that the image is rendered clearly and correctly positioned on the enlarged canvas. - * - * @param originalCanvas - The original canvas containing the image to be redrawn. - */ - redrawCanvas(originalCanvas: HTMLCanvasElement): void { - const enlargedCanvas = this.enlargedCanvas().nativeElement; - const context = enlargedCanvas.getContext('2d'); - context!.clearRect(0, 0, enlargedCanvas.width, enlargedCanvas.height); - context!.drawImage(originalCanvas, 0, 0, enlargedCanvas.width, enlargedCanvas.height); - } - - /** - * Adjusts the position of the enlarged canvas to center it within the viewport of the PDF container. - * This method ensures that the canvas is both vertically and horizontally centered, providing a consistent - * and visually appealing layout. - */ - positionCanvas(): void { - const enlargedCanvas = this.enlargedCanvas().nativeElement; - const containerWidth = this.pdfContainer().nativeElement.clientWidth; - const containerHeight = this.pdfContainer().nativeElement.clientHeight; - - enlargedCanvas.style.position = 'absolute'; - enlargedCanvas.style.left = `${(containerWidth - enlargedCanvas.width) / 2}px`; - enlargedCanvas.style.top = `${(containerHeight - enlargedCanvas.height) / 2}px`; - enlargedCanvas.parentElement!.style.top = `${this.pdfContainer().nativeElement.scrollTop}px`; - } - - /** - * Closes the enlarged view of the PDF and re-enables scrolling in the PDF container. - */ - closeEnlargedView(event: MouseEvent) { - this.isEnlargedView.set(false); - this.adjustPdfContainerSize(false); - this.toggleBodyScroll(false); - event.stopPropagation(); - } - - /** - * Toggles the ability to scroll through the PDF container. - * @param disable A boolean flag indicating whether scrolling should be disabled (`true`) or enabled (`false`). - */ - toggleBodyScroll(disable: boolean): void { - this.pdfContainer().nativeElement.style.overflow = disable ? 'hidden' : 'auto'; - } - - /** - * Closes the enlarged view if a click event occurs outside the actual canvas area but within the enlarged container. - * @param event The mouse event captured, used to determine the location of the click. - */ - closeIfOutside(event: MouseEvent): void { - const target = event.target as HTMLElement; - const enlargedCanvas = this.enlargedCanvas().nativeElement; + if (this.attachment()) { + this.attachmentToBeEdited.set(this.attachment()); + this.attachmentToBeEdited()!.version!++; + this.attachmentToBeEdited()!.uploadDate = dayjs(); - if (target.classList.contains('enlarged-container') && target !== enlargedCanvas) { - this.closeEnlargedView(event); - } - } + this.attachmentService.update(this.attachmentToBeEdited()!.id!, this.attachmentToBeEdited()!, pdfFile).subscribe({ + next: () => { + this.alertService.success('artemisApp.attachment.pdfPreview.attachmentUpdateSuccess'); + this.router.navigate(['course-management', this.course()?.id, 'lectures', this.attachment()!.lecture!.id, 'attachments']); + }, + error: (error) => { + this.alertService.error('artemisApp.attachment.pdfPreview.attachmentUpdateError', { error: error.message }); + }, + }); + } else if (this.attachmentUnit()) { + this.attachmentToBeEdited.set(this.attachmentUnit()!.attachment!); + this.attachmentToBeEdited()!.version!++; + this.attachmentToBeEdited()!.uploadDate = dayjs(); - /** - * Handles navigation between PDF pages and stops event propagation to prevent unwanted side effects. - * @param direction The direction to navigate. - * @param event The MouseEvent to be stopped. - */ - handleNavigation(direction: NavigationDirection, event: MouseEvent): void { - event.stopPropagation(); - this.navigatePages(direction); - } + const formData = new FormData(); + formData.append('file', pdfFile); + formData.append('attachment', objectToJsonBlob(this.attachmentToBeEdited()!)); + formData.append('attachmentUnit', objectToJsonBlob(this.attachmentUnit()!)); - /** - * Navigates to a specific page in the PDF based on the direction relative to the current page. - * @param direction The navigation direction (next or previous). - */ - navigatePages(direction: NavigationDirection) { - const nextPageIndex = direction === 'next' ? this.currentPage() + 1 : this.currentPage() - 1; - if (nextPageIndex > 0 && nextPageIndex <= this.totalPages()) { - this.currentPage.set(nextPageIndex); - const canvas = this.pdfContainer().nativeElement.querySelectorAll('.pdf-canvas-container canvas')[this.currentPage() - 1] as HTMLCanvasElement; - this.updateEnlargedCanvas(canvas); + this.attachmentUnitService.update(this.attachmentUnit()!.lecture!.id!, this.attachmentUnit()!.id!, formData).subscribe({ + next: () => { + this.alertService.success('artemisApp.attachment.pdfPreview.attachmentUpdateSuccess'); + this.router.navigate(['course-management', this.course()?.id, 'lectures', this.attachmentUnit()!.lecture!.id, 'unit-management']); + }, + error: (error) => { + this.alertService.error('artemisApp.attachment.pdfPreview.attachmentUpdateError', { error: error.message }); + }, + }); } } @@ -494,7 +181,7 @@ export class PdfPreviewComponent implements OnInit, OnDestroy { const existingPdfBytes = await this.currentPdfBlob()!.arrayBuffer(); const pdfDoc = await PDFDocument.load(existingPdfBytes); - const pagesToDelete = Array.from(this.selectedPages()) + const pagesToDelete = Array.from(this.selectedPages()!) .map((page) => page - 1) .sort((a, b) => b - a); pagesToDelete.forEach((pageIndex) => { @@ -504,13 +191,12 @@ export class PdfPreviewComponent implements OnInit, OnDestroy { this.isFileChanged.set(true); const pdfBytes = await pdfDoc.save(); this.currentPdfBlob.set(new Blob([pdfBytes], { type: 'application/pdf' })); - this.selectedPages().clear(); + this.selectedPages()!.clear(); const objectUrl = URL.createObjectURL(this.currentPdfBlob()!); - await this.loadOrAppendPdf(objectUrl, false).then(() => { - this.dialogErrorSource.next(''); - }); - URL.revokeObjectURL(objectUrl); + this.currentPdfUrl.set(objectUrl); + this.appendFile.set(false); + this.dialogErrorSource.next(''); } catch (error) { this.alertService.error('artemisApp.attachment.pdfPreview.pageDeleteError', { error: error.message }); } finally { @@ -518,13 +204,6 @@ export class PdfPreviewComponent implements OnInit, OnDestroy { } } - /** - * Triggers the file input to select files. - */ - triggerFileInput(): void { - this.fileInput().nativeElement.click(); - } - /** * Adds a selected PDF file at the end of the current PDF document. * @param event - The event containing the file input. @@ -546,75 +225,16 @@ export class PdfPreviewComponent implements OnInit, OnDestroy { const mergedPdfBytes = await existingPdfDoc.save(); this.currentPdfBlob.set(new Blob([mergedPdfBytes], { type: 'application/pdf' })); - this.selectedPages().clear(); + this.selectedPages()!.clear(); const objectUrl = URL.createObjectURL(this.currentPdfBlob()!); - await this.loadOrAppendPdf(objectUrl, true).then(() => URL.revokeObjectURL(objectUrl)); + this.currentPdfUrl.set(objectUrl); + this.appendFile.set(true); } catch (error) { this.alertService.error('artemisApp.attachment.pdfPreview.mergeFailedError', { error: error.message }); } finally { this.isPdfLoading.set(false); - } - } - - /** - * Updates the IDs of remaining pages after some have been removed. - */ - updatePageIDs() { - const remainingPages = this.pdfContainer().nativeElement.querySelectorAll('.pdf-canvas-container'); - remainingPages.forEach((container, index) => { - const pageIndex = index + 1; - container.id = `pdf-page-${pageIndex}`; - const canvas = container.querySelector('canvas'); - const overlay = container.querySelector('div'); - const checkbox = container.querySelector('input[type="checkbox"]'); - canvas!.id = String(pageIndex); - overlay!.innerHTML = `${pageIndex}`; - checkbox!.id = String(pageIndex); - }); - } - - updateAttachmentWithFile(): void { - const pdfFile = new File([this.currentPdfBlob()!], 'updatedAttachment.pdf', { type: 'application/pdf' }); - - if (pdfFile.size > MAX_FILE_SIZE) { - this.alertService.error('artemisApp.attachment.pdfPreview.fileSizeError'); - return; - } - - if (this.attachment()) { - this.attachmentToBeEdited.set(this.attachment()); - this.attachmentToBeEdited()!.version!++; - this.attachmentToBeEdited()!.uploadDate = dayjs(); - - this.attachmentService.update(this.attachmentToBeEdited()!.id!, this.attachmentToBeEdited()!, pdfFile).subscribe({ - next: () => { - this.alertService.success('artemisApp.attachment.pdfPreview.attachmentUpdateSuccess'); - this.router.navigate(['course-management', this.course()?.id, 'lectures', this.attachment()!.lecture!.id, 'attachments']); - }, - error: (error) => { - this.alertService.error('artemisApp.attachment.pdfPreview.attachmentUpdateError', { error: error.message }); - }, - }); - } else if (this.attachmentUnit()) { - this.attachmentToBeEdited.set(this.attachmentUnit()!.attachment!); - this.attachmentToBeEdited()!.version!++; - this.attachmentToBeEdited()!.uploadDate = dayjs(); - - const formData = new FormData(); - formData.append('file', pdfFile); - formData.append('attachment', objectToJsonBlob(this.attachmentToBeEdited()!)); - formData.append('attachmentUnit', objectToJsonBlob(this.attachmentUnit()!)); - - this.attachmentUnitService.update(this.attachmentUnit()!.lecture!.id!, this.attachmentUnit()!.id!, formData).subscribe({ - next: () => { - this.alertService.success('artemisApp.attachment.pdfPreview.attachmentUpdateSuccess'); - this.router.navigate(['course-management', this.course()?.id, 'lectures', this.attachmentUnit()!.lecture!.id, 'unit-management']); - }, - error: (error) => { - this.alertService.error('artemisApp.attachment.pdfPreview.attachmentUpdateError', { error: error.message }); - }, - }); + this.fileInput()!.nativeElement.value = ''; } } } diff --git a/src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-summary.component.html b/src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-summary.component.html index 7021b3f79c4d..9f75be4268e7 100644 --- a/src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-summary.component.html +++ b/src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-summary.component.html @@ -1,11 +1,24 @@
-

@if (buildAgents) { -

- {{ buildAgents.length }} : {{ currentBuilds }} - {{ buildCapacity }} -

-
+
+
+

+

+ {{ buildAgents.length }} : {{ currentBuilds }} + {{ buildCapacity }} +

+
+
+ + +
+

}

+ + + + + + + diff --git a/src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-summary.component.ts b/src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-summary.component.ts index 711fa4a40dce..5e79ceac3c75 100644 --- a/src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-summary.component.ts +++ b/src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-summary.component.ts @@ -1,12 +1,14 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; -import { BuildAgentInformation } from 'app/entities/programming/build-agent-information.model'; +import { BuildAgentInformation, BuildAgentStatus } from 'app/entities/programming/build-agent-information.model'; import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; import { BuildAgentsService } from 'app/localci/build-agents/build-agents.service'; import { Subscription } from 'rxjs'; -import { faTimes } from '@fortawesome/free-solid-svg-icons'; +import { faPause, faPlay, faTimes } from '@fortawesome/free-solid-svg-icons'; import { BuildQueueService } from 'app/localci/build-queue/build-queue.service'; import { Router } from '@angular/router'; import { BuildAgent } from 'app/entities/programming/build-agent.model'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { AlertService, AlertType } from 'app/core/util/alert.service'; @Component({ selector: 'jhi-build-agents', @@ -23,13 +25,17 @@ export class BuildAgentSummaryComponent implements OnInit, OnDestroy { routerLink: string; //icons - faTimes = faTimes; + protected readonly faTimes = faTimes; + protected readonly faPause = faPause; + protected readonly faPlay = faPlay; constructor( private websocketService: JhiWebsocketService, private buildAgentsService: BuildAgentsService, private buildQueueService: BuildQueueService, private router: Router, + private modalService: NgbModal, + private alertService: AlertService, ) {} ngOnInit() { @@ -59,7 +65,9 @@ export class BuildAgentSummaryComponent implements OnInit, OnDestroy { private updateBuildAgents(buildAgents: BuildAgentInformation[]) { this.buildAgents = buildAgents; - this.buildCapacity = this.buildAgents.reduce((sum, agent) => sum + (agent.maxNumberOfConcurrentBuildJobs || 0), 0); + this.buildCapacity = this.buildAgents + .filter((agent) => agent.status !== BuildAgentStatus.PAUSED) + .reduce((sum, agent) => sum + (agent.maxNumberOfConcurrentBuildJobs || 0), 0); this.currentBuilds = this.buildAgents.reduce((sum, agent) => sum + (agent.numberOfCurrentBuildJobs || 0), 0); } @@ -86,4 +94,47 @@ export class BuildAgentSummaryComponent implements OnInit, OnDestroy { this.buildQueueService.cancelAllRunningBuildJobsForAgent(buildAgentToCancel.buildAgent?.name).subscribe(); } } + + displayPauseBuildAgentModal(modal: any) { + this.modalService.open(modal); + } + + pauseAllBuildAgents(modal?: any) { + this.buildAgentsService.pauseAllBuildAgents().subscribe({ + next: () => { + this.load(); + this.alertService.addAlert({ + type: AlertType.SUCCESS, + message: 'artemisApp.buildAgents.alerts.buildAgentsPaused', + }); + }, + error: () => { + this.alertService.addAlert({ + type: AlertType.DANGER, + message: 'artemisApp.buildAgents.alerts.buildAgentPauseFailed', + }); + }, + }); + if (modal) { + modal.close(); + } + } + + resumeAllBuildAgents() { + this.buildAgentsService.resumeAllBuildAgents().subscribe({ + next: () => { + this.load(); + this.alertService.addAlert({ + type: AlertType.SUCCESS, + message: 'artemisApp.buildAgents.alerts.buildAgentsResumed', + }); + }, + error: () => { + this.alertService.addAlert({ + type: AlertType.DANGER, + message: 'artemisApp.buildAgents.alerts.buildAgentResumeFailed', + }); + }, + }); + } } diff --git a/src/main/webapp/app/localci/build-agents/build-agents.service.ts b/src/main/webapp/app/localci/build-agents/build-agents.service.ts index 6aac582940d3..c3b388586bcf 100644 --- a/src/main/webapp/app/localci/build-agents/build-agents.service.ts +++ b/src/main/webapp/app/localci/build-agents/build-agents.service.ts @@ -33,22 +33,44 @@ export class BuildAgentsService { */ pauseBuildAgent(agentName: string): Observable { const encodedAgentName = encodeURIComponent(agentName); - return this.http.put(`${this.adminResourceUrl}/agent/${encodedAgentName}/pause`, null).pipe( + return this.http.put(`${this.adminResourceUrl}/agents/${encodedAgentName}/pause`, null).pipe( catchError((err) => { return throwError(() => new Error(`Failed to pause build agent ${agentName}\n${err.message}`)); }), ); } + /** + * Pause All Build Agents + */ + pauseAllBuildAgents(): Observable { + return this.http.put(`${this.adminResourceUrl}/agents/pause-all`, null).pipe( + catchError((err) => { + return throwError(() => new Error(`Failed to pause build agents\n${err.message}`)); + }), + ); + } + /** * Resume Build Agent */ resumeBuildAgent(agentName: string): Observable { const encodedAgentName = encodeURIComponent(agentName); - return this.http.put(`${this.adminResourceUrl}/agent/${encodedAgentName}/resume`, null).pipe( + return this.http.put(`${this.adminResourceUrl}/agents/${encodedAgentName}/resume`, null).pipe( catchError((err) => { return throwError(() => new Error(`Failed to resume build agent ${agentName}\n${err.message}`)); }), ); } + + /** + * Resume all Build Agents + */ + resumeAllBuildAgents(): Observable { + return this.http.put(`${this.adminResourceUrl}/agents/resume-all`, null).pipe( + catchError((err) => { + return throwError(() => new Error(`Failed to resume build agents\n${err.message}`)); + }), + ); + } } diff --git a/src/main/webapp/app/overview/course-conversations/course-conversations.component.html b/src/main/webapp/app/overview/course-conversations/course-conversations.component.html index 1e7b44fa6fb7..6b2bac7d8ada 100644 --- a/src/main/webapp/app/overview/course-conversations/course-conversations.component.html +++ b/src/main/webapp/app/overview/course-conversations/course-conversations.component.html @@ -38,7 +38,7 @@ [sidebarItemAlwaysShow]="DEFAULT_SHOW_ALWAYS" [collapseState]="DEFAULT_COLLAPSE_STATE" [inCommunication]="true" - [reEmitNonDistinctSidebarEvents]="isMobile" + [reEmitNonDistinctSidebarEvents]="true" />
@if (course && !activeConversation && isCodeOfConductPresented) { @@ -57,9 +57,15 @@ (openThread)="postInThread = $event" [course]="course" [searchbarCollapsed]="channelSearchCollapsed" + [focusPostId]="focusPostId" + [openThreadOnFocus]="openThreadOnFocus" /> } @else { - + @if (selectedSavedPostStatus === null) { + + } @else { + + } }
diff --git a/src/main/webapp/app/overview/course-conversations/course-conversations.component.ts b/src/main/webapp/app/overview/course-conversations/course-conversations.component.ts index 1b194b218bd3..f65878c794e1 100644 --- a/src/main/webapp/app/overview/course-conversations/course-conversations.component.ts +++ b/src/main/webapp/app/overview/course-conversations/course-conversations.component.ts @@ -1,4 +1,4 @@ -import { Component, ElementRef, EventEmitter, HostListener, OnDestroy, OnInit, ViewChild, ViewEncapsulation, inject } from '@angular/core'; +import { ChangeDetectorRef, Component, ElementRef, EventEmitter, HostListener, OnDestroy, OnInit, ViewChild, ViewEncapsulation, inject } from '@angular/core'; import { ConversationDTO } from 'app/entities/metis/conversation/conversation.model'; import { Post } from 'app/entities/metis/post.model'; import { ActivatedRoute, Router } from '@angular/router'; @@ -10,7 +10,21 @@ import { ChannelDTO, ChannelSubType, getAsChannelDTO } from 'app/entities/metis/ import { MetisService } from 'app/shared/metis/metis.service'; import { Course, isMessagingEnabled } from 'app/entities/course.model'; import { PageType, SortDirection } from 'app/shared/metis/metis.util'; -import { faBan, faComment, faComments, faFile, faFilter, faGraduationCap, faHeart, faList, faMessage, faPlus, faSearch, faTimes } from '@fortawesome/free-solid-svg-icons'; +import { + faBan, + faBookmark, + faComment, + faComments, + faFile, + faFilter, + faGraduationCap, + faHeart, + faList, + faMessage, + faPlus, + faSearch, + faTimes, +} from '@fortawesome/free-solid-svg-icons'; import { ButtonType } from 'app/shared/components/button.component'; import { CourseWideSearchComponent, CourseWideSearchConfig } from 'app/overview/course-conversations/course-wide-search/course-wide-search.component'; import { AccordionGroups, ChannelAccordionShowAdd, ChannelTypeIcons, CollapseState, SidebarCardElement, SidebarData, SidebarItemShowAlways } from 'app/types/sidebar'; @@ -25,6 +39,7 @@ import { ChannelsCreateDialogComponent } from 'app/overview/course-conversations import { CourseSidebarService } from 'app/overview/course-sidebar.service'; import { LayoutService } from 'app/shared/breakpoints/layout.service'; import { CustomBreakpointNames } from 'app/shared/breakpoints/breakpoints.service'; +import { Posting, PostingType, SavedPostStatus, SavedPostStatusMap } from 'app/entities/metis/posting.model'; const DEFAULT_CHANNEL_GROUPS: AccordionGroups = { favoriteChannels: { entityData: [] }, @@ -33,6 +48,7 @@ const DEFAULT_CHANNEL_GROUPS: AccordionGroups = { lectureChannels: { entityData: [] }, examChannels: { entityData: [] }, hiddenChannels: { entityData: [] }, + savedPosts: { entityData: [] }, }; const CHANNEL_TYPE_SHOW_ADD_OPTION: ChannelAccordionShowAdd = { @@ -44,6 +60,7 @@ const CHANNEL_TYPE_SHOW_ADD_OPTION: ChannelAccordionShowAdd = { favoriteChannels: false, lectureChannels: true, hiddenChannels: false, + savedPosts: false, }; const CHANNEL_TYPE_ICON: ChannelTypeIcons = { @@ -55,6 +72,7 @@ const CHANNEL_TYPE_ICON: ChannelTypeIcons = { favoriteChannels: faHeart, lectureChannels: faFile, hiddenChannels: faBan, + savedPosts: faBookmark, }; const DEFAULT_COLLAPSE_STATE: CollapseState = { @@ -66,6 +84,7 @@ const DEFAULT_COLLAPSE_STATE: CollapseState = { favoriteChannels: false, lectureChannels: true, hiddenChannels: true, + savedPosts: true, }; const DEFAULT_SHOW_ALWAYS: SidebarItemShowAlways = { @@ -77,6 +96,7 @@ const DEFAULT_SHOW_ALWAYS: SidebarItemShowAlways = { favoriteChannels: true, lectureChannels: false, hiddenChannels: false, + savedPosts: true, }; @Component({ @@ -110,6 +130,9 @@ export class CourseConversationsComponent implements OnInit, OnDestroy { isProduction = true; isTestServer = false; isMobile = false; + focusPostId: number | undefined = undefined; + openThreadOnFocus = false; + selectedSavedPostStatus: null | SavedPostStatus = null; readonly CHANNEL_TYPE_SHOW_ADD_OPTION = CHANNEL_TYPE_SHOW_ADD_OPTION; readonly CHANNEL_TYPE_ICON = CHANNEL_TYPE_ICON; @@ -140,6 +163,7 @@ export class CourseConversationsComponent implements OnInit, OnDestroy { private courseSidebarService: CourseSidebarService = inject(CourseSidebarService); private layoutService: LayoutService = inject(LayoutService); + private changeDetector: ChangeDetectorRef = inject(ChangeDetectorRef); constructor( private router: Router, @@ -251,9 +275,23 @@ export class CourseConversationsComponent implements OnInit, OnDestroy { subscribeToQueryParameter() { this.activatedRoute.queryParams.pipe(take(1), takeUntil(this.ngUnsubscribe)).subscribe((queryParams) => { if (queryParams.conversationId) { - this.metisConversationService.setActiveConversation(Number(queryParams.conversationId)); - - this.closeSidebarOnMobile(); + if ( + isNaN(Number(queryParams.conversationId)) && + Object.values(SavedPostStatusMap) + .map((s) => s.toString()) + .includes(queryParams.conversationId) + ) { + this.selectedSavedPostStatus = Posting.mapToStatus(queryParams.conversationId as SavedPostStatusMap); + } else { + this.metisConversationService.setActiveConversation(Number(queryParams.conversationId)); + this.closeSidebarOnMobile(); + } + } + if (queryParams.focusPostId) { + this.focusPostId = Number(queryParams.focusPostId); + } + if (queryParams.openThreadOnFocus) { + this.openThreadOnFocus = queryParams.openThreadOnFocus; } if (queryParams.messageId) { this.postInThread = { id: Number(queryParams.messageId) } as Post; @@ -265,11 +303,22 @@ export class CourseConversationsComponent implements OnInit, OnDestroy { }); } + onNavigateToPost(post: Posting) { + if (post.referencePostId === undefined || post.conversation?.id === undefined) { + return; + } + + this.focusPostId = post.referencePostId; + this.openThreadOnFocus = (post.postingType as PostingType) === PostingType.ANSWER; + this.metisConversationService.setActiveConversation(post.conversation!.id!); + this.changeDetector.detectChanges(); + } + updateQueryParameters() { this.router.navigate([], { relativeTo: this.activatedRoute, queryParams: { - conversationId: this.activeConversation?.id, + conversationId: this.activeConversation?.id ?? (this.selectedSavedPostStatus !== null ? Posting.statusToMap(this.selectedSavedPostStatus) : undefined), }, replaceUrl: true, }); @@ -348,6 +397,7 @@ export class CourseConversationsComponent implements OnInit, OnDestroy { this.courseSidebarService.openSidebar(); } } + this.selectedSavedPostStatus = null; this.metisConversationService.setActiveConversation(undefined); this.activeConversation = undefined; this.updateQueryParameters(); @@ -377,9 +427,29 @@ export class CourseConversationsComponent implements OnInit, OnDestroy { }; } - onConversationSelected(conversationId: number) { + onConversationSelected(conversationId: number | string) { this.closeSidebarOnMobile(); - this.metisConversationService.setActiveConversation(conversationId); + this.focusPostId = undefined; + this.openThreadOnFocus = false; + if (typeof conversationId === 'string') { + if ( + Object.values(SavedPostStatusMap) + .map((s) => s.toString()) + .includes(conversationId) + ) { + this.selectedSavedPostStatus = Posting.mapToStatus(conversationId as SavedPostStatusMap); + this.postInThread = undefined; + this.metisConversationService.setActiveConversation(undefined); + this.activeConversation = undefined; + this.updateQueryParameters(); + this.metisService.resetCachedPosts(); + this.changeDetector.detectChanges(); + } + } else { + conversationId = +conversationId; + this.selectedSavedPostStatus = null; + this.metisConversationService.setActiveConversation(conversationId); + } } toggleSidebar() { diff --git a/src/main/webapp/app/overview/course-conversations/course-conversations.module.ts b/src/main/webapp/app/overview/course-conversations/course-conversations.module.ts index 0646118f577f..10d2cea22583 100644 --- a/src/main/webapp/app/overview/course-conversations/course-conversations.module.ts +++ b/src/main/webapp/app/overview/course-conversations/course-conversations.module.ts @@ -31,6 +31,8 @@ import { CourseConversationsCodeOfConductComponent } from 'app/overview/course-c import { CourseWideSearchComponent } from 'app/overview/course-conversations/course-wide-search/course-wide-search.component'; import { ArtemisSidebarModule } from 'app/shared/sidebar/sidebar.module'; import { ProfilePictureComponent } from 'app/shared/profile-picture/profile-picture.component'; +import { SavedPostsComponent } from 'app/overview/course-conversations/saved-posts/saved-posts.component'; +import { PostingSummaryComponent } from 'app/overview/course-conversations/posting-summary/posting-summary.component'; const routes: Routes = [ { @@ -79,6 +81,8 @@ const routes: Routes = [ OneToOneChatCreateDialogComponent, GroupChatCreateDialogComponent, CourseWideSearchComponent, + SavedPostsComponent, + PostingSummaryComponent, ], }) export class CourseConversationsModule {} diff --git a/src/main/webapp/app/overview/course-conversations/course-wide-search/course-wide-search.component.html b/src/main/webapp/app/overview/course-conversations/course-wide-search/course-wide-search.component.html index 1295be3edf63..c9641f8aa287 100644 --- a/src/main/webapp/app/overview/course-conversations/course-wide-search/course-wide-search.component.html +++ b/src/main/webapp/app/overview/course-conversations/course-wide-search/course-wide-search.component.html @@ -72,7 +72,7 @@

}
-