diff --git a/src/main/java/de/tum/cit/aet/artemis/assessment/domain/Result.java b/src/main/java/de/tum/cit/aet/artemis/assessment/domain/Result.java index 77c01c6fae19..88d5d44e5271 100644 --- a/src/main/java/de/tum/cit/aet/artemis/assessment/domain/Result.java +++ b/src/main/java/de/tum/cit/aet/artemis/assessment/domain/Result.java @@ -108,8 +108,12 @@ public class Result extends DomainObject implements Comparable { @JsonView(QuizView.Before.class) private List feedbacks = new ArrayList<>(); + /** + * @deprecated: Will be removed for 8.0, please use submission.participation instead + */ @ManyToOne @JsonView(QuizView.Before.class) + @Deprecated(since = "7.7", forRemoval = true) private Participation participation; @ManyToOne(fetch = FetchType.LAZY) @@ -385,15 +389,31 @@ private boolean feedbackTextHasChanged(String existingText, String newText) { return !Objects.equals(existingText, newText); } + /** + * @deprecated: Will be removed for 8.0, please use submission.participation instead + * @return the participation + */ + @Deprecated(since = "7.7", forRemoval = true) public Participation getParticipation() { return participation; } + /** + * @deprecated: Will be removed for 8.0, please use submission.participation instead + * @param participation the participation to set + * @return the result + */ + @Deprecated(since = "7.7", forRemoval = true) public Result participation(Participation participation) { this.participation = participation; return this; } + /** + * @deprecated: Will be removed for 8.0, please use submission.participation instead + * @param participation the participation to set + */ + @Deprecated(since = "7.7", forRemoval = true) public void setParticipation(Participation participation) { this.participation = participation; } diff --git a/src/main/java/de/tum/cit/aet/artemis/assessment/service/ResultService.java b/src/main/java/de/tum/cit/aet/artemis/assessment/service/ResultService.java index 50576953916b..1f62ef78665b 100644 --- a/src/main/java/de/tum/cit/aet/artemis/assessment/service/ResultService.java +++ b/src/main/java/de/tum/cit/aet/artemis/assessment/service/ResultService.java @@ -692,4 +692,15 @@ public void deleteLongFeedback(List feedbackList, Result result) { List feedbacks = new ArrayList<>(feedbackList); result.updateAllFeedbackItems(feedbacks, true); } + + /** + * Retrieves the number of students affected by a specific feedback detail text for a given exercise. + * + * @param exerciseId for which the affected student count is requested. + * @param detailText used to filter affected students. + * @return the total number of distinct students affected by the feedback detail text. + */ + public long getAffectedStudentCountByFeedbackDetailText(long exerciseId, String detailText) { + return studentParticipationRepository.countAffectedStudentsByFeedbackDetailText(exerciseId, detailText); + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/assessment/web/ResultResource.java b/src/main/java/de/tum/cit/aet/artemis/assessment/web/ResultResource.java index 431fb66373e8..ed6bc5ce12d3 100644 --- a/src/main/java/de/tum/cit/aet/artemis/assessment/web/ResultResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/assessment/web/ResultResource.java @@ -373,4 +373,18 @@ public ResponseEntity> getAffectedStudentsWithF return ResponseEntity.ok(participation); } + + /** + * GET /exercises/{exerciseId}/feedback-detail/affected-students : Retrieves the count of students affected by a specific feedback detail text. + * + * @param exerciseId The ID of the exercise for which affected students are counted. + * @param detailText The feedback detail text to filter by. + * @return A {@link ResponseEntity} containing the count of affected students. + */ + @GetMapping("exercises/{exerciseId}/feedback-detail/affected-students") + @EnforceAtLeastEditorInExercise + public ResponseEntity countAffectedStudentsByFeedbackDetailText(@PathVariable long exerciseId, @RequestParam("detailText") String detailText) { + long affectedStudentCount = resultService.getAffectedStudentCountByFeedbackDetailText(exerciseId, detailText); + return ResponseEntity.ok(affectedStudentCount); + } } 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 2c2e8b8e16bb..8b43315ab01b 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 @@ -360,7 +360,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() { 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/domain/User.java b/src/main/java/de/tum/cit/aet/artemis/core/domain/User.java index 6498340f3bc2..2ef2478cf295 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; @@ -180,6 +181,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") }) diff --git a/src/main/java/de/tum/cit/aet/artemis/core/service/user/UserService.java b/src/main/java/de/tum/cit/aet/artemis/core/service/user/UserService.java index d06ecfec87af..31937ac6a5b6 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/service/user/UserService.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/service/user/UserService.java @@ -39,6 +39,8 @@ import org.springframework.util.StringUtils; import de.tum.cit.aet.artemis.atlas.repository.ScienceEventRepository; +import de.tum.cit.aet.artemis.communication.domain.SavedPost; +import de.tum.cit.aet.artemis.communication.repository.SavedPostRepository; import de.tum.cit.aet.artemis.core.domain.Authority; import de.tum.cit.aet.artemis.core.domain.GuidedTourSetting; import de.tum.cit.aet.artemis.core.domain.User; @@ -113,11 +115,13 @@ public class UserService { private final ParticipationVcsAccessTokenService participationVCSAccessTokenService; + private final SavedPostRepository savedPostRepository; + public UserService(UserCreationService userCreationService, UserRepository userRepository, AuthorityService authorityService, AuthorityRepository authorityRepository, CacheManager cacheManager, Optional ldapUserService, GuidedTourSettingsRepository guidedTourSettingsRepository, PasswordService passwordService, Optional optionalVcsUserManagementService, Optional optionalCIUserManagementService, InstanceMessageSendService instanceMessageSendService, FileService fileService, ScienceEventRepository scienceEventRepository, - ParticipationVcsAccessTokenService participationVCSAccessTokenService) { + ParticipationVcsAccessTokenService participationVCSAccessTokenService, SavedPostRepository savedPostRepository) { this.userCreationService = userCreationService; this.userRepository = userRepository; this.authorityService = authorityService; @@ -132,6 +136,7 @@ public UserService(UserCreationService userCreationService, UserRepository userR this.fileService = fileService; this.scienceEventRepository = scienceEventRepository; this.participationVCSAccessTokenService = participationVCSAccessTokenService; + this.savedPostRepository = savedPostRepository; } /** @@ -493,6 +498,12 @@ 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(); 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/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/service/localci/SharedQueueManagementService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/SharedQueueManagementService.java index 87b44d4872ba..059df76379da 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,45 @@ 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); } + /** + * @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 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 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(); } @@ -156,9 +165,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 +207,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 +219,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 +228,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 +244,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 +259,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()); } 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/master.xml b/src/main/resources/config/liquibase/master.xml index 6a06b398b783..e29f09657055 100644 --- a/src/main/resources/config/liquibase/master.xml +++ b/src/main/resources/config/liquibase/master.xml @@ -33,6 +33,7 @@ + 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/entities/metis/conversation/conversation.model.ts b/src/main/webapp/app/entities/metis/conversation/conversation.model.ts index ddb146f12d1f..841a8a5f53de 100644 --- a/src/main/webapp/app/entities/metis/conversation/conversation.model.ts +++ b/src/main/webapp/app/entities/metis/conversation/conversation.model.ts @@ -23,6 +23,7 @@ export abstract class Conversation implements BaseEntity { public creator?: User; public creationDate?: dayjs.Dayjs; public lastMessageDate?: dayjs.Dayjs; + public title?: string; protected constructor(type: ConversationType) { this.type = type; diff --git a/src/main/webapp/app/entities/metis/post.model.ts b/src/main/webapp/app/entities/metis/post.model.ts index 60adfe3c64c0..39950a74af0b 100644 --- a/src/main/webapp/app/entities/metis/post.model.ts +++ b/src/main/webapp/app/entities/metis/post.model.ts @@ -2,7 +2,6 @@ import { AnswerPost } from 'app/entities/metis/answer-post.model'; import { Posting } from 'app/entities/metis/posting.model'; import { DisplayPriority } from 'app/shared/metis/metis.util'; import { PlagiarismCase } from 'app/exercises/shared/plagiarism/types/PlagiarismCase'; -import { Conversation } from 'app/entities/metis/conversation/conversation.model'; export class Post extends Posting { public title?: string; @@ -10,7 +9,6 @@ export class Post extends Posting { public answers?: AnswerPost[]; public tags?: string[]; public plagiarismCase?: PlagiarismCase; - public conversation?: Conversation; public displayPriority?: DisplayPriority; public resolved?: boolean; public isConsecutive?: boolean = false; diff --git a/src/main/webapp/app/entities/metis/posting.model.ts b/src/main/webapp/app/entities/metis/posting.model.ts index fc7d5d206095..dff35c800654 100644 --- a/src/main/webapp/app/entities/metis/posting.model.ts +++ b/src/main/webapp/app/entities/metis/posting.model.ts @@ -3,13 +3,58 @@ import { User } from 'app/core/user/user.model'; import dayjs from 'dayjs/esm'; import { Reaction } from 'app/entities/metis/reaction.model'; import { UserRole } from 'app/shared/metis/metis.util'; +import { Conversation } from 'app/entities/metis/conversation/conversation.model'; + +export enum SavedPostStatus { + PROGRESS = 0, + COMPLETED = 1, + ARCHIVED = 2, +} + +export enum SavedPostStatusMap { + PROGRESS = 'progress', + COMPLETED = 'completed', + ARCHIVED = 'archived', +} + +export enum PostingType { + POST = 0, + ANSWER = 1, +} export abstract class Posting implements BaseEntity { public id?: number; + public referencePostId?: number; public author?: User; public authorRole?: UserRole; public creationDate?: dayjs.Dayjs; public updatedDate?: dayjs.Dayjs; public content?: string; + public isSaved?: boolean; + public savedPostStatus?: number; + public postingType?: number; public reactions?: Reaction[]; + public conversation?: Conversation; + + public static mapToStatus(map: SavedPostStatusMap) { + switch (map) { + case SavedPostStatusMap.COMPLETED: + return SavedPostStatus.COMPLETED; + case SavedPostStatusMap.ARCHIVED: + return SavedPostStatus.ARCHIVED; + default: + return SavedPostStatus.PROGRESS; + } + } + + public static statusToMap(status: SavedPostStatus) { + switch (status) { + case SavedPostStatus.COMPLETED: + return SavedPostStatusMap.COMPLETED; + case SavedPostStatus.ARCHIVED: + return SavedPostStatusMap.ARCHIVED; + default: + return SavedPostStatusMap.PROGRESS; + } + } } diff --git a/src/main/webapp/app/entities/participation/participation.model.ts b/src/main/webapp/app/entities/participation/participation.model.ts index 0dfd62e9865c..540aab6900cb 100644 --- a/src/main/webapp/app/entities/participation/participation.model.ts +++ b/src/main/webapp/app/entities/participation/participation.model.ts @@ -39,6 +39,9 @@ export abstract class Participation implements BaseEntity { public initializationDate?: dayjs.Dayjs; public individualDueDate?: dayjs.Dayjs; public presentationScore?: number; + /** + * @deprecated This property will be removed in Artemis 8.0. Use `submissions.results` instead. + */ public results?: Result[]; public submissions?: Submission[]; public exercise?: Exercise; diff --git a/src/main/webapp/app/entities/result.model.ts b/src/main/webapp/app/entities/result.model.ts index 47fff80fda31..cea278d8cb53 100644 --- a/src/main/webapp/app/entities/result.model.ts +++ b/src/main/webapp/app/entities/result.model.ts @@ -30,6 +30,9 @@ export class Result implements BaseEntity { public submission?: Submission; public assessor?: User; public feedbacks?: Feedback[]; + /** + * @deprecated This property will be removed in Artemis 8.0. Use `submission.participation` instead. + */ public participation?: Participation; // helper attributes diff --git a/src/main/webapp/app/exam/manage/exam-management.route.ts b/src/main/webapp/app/exam/manage/exam-management.route.ts index c6933eb74d2f..e9fda87890c7 100644 --- a/src/main/webapp/app/exam/manage/exam-management.route.ts +++ b/src/main/webapp/app/exam/manage/exam-management.route.ts @@ -65,6 +65,7 @@ import { RepositoryViewComponent } from 'app/localvc/repository-view/repository- import { LocalVCGuard } from 'app/localvc/localvc-guard.service'; import { CommitHistoryComponent } from 'app/localvc/commit-history/commit-history.component'; import { CommitDetailsViewComponent } from 'app/localvc/commit-details-view/commit-details-view.component'; +import { VcsRepositoryAccessLogViewComponent } from 'app/localvc/vcs-repository-access-log-view/vcs-repository-access-log-view.component'; export const examManagementRoute: Routes = [ { @@ -652,6 +653,30 @@ export const examManagementRoute: Routes = [ }, canActivate: [UserRouteAccessService, LocalVCGuard], }, + { + path: ':examId/exercise-groups/:exerciseGroupId/programming-exercises/:exerciseId/repository/:repositoryType/vcs-access-log', + component: VcsRepositoryAccessLogViewComponent, + data: { + authorities: [Authority.ADMIN, Authority.INSTRUCTOR], + pageTitle: 'artemisApp.repository.title', + flushRepositoryCacheAfter: 900000, // 15 min + participationCache: {}, + repositoryCache: {}, + }, + canActivate: [UserRouteAccessService, LocalVCGuard], + }, + { + path: ':examId/exercise-groups/:exerciseGroupId/programming-exercises/:exerciseId/repository/:repositoryType/repo/:repositoryId', + component: RepositoryViewComponent, + data: { + authorities: [Authority.ADMIN, Authority.INSTRUCTOR, Authority.EDITOR, Authority.TA], + pageTitle: 'artemisApp.repository.title', + flushRepositoryCacheAfter: 900000, // 15 min + participationCache: {}, + repositoryCache: {}, + }, + canActivate: [UserRouteAccessService, LocalVCGuard], + }, { path: ':examId/exercise-groups/:exerciseGroupId/programming-exercises/:exerciseId/repository/:repositoryType/commit-history', component: CommitHistoryComponent, @@ -688,6 +713,18 @@ export const examManagementRoute: Routes = [ }, canActivate: [UserRouteAccessService, LocalVCGuard], }, + { + path: ':examId/exercise-groups/:exerciseGroupId/programming-exercises/:exerciseId/participations/:participationId/repository/vcs-access-log', + component: VcsRepositoryAccessLogViewComponent, + data: { + authorities: [Authority.ADMIN, Authority.INSTRUCTOR], + pageTitle: 'artemisApp.repository.title', + flushRepositoryCacheAfter: 900000, // 15 min + participationCache: {}, + repositoryCache: {}, + }, + canActivate: [UserRouteAccessService, LocalVCGuard], + }, { path: ':examId/exercise-groups/:exerciseGroupId/programming-exercises/:exerciseId/participations/:participationId/repository/commit-history', component: CommitHistoryComponent, diff --git a/src/main/webapp/app/exam/manage/exercise-groups/exercise-groups.component.html b/src/main/webapp/app/exam/manage/exercise-groups/exercise-groups.component.html index 8b72a9f8f417..43cd6963ce39 100644 --- a/src/main/webapp/app/exam/manage/exercise-groups/exercise-groups.component.html +++ b/src/main/webapp/app/exam/manage/exercise-groups/exercise-groups.component.html @@ -242,7 +242,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/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 @@

}
-