From 58b8b59b4cb7709344678bfcccecda037f67ef73 Mon Sep 17 00:00:00 2001 From: Simon Entholzer <33342534+SimonEntholzer@users.noreply.github.com> Date: Fri, 29 Nov 2024 16:43:15 +0100 Subject: [PATCH 01/10] Integrated code lifecycle: Support multiple SSH keys per user (#9478) --- .../tum/cit/aet/artemis/core/domain/User.java | 27 --- .../tum/cit/aet/artemis/core/dto/UserDTO.java | 20 -- .../core/repository/UserRepository.java | 12 -- .../aet/artemis/core/web/AccountResource.java | 49 ----- .../core/web/open/PublicAccountResource.java | 2 - .../programming/domain/UserSshPublicKey.java | 131 +++++++++++++ .../programming/dto/UserSshPublicKeyDTO.java | 16 ++ .../UserSshPublicKeyRepository.java | 27 +++ .../service/UserSshPublicKeyService.java | 139 ++++++++++++++ .../GitPublickeyAuthenticatorService.java | 127 ++++++++++--- .../localvc/ssh/SshPublicKeysResource.java | 117 ++++++++++++ .../changelog/20241105150000_changelog.xml | 59 ++++++ .../resources/config/liquibase/master.xml | 3 +- .../webapp/app/core/auth/account.service.ts | 23 --- src/main/webapp/app/core/user/user.model.ts | 4 - .../programming/user-ssh-public-key.model.ts | 13 ++ .../code-button/code-button.component.html | 5 +- .../code-button/code-button.component.ts | 27 ++- ...h-user-settings-key-details.component.html | 138 ++++++++++++++ ...ssh-user-settings-key-details.component.ts | 141 ++++++++++++++ .../ssh-user-settings.component.html | 167 +++++++---------- .../ssh-user-settings.component.scss | 118 +++++------- .../ssh-user-settings.component.ts | 133 +++++-------- .../ssh-settings/ssh-user-settings.service.ts | 94 ++++++++++ .../user-settings/user-settings.module.ts | 2 + .../user-settings/user-settings.route.ts | 15 ++ .../content/scss/themes/_dark-variables.scss | 2 +- src/main/webapp/i18n/de/exercise-actions.json | 1 + src/main/webapp/i18n/de/userSettings.json | 25 ++- src/main/webapp/i18n/en/exercise-actions.json | 3 +- src/main/webapp/i18n/en/userSettings.json | 25 ++- .../UserAccountLocalVcsIntegrationTest.java | 6 - .../core/user/util/UserTestService.java | 31 +--- .../icl/LocalVCSshIntegrationTest.java | 50 ++++- .../icl/LocalVCSshSettingsTest.java | 70 +++++++ .../icl/util/SshSettingsTestService.java | 174 ++++++++++++++++++ .../base/AbstractArtemisIntegrationTest.java | 4 + .../account-information.component.spec.ts | 3 +- ...ser-settings-key-details.component.spec.ts | 163 ++++++++++++++++ .../ssh-user-settings.component.spec.ts | 168 +++++------------ .../shared/code-button.component.spec.ts | 10 + .../mocks/service/mock-account.service.ts | 2 +- .../service/mock-ssh-user-settings.service.ts | 11 ++ .../spec/service/account.service.spec.ts | 56 ++++++ .../ssh-user-settings.service.spec.ts | 85 +++++++++ .../resources/config/application-artemis.yml | 2 +- src/test/resources/config/application.yml | 4 +- 47 files changed, 1917 insertions(+), 587 deletions(-) create mode 100644 src/main/java/de/tum/cit/aet/artemis/programming/domain/UserSshPublicKey.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/programming/dto/UserSshPublicKeyDTO.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/programming/repository/UserSshPublicKeyRepository.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/programming/service/UserSshPublicKeyService.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/programming/web/localvc/ssh/SshPublicKeysResource.java create mode 100644 src/main/resources/config/liquibase/changelog/20241105150000_changelog.xml create mode 100644 src/main/webapp/app/entities/programming/user-ssh-public-key.model.ts create mode 100644 src/main/webapp/app/shared/user-settings/ssh-settings/details/ssh-user-settings-key-details.component.html create mode 100644 src/main/webapp/app/shared/user-settings/ssh-settings/details/ssh-user-settings-key-details.component.ts create mode 100644 src/main/webapp/app/shared/user-settings/ssh-settings/ssh-user-settings.service.ts create mode 100644 src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalVCSshSettingsTest.java create mode 100644 src/test/java/de/tum/cit/aet/artemis/programming/icl/util/SshSettingsTestService.java create mode 100644 src/test/javascript/spec/component/account/ssh-user-settings-key-details.component.spec.ts create mode 100644 src/test/javascript/spec/helpers/mocks/service/mock-ssh-user-settings.service.ts create mode 100644 src/test/javascript/spec/service/settings/ssh-user-settings.service.spec.ts 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 2ef2478cf295..c435194cdf40 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/domain/User.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/domain/User.java @@ -156,23 +156,6 @@ public class User extends AbstractAuditingEntity implements Participant { @Column(name = "vcs_access_token_expiry_date") private ZonedDateTime vcsAccessTokenExpiryDate = null; - /** - * The actual full public ssh key of a user used to authenticate git clone and git push operations if available - */ - @Nullable - @JsonIgnore - @Column(name = "ssh_public_key") - private final String sshPublicKey = null; - - /** - * A hash of the public ssh key for fast comparison in the database (with an index) - */ - @Nullable - @Size(max = 100) - @JsonIgnore - @Column(name = "ssh_public_key_hash") - private final String sshPublicKeyHash = null; - @ElementCollection(fetch = FetchType.LAZY) @CollectionTable(name = "user_groups", joinColumns = @JoinColumn(name = "user_id")) @Column(name = "user_groups") @@ -560,14 +543,4 @@ public void hasAcceptedIrisElseThrow() { throw new AccessForbiddenException("The user has not accepted the Iris privacy policy yet."); } } - - @Nullable - public String getSshPublicKey() { - return sshPublicKey; - } - - @Nullable - public @Size(max = 100) String getSshPublicKeyHash() { - return sshPublicKeyHash; - } } diff --git a/src/main/java/de/tum/cit/aet/artemis/core/dto/UserDTO.java b/src/main/java/de/tum/cit/aet/artemis/core/dto/UserDTO.java index 3d627425a8f7..1f5ea1e653b8 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/dto/UserDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/dto/UserDTO.java @@ -76,10 +76,6 @@ public class UserDTO extends AuditingEntityDTO { private ZonedDateTime vcsAccessTokenExpiryDate; - private String sshPublicKey; - - private String sshKeyHash; - private ZonedDateTime irisAccepted; public UserDTO() { @@ -262,14 +258,6 @@ public ZonedDateTime getVcsAccessTokenExpiryDate() { return vcsAccessTokenExpiryDate; } - public String getSshPublicKey() { - return sshPublicKey; - } - - public void setSshPublicKey(String sshPublicKey) { - this.sshPublicKey = sshPublicKey; - } - @Override public String toString() { return "UserDTO{" + "login='" + login + '\'' + ", firstName='" + firstName + '\'' + ", lastName='" + lastName + '\'' + ", email='" + email + '\'' + ", imageUrl='" @@ -293,12 +281,4 @@ public ZonedDateTime getIrisAccepted() { public void setIrisAccepted(ZonedDateTime irisAccepted) { this.irisAccepted = irisAccepted; } - - public String getSshKeyHash() { - return sshKeyHash; - } - - public void setSshKeyHash(String sshKeyHash) { - this.sshKeyHash = sshKeyHash; - } } diff --git a/src/main/java/de/tum/cit/aet/artemis/core/repository/UserRepository.java b/src/main/java/de/tum/cit/aet/artemis/core/repository/UserRepository.java index 5b66b31aee98..0d3280cf5d96 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/repository/UserRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/repository/UserRepository.java @@ -724,16 +724,6 @@ default Page searchAllWithGroupsByLoginOrNameInCourseAndReturnPage(Pageabl """) void updateUserLanguageKey(@Param("userId") long userId, @Param("languageKey") String languageKey); - @Modifying - @Transactional // ok because of modifying query - @Query(""" - UPDATE User user - SET user.sshPublicKeyHash = :sshPublicKeyHash, - user.sshPublicKey = :sshPublicKey - WHERE user.id = :userId - """) - void updateUserSshPublicKeyHash(@Param("userId") long userId, @Param("sshPublicKeyHash") String sshPublicKeyHash, @Param("sshPublicKey") String sshPublicKey); - @Modifying @Transactional // ok because of modifying query @Query(""" @@ -1120,8 +1110,6 @@ default boolean isCurrentUser(String login) { return SecurityUtils.getCurrentUserLogin().map(currentLogin -> currentLogin.equals(login)).orElse(false); } - Optional findBySshPublicKeyHash(String keyString); - /** * Finds all users which a non-null VCS access token that expires before some given date. * diff --git a/src/main/java/de/tum/cit/aet/artemis/core/web/AccountResource.java b/src/main/java/de/tum/cit/aet/artemis/core/web/AccountResource.java index 997574c76da7..16c94629047c 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/web/AccountResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/web/AccountResource.java @@ -2,19 +2,15 @@ import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; -import java.io.IOException; import java.net.URI; import java.net.URISyntaxException; import java.nio.file.Path; -import java.security.GeneralSecurityException; -import java.security.PublicKey; import java.time.ZonedDateTime; import java.util.Optional; import jakarta.validation.Valid; import jakarta.ws.rs.BadRequestException; -import org.apache.sshd.common.config.keys.AuthorizedKeyEntry; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Profile; @@ -45,7 +41,6 @@ import de.tum.cit.aet.artemis.core.service.user.UserCreationService; import de.tum.cit.aet.artemis.core.service.user.UserService; import de.tum.cit.aet.artemis.programming.service.localvc.LocalVCPersonalAccessTokenManagementService; -import de.tum.cit.aet.artemis.programming.service.localvc.ssh.HashUtils; /** * REST controller for managing the current user's account. @@ -128,50 +123,6 @@ public ResponseEntity changePassword(@RequestBody PasswordChangeDTO passwo return ResponseEntity.ok().build(); } - /** - * PUT account/ssh-public-key : sets the ssh public key - * - * @param sshPublicKey the ssh public key to set - * - * @return the ResponseEntity with status 200 (OK), with status 404 (Not Found), or with status 400 (Bad Request) - */ - @PutMapping("account/ssh-public-key") - @EnforceAtLeastStudent - public ResponseEntity addSshPublicKey(@RequestBody String sshPublicKey) throws GeneralSecurityException, IOException { - - User user = userRepository.getUser(); - log.debug("REST request to add SSH key to user {}", user.getLogin()); - // Parse the public key string - AuthorizedKeyEntry keyEntry; - try { - keyEntry = AuthorizedKeyEntry.parseAuthorizedKeyEntry(sshPublicKey); - } - catch (IllegalArgumentException e) { - throw new BadRequestAlertException("Invalid SSH key format", "SSH key", "invalidKeyFormat", true); - } - // Extract the PublicKey object - PublicKey publicKey = keyEntry.resolvePublicKey(null, null, null); - String keyHash = HashUtils.getSha512Fingerprint(publicKey); - userRepository.updateUserSshPublicKeyHash(user.getId(), keyHash, sshPublicKey); - return ResponseEntity.ok().build(); - } - - /** - * PUT account/ssh-public-key : sets the ssh public key - * - * @return the ResponseEntity with status 200 (OK), with status 404 (Not Found), or with status 400 (Bad Request) - */ - @DeleteMapping("account/ssh-public-key") - @EnforceAtLeastStudent - public ResponseEntity deleteSshPublicKey() { - User user = userRepository.getUser(); - log.debug("REST request to remove SSH key of user {}", user.getLogin()); - userRepository.updateUserSshPublicKeyHash(user.getId(), null, null); - - log.debug("Successfully deleted SSH key of user {}", user.getLogin()); - return ResponseEntity.ok().build(); - } - /** * PUT account/user-vcs-access-token : creates a vcsAccessToken for a user * diff --git a/src/main/java/de/tum/cit/aet/artemis/core/web/open/PublicAccountResource.java b/src/main/java/de/tum/cit/aet/artemis/core/web/open/PublicAccountResource.java index bd673ece51d4..543ad85964da 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/web/open/PublicAccountResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/web/open/PublicAccountResource.java @@ -166,8 +166,6 @@ public ResponseEntity getAccount() { // we set this value on purpose here: the user can only fetch their own information, make the token available for constructing the token-based clone-URL userDTO.setVcsAccessToken(user.getVcsAccessToken()); userDTO.setVcsAccessTokenExpiryDate(user.getVcsAccessTokenExpiryDate()); - userDTO.setSshPublicKey(user.getSshPublicKey()); - userDTO.setSshKeyHash(user.getSshPublicKeyHash()); log.info("GET /account {} took {}ms", user.getLogin(), System.currentTimeMillis() - start); return ResponseEntity.ok(userDTO); } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/domain/UserSshPublicKey.java b/src/main/java/de/tum/cit/aet/artemis/programming/domain/UserSshPublicKey.java new file mode 100644 index 000000000000..8348c7eb656c --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/domain/UserSshPublicKey.java @@ -0,0 +1,131 @@ +package de.tum.cit.aet.artemis.programming.domain; + +import java.time.ZonedDateTime; + +import jakarta.annotation.Nullable; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import org.hibernate.annotations.Cache; +import org.hibernate.annotations.CacheConcurrencyStrategy; + +import de.tum.cit.aet.artemis.core.domain.DomainObject; + +/** + * A public SSH key of a user. + */ +@Entity +@Table(name = "user_public_ssh_key") +@Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) +public class UserSshPublicKey extends DomainObject { + + /** + * The user who is owner of the public key + */ + @NotNull + @Column(name = "user_id") + private long userId; + + /** + * The label of the SSH key shwon in the UI + */ + @Size(max = 50) + @Column(name = "label", length = 50) + private String label; + + /** + * The actual full public ssh key of a user used to authenticate git clone and git push operations if available + */ + @NotNull + @Column(name = "public_key") + private String publicKey; + + /** + * A hash of the public ssh key for fast comparison in the database (with an index) + */ + @Size(max = 100) + @Column(name = "key_hash") + private String keyHash; + + /** + * The creation date of the public SSH key + */ + @Column(name = "creation_date") + private ZonedDateTime creationDate = null; + + /** + * The last used date of the public SSH key + */ + @Nullable + @Column(name = "last_used_date") + private ZonedDateTime lastUsedDate = null; + + /** + * The expiry date of the public SSH key + */ + @Nullable + @Column(name = "expiry_date") + private ZonedDateTime expiryDate = null; + + public @NotNull long getUserId() { + return userId; + } + + public void setUserId(@NotNull long userId) { + this.userId = userId; + } + + public @Size(max = 50) String getLabel() { + return label; + } + + public void setLabel(@Size(max = 50) String label) { + this.label = label; + } + + public String getPublicKey() { + return publicKey; + } + + public void setPublicKey(String publicKey) { + this.publicKey = publicKey; + } + + @Nullable + public @Size(max = 100) String getKeyHash() { + return keyHash; + } + + public void setKeyHash(@Nullable @Size(max = 100) String keyHash) { + this.keyHash = keyHash; + } + + public ZonedDateTime getCreationDate() { + return creationDate; + } + + public void setCreationDate(ZonedDateTime creationDate) { + this.creationDate = creationDate; + } + + @Nullable + public ZonedDateTime getLastUsedDate() { + return lastUsedDate; + } + + public void setLastUsedDate(@Nullable ZonedDateTime lastUsedDate) { + this.lastUsedDate = lastUsedDate; + } + + @Nullable + public ZonedDateTime getExpiryDate() { + return expiryDate; + } + + public void setExpiryDate(@Nullable ZonedDateTime expiryDate) { + this.expiryDate = expiryDate; + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/dto/UserSshPublicKeyDTO.java b/src/main/java/de/tum/cit/aet/artemis/programming/dto/UserSshPublicKeyDTO.java new file mode 100644 index 000000000000..ecfeedab8126 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/dto/UserSshPublicKeyDTO.java @@ -0,0 +1,16 @@ +package de.tum.cit.aet.artemis.programming.dto; + +import java.time.ZonedDateTime; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.cit.aet.artemis.programming.domain.UserSshPublicKey; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record UserSshPublicKeyDTO(Long id, String label, String publicKey, String keyHash, ZonedDateTime creationDate, ZonedDateTime lastUsedDate, ZonedDateTime expiryDate) { + + public static UserSshPublicKeyDTO of(UserSshPublicKey userSshPublicKey) { + return new UserSshPublicKeyDTO(userSshPublicKey.getId(), userSshPublicKey.getLabel(), userSshPublicKey.getPublicKey(), userSshPublicKey.getKeyHash(), + userSshPublicKey.getCreationDate(), userSshPublicKey.getLastUsedDate(), userSshPublicKey.getExpiryDate()); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/repository/UserSshPublicKeyRepository.java b/src/main/java/de/tum/cit/aet/artemis/programming/repository/UserSshPublicKeyRepository.java new file mode 100644 index 000000000000..b177b7b72089 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/repository/UserSshPublicKeyRepository.java @@ -0,0 +1,27 @@ +package de.tum.cit.aet.artemis.programming.repository; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import java.util.List; +import java.util.Optional; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Repository; + +import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository; +import de.tum.cit.aet.artemis.programming.domain.UserSshPublicKey; + +@Profile(PROFILE_CORE) +@Repository +public interface UserSshPublicKeyRepository extends ArtemisJpaRepository { + + List findAllByUserId(Long userId); + + Optional findByKeyHash(String keyHash); + + Optional findByIdAndUserId(Long keyId, Long userId); + + boolean existsByIdAndUserId(Long id, Long userId); + + boolean existsByUserId(Long userId); +} diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/UserSshPublicKeyService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/UserSshPublicKeyService.java new file mode 100644 index 000000000000..232afd327663 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/UserSshPublicKeyService.java @@ -0,0 +1,139 @@ +package de.tum.cit.aet.artemis.programming.service; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.security.PublicKey; +import java.time.ZonedDateTime; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +import org.apache.commons.lang3.StringUtils; +import org.apache.sshd.common.config.keys.AuthorizedKeyEntry; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import de.tum.cit.aet.artemis.core.domain.User; +import de.tum.cit.aet.artemis.core.exception.AccessForbiddenException; +import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException; +import de.tum.cit.aet.artemis.programming.domain.UserSshPublicKey; +import de.tum.cit.aet.artemis.programming.dto.UserSshPublicKeyDTO; +import de.tum.cit.aet.artemis.programming.repository.UserSshPublicKeyRepository; +import de.tum.cit.aet.artemis.programming.service.localvc.ssh.HashUtils; + +@Profile(PROFILE_CORE) +@Service +public class UserSshPublicKeyService { + + private final UserSshPublicKeyRepository userSshPublicKeyRepository; + + public UserSshPublicKeyService(UserSshPublicKeyRepository userSshPublicKeyRepository) { + this.userSshPublicKeyRepository = userSshPublicKeyRepository; + } + + /** + * Creates a new SSH public key for the specified user, ensuring that the key is unique + * based on its SHA-512 hash fingerprint. If the key already exists, an exception is thrown. + * + * @param user the {@link User} for whom the SSH key is being created. + * @param keyEntry the {@link AuthorizedKeyEntry} containing the SSH public key details, used to resolve the {@link PublicKey}. + * @param sshPublicKey the {@link UserSshPublicKey} object containing metadata about the SSH key such as the key itself, label, and expiry date. + */ + public void createSshKeyForUser(User user, AuthorizedKeyEntry keyEntry, UserSshPublicKeyDTO sshPublicKey) throws GeneralSecurityException, IOException { + PublicKey publicKey = keyEntry.resolvePublicKey(null, null, null); + String keyHash = HashUtils.getSha512Fingerprint(publicKey); + + if (userSshPublicKeyRepository.findByKeyHash(keyHash).isPresent()) { + throw new BadRequestAlertException("Key already exists", "SSH key", "keyAlreadyExists", true); + } + + UserSshPublicKey newUserSshPublicKey = new UserSshPublicKey(); + newUserSshPublicKey.setUserId(user.getId()); + newUserSshPublicKey.setPublicKey(sshPublicKey.publicKey()); + newUserSshPublicKey.setKeyHash(keyHash); + setLabelForKey(newUserSshPublicKey, sshPublicKey.label()); + newUserSshPublicKey.setCreationDate(ZonedDateTime.now()); + newUserSshPublicKey.setExpiryDate(sshPublicKey.expiryDate()); + userSshPublicKeyRepository.save(newUserSshPublicKey); + } + + /** + * Sets the label for the provided SSH public key. If the given label is null or empty, + * the label is extracted from the public key or defaults to a predefined value. + * + * @param newSshPublicKey the {@link UserSshPublicKey} for which the label is being set. + * @param label the label to assign to the SSH key, or null/empty to use the default logic. + * @throws BadRequestAlertException if the key label is longer than 50 characters + */ + private void setLabelForKey(UserSshPublicKey newSshPublicKey, String label) { + if (StringUtils.isBlank(label)) { + String[] parts = newSshPublicKey.getPublicKey().split("\\s+"); + + // we are only interested in the comment of the key. A typical key looks like this, the key prefix, the actual key and then the comment: + // ssh-rsa AAAAB3NzaC1yc2EAAAADAYVTLQ== comment + if (parts.length >= 3) { + label = String.join(" ", Arrays.copyOfRange(parts, 2, parts.length)); + } + else { + label = "Key " + (userSshPublicKeyRepository.findAllByUserId(newSshPublicKey.getUserId()).size() + 1); + } + } + if (label.length() <= 50) { + newSshPublicKey.setLabel(label); + } + else { + throw new BadRequestAlertException("Key label is too long", "SSH key", "keyLabelTooLong", true); + } + } + + /** + * Retrieves the SSH public key for the specified user by key ID. + * + * @param user the {@link User} to whom the SSH key belongs. + * @param keyId the ID of the SSH key. + * @return the {@link UserSshPublicKey} if found and belongs to the user. + * @throws AccessForbiddenException if the key does not belong to the user, or does not exist + */ + public UserSshPublicKey getSshKeyForUser(User user, Long keyId) { + Optional userSshPublicKey = userSshPublicKeyRepository.findByIdAndUserId(keyId, user.getId()); + return userSshPublicKey.orElseThrow(() -> new AccessForbiddenException("SSH key", keyId)); + } + + /** + * Retrieves all SSH public keys associated with the specified user. + * + * @param user the {@link User} whose SSH keys are to be retrieved. + * @return a list of {@link UserSshPublicKey} objects for the user. + */ + public List getAllSshKeysForUser(User user) { + return userSshPublicKeyRepository.findAllByUserId(user.getId()).stream().map(UserSshPublicKeyDTO::of).toList(); + } + + /** + * Deletes the specified SSH public key for the given user ID. + * + * @param userId the ID of the user. + * @param keyId the ID of the SSH key to delete. + * @throws AccessForbiddenException if the key does not belong to the user. + */ + public void deleteUserSshPublicKey(Long userId, Long keyId) { + if (userSshPublicKeyRepository.existsByIdAndUserId(keyId, userId)) { + userSshPublicKeyRepository.deleteById(keyId); + } + else { + throw new AccessForbiddenException("SSH key", keyId); + } + } + + /** + * Returns whether the user of the specified id has stored SSH keys + * + * @param userId the ID of the user. + * @return true if the user has SSH keys, false if not + */ + public boolean hasUserSSHkeys(Long userId) { + return userSshPublicKeyRepository.existsByUserId(userId); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/GitPublickeyAuthenticatorService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/GitPublickeyAuthenticatorService.java index 4f0cad0aa4e9..3c9bc85f83a4 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/GitPublickeyAuthenticatorService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/GitPublickeyAuthenticatorService.java @@ -5,6 +5,7 @@ import java.io.IOException; import java.security.GeneralSecurityException; import java.security.PublicKey; +import java.time.ZonedDateTime; import java.util.Objects; import java.util.Optional; @@ -13,11 +14,14 @@ import org.apache.sshd.server.session.ServerSession; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; import de.tum.cit.aet.artemis.buildagent.dto.BuildAgentInformation; import de.tum.cit.aet.artemis.core.repository.UserRepository; +import de.tum.cit.aet.artemis.programming.domain.UserSshPublicKey; +import de.tum.cit.aet.artemis.programming.repository.UserSshPublicKeyRepository; import de.tum.cit.aet.artemis.programming.service.localci.SharedQueueManagementService; import de.tum.cit.aet.artemis.programming.service.localvc.ssh.HashUtils; import de.tum.cit.aet.artemis.programming.service.localvc.ssh.SshConstants; @@ -32,41 +36,85 @@ public class GitPublickeyAuthenticatorService implements PublickeyAuthenticator private final Optional localCIBuildJobQueueService; - public GitPublickeyAuthenticatorService(UserRepository userRepository, Optional localCIBuildJobQueueService) { + private final UserSshPublicKeyRepository userSshPublicKeyRepository; + + private final int AUTHENTICATION_FAILED_CODE = 10; + + @Value("${server.url}") + private String artemisServerUrl; + + public GitPublickeyAuthenticatorService(UserRepository userRepository, Optional localCIBuildJobQueueService, + UserSshPublicKeyRepository userSshPublicKeyRepository) { this.userRepository = userRepository; this.localCIBuildJobQueueService = localCIBuildJobQueueService; + this.userSshPublicKeyRepository = userSshPublicKeyRepository; } @Override public boolean authenticate(String username, PublicKey publicKey, ServerSession session) { String keyHash = HashUtils.getSha512Fingerprint(publicKey); - var user = userRepository.findBySshPublicKeyHash(keyHash); - if (user.isPresent()) { - try { - // Retrieve the stored public key string - String storedPublicKeyString = user.get().getSshPublicKey(); - - // Parse the stored public key string - AuthorizedKeyEntry keyEntry = AuthorizedKeyEntry.parseAuthorizedKeyEntry(storedPublicKeyString); - PublicKey storedPublicKey = keyEntry.resolvePublicKey(null, null, null); - - // Compare the stored public key with the provided public key - if (Objects.equals(storedPublicKey, publicKey)) { - log.debug("Found user {} for public key authentication", user.get().getLogin()); - session.setAttribute(SshConstants.USER_KEY, user.get()); - session.setAttribute(SshConstants.IS_BUILD_AGENT_KEY, false); - return true; - } - else { - log.warn("Public key mismatch for user {}", user.get().getLogin()); - } + var userSshPublicKey = userSshPublicKeyRepository.findByKeyHash(keyHash); + return userSshPublicKey.map(sshPublicKey -> { + ZonedDateTime expiryDate = sshPublicKey.getExpiryDate(); + if (expiryDate == null || expiryDate.isAfter(ZonedDateTime.now())) { + return authenticateUser(sshPublicKey, publicKey, session); + } + else { + disconnectBecauseKeyHasExpired(session); + } + + return false; + }).orElseGet(() -> authenticateBuildAgent(publicKey, session)); + } + + /** + * Tries to authenticate a user by the provided key + * + * @param storedKey The key stored in the Artemis database + * @param providedKey The key provided by the user for authentication + * @param session The SSH server session + * + * @return true if the authentication succeeds, and false if it doesn't + */ + private boolean authenticateUser(UserSshPublicKey storedKey, PublicKey providedKey, ServerSession session) { + try { + var user = userRepository.findById(storedKey.getUserId()); + if (user.isEmpty()) { + return false; + } + // Retrieve and parse the stored public key string + AuthorizedKeyEntry keyEntry = AuthorizedKeyEntry.parseAuthorizedKeyEntry(storedKey.getPublicKey()); + PublicKey storedPublicKey = keyEntry.resolvePublicKey(null, null, null); + + // Compare the stored public key with the provided public key + if (Objects.equals(storedPublicKey, providedKey)) { + log.debug("Found user {} for public key authentication", user.get().getLogin()); + session.setAttribute(SshConstants.USER_KEY, user.get()); + session.setAttribute(SshConstants.IS_BUILD_AGENT_KEY, false); + return true; } - catch (Exception e) { - log.error("Failed to convert stored public key string to PublicKey object", e); + else { + log.warn("Public key mismatch for user {}", user.get().getLogin()); } } - else if (localCIBuildJobQueueService.isPresent() - && localCIBuildJobQueueService.get().getBuildAgentInformation().stream().anyMatch(agent -> checkPublicKeyMatchesBuildAgentPublicKey(agent, publicKey))) { + catch (Exception e) { + log.error("Failed to convert stored public key string to PublicKey object", e); + } + return false; + } + + /** + * Tries to authenticate a build agent by the provided key + * + * @param providedKey The key provided by the user for authentication + * @param session The SSH server session + * + * @return true if the authentication succeeds, and false if it doesn't + */ + private boolean authenticateBuildAgent(PublicKey providedKey, ServerSession session) { + if (localCIBuildJobQueueService.isPresent() + && localCIBuildJobQueueService.get().getBuildAgentInformation().stream().anyMatch(agent -> checkPublicKeyMatchesBuildAgentPublicKey(agent, providedKey))) { + log.info("Authenticating as build agent"); session.setAttribute(SshConstants.IS_BUILD_AGENT_KEY, true); return true; @@ -74,6 +122,14 @@ else if (localCIBuildJobQueueService.isPresent() return false; } + /** + * Checks whether a provided key matches the build agents public key + * + * @param agent The build agent which tires to be authenticated by Artemis + * @param publicKey The provided public key + * + * @return true if the build agents has this public key, and false if it doesn't + */ private boolean checkPublicKeyMatchesBuildAgentPublicKey(BuildAgentInformation agent, PublicKey publicKey) { if (agent.publicSshKey() == null) { return false; @@ -90,4 +146,25 @@ private boolean checkPublicKeyMatchesBuildAgentPublicKey(BuildAgentInformation a return agentPublicKey.equals(publicKey); } + + /** + * Disconnects the client from the session and informs that the key used to authenticate with has expired + * + * @param session the session with the client + */ + private void disconnectBecauseKeyHasExpired(ServerSession session) { + try { + var keyExpiredErrorMessage = String.format(""" + Keys expired. + + One of your SSH keys has expired. Renew it in the Artemis settings: + %s/user-settings/ssh + """, artemisServerUrl); + + session.disconnect(AUTHENTICATION_FAILED_CODE, keyExpiredErrorMessage); + } + catch (IOException e) { + log.info("Failed to disconnect SSH client session {}", e.getMessage()); + } + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/web/localvc/ssh/SshPublicKeysResource.java b/src/main/java/de/tum/cit/aet/artemis/programming/web/localvc/ssh/SshPublicKeysResource.java new file mode 100644 index 000000000000..8eb38fdfd263 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/web/localvc/ssh/SshPublicKeysResource.java @@ -0,0 +1,117 @@ +package de.tum.cit.aet.artemis.programming.web.localvc.ssh; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_LOCALVC; + +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.util.List; + +import org.apache.sshd.common.config.keys.AuthorizedKeyEntry; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import de.tum.cit.aet.artemis.core.domain.User; +import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException; +import de.tum.cit.aet.artemis.core.repository.UserRepository; +import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastStudent; +import de.tum.cit.aet.artemis.programming.domain.UserSshPublicKey; +import de.tum.cit.aet.artemis.programming.dto.UserSshPublicKeyDTO; +import de.tum.cit.aet.artemis.programming.service.UserSshPublicKeyService; + +@Profile(PROFILE_LOCALVC) +@RestController +@RequestMapping("api/ssh-settings/") +public class SshPublicKeysResource { + + private static final Logger log = LoggerFactory.getLogger(SshPublicKeysResource.class); + + private final UserSshPublicKeyService userSshPublicKeyService; + + private final UserRepository userRepository; + + public SshPublicKeysResource(UserSshPublicKeyService userSshPublicKeyService, UserRepository userRepository) { + this.userSshPublicKeyService = userSshPublicKeyService; + this.userRepository = userRepository; + } + + /** + * GET public-keys : retrieves all SSH keys of a user + * + * @return the ResponseEntity containing all public SSH keys of a user with status 200 (OK) + */ + @GetMapping("public-keys") + @EnforceAtLeastStudent + public ResponseEntity> getSshPublicKeys() { + User user = userRepository.getUser(); + List keys = userSshPublicKeyService.getAllSshKeysForUser(user); + return ResponseEntity.ok(keys); + } + + /** + * GET public-key : gets the ssh public key + * + * @param keyId The id of the key that should be fetched + * + * @return the ResponseEntity containing the requested public SSH key of a user with status 200 (OK), or with status 403 (Access Forbidden) if the key does not exist or is not + * owned by the requesting user + */ + @GetMapping("public-key/{keyId}") + @EnforceAtLeastStudent + public ResponseEntity getSshPublicKey(@PathVariable Long keyId) { + User user = userRepository.getUser(); + UserSshPublicKey key = userSshPublicKeyService.getSshKeyForUser(user, keyId); + return ResponseEntity.ok(UserSshPublicKeyDTO.of(key)); + } + + /** + * POST public-key : creates a new ssh public key for a user + * + * @param sshPublicKey the ssh public key to create + * + * @return the ResponseEntity with status 200 (OK), or with status 400 (Bad Request) when the SSH key is malformed, the label is too long, or when a key with the same hash + * already exists + */ + @PostMapping("public-key") + @EnforceAtLeastStudent + public ResponseEntity addSshPublicKey(@RequestBody UserSshPublicKeyDTO sshPublicKey) throws GeneralSecurityException, IOException { + User user = userRepository.getUser(); + log.debug("REST request to add SSH key to user {}", user.getLogin()); + AuthorizedKeyEntry keyEntry; + try { + keyEntry = AuthorizedKeyEntry.parseAuthorizedKeyEntry(sshPublicKey.publicKey()); + } + catch (IllegalArgumentException e) { + throw new BadRequestAlertException("Invalid SSH key format", "SSH key", "invalidKeyFormat", true); + } + + userSshPublicKeyService.createSshKeyForUser(user, keyEntry, sshPublicKey); + return ResponseEntity.ok().build(); + } + + /** + * Delete - public-key : deletes the ssh public key by its keyId + * + * @param keyId The id of the key that should be deleted + * + * @return the ResponseEntity with status 200 (OK) when the deletion succeeded, or with status 403 (Access Forbidden) if the key does not belong to the user, or does not exist + */ + @DeleteMapping("public-key/{keyId}") + @EnforceAtLeastStudent + public ResponseEntity deleteSshPublicKey(@PathVariable Long keyId) { + User user = userRepository.getUser(); + log.debug("REST request to remove SSH key of user {}", user.getLogin()); + userSshPublicKeyService.deleteUserSshPublicKey(user.getId(), keyId); + + log.debug("Successfully deleted SSH key with id {} of user {}", keyId, user.getLogin()); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/resources/config/liquibase/changelog/20241105150000_changelog.xml b/src/main/resources/config/liquibase/changelog/20241105150000_changelog.xml new file mode 100644 index 000000000000..0e7e5e68aac6 --- /dev/null +++ b/src/main/resources/config/liquibase/changelog/20241105150000_changelog.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO user_public_ssh_key (user_id, label, public_key, key_hash, creation_date, last_used_date, expiry_date) + SELECT id, 'Key 1', ssh_public_key, ssh_public_key_hash, CURRENT_TIMESTAMP, NULL, NULL + FROM jhi_user + WHERE ssh_public_key IS NOT NULL; + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/config/liquibase/master.xml b/src/main/resources/config/liquibase/master.xml index e29f09657055..d331337ceef4 100644 --- a/src/main/resources/config/liquibase/master.xml +++ b/src/main/resources/config/liquibase/master.xml @@ -29,11 +29,12 @@ - + + diff --git a/src/main/webapp/app/core/auth/account.service.ts b/src/main/webapp/app/core/auth/account.service.ts index a89944954c05..8745ae5357b9 100644 --- a/src/main/webapp/app/core/auth/account.service.ts +++ b/src/main/webapp/app/core/auth/account.service.ts @@ -30,7 +30,6 @@ export interface IAccountService { isAuthenticated: () => boolean; getAuthenticationState: () => Observable; getImageUrl: () => string | undefined; - addSshPublicKey: (sshPublicKey: string) => Observable; } @Injectable({ providedIn: 'root' }) @@ -325,28 +324,6 @@ export class AccountService implements IAccountService { this.prefilledUsernameValue = prefilledUsername; } - /** - * Sends the added SSH key to the server - * - * @param sshPublicKey - */ - addSshPublicKey(sshPublicKey: string): Observable { - if (this.userIdentity) { - this.userIdentity.sshPublicKey = sshPublicKey; - } - return this.http.put('api/account/ssh-public-key', sshPublicKey); - } - - /** - * Sends a request to the server to delete the user's current SSH key - */ - deleteSshPublicKey(): Observable { - if (this.userIdentity) { - this.userIdentity.sshPublicKey = undefined; - } - return this.http.delete('api/account/ssh-public-key'); - } - /** * Sends a request to the server to delete the user's current vcsAccessToken */ diff --git a/src/main/webapp/app/core/user/user.model.ts b/src/main/webapp/app/core/user/user.model.ts index f793581b21a3..52fa28f56ab9 100644 --- a/src/main/webapp/app/core/user/user.model.ts +++ b/src/main/webapp/app/core/user/user.model.ts @@ -15,8 +15,6 @@ export class User extends Account { public password?: string; public vcsAccessToken?: string; public vcsAccessTokenExpiryDate?: string; - public sshPublicKey?: string; - public sshKeyHash?: string; public irisAccepted?: dayjs.Dayjs; constructor( @@ -38,7 +36,6 @@ export class User extends Account { imageUrl?: string, vcsAccessToken?: string, vcsAccessTokenExpiryDate?: string, - sshPublicKey?: string, irisAccepted?: dayjs.Dayjs, ) { super(activated, authorities, email, firstName, langKey, lastName, login, imageUrl); @@ -52,7 +49,6 @@ export class User extends Account { this.password = password; this.vcsAccessToken = vcsAccessToken; this.vcsAccessTokenExpiryDate = vcsAccessTokenExpiryDate; - this.sshPublicKey = sshPublicKey; this.irisAccepted = irisAccepted; } } diff --git a/src/main/webapp/app/entities/programming/user-ssh-public-key.model.ts b/src/main/webapp/app/entities/programming/user-ssh-public-key.model.ts new file mode 100644 index 000000000000..4d53e9d2cef5 --- /dev/null +++ b/src/main/webapp/app/entities/programming/user-ssh-public-key.model.ts @@ -0,0 +1,13 @@ +import { BaseEntity } from 'app/shared/model/base-entity'; +import dayjs from 'dayjs/esm'; + +export class UserSshPublicKey implements BaseEntity { + id: number; + label: string; + publicKey: string; + keyHash: string; + expiryDate?: dayjs.Dayjs; + lastUsedDate?: dayjs.Dayjs; + creationDate: dayjs.Dayjs; + hasExpired?: boolean; +} diff --git a/src/main/webapp/app/shared/components/code-button/code-button.component.html b/src/main/webapp/app/shared/components/code-button/code-button.component.html index 80972a802a38..c8510599a4e6 100644 --- a/src/main/webapp/app/shared/components/code-button/code-button.component.html +++ b/src/main/webapp/app/shared/components/code-button/code-button.component.html @@ -14,9 +14,12 @@ container="body" > - @if (useSsh && !copyEnabled) { + @if (useSsh && !doesUserHaveSSHkeys) {
} + @if (useSsh && areAnySshKeysExpired) { +
+ } @if (useToken && tokenMissing) {
} diff --git a/src/main/webapp/app/shared/components/code-button/code-button.component.ts b/src/main/webapp/app/shared/components/code-button/code-button.component.ts index 7e4e0d33e766..36046020db1b 100644 --- a/src/main/webapp/app/shared/components/code-button/code-button.component.ts +++ b/src/main/webapp/app/shared/components/code-button/code-button.component.ts @@ -16,6 +16,8 @@ import { isPracticeMode } from 'app/entities/participation/student-participation import { faCode, faExternalLink } from '@fortawesome/free-solid-svg-icons'; import { IdeSettingsService } from 'app/shared/user-settings/ide-preferences/ide-settings.service'; import { Ide } from 'app/shared/user-settings/ide-preferences/ide.model'; +import { SshUserSettingsService } from 'app/shared/user-settings/ssh-settings/ssh-user-settings.service'; +import { UserSshPublicKey } from 'app/entities/programming/user-ssh-public-key.model'; @Component({ selector: 'jhi-code-button', @@ -50,12 +52,16 @@ export class CodeButtonComponent implements OnInit, OnChanges { gitlabVCEnabled = false; showCloneUrlWithoutToken = true; copyEnabled? = true; + doesUserHaveSSHkeys = false; + areAnySshKeysExpired = false; sshKeyMissingTip: string; + sshKeysExpiredTip: string; tokenMissingTip: string; tokenExpiredTip: string; user: User; + sshKeys?: UserSshPublicKey[]; cloneHeadline: string; wasCopied = false; isTeamParticipation: boolean; @@ -73,6 +79,7 @@ export class CodeButtonComponent implements OnInit, OnChanges { constructor( private translateService: TranslateService, private externalCloningService: ExternalCloningService, + private sshUserSettingsService: SshUserSettingsService, private accountService: AccountService, private profileService: ProfileService, private localStorage: LocalStorageService, @@ -87,6 +94,7 @@ export class CodeButtonComponent implements OnInit, OnChanges { } this.user = user; + await this.checkForSshKeys(); this.refreshTokenState(); this.copyEnabled = true; @@ -116,6 +124,7 @@ export class CodeButtonComponent implements OnInit, OnChanges { this.sshSettingsUrl = profileInfo.sshKeysURL; } this.sshKeyMissingTip = this.formatTip('artemisApp.exerciseActions.sshKeyTip', this.sshSettingsUrl); + this.sshKeysExpiredTip = this.formatTip('artemisApp.exerciseActions.sshKeyExpiredTip', this.sshSettingsUrl); if (this.useSsh) { this.useSshUrl(); @@ -152,7 +161,7 @@ export class CodeButtonComponent implements OnInit, OnChanges { public useSshUrl() { this.useSsh = true; this.useToken = false; - this.copyEnabled = this.useSsh && (!!this.user.sshPublicKey || this.gitlabVCEnabled); + this.copyEnabled = this.doesUserHaveSSHkeys || this.gitlabVCEnabled; this.storeToLocalStorage(); } @@ -347,4 +356,20 @@ export class CodeButtonComponent implements OnInit, OnChanges { this.user.vcsAccessToken = this.activeParticipation.vcsAccessToken; } } + + /** + * Checks whether the user owns any SSH keys, and checks if any of them is expired + */ + private async checkForSshKeys() { + this.sshKeys = await this.sshUserSettingsService.getCachedSshKeys(); + if (this.sshKeys) { + const now = dayjs(); + this.doesUserHaveSSHkeys = this.sshKeys.length > 0; + this.areAnySshKeysExpired = this.sshKeys.some((key) => { + if (key.expiryDate) { + return dayjs(key.expiryDate).isBefore(now); + } + }); + } + } } diff --git a/src/main/webapp/app/shared/user-settings/ssh-settings/details/ssh-user-settings-key-details.component.html b/src/main/webapp/app/shared/user-settings/ssh-settings/details/ssh-user-settings-key-details.component.html new file mode 100644 index 000000000000..d1ecbd74bbc3 --- /dev/null +++ b/src/main/webapp/app/shared/user-settings/ssh-settings/details/ssh-user-settings-key-details.component.html @@ -0,0 +1,138 @@ +@if (isLoading) { +

+} @else { + @if (!isCreateMode) { +

+ } @else { +

+ } + +
+ +
+
+

+ + +

+
+ + @if (isCreateMode) { +
+

+
+ } @else { +
+

{{ displayedKeyLabel }}

+
+ } +
+ +
+ + + @if (isCreateMode) { +
+

+ + {{ copyInstructions }} +

+
+ +
+

+ +

+ +

+
+ + +
+ +

+
+
+ +
+
+ +
+ + + @if (selectedOption === 'useExpiration') { +
+
+ +
+
+ } + } @else { + + @if (displayCreationDate) { +
+
+
+ {{ displayCreationDate | artemisDate: 'long-date' }} +
+
+ } + @if (displayedLastUsedDate) { +
+
+
+ {{ displayedLastUsedDate | artemisDate: 'long-date' }} +
+
+ } + @if (displayedExpiryDate) { +
+
+
+ {{ displayedExpiryDate | artemisDate: 'long-date' }} +
+
+ } + } +
+ @if (isCreateMode) { +
+ +
+ } +
+ +
+
+
+
+} diff --git a/src/main/webapp/app/shared/user-settings/ssh-settings/details/ssh-user-settings-key-details.component.ts b/src/main/webapp/app/shared/user-settings/ssh-settings/details/ssh-user-settings-key-details.component.ts new file mode 100644 index 000000000000..459a0c2edc4e --- /dev/null +++ b/src/main/webapp/app/shared/user-settings/ssh-settings/details/ssh-user-settings-key-details.component.ts @@ -0,0 +1,141 @@ +import { Component, OnDestroy, OnInit, inject } from '@angular/core'; +import { Subject, Subscription, concatMap, filter, tap } from 'rxjs'; +import { ActivatedRoute, Router } from '@angular/router'; +import { faEdit, faSave } from '@fortawesome/free-solid-svg-icons'; +import { DocumentationType } from 'app/shared/components/documentation-button/documentation-button.component'; +import { ButtonSize, ButtonType } from 'app/shared/components/button.component'; +import { AlertService } from 'app/core/util/alert.service'; +import { getOS } from 'app/shared/util/os-detector.util'; +import { UserSshPublicKey } from 'app/entities/programming/user-ssh-public-key.model'; +import dayjs from 'dayjs/esm'; +import { SshUserSettingsService } from 'app/shared/user-settings/ssh-settings/ssh-user-settings.service'; + +@Component({ + selector: 'jhi-account-information', + templateUrl: './ssh-user-settings-key-details.component.html', + styleUrls: ['../../user-settings.scss', '../ssh-user-settings.component.scss'], +}) +export class SshUserSettingsKeyDetailsComponent implements OnInit, OnDestroy { + private sshUserSettingsService = inject(SshUserSettingsService); + readonly route = inject(ActivatedRoute); + readonly router = inject(Router); + readonly alertService = inject(AlertService); + + readonly documentationType: DocumentationType = 'SshSetup'; + readonly invalidKeyFormat = 'invalidKeyFormat'; + readonly keyAlreadyExists = 'keyAlreadyExists'; + readonly keyLabelTooLong = 'keyLabelTooLong'; + + protected readonly faEdit = faEdit; + protected readonly faSave = faSave; + protected readonly ButtonType = ButtonType; + protected readonly ButtonSize = ButtonSize; + + subscription: Subscription; + + // state change variables + isCreateMode = false; // true when creating new key, false when viewing existing key + isLoading = true; + + copyInstructions = ''; + selectedOption: string = 'doNotUseExpiration'; + + // Key details from input fields + displayedKeyLabel = ''; + displayedSshKey = ''; + displayedKeyHash = ''; + displayedExpiryDate?: dayjs.Dayjs; + isExpiryDateValid = false; + displayCreationDate: dayjs.Dayjs; + displayedLastUsedDate?: dayjs.Dayjs; + currentDate: dayjs.Dayjs; + + private dialogErrorSource = new Subject(); + dialogError$ = this.dialogErrorSource.asObservable(); + + ngOnInit() { + this.setMessageBasedOnOS(getOS()); + this.currentDate = dayjs(); + + this.subscription = this.route.params + .pipe( + filter((params) => { + const keyId = Number(params['keyId']); + if (keyId) { + this.isCreateMode = false; + return true; + } else { + this.isLoading = false; + this.isCreateMode = true; + return false; + } + }), + concatMap((params) => { + return this.sshUserSettingsService.getSshPublicKey(Number(params['keyId'])); + }), + tap((publicKey: UserSshPublicKey) => { + this.displayedSshKey = publicKey.publicKey; + this.displayedKeyLabel = publicKey.label; + this.displayedKeyHash = publicKey.keyHash; + this.displayCreationDate = publicKey.creationDate; + this.displayedExpiryDate = publicKey.expiryDate; + this.displayedLastUsedDate = publicKey.lastUsedDate; + this.isLoading = false; + }), + ) + .subscribe(); + } + + ngOnDestroy() { + this.subscription.unsubscribe(); + } + + saveSshKey() { + const newUserSshKey = { + label: this.displayedKeyLabel, + publicKey: this.displayedSshKey, + expiryDate: this.displayedExpiryDate, + } as UserSshPublicKey; + this.sshUserSettingsService.addNewSshPublicKey(newUserSshKey).subscribe({ + next: () => { + this.alertService.success('artemisApp.userSettings.sshSettingsPage.saveSuccess'); + this.goBack(); + }, + error: (error) => { + const errorKey = error.error.errorKey; + if ([this.invalidKeyFormat, this.keyAlreadyExists, this.keyLabelTooLong].indexOf(errorKey) > -1) { + this.alertService.error(`artemisApp.userSettings.sshSettingsPage.${errorKey}`); + } else { + this.alertService.error('artemisApp.userSettings.sshSettingsPage.saveFailure'); + } + }, + }); + } + + goBack() { + this.router.navigate(['/user-settings/ssh']); + } + + validateExpiryDate() { + this.isExpiryDateValid = !!this.displayedExpiryDate?.isValid(); + } + + private setMessageBasedOnOS(os: string): void { + switch (os) { + case 'Windows': + this.copyInstructions = 'cat ~/.ssh/id_ed25519.pub | clip'; + break; + case 'MacOS': + this.copyInstructions = 'pbcopy < ~/.ssh/id_ed25519.pub'; + break; + case 'Linux': + this.copyInstructions = 'xclip -selection clipboard < ~/.ssh/id_ed25519.pub'; + break; + case 'Android': + this.copyInstructions = 'termux-clipboard-set < ~/.ssh/id_ed25519.pub'; + break; + default: + this.copyInstructions = 'Ctrl + C'; + } + } +} diff --git a/src/main/webapp/app/shared/user-settings/ssh-settings/ssh-user-settings.component.html b/src/main/webapp/app/shared/user-settings/ssh-settings/ssh-user-settings.component.html index 7372a07ac241..a5650cef05a9 100644 --- a/src/main/webapp/app/shared/user-settings/ssh-settings/ssh-user-settings.component.html +++ b/src/main/webapp/app/shared/user-settings/ssh-settings/ssh-user-settings.component.html @@ -1,10 +1,10 @@ -

- -

-@if (currentUser) { + +

+ +@if (!isLoading) {
- @if (keyCount === 0 && !showSshKey) { + @if (keyCount === 0) {

@@ -17,21 +17,16 @@

- + + +

} - @if (keyCount > 0 && !showSshKey) { -
-

- + @if (keyCount > 0) { +

@@ -48,108 +43,72 @@

- - -
-
- {{ sshKeyHash }} -
- + @for (key of sshPublicKeys; track key; let i = $index) { + + +
+ {{ key.label }} +
+
+ {{ key.keyHash }} +
+ @if (key.expiryDate) { +
+
+
+ {{ key.expiryDate | artemisDate: 'long-date' }} +
+
+ } + + + + -
- @if (isEnlargedView()) { -
- - - @if (currentPage() !== 1) { - - } - @if (currentPage() !== totalPages()) { - - } -
{{ currentPage() }}
-
- } -
+ @if (currentPdfUrl()) { + + } @else { +
+ }
>('pdfContainer'); - enlargedCanvas = viewChild.required>('enlargedCanvas'); fileInput = viewChild.required>('fileInput'); attachmentSub: Subscription; attachmentUnitSub: Subscription; - readonly DEFAULT_SLIDE_WIDTH = 250; - readonly DEFAULT_SLIDE_HEIGHT = 800; + // Signals course = signal(undefined); attachment = signal(undefined); attachmentUnit = signal(undefined); - isEnlargedView = signal(false); - isFileChanged = signal(false); - currentPage = signal(1); - totalPages = signal(0); - selectedPages = signal>(new Set()); isPdfLoading = signal(false); attachmentToBeEdited = signal(undefined); - currentPdfBlob = signal(null); + currentPdfBlob = signal(undefined); + currentPdfUrl = signal(undefined); + totalPages = signal(0); + appendFile = signal(false); + isFileChanged = signal(false); + selectedPages = signal>(new Set()); + allPagesSelected = computed(() => this.selectedPages().size === this.totalPages()); // Injected services private readonly route = inject(ActivatedRoute); @@ -74,386 +69,78 @@ export class PdfPreviewComponent implements OnInit, OnDestroy { if ('attachment' in data) { this.attachment.set(data.attachment); this.attachmentSub = this.attachmentService.getAttachmentFile(this.course()!.id!, this.attachment()!.id!).subscribe({ - next: (blob: Blob) => this.handleBlob(blob), + next: (blob: Blob) => { + this.currentPdfBlob.set(blob); + this.currentPdfUrl.set(URL.createObjectURL(blob)); + }, error: (error: HttpErrorResponse) => onError(this.alertService, error), }); } else if ('attachmentUnit' in data) { this.attachmentUnit.set(data.attachmentUnit); this.attachmentUnitSub = this.attachmentUnitService.getAttachmentFile(this.course()!.id!, this.attachmentUnit()!.id!).subscribe({ - next: (blob: Blob) => this.handleBlob(blob), + next: (blob: Blob) => { + this.currentPdfBlob.set(blob); + this.currentPdfUrl.set(URL.createObjectURL(blob)); + }, error: (error: HttpErrorResponse) => onError(this.alertService, error), }); } }); } - handleBlob(blob: Blob): void { - this.currentPdfBlob.set(blob); - const objectUrl = URL.createObjectURL(blob); - this.loadOrAppendPdf(objectUrl).then(() => URL.revokeObjectURL(objectUrl)); - } - ngOnDestroy() { this.attachmentSub?.unsubscribe(); this.attachmentUnitSub?.unsubscribe(); } /** - * Checks if all pages are selected. - * @returns True if the number of selected pages equals the total number of pages, otherwise false. - */ - allPagesSelected() { - return this.selectedPages().size === this.totalPages(); - } - - /** - * Handles navigation within the PDF viewer using keyboard arrow keys. - * @param event - The keyboard event captured for navigation. - */ - @HostListener('document:keydown', ['$event']) - handleKeyboardEvents(event: KeyboardEvent) { - if (this.isEnlargedView()) { - if (event.key === 'ArrowRight' && this.currentPage() < this.totalPages()) { - this.navigatePages('next'); - } else if (event.key === 'ArrowLeft' && this.currentPage() > 1) { - this.navigatePages('prev'); - } - } - } - - /** - * Adjusts the canvas size based on the window resize event to ensure proper display. - */ - @HostListener('window:resize') - resizeCanvasBasedOnContainer() { - this.adjustCanvasSize(); - } - - /** - * Loads or appends a PDF from a provided URL. - * @param fileUrl The URL of the file to load or append. - * @param append Whether the document should be appended to the existing one. - * @returns A promise that resolves when the PDF is loaded. - */ - async loadOrAppendPdf(fileUrl: string, append = false): Promise { - this.pdfContainer() - .nativeElement.querySelectorAll('.pdf-canvas-container') - .forEach((canvas) => canvas.remove()); - this.totalPages.set(0); - this.isPdfLoading.set(true); - try { - const loadingTask = PDFJS.getDocument(fileUrl); - const pdf = await loadingTask.promise; - this.totalPages.set(pdf.numPages); - - for (let i = 1; i <= this.totalPages(); i++) { - const page = await pdf.getPage(i); - const viewport = page.getViewport({ scale: 2 }); - const canvas = this.createCanvas(viewport, i); - const context = canvas.getContext('2d'); - await page.render({ canvasContext: context!, viewport }).promise; - - const canvasContainer = this.createCanvasContainer(canvas, i); - this.pdfContainer().nativeElement.appendChild(canvasContainer); - } - - if (append) { - this.scrollToBottom(); - } - } catch (error) { - onError(this.alertService, error); - } finally { - this.isPdfLoading.set(false); - if (append) { - this.fileInput().nativeElement.value = ''; - } - } - } - - /** - * Scrolls the PDF container to the bottom after appending new pages. - */ - scrollToBottom(): void { - const scrollOptions: ScrollToOptions = { - top: this.pdfContainer().nativeElement.scrollHeight, - left: 0, - behavior: 'smooth' as ScrollBehavior, - }; - this.pdfContainer().nativeElement.scrollTo(scrollOptions); - } - - /** - * Creates a canvas for each page of the PDF to allow for individual page rendering. - * @param viewport The viewport settings used for rendering the page. - * @param pageIndex The index of the page within the PDF document. - * @returns A new HTMLCanvasElement configured for the PDF page. - */ - createCanvas(viewport: PDFJS.PageViewport, pageIndex: number): HTMLCanvasElement { - const canvas = document.createElement('canvas'); - canvas.id = `${pageIndex}`; - /* Canvas styling is predefined because Canvas tags do not support CSS classes - * as they are not HTML elements but rather a bitmap drawing surface. - * See: https://stackoverflow.com/a/29675448 - * */ - canvas.height = viewport.height; - canvas.width = viewport.width; - const fixedWidth = this.DEFAULT_SLIDE_WIDTH; - const scaleFactor = fixedWidth / viewport.width; - canvas.style.width = `${fixedWidth}px`; - canvas.style.height = `${viewport.height * scaleFactor}px`; - return canvas; - } - - /** - * Creates a container div for each canvas, facilitating layering and interaction. - * @param canvas The canvas element that displays a PDF page. - * @param pageIndex The index of the page within the PDF document. - * @returns A configured div element that includes the canvas and interactive overlays. - */ - createCanvasContainer(canvas: HTMLCanvasElement, pageIndex: number): HTMLDivElement { - const container = document.createElement('div'); - /* Dynamically created elements are not detected by DOM, that is why we need to set the styles manually. - * See: https://stackoverflow.com/a/70911189 - */ - container.id = `pdf-page-${pageIndex}`; - container.classList.add('pdf-canvas-container'); - container.style.cssText = `position: relative; display: inline-block; width: ${canvas.style.width}; height: ${canvas.style.height}; margin: 20px; box-shadow: 0 2px 6px var(--pdf-preview-canvas-shadow);`; - - const overlay = this.createOverlay(pageIndex); - const checkbox = this.createCheckbox(pageIndex); - container.appendChild(canvas); - container.appendChild(overlay); - container.appendChild(checkbox); - - container.addEventListener('mouseenter', () => { - overlay.style.opacity = '1'; - }); - container.addEventListener('mouseleave', () => { - overlay.style.opacity = '0'; - }); - overlay.addEventListener('click', () => this.displayEnlargedCanvas(canvas)); - - return container; - } - - /** - * Generates an interactive overlay for each PDF page to allow for user interactions. - * @param pageIndex The index of the page. - * @returns A div element styled as an overlay. - */ - private createOverlay(pageIndex: number): HTMLDivElement { - const overlay = document.createElement('div'); - overlay.innerHTML = `${pageIndex}`; - /* Dynamically created elements are not detected by DOM, that is why we need to set the styles manually. - * See: https://stackoverflow.com/a/70911189 - */ - overlay.style.cssText = `position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; justify-content: center; align-items: center; font-size: 24px; color: white; z-index: 1; transition: opacity 0.3s ease; opacity: 0; cursor: pointer; background-color: var(--pdf-preview-container-overlay)`; - return overlay; - } - - private createCheckbox(pageIndex: number): HTMLDivElement { - const checkbox = document.createElement('input'); - checkbox.type = 'checkbox'; - checkbox.id = String(pageIndex); - checkbox.style.cssText = `position: absolute; top: -5px; right: -5px; z-index: 4;`; - checkbox.checked = this.selectedPages().has(pageIndex); - checkbox.addEventListener('change', () => { - if (checkbox.checked) { - this.selectedPages().add(Number(checkbox.id)); - } else { - this.selectedPages().delete(Number(checkbox.id)); - } - }); - return checkbox; - } - - /** - * Dynamically updates the canvas size within an enlarged view based on the viewport. - */ - adjustCanvasSize = () => { - if (this.isEnlargedView()) { - const canvasElements = this.pdfContainer().nativeElement.querySelectorAll('.pdf-canvas-container canvas'); - if (this.currentPage() - 1 < canvasElements.length) { - const canvas = canvasElements[this.currentPage() - 1] as HTMLCanvasElement; - this.updateEnlargedCanvas(canvas); - } - } - }; - - /** - * Adjusts the size of the PDF container based on whether the enlarged view is active or not. - * If the enlarged view is active, the container's size is reduced to focus on the enlarged content. - * If the enlarged view is closed, the container returns to its original size. - * - * @param isVertical A boolean flag indicating whether to enlarge or reset the container size. - */ - adjustPdfContainerSize(isVertical: boolean): void { - const pdfContainer = this.pdfContainer().nativeElement; - if (isVertical) { - pdfContainer.style.height = `${this.DEFAULT_SLIDE_HEIGHT}px`; - } else { - pdfContainer.style.height = '60vh'; - } - } - - /** - * Displays the selected PDF page in an enlarged view for detailed examination. - * @param originalCanvas - The original canvas element of the PDF page to be enlarged. - * */ - displayEnlargedCanvas(originalCanvas: HTMLCanvasElement) { - const isVertical = originalCanvas.height > originalCanvas.width; - this.adjustPdfContainerSize(isVertical); - this.isEnlargedView.set(true); - this.currentPage.set(Number(originalCanvas.id)); - this.toggleBodyScroll(true); - setTimeout(() => { - this.updateEnlargedCanvas(originalCanvas); - }, 50); - } - - /** - * Updates the enlarged canvas dimensions to optimize PDF page display within the current viewport. - * This method dynamically adjusts the size, position, and scale of the canvas to maintain the aspect ratio, - * ensuring the content is centered and displayed appropriately within the available space. - * It is called within an animation frame to synchronize updates with the browser's render cycle for smooth visuals. - * - * @param originalCanvas - The source canvas element used to extract image data for resizing and redrawing. + * Triggers the file input to select files. */ - updateEnlargedCanvas(originalCanvas: HTMLCanvasElement) { - requestAnimationFrame(() => { - if (!this.isEnlargedView) return; - - const isVertical = originalCanvas.height > originalCanvas.width; - this.adjustPdfContainerSize(isVertical); - - const scaleFactor = this.calculateScaleFactor(originalCanvas); - this.resizeCanvas(originalCanvas, scaleFactor); - this.redrawCanvas(originalCanvas); - this.positionCanvas(); - }); + triggerFileInput(): void { + this.fileInput().nativeElement.click(); } - /** - * Calculates the scaling factor to adjust the canvas size based on the dimensions of the container. - * This method ensures that the canvas is scaled to fit within the container without altering the aspect ratio. - * - * @param originalCanvas - The original canvas element representing the PDF page. - * @returns The scaling factor used to resize the original canvas to fit within the container dimensions. - */ - calculateScaleFactor(originalCanvas: HTMLCanvasElement): number { - const containerWidth = this.pdfContainer().nativeElement.clientWidth; - const containerHeight = this.pdfContainer().nativeElement.clientHeight; - - let scaleX, scaleY; + updateAttachmentWithFile(): void { + const pdfFile = new File([this.currentPdfBlob()!], 'updatedAttachment.pdf', { type: 'application/pdf' }); - if (originalCanvas.height > originalCanvas.width) { - // Vertical slide - const fixedHeight = this.DEFAULT_SLIDE_HEIGHT; - scaleY = fixedHeight / originalCanvas.height; - scaleX = containerWidth / originalCanvas.width; - } else { - // Horizontal slide - scaleX = containerWidth / originalCanvas.width; - scaleY = containerHeight / originalCanvas.height; + if (pdfFile.size > MAX_FILE_SIZE) { + this.alertService.error('artemisApp.attachment.pdfPreview.fileSizeError'); + return; } - return Math.min(scaleX, scaleY); - } - - /** - * Resizes the canvas according to the computed scale factor. - * This method updates the dimensions of the enlarged canvas element to ensure that the entire PDF page - * is visible and properly scaled within the viewer. - * - * @param originalCanvas - The canvas element from which the image is scaled. - * @param scaleFactor - The factor by which the canvas is resized. - */ - resizeCanvas(originalCanvas: HTMLCanvasElement, scaleFactor: number): void { - const enlargedCanvas = this.enlargedCanvas().nativeElement; - enlargedCanvas.width = originalCanvas.width * scaleFactor; - enlargedCanvas.height = originalCanvas.height * scaleFactor; - } - - /** - * Redraws the original canvas content onto the enlarged canvas at the updated scale. - * This method ensures that the image is rendered clearly and correctly positioned on the enlarged canvas. - * - * @param originalCanvas - The original canvas containing the image to be redrawn. - */ - redrawCanvas(originalCanvas: HTMLCanvasElement): void { - const enlargedCanvas = this.enlargedCanvas().nativeElement; - const context = enlargedCanvas.getContext('2d'); - context!.clearRect(0, 0, enlargedCanvas.width, enlargedCanvas.height); - context!.drawImage(originalCanvas, 0, 0, enlargedCanvas.width, enlargedCanvas.height); - } - - /** - * Adjusts the position of the enlarged canvas to center it within the viewport of the PDF container. - * This method ensures that the canvas is both vertically and horizontally centered, providing a consistent - * and visually appealing layout. - */ - positionCanvas(): void { - const enlargedCanvas = this.enlargedCanvas().nativeElement; - const containerWidth = this.pdfContainer().nativeElement.clientWidth; - const containerHeight = this.pdfContainer().nativeElement.clientHeight; - - enlargedCanvas.style.position = 'absolute'; - enlargedCanvas.style.left = `${(containerWidth - enlargedCanvas.width) / 2}px`; - enlargedCanvas.style.top = `${(containerHeight - enlargedCanvas.height) / 2}px`; - enlargedCanvas.parentElement!.style.top = `${this.pdfContainer().nativeElement.scrollTop}px`; - } - - /** - * Closes the enlarged view of the PDF and re-enables scrolling in the PDF container. - */ - closeEnlargedView(event: MouseEvent) { - this.isEnlargedView.set(false); - this.adjustPdfContainerSize(false); - this.toggleBodyScroll(false); - event.stopPropagation(); - } - - /** - * Toggles the ability to scroll through the PDF container. - * @param disable A boolean flag indicating whether scrolling should be disabled (`true`) or enabled (`false`). - */ - toggleBodyScroll(disable: boolean): void { - this.pdfContainer().nativeElement.style.overflow = disable ? 'hidden' : 'auto'; - } - - /** - * Closes the enlarged view if a click event occurs outside the actual canvas area but within the enlarged container. - * @param event The mouse event captured, used to determine the location of the click. - */ - closeIfOutside(event: MouseEvent): void { - const target = event.target as HTMLElement; - const enlargedCanvas = this.enlargedCanvas().nativeElement; + if (this.attachment()) { + this.attachmentToBeEdited.set(this.attachment()); + this.attachmentToBeEdited()!.version!++; + this.attachmentToBeEdited()!.uploadDate = dayjs(); - if (target.classList.contains('enlarged-container') && target !== enlargedCanvas) { - this.closeEnlargedView(event); - } - } + this.attachmentService.update(this.attachmentToBeEdited()!.id!, this.attachmentToBeEdited()!, pdfFile).subscribe({ + next: () => { + this.alertService.success('artemisApp.attachment.pdfPreview.attachmentUpdateSuccess'); + this.router.navigate(['course-management', this.course()?.id, 'lectures', this.attachment()!.lecture!.id, 'attachments']); + }, + error: (error) => { + this.alertService.error('artemisApp.attachment.pdfPreview.attachmentUpdateError', { error: error.message }); + }, + }); + } else if (this.attachmentUnit()) { + this.attachmentToBeEdited.set(this.attachmentUnit()!.attachment!); + this.attachmentToBeEdited()!.version!++; + this.attachmentToBeEdited()!.uploadDate = dayjs(); - /** - * Handles navigation between PDF pages and stops event propagation to prevent unwanted side effects. - * @param direction The direction to navigate. - * @param event The MouseEvent to be stopped. - */ - handleNavigation(direction: NavigationDirection, event: MouseEvent): void { - event.stopPropagation(); - this.navigatePages(direction); - } + const formData = new FormData(); + formData.append('file', pdfFile); + formData.append('attachment', objectToJsonBlob(this.attachmentToBeEdited()!)); + formData.append('attachmentUnit', objectToJsonBlob(this.attachmentUnit()!)); - /** - * Navigates to a specific page in the PDF based on the direction relative to the current page. - * @param direction The navigation direction (next or previous). - */ - navigatePages(direction: NavigationDirection) { - const nextPageIndex = direction === 'next' ? this.currentPage() + 1 : this.currentPage() - 1; - if (nextPageIndex > 0 && nextPageIndex <= this.totalPages()) { - this.currentPage.set(nextPageIndex); - const canvas = this.pdfContainer().nativeElement.querySelectorAll('.pdf-canvas-container canvas')[this.currentPage() - 1] as HTMLCanvasElement; - this.updateEnlargedCanvas(canvas); + this.attachmentUnitService.update(this.attachmentUnit()!.lecture!.id!, this.attachmentUnit()!.id!, formData).subscribe({ + next: () => { + this.alertService.success('artemisApp.attachment.pdfPreview.attachmentUpdateSuccess'); + this.router.navigate(['course-management', this.course()?.id, 'lectures', this.attachmentUnit()!.lecture!.id, 'unit-management']); + }, + error: (error) => { + this.alertService.error('artemisApp.attachment.pdfPreview.attachmentUpdateError', { error: error.message }); + }, + }); } } @@ -494,7 +181,7 @@ export class PdfPreviewComponent implements OnInit, OnDestroy { const existingPdfBytes = await this.currentPdfBlob()!.arrayBuffer(); const pdfDoc = await PDFDocument.load(existingPdfBytes); - const pagesToDelete = Array.from(this.selectedPages()) + const pagesToDelete = Array.from(this.selectedPages()!) .map((page) => page - 1) .sort((a, b) => b - a); pagesToDelete.forEach((pageIndex) => { @@ -504,13 +191,12 @@ export class PdfPreviewComponent implements OnInit, OnDestroy { this.isFileChanged.set(true); const pdfBytes = await pdfDoc.save(); this.currentPdfBlob.set(new Blob([pdfBytes], { type: 'application/pdf' })); - this.selectedPages().clear(); + this.selectedPages()!.clear(); const objectUrl = URL.createObjectURL(this.currentPdfBlob()!); - await this.loadOrAppendPdf(objectUrl, false).then(() => { - this.dialogErrorSource.next(''); - }); - URL.revokeObjectURL(objectUrl); + this.currentPdfUrl.set(objectUrl); + this.appendFile.set(false); + this.dialogErrorSource.next(''); } catch (error) { this.alertService.error('artemisApp.attachment.pdfPreview.pageDeleteError', { error: error.message }); } finally { @@ -518,13 +204,6 @@ export class PdfPreviewComponent implements OnInit, OnDestroy { } } - /** - * Triggers the file input to select files. - */ - triggerFileInput(): void { - this.fileInput().nativeElement.click(); - } - /** * Adds a selected PDF file at the end of the current PDF document. * @param event - The event containing the file input. @@ -546,75 +225,16 @@ export class PdfPreviewComponent implements OnInit, OnDestroy { const mergedPdfBytes = await existingPdfDoc.save(); this.currentPdfBlob.set(new Blob([mergedPdfBytes], { type: 'application/pdf' })); - this.selectedPages().clear(); + this.selectedPages()!.clear(); const objectUrl = URL.createObjectURL(this.currentPdfBlob()!); - await this.loadOrAppendPdf(objectUrl, true).then(() => URL.revokeObjectURL(objectUrl)); + this.currentPdfUrl.set(objectUrl); + this.appendFile.set(true); } catch (error) { this.alertService.error('artemisApp.attachment.pdfPreview.mergeFailedError', { error: error.message }); } finally { this.isPdfLoading.set(false); - } - } - - /** - * Updates the IDs of remaining pages after some have been removed. - */ - updatePageIDs() { - const remainingPages = this.pdfContainer().nativeElement.querySelectorAll('.pdf-canvas-container'); - remainingPages.forEach((container, index) => { - const pageIndex = index + 1; - container.id = `pdf-page-${pageIndex}`; - const canvas = container.querySelector('canvas'); - const overlay = container.querySelector('div'); - const checkbox = container.querySelector('input[type="checkbox"]'); - canvas!.id = String(pageIndex); - overlay!.innerHTML = `${pageIndex}`; - checkbox!.id = String(pageIndex); - }); - } - - updateAttachmentWithFile(): void { - const pdfFile = new File([this.currentPdfBlob()!], 'updatedAttachment.pdf', { type: 'application/pdf' }); - - if (pdfFile.size > MAX_FILE_SIZE) { - this.alertService.error('artemisApp.attachment.pdfPreview.fileSizeError'); - return; - } - - if (this.attachment()) { - this.attachmentToBeEdited.set(this.attachment()); - this.attachmentToBeEdited()!.version!++; - this.attachmentToBeEdited()!.uploadDate = dayjs(); - - this.attachmentService.update(this.attachmentToBeEdited()!.id!, this.attachmentToBeEdited()!, pdfFile).subscribe({ - next: () => { - this.alertService.success('artemisApp.attachment.pdfPreview.attachmentUpdateSuccess'); - this.router.navigate(['course-management', this.course()?.id, 'lectures', this.attachment()!.lecture!.id, 'attachments']); - }, - error: (error) => { - this.alertService.error('artemisApp.attachment.pdfPreview.attachmentUpdateError', { error: error.message }); - }, - }); - } else if (this.attachmentUnit()) { - this.attachmentToBeEdited.set(this.attachmentUnit()!.attachment!); - this.attachmentToBeEdited()!.version!++; - this.attachmentToBeEdited()!.uploadDate = dayjs(); - - const formData = new FormData(); - formData.append('file', pdfFile); - formData.append('attachment', objectToJsonBlob(this.attachmentToBeEdited()!)); - formData.append('attachmentUnit', objectToJsonBlob(this.attachmentUnit()!)); - - this.attachmentUnitService.update(this.attachmentUnit()!.lecture!.id!, this.attachmentUnit()!.id!, formData).subscribe({ - next: () => { - this.alertService.success('artemisApp.attachment.pdfPreview.attachmentUpdateSuccess'); - this.router.navigate(['course-management', this.course()?.id, 'lectures', this.attachmentUnit()!.lecture!.id, 'unit-management']); - }, - error: (error) => { - this.alertService.error('artemisApp.attachment.pdfPreview.attachmentUpdateError', { error: error.message }); - }, - }); + this.fileInput()!.nativeElement.value = ''; } } } diff --git a/src/test/javascript/spec/component/lecture/pdf-preview.component.spec.ts b/src/test/javascript/spec/component/lecture/pdf-preview.component.spec.ts deleted file mode 100644 index e3b8a248fb9f..000000000000 --- a/src/test/javascript/spec/component/lecture/pdf-preview.component.spec.ts +++ /dev/null @@ -1,713 +0,0 @@ -import { MockTranslateService } from '../../helpers/mocks/service/mock-translate.service'; -import { MAX_FILE_SIZE } from 'app/shared/constants/input.constants'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute } from '@angular/router'; -import { of, throwError } from 'rxjs'; -import { AttachmentService } from 'app/lecture/attachment.service'; -import { AttachmentUnitService } from 'app/lecture/lecture-unit/lecture-unit-management/attachmentUnit.service'; -import { PdfPreviewComponent } from 'app/lecture/pdf-preview/pdf-preview.component'; -import { signal } from '@angular/core'; -import { AlertService } from 'app/core/util/alert.service'; -import { HttpClientModule, HttpErrorResponse } from '@angular/common/http'; -import { TranslateService } from '@ngx-translate/core'; -import { PDFDocument } from 'pdf-lib'; - -jest.mock('pdf-lib', () => { - const originalModule = jest.requireActual('pdf-lib'); - - return { - ...originalModule, - PDFDocument: { - ...originalModule.PDFDocument, - load: jest.fn(), - create: jest.fn(), - prototype: { - removePage: jest.fn(), - save: jest.fn(), - }, - }, - }; -}); - -jest.mock('pdfjs-dist', () => { - return { - getDocument: jest.fn(() => ({ - promise: Promise.resolve({ - numPages: 1, - getPage: jest.fn(() => - Promise.resolve({ - getViewport: jest.fn(() => ({ width: 600, height: 800, scale: 1 })), - render: jest.fn(() => ({ - promise: Promise.resolve(), - })), - }), - ), - }), - })), - }; -}); - -jest.mock('pdfjs-dist/build/pdf.worker', () => { - return {}; -}); - -function createMockEvent(target: Element, eventType = 'click'): MouseEvent { - const event = new MouseEvent(eventType, { - view: window, - bubbles: true, - cancelable: true, - }); - Object.defineProperty(event, 'target', { value: target, writable: false }); - return event; -} - -describe('PdfPreviewComponent', () => { - let component: PdfPreviewComponent; - let fixture: ComponentFixture; - let attachmentServiceMock: any; - let attachmentUnitServiceMock: any; - let alertServiceMock: any; - let routeMock: any; - let mockCanvasElement: HTMLCanvasElement; - let mockEnlargedCanvas: HTMLCanvasElement; - let mockContext: any; - let mockOverlay: HTMLDivElement; - - beforeEach(async () => { - global.URL.createObjectURL = jest.fn().mockReturnValue('mocked_blob_url'); - attachmentServiceMock = { - getAttachmentFile: jest.fn().mockReturnValue(of(new Blob([''], { type: 'application/pdf' }))), - update: jest.fn().mockReturnValue(of({})), - }; - attachmentUnitServiceMock = { - getAttachmentFile: jest.fn().mockReturnValue(of(new Blob([''], { type: 'application/pdf' }))), - update: jest.fn().mockReturnValue(of({})), - }; - routeMock = { - data: of({ - course: { id: 1, name: 'Example Course' }, - attachment: { id: 1, name: 'Example PDF', lecture: { id: 1 } }, - attachmentUnit: { id: 1, name: 'Chapter 1', lecture: { id: 1 } }, - }), - }; - alertServiceMock = { - addAlert: jest.fn(), - error: jest.fn(), - success: jest.fn(), - }; - - await TestBed.configureTestingModule({ - imports: [PdfPreviewComponent, HttpClientModule], - providers: [ - { provide: ActivatedRoute, useValue: routeMock }, - { provide: AttachmentService, useValue: attachmentServiceMock }, - { provide: AttachmentUnitService, useValue: attachmentUnitServiceMock }, - { provide: AlertService, useValue: alertServiceMock }, - { provide: TranslateService, useClass: MockTranslateService }, - ], - }).compileComponents(); - - const pdfContainerElement = document.createElement('div'); - Object.defineProperty(pdfContainerElement, 'clientWidth', { value: 800 }); - Object.defineProperty(pdfContainerElement, 'clientHeight', { value: 600 }); - - fixture = TestBed.createComponent(PdfPreviewComponent); - component = fixture.componentInstance; - - mockCanvasElement = document.createElement('canvas'); - mockCanvasElement.width = 800; - mockCanvasElement.height = 600; - - jest.spyOn(component, 'updateEnlargedCanvas').mockImplementation(() => { - component.enlargedCanvas()!.nativeElement = mockCanvasElement; - }); - - mockEnlargedCanvas = document.createElement('canvas'); - mockEnlargedCanvas.classList.add('enlarged-canvas'); - component.enlargedCanvas = signal({ nativeElement: mockEnlargedCanvas }); - - mockContext = { - clearRect: jest.fn(), - drawImage: jest.fn(), - } as unknown as CanvasRenderingContext2D; - jest.spyOn(mockCanvasElement, 'getContext').mockReturnValue(mockContext); - - jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb: FrameRequestCallback) => { - cb(0); - return 0; - }); - mockOverlay = document.createElement('div'); - mockOverlay.style.opacity = '0'; - mockCanvasElement.appendChild(mockOverlay); - component.currentPdfBlob.set(new Blob(['dummy content'], { type: 'application/pdf' })); - - global.URL.createObjectURL = jest.fn().mockReturnValue('blob-url'); - fixture.detectChanges(); - - component.pdfContainer = signal({ nativeElement: document.createElement('div') }); - component.enlargedCanvas = signal({ nativeElement: document.createElement('canvas') }); - fixture.detectChanges(); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('should load attachment file and verify service calls when attachment data is available', () => { - component.ngOnInit(); - expect(attachmentServiceMock.getAttachmentFile).toHaveBeenCalledWith(1, 1); - expect(attachmentUnitServiceMock.getAttachmentFile).not.toHaveBeenCalled(); - }); - - it('should load attachment unit file and verify service calls when attachment unit data is available', () => { - routeMock.data = of({ - course: { id: 1, name: 'Example Course' }, - attachmentUnit: { id: 1, name: 'Chapter 1' }, - }); - component.ngOnInit(); - expect(attachmentUnitServiceMock.getAttachmentFile).toHaveBeenCalledWith(1, 1); - expect(attachmentServiceMock.getAttachmentFile).toHaveBeenCalled(); - }); - - it('should handle errors and trigger alert when loading an attachment file fails', () => { - const errorResponse = new HttpErrorResponse({ - status: 404, - statusText: 'Not Found', - error: 'File not found', - }); - - const attachmentService = TestBed.inject(AttachmentService); - jest.spyOn(attachmentService, 'getAttachmentFile').mockReturnValue(throwError(() => errorResponse)); - const alertServiceSpy = jest.spyOn(alertServiceMock, 'error'); - - component.ngOnInit(); - fixture.detectChanges(); - - expect(alertServiceSpy).toHaveBeenCalled(); - }); - - it('should handle errors and trigger alert when loading an attachment unit file fails', () => { - routeMock.data = of({ - course: { id: 1, name: 'Example Course' }, - attachmentUnit: { id: 1, name: 'Chapter 1' }, - }); - const errorResponse = new HttpErrorResponse({ - status: 404, - statusText: 'Not Found', - error: 'File not found', - }); - - const attachmentUnitService = TestBed.inject(AttachmentUnitService); - jest.spyOn(attachmentUnitService, 'getAttachmentFile').mockReturnValue(throwError(() => errorResponse)); - const alertServiceSpy = jest.spyOn(alertServiceMock, 'error'); - - component.ngOnInit(); - fixture.detectChanges(); - - expect(alertServiceSpy).toHaveBeenCalled(); - }); - - it('should load PDF and verify rendering of pages', async () => { - const spyCreateCanvas = jest.spyOn(component, 'createCanvas'); - const spyCreateCanvasContainer = jest.spyOn(component, 'createCanvasContainer'); - const spyAppendChild = jest.spyOn(component.pdfContainer()!.nativeElement, 'appendChild'); - - await component.loadOrAppendPdf('fake-url'); - - expect(spyCreateCanvas).toHaveBeenCalled(); - expect(spyCreateCanvasContainer).toHaveBeenCalled(); - expect(spyAppendChild).toHaveBeenCalled(); - expect(component.totalPages()).toBe(1); - expect(component.isPdfLoading()).toBeFalsy(); - expect(component.fileInput()!.nativeElement.value).toBe(''); - }); - - it('should navigate through pages using keyboard in enlarged view', () => { - component.isEnlargedView.set(true); - component.totalPages.set(5); - component.currentPage.set(3); - - const eventRight = new KeyboardEvent('keydown', { key: 'ArrowRight' }); - const eventLeft = new KeyboardEvent('keydown', { key: 'ArrowLeft' }); - - component.handleKeyboardEvents(eventRight); - expect(component.currentPage()).toBe(4); - - component.handleKeyboardEvents(eventLeft); - expect(component.currentPage()).toBe(3); - }); - - it('should toggle enlarged view state', () => { - const mockCanvas = document.createElement('canvas'); - component.displayEnlargedCanvas(mockCanvas); - expect(component.isEnlargedView()).toBeTruthy(); - - const clickEvent = new MouseEvent('click', { - button: 0, - }); - - component.closeEnlargedView(clickEvent); - expect(component.isEnlargedView()).toBeFalsy(); - }); - - it('should prevent scrolling when enlarged view is active', () => { - component.toggleBodyScroll(true); - expect(component.pdfContainer()!.nativeElement.style.overflow).toBe('hidden'); - - component.toggleBodyScroll(false); - expect(component.pdfContainer()!.nativeElement.style.overflow).toBe('auto'); - }); - - it('should not update canvas size if not in enlarged view', () => { - component.isEnlargedView.set(false); - component.currentPage.set(3); - - const spy = jest.spyOn(component, 'updateEnlargedCanvas'); - component.adjustCanvasSize(); - - expect(spy).not.toHaveBeenCalled(); - }); - - it('should not update canvas size if the current page canvas does not exist', () => { - component.isEnlargedView.set(true); - component.currentPage.set(10); - - const spy = jest.spyOn(component, 'updateEnlargedCanvas'); - component.adjustCanvasSize(); - - expect(spy).not.toHaveBeenCalled(); - }); - - it('should prevent navigation beyond last page', () => { - component.currentPage.set(5); - component.totalPages.set(5); - component.handleKeyboardEvents(new KeyboardEvent('keydown', { key: 'ArrowRight' })); - - expect(component.currentPage()).toBe(5); - }); - - it('should prevent navigation before first page', () => { - component.currentPage.set(1); - component.handleKeyboardEvents(new KeyboardEvent('keydown', { key: 'ArrowLeft' })); - - expect(component.currentPage()).toBe(1); - }); - - it('should unsubscribe attachment subscription during component destruction', () => { - const spySub = jest.spyOn(component.attachmentSub, 'unsubscribe'); - component.ngOnDestroy(); - expect(spySub).toHaveBeenCalled(); - }); - - it('should unsubscribe attachmentUnit subscription during component destruction', () => { - routeMock.data = of({ - course: { id: 1, name: 'Example Course' }, - attachmentUnit: { id: 1, name: 'Chapter 1' }, - }); - component.ngOnInit(); - fixture.detectChanges(); - expect(component.attachmentUnitSub).toBeDefined(); - const spySub = jest.spyOn(component.attachmentUnitSub, 'unsubscribe'); - component.ngOnDestroy(); - expect(spySub).toHaveBeenCalled(); - }); - - it('should stop event propagation and navigate pages', () => { - const navigateSpy = jest.spyOn(component, 'navigatePages'); - const eventMock = { stopPropagation: jest.fn() } as unknown as MouseEvent; - - component.handleNavigation('next', eventMock); - - expect(eventMock.stopPropagation).toHaveBeenCalled(); - expect(navigateSpy).toHaveBeenCalledWith('next'); - }); - - it('should call updateEnlargedCanvas when window is resized and conditions are met', () => { - component.isEnlargedView.set(true); - component.currentPage.set(1); - - const canvas = document.createElement('canvas'); - const pdfContainer = document.createElement('div'); - pdfContainer.classList.add('pdf-canvas-container'); - pdfContainer.appendChild(canvas); - component.pdfContainer = signal({ nativeElement: pdfContainer }); - - const updateEnlargedCanvasSpy = jest.spyOn(component, 'updateEnlargedCanvas'); - const adjustCanvasSizeSpy = jest.spyOn(component, 'adjustCanvasSize'); - - window.dispatchEvent(new Event('resize')); - expect(adjustCanvasSizeSpy).toHaveBeenCalled(); - expect(updateEnlargedCanvasSpy).toHaveBeenCalledWith(canvas); - }); - - it('should close the enlarged view if click is outside the canvas within the enlarged container', () => { - const target = document.createElement('div'); - target.classList.add('enlarged-container'); - const mockEvent = createMockEvent(target); - - component.isEnlargedView.set(true); - const closeSpy = jest.spyOn(component, 'closeEnlargedView'); - - component.closeIfOutside(mockEvent); - - expect(closeSpy).toHaveBeenCalled(); - expect(component.isEnlargedView()).toBeFalsy(); - }); - - it('should not close the enlarged view if the click is on the canvas itself', () => { - const mockEvent = createMockEvent(mockEnlargedCanvas); - Object.defineProperty(mockEvent, 'target', { value: mockEnlargedCanvas, writable: false }); - - component.isEnlargedView.set(true); - - const closeSpy = jest.spyOn(component, 'closeEnlargedView'); - - component.closeIfOutside(mockEvent as unknown as MouseEvent); - - expect(closeSpy).not.toHaveBeenCalled(); - }); - - it('should calculate the correct scale factor for horizontal slides', () => { - // Mock container dimensions - Object.defineProperty(component.pdfContainer()!.nativeElement, 'clientWidth', { value: 1000, configurable: true }); - Object.defineProperty(component.pdfContainer()!.nativeElement, 'clientHeight', { value: 800, configurable: true }); - - // Mock a horizontal canvas (width > height) - mockCanvasElement.width = 500; - mockCanvasElement.height = 400; - const scaleFactor = component.calculateScaleFactor(mockCanvasElement); - - // Expect scale factor to be based on width (scaleX) and height (scaleY), whichever is smaller - expect(scaleFactor).toBe(2); // Min of 1000/500 (scaleX = 2) and 800/400 (scaleY = 2) - }); - - it('should calculate the correct scale factor for vertical slides', () => { - Object.defineProperty(component.pdfContainer()!.nativeElement, 'clientWidth', { value: 1000, configurable: true }); - Object.defineProperty(component.pdfContainer()!.nativeElement, 'clientHeight', { value: 800, configurable: true }); - - // Mock a vertical canvas (height > width) - mockCanvasElement.width = 400; - mockCanvasElement.height = 500; - const scaleFactor = component.calculateScaleFactor(mockCanvasElement); - - // For vertical slides, scaleY is based on DEFAULT_SLIDE_HEIGHT, and scaleX is based on containerWidth - // Expect scaleY to be 800/500 = 1.6 and scaleX to be 1000/400 = 2.5 - expect(scaleFactor).toBe(1.6); // Min of 1.6 (scaleY) and 2.5 (scaleX) - }); - - it('should resize the canvas based on the given scale factor', () => { - mockCanvasElement.width = 500; - mockCanvasElement.height = 400; - component.resizeCanvas(mockCanvasElement, 2); - - expect(component.enlargedCanvas()!.nativeElement.width).toBe(1000); - expect(component.enlargedCanvas()!.nativeElement.height).toBe(800); - }); - - it('should clear and redraw the canvas with the new dimensions', () => { - mockCanvasElement.width = 500; - mockCanvasElement.height = 400; - - jest.spyOn(mockContext, 'clearRect'); - jest.spyOn(mockContext, 'drawImage'); - - component.resizeCanvas(mockCanvasElement, 2); - component.redrawCanvas(mockCanvasElement); - - expect(component.enlargedCanvas()!.nativeElement.width).toBe(1000); // 500 * 2 - expect(component.enlargedCanvas()!.nativeElement.height).toBe(800); // 400 * 2 - - expect(mockContext.clearRect).toHaveBeenCalledWith(0, 0, 1000, 800); - expect(mockContext.drawImage).toHaveBeenCalledWith(mockCanvasElement, 0, 0, 1000, 800); - }); - - it('should correctly position the canvas', () => { - const parent = document.createElement('div'); - - const mockDivElement = document.createElement('div'); - Object.defineProperty(mockDivElement, 'clientWidth', { value: 1000 }); - Object.defineProperty(mockDivElement, 'clientHeight', { value: 800 }); - Object.defineProperty(mockDivElement, 'scrollTop', { value: 500, writable: true }); - - component.pdfContainer = signal({ nativeElement: mockDivElement }); - const canvasElem = component.enlargedCanvas()!.nativeElement; - parent.appendChild(canvasElem); - canvasElem.width = 500; - canvasElem.height = 400; - component.positionCanvas(); - expect(canvasElem.style.left).toBe('250px'); - expect(canvasElem.style.top).toBe('200px'); - expect(parent.style.top).toBe('500px'); - }); - - it('should create a container with correct styles and children', () => { - const mockCanvas = document.createElement('canvas'); - mockCanvas.style.width = '600px'; - mockCanvas.style.height = '400px'; - - const container = component.createCanvasContainer(mockCanvas, 1); - expect(container.tagName).toBe('DIV'); - expect(container.classList.contains('pdf-canvas-container')).toBeTruthy(); - expect(container.style.position).toBe('relative'); - expect(container.style.display).toBe('inline-block'); - expect(container.style.width).toBe('600px'); - expect(container.style.height).toBe('400px'); - expect(container.style.margin).toBe('20px'); - expect(container.children).toHaveLength(3); - - expect(container.firstChild).toBe(mockCanvas); - }); - - it('should handle mouseenter and mouseleave events correctly', () => { - const mockCanvas = document.createElement('canvas'); - const container = component.createCanvasContainer(mockCanvas, 1); - const overlay = container.children[1] as HTMLElement; - - // Trigger mouseenter - const mouseEnterEvent = new Event('mouseenter'); - container.dispatchEvent(mouseEnterEvent); - expect(overlay.style.opacity).toBe('1'); - - // Trigger mouseleave - const mouseLeaveEvent = new Event('mouseleave'); - container.dispatchEvent(mouseLeaveEvent); - expect(overlay.style.opacity).toBe('0'); - }); - - it('should handle click event on overlay to trigger displayEnlargedCanvas', () => { - jest.spyOn(component, 'displayEnlargedCanvas'); - const mockCanvas = document.createElement('canvas'); - const container = component.createCanvasContainer(mockCanvas, 1); - const overlay = container.children[1]; - - overlay.dispatchEvent(new Event('click')); - expect(component.displayEnlargedCanvas).toHaveBeenCalledWith(mockCanvas); - }); - - it('should trigger the file input click event', () => { - const mockFileInput = document.createElement('input'); - mockFileInput.type = 'file'; - component.fileInput = signal({ nativeElement: mockFileInput }); - - const clickSpy = jest.spyOn(component.fileInput()!.nativeElement, 'click'); - component.triggerFileInput(); - expect(clickSpy).toHaveBeenCalled(); - }); - - it('should merge PDF files correctly and update the component state', async () => { - const mockFile = new File(['new pdf'], 'test.pdf', { type: 'application/pdf' }); - mockFile.arrayBuffer = jest.fn().mockResolvedValue(new ArrayBuffer(8)); // Return an empty ArrayBuffer for simplicity - const mockEvent = { target: { files: [mockFile] } }; - - const existingPdfDoc = { - copyPages: jest.fn().mockResolvedValue(['page']), - addPage: jest.fn(), - save: jest.fn().mockResolvedValue(new Uint8Array([1, 2, 3])), - }; - - const newPdfDoc = { - getPageIndices: jest.fn().mockReturnValue([0]), - }; - - PDFDocument.load = jest - .fn() - .mockImplementationOnce(() => Promise.resolve(existingPdfDoc)) - .mockImplementationOnce(() => Promise.resolve(newPdfDoc)); - - component.currentPdfBlob.set(new Blob(['existing pdf'], { type: 'application/pdf' })); - component.currentPdfBlob()!.arrayBuffer = jest.fn().mockResolvedValue(new ArrayBuffer(8)); // Return an empty ArrayBuffer for simplicity - - component.selectedPages.set(new Set([1])); // Assume there is initially a selected page - - await component.mergePDF(mockEvent as any); - - expect(PDFDocument.load).toHaveBeenCalledTimes(2); - expect(existingPdfDoc.copyPages).toHaveBeenCalledWith(newPdfDoc, [0]); - expect(existingPdfDoc.addPage).toHaveBeenCalled(); - expect(existingPdfDoc.save).toHaveBeenCalled(); - expect(component.currentPdfBlob).toBeDefined(); - expect(component.selectedPages()!.size).toBe(0); - expect(component.isPdfLoading()).toBeFalsy(); - expect(URL.createObjectURL).toHaveBeenCalledWith(new Blob([new Uint8Array([1, 2, 3])], { type: 'application/pdf' })); - }); - - it('should handle errors when merging PDFs fails', async () => { - const mockFile = new File(['new pdf'], 'test.pdf', { type: 'application/pdf' }); - - // Mock the arrayBuffer method for the file object - mockFile.arrayBuffer = jest.fn().mockResolvedValue(new ArrayBuffer(8)); // Return an empty ArrayBuffer for simplicity - - const mockEvent = { target: { files: [mockFile] } }; - const error = new Error('Error loading PDF'); - - component.currentPdfBlob.set(new Blob(['existing pdf'], { type: 'application/pdf' })); - component.currentPdfBlob()!.arrayBuffer = jest.fn().mockResolvedValue(new ArrayBuffer(8)); // Return an empty ArrayBuffer for simp - - // Mock PDFDocument.load to throw an error on the first call - PDFDocument.load = jest - .fn() - .mockImplementationOnce(() => Promise.reject(error)) // First call throws an error - .mockImplementationOnce(() => Promise.resolve({})); // Second call (not actually needed here) - - await component.mergePDF(mockEvent as any); - - expect(alertServiceMock.error).toHaveBeenCalledWith('artemisApp.attachment.pdfPreview.mergeFailedError', { error: error.message }); - expect(component.isPdfLoading()).toBeFalsy(); - }); - - it('should update the IDs of remaining pages after some have been removed', () => { - const mockContainer = document.createElement('div'); - - for (let i = 1; i <= 3; i++) { - const mockPageContainer = document.createElement('div'); - mockPageContainer.classList.add('pdf-canvas-container'); - mockPageContainer.id = `pdf-page-${i}`; - - const mockCanvas = document.createElement('canvas'); - mockCanvas.id = String(i); - mockPageContainer.appendChild(mockCanvas); - - const mockOverlay = document.createElement('div'); - mockOverlay.innerHTML = `${i}`; - mockPageContainer.appendChild(mockOverlay); - - const mockCheckbox = document.createElement('input'); - mockCheckbox.type = 'checkbox'; - mockCheckbox.id = String(i); - mockPageContainer.appendChild(mockCheckbox); - - mockContainer.appendChild(mockPageContainer); - } - - component.pdfContainer = signal({ nativeElement: mockContainer }); - component.updatePageIDs(); - - const remainingPages = component.pdfContainer()!.nativeElement.querySelectorAll('.pdf-canvas-container'); - remainingPages.forEach((pageContainer, index) => { - const pageIndex = index + 1; - const canvas = pageContainer.querySelector('canvas'); - const overlay = pageContainer.querySelector('div'); - const checkbox = pageContainer.querySelector('input[type="checkbox"]'); - - expect(pageContainer.id).toBe(`pdf-page-${pageIndex}`); - expect(canvas!.id).toBe(String(pageIndex)); - expect(overlay!.innerHTML).toBe(`${pageIndex}`); - expect(checkbox!.id).toBe(String(pageIndex)); - }); - while (mockContainer.firstChild) { - mockContainer.removeChild(mockContainer.firstChild); - } - }); - - it('should update attachment successfully and show success alert', () => { - component.attachment.set({ id: 1, version: 1 }); - component.updateAttachmentWithFile(); - - expect(attachmentServiceMock.update).toHaveBeenCalled(); - expect(alertServiceMock.success).toHaveBeenCalledWith('artemisApp.attachment.pdfPreview.attachmentUpdateSuccess'); - }); - - it('should not update attachment if file size exceeds the limit and show an error alert', () => { - const oversizedData = new Uint8Array(MAX_FILE_SIZE + 1).fill(0); - component.currentPdfBlob.set(new Blob([oversizedData], { type: 'application/pdf' })); - - component.updateAttachmentWithFile(); - - expect(attachmentServiceMock.update).not.toHaveBeenCalled(); - expect(alertServiceMock.error).toHaveBeenCalledWith('artemisApp.attachment.pdfPreview.fileSizeError'); - }); - - it('should handle errors when updating an attachment fails', () => { - attachmentServiceMock.update.mockReturnValue(throwError(() => new Error('Update failed'))); - component.attachment.set({ id: 1, version: 1 }); - - component.updateAttachmentWithFile(); - - expect(attachmentServiceMock.update).toHaveBeenCalled(); - expect(alertServiceMock.error).toHaveBeenCalledWith('artemisApp.attachment.pdfPreview.attachmentUpdateError', { error: 'Update failed' }); - }); - - it('should update attachment unit successfully and show success alert', () => { - component.attachment.set(undefined); - component.attachmentUnit.set({ - id: 1, - lecture: { id: 1 }, - attachment: { id: 1, version: 1 }, - }); - attachmentUnitServiceMock.update.mockReturnValue(of({})); - - component.updateAttachmentWithFile(); - - expect(attachmentUnitServiceMock.update).toHaveBeenCalledWith(1, 1, expect.any(FormData)); - expect(alertServiceMock.success).toHaveBeenCalledWith('artemisApp.attachment.pdfPreview.attachmentUpdateSuccess'); - }); - - it('should handle errors when updating an attachment unit fails', () => { - component.attachment.set(undefined); - component.attachmentUnit.set({ - id: 1, - lecture: { id: 1 }, - attachment: { id: 1, version: 1 }, - }); - const errorResponse = { message: 'Update failed' }; - attachmentUnitServiceMock.update.mockReturnValue(throwError(() => errorResponse)); - - component.updateAttachmentWithFile(); - - expect(attachmentUnitServiceMock.update).toHaveBeenCalledWith(1, 1, expect.any(FormData)); - expect(alertServiceMock.error).toHaveBeenCalledWith('artemisApp.attachment.pdfPreview.attachmentUpdateError', { error: 'Update failed' }); - }); - - it('should delete selected slides and update the PDF', async () => { - const existingPdfDoc = { - removePage: jest.fn(), - save: jest.fn().mockResolvedValue(new Uint8Array([1, 2, 3])), - }; - - PDFDocument.load = jest.fn().mockResolvedValue(existingPdfDoc); - const mockArrayBuffer = new ArrayBuffer(8); - component.currentPdfBlob.set(new Blob(['existing pdf'], { type: 'application/pdf' })); - component.currentPdfBlob()!.arrayBuffer = jest.fn().mockResolvedValue(mockArrayBuffer); - - const objectUrl = 'blob-url'; - global.URL.createObjectURL = jest.fn().mockReturnValue(objectUrl); - global.URL.revokeObjectURL = jest.fn(); - - component.selectedPages.set(new Set([1, 2])); // Pages 1 and 2 selected - - const loadOrAppendPdfSpy = jest.spyOn(component, 'loadOrAppendPdf'); - const alertServiceErrorSpy = jest.spyOn(alertServiceMock, 'error'); - - await component.deleteSelectedSlides(); - - expect(PDFDocument.load).toHaveBeenCalledWith(mockArrayBuffer); - expect(existingPdfDoc.removePage).toHaveBeenCalledWith(1); - expect(existingPdfDoc.removePage).toHaveBeenCalledWith(0); - expect(existingPdfDoc.removePage).toHaveBeenCalledTimes(2); - expect(existingPdfDoc.save).toHaveBeenCalled(); - expect(component.currentPdfBlob()).toEqual(new Blob([new Uint8Array([1, 2, 3])], { type: 'application/pdf' })); - expect(loadOrAppendPdfSpy).toHaveBeenCalledWith(objectUrl, false); - expect(component.selectedPages()!.size).toBe(0); - expect(alertServiceErrorSpy).not.toHaveBeenCalled(); - expect(URL.revokeObjectURL).toHaveBeenCalledWith(objectUrl); - expect(component.isPdfLoading()).toBeFalsy(); - }); - - it('should handle errors when deleting slides', async () => { - // Mock the arrayBuffer method for the current PDF Blob - component.currentPdfBlob.set(new Blob(['existing pdf'], { type: 'application/pdf' })); - component.currentPdfBlob()!.arrayBuffer = jest.fn().mockRejectedValue(new Error('Failed to load PDF')); - - // Spy on the alert service - const alertServiceErrorSpy = jest.spyOn(alertServiceMock, 'error'); - - // Call the method - await component.deleteSelectedSlides(); - - // Ensure the alert service was called with the correct error message - expect(alertServiceErrorSpy).toHaveBeenCalledWith('artemisApp.attachment.pdfPreview.pageDeleteError', { error: 'Failed to load PDF' }); - - // Verify that the loading state is set to false after the operation - expect(component.isPdfLoading()).toBeFalsy(); - }); -}); diff --git a/src/test/javascript/spec/component/lecture/pdf-preview/pdf-preview-enlarged-canvas.component.spec.ts b/src/test/javascript/spec/component/lecture/pdf-preview/pdf-preview-enlarged-canvas.component.spec.ts new file mode 100644 index 000000000000..01812980adc0 --- /dev/null +++ b/src/test/javascript/spec/component/lecture/pdf-preview/pdf-preview-enlarged-canvas.component.spec.ts @@ -0,0 +1,202 @@ +import { PdfPreviewEnlargedCanvasComponent } from 'app/lecture/pdf-preview/pdf-preview-enlarged-canvas/pdf-preview-enlarged-canvas.component'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { HttpClientModule } from '@angular/common/http'; +import { ActivatedRoute } from '@angular/router'; +import { of } from 'rxjs'; +import { AlertService } from 'app/core/util/alert.service'; +import { TranslateService } from '@ngx-translate/core'; +import { MockTranslateService } from '../../../helpers/mocks/service/mock-translate.service'; +import { signal } from '@angular/core'; + +function createMockEvent(target: Element, eventType = 'click'): MouseEvent { + const event = new MouseEvent(eventType, { + view: window, + bubbles: true, + cancelable: true, + }); + Object.defineProperty(event, 'target', { value: target, writable: false }); + return event; +} + +describe('PdfPreviewEnlargedCanvasComponent', () => { + let component: PdfPreviewEnlargedCanvasComponent; + let fixture: ComponentFixture; + let mockCanvasElement: HTMLCanvasElement; + let mockEnlargedCanvas: HTMLCanvasElement; + let mockContainer: HTMLDivElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [PdfPreviewEnlargedCanvasComponent, HttpClientModule], + providers: [ + { provide: ActivatedRoute, useValue: { data: of({}) } }, + { provide: AlertService, useValue: { error: jest.fn() } }, + { provide: TranslateService, useClass: MockTranslateService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(PdfPreviewEnlargedCanvasComponent); + component = fixture.componentInstance; + + mockEnlargedCanvas = document.createElement('canvas'); + component.enlargedCanvas = signal({ nativeElement: mockEnlargedCanvas }); + + mockContainer = document.createElement('div'); + fixture.componentRef.setInput('pdfContainer', mockContainer); + + mockCanvasElement = document.createElement('canvas'); + + const mockOriginalCanvas = document.createElement('canvas'); + mockOriginalCanvas.id = 'canvas-3'; + fixture.componentRef.setInput('originalCanvas', mockOriginalCanvas); + + fixture.detectChanges(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('Keyboard Navigation', () => { + it('should navigate through pages using keyboard in enlarged view', () => { + component.isEnlargedViewOutput.emit(true); + const mockCanvas = document.createElement('canvas'); + mockCanvas.id = 'canvas-3'; + fixture.componentRef.setInput('originalCanvas', mockCanvas); + fixture.componentRef.setInput('totalPages', 5); + component.currentPage.set(3); + + const eventRight = new KeyboardEvent('keydown', { key: 'ArrowRight' }); + const eventLeft = new KeyboardEvent('keydown', { key: 'ArrowLeft' }); + + component.handleKeyboardEvents(eventRight); + expect(component.currentPage()).toBe(4); + + component.handleKeyboardEvents(eventLeft); + expect(component.currentPage()).toBe(3); + }); + + it('should prevent navigation beyond last page', () => { + component.currentPage.set(5); + fixture.componentRef.setInput('totalPages', 5); + component.handleKeyboardEvents(new KeyboardEvent('keydown', { key: 'ArrowRight' })); + + expect(component.currentPage()).toBe(5); + }); + + it('should prevent navigation before first page', () => { + component.currentPage.set(1); + component.handleKeyboardEvents(new KeyboardEvent('keydown', { key: 'ArrowLeft' })); + + expect(component.currentPage()).toBe(1); + }); + + it('should stop event propagation and navigate pages', () => { + const navigateSpy = jest.spyOn(component, 'navigatePages'); + const eventMock = { stopPropagation: jest.fn() } as unknown as MouseEvent; + + component.handleNavigation('next', eventMock); + + expect(eventMock.stopPropagation).toHaveBeenCalled(); + expect(navigateSpy).toHaveBeenCalledWith('next'); + }); + }); + + describe('Canvas Rendering', () => { + it('should calculate the correct scale factor for horizontal slides', () => { + // Mock container dimensions + Object.defineProperty(mockContainer, 'clientWidth', { value: 1000, configurable: true }); + Object.defineProperty(mockContainer, 'clientHeight', { value: 800, configurable: true }); + + // Mock a horizontal canvas (width > height) + mockCanvasElement.width = 500; + mockCanvasElement.height = 400; + const scaleFactor = component.calculateScaleFactor(mockCanvasElement); + + expect(scaleFactor).toBe(2); // Min of 1000/500 (scaleX) and 800/400 (scaleY) + }); + + it('should calculate the correct scale factor for vertical slides', () => { + Object.defineProperty(mockContainer, 'clientWidth', { value: 1000, configurable: true }); + Object.defineProperty(mockContainer, 'clientHeight', { value: 800, configurable: true }); + + // Mock a vertical canvas (height > width) + mockCanvasElement.width = 400; + mockCanvasElement.height = 500; + const scaleFactor = component.calculateScaleFactor(mockCanvasElement); + + expect(scaleFactor).toBe(1.6); // Min of 1.6 (scaleY) and 2.5 (scaleX) + }); + + it('should resize the canvas based on the given scale factor', () => { + mockCanvasElement.width = 500; + mockCanvasElement.height = 400; + component.resizeCanvas(mockCanvasElement, 2); + + expect(mockEnlargedCanvas.width).toBe(1000); + expect(mockEnlargedCanvas.height).toBe(800); + }); + + it('should clear and redraw the canvas with the new dimensions', () => { + mockCanvasElement.width = 500; + mockCanvasElement.height = 400; + + const mockContext = mockEnlargedCanvas.getContext('2d')!; + jest.spyOn(mockContext, 'clearRect'); + jest.spyOn(mockContext, 'drawImage'); + + component.resizeCanvas(mockCanvasElement, 2); + component.redrawCanvas(mockCanvasElement); + + expect(mockEnlargedCanvas.width).toBe(1000); // 500 * 2 + expect(mockEnlargedCanvas.height).toBe(800); // 400 * 2 + expect(mockContext.clearRect).toHaveBeenCalledWith(0, 0, 1000, 800); + expect(mockContext.drawImage).toHaveBeenCalledWith(mockCanvasElement, 0, 0, 1000, 800); + }); + }); + + describe('Layout', () => { + it('should prevent scrolling when enlarged view is active', () => { + component.toggleBodyScroll(true); + expect(mockContainer.style.overflow).toBe('hidden'); + + component.toggleBodyScroll(false); + expect(mockContainer.style.overflow).toBe('auto'); + }); + + it('should not update canvas size if not in enlarged view', () => { + component.isEnlargedViewOutput.emit(false); + component.currentPage.set(3); + + const spy = jest.spyOn(component, 'updateEnlargedCanvas'); + component.adjustCanvasSize(); + + expect(spy).not.toHaveBeenCalled(); + }); + }); + + describe('Enlarged View Management', () => { + it('should close the enlarged view if click is outside the canvas within the enlarged container', () => { + const target = document.createElement('div'); + target.classList.add('enlarged-container'); + const mockEvent = createMockEvent(target); + + const closeSpy = jest.fn(); + component.isEnlargedViewOutput.subscribe(closeSpy); + + component.closeIfOutside(mockEvent); + + expect(closeSpy).toHaveBeenCalledWith(false); + }); + + it('should not close the enlarged view if the click is on the canvas itself', () => { + const mockEvent = createMockEvent(mockEnlargedCanvas); + component.isEnlargedViewOutput.emit(true); + + const closeSpy = jest.spyOn(component, 'closeEnlargedView'); + + component.closeIfOutside(mockEvent as unknown as MouseEvent); + expect(closeSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/test/javascript/spec/component/lecture/pdf-preview/pdf-preview-thumbnail-grid.component.spec.ts b/src/test/javascript/spec/component/lecture/pdf-preview/pdf-preview-thumbnail-grid.component.spec.ts new file mode 100644 index 000000000000..968e6830506f --- /dev/null +++ b/src/test/javascript/spec/component/lecture/pdf-preview/pdf-preview-thumbnail-grid.component.spec.ts @@ -0,0 +1,102 @@ +import { MockTranslateService } from '../../../helpers/mocks/service/mock-translate.service'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { of } from 'rxjs'; +import { AlertService } from 'app/core/util/alert.service'; +import { HttpClientModule } from '@angular/common/http'; +import { TranslateService } from '@ngx-translate/core'; +import { PdfPreviewThumbnailGridComponent } from 'app/lecture/pdf-preview/pdf-preview-thumbnail-grid/pdf-preview-thumbnail-grid.component'; + +jest.mock('pdfjs-dist', () => { + return { + getDocument: jest.fn(() => ({ + promise: Promise.resolve({ + numPages: 1, + getPage: jest.fn(() => + Promise.resolve({ + getViewport: jest.fn(() => ({ width: 600, height: 800, scale: 1 })), + render: jest.fn(() => ({ + promise: Promise.resolve(), + })), + }), + ), + }), + })), + }; +}); + +jest.mock('pdfjs-dist/build/pdf.worker', () => { + return {}; +}); + +describe('PdfPreviewThumbnailGridComponent', () => { + let component: PdfPreviewThumbnailGridComponent; + let fixture: ComponentFixture; + let alertServiceMock: any; + + beforeEach(async () => { + alertServiceMock = { + error: jest.fn(), + }; + + await TestBed.configureTestingModule({ + imports: [PdfPreviewThumbnailGridComponent, HttpClientModule], + providers: [ + { provide: ActivatedRoute, useValue: { data: of({}) } }, + { provide: AlertService, useValue: alertServiceMock }, + { provide: TranslateService, useClass: MockTranslateService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(PdfPreviewThumbnailGridComponent); + component = fixture.componentInstance; + + fixture.detectChanges(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should load PDF and render pages', async () => { + const spyCreateCanvas = jest.spyOn(component, 'createCanvas'); + const spyCreateCanvasContainer = jest.spyOn(component, 'createCanvasContainer'); + + await component.loadOrAppendPdf('fake-url'); + + expect(spyCreateCanvas).toHaveBeenCalled(); + expect(spyCreateCanvasContainer).toHaveBeenCalled(); + expect(component.totalPages()).toBe(1); + }); + + it('should toggle enlarged view state', () => { + const mockCanvas = document.createElement('canvas'); + component.displayEnlargedCanvas(mockCanvas); + expect(component.isEnlargedView()).toBeTruthy(); + + component.isEnlargedView.set(false); + expect(component.isEnlargedView()).toBeFalsy(); + }); + + it('should handle mouseenter and mouseleave events correctly', () => { + const mockCanvas = document.createElement('canvas'); + const container = component.createCanvasContainer(mockCanvas, 1); + const overlay = container.querySelector('div'); + + container.dispatchEvent(new Event('mouseenter')); + expect(overlay!.style.opacity).toBe('1'); + + container.dispatchEvent(new Event('mouseleave')); + expect(overlay!.style.opacity).toBe('0'); + }); + + it('should handle click event on overlay to trigger displayEnlargedCanvas', () => { + const displayEnlargedCanvasSpy = jest.spyOn(component, 'displayEnlargedCanvas'); + const mockCanvas = document.createElement('canvas'); + const container = component.createCanvasContainer(mockCanvas, 1); + const overlay = container.querySelector('div'); + + overlay!.dispatchEvent(new Event('click')); + expect(displayEnlargedCanvasSpy).toHaveBeenCalledWith(mockCanvas); + }); +}); diff --git a/src/test/javascript/spec/component/lecture/pdf-preview/pdf-preview.component.spec.ts b/src/test/javascript/spec/component/lecture/pdf-preview/pdf-preview.component.spec.ts new file mode 100644 index 000000000000..2f0b2ed366f7 --- /dev/null +++ b/src/test/javascript/spec/component/lecture/pdf-preview/pdf-preview.component.spec.ts @@ -0,0 +1,421 @@ +import { MockTranslateService } from '../../../helpers/mocks/service/mock-translate.service'; +import { MAX_FILE_SIZE } from 'app/shared/constants/input.constants'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; +import { of, throwError } from 'rxjs'; +import { AttachmentService } from 'app/lecture/attachment.service'; +import { AttachmentUnitService } from 'app/lecture/lecture-unit/lecture-unit-management/attachmentUnit.service'; +import { LectureUnitService } from 'app/lecture/lecture-unit/lecture-unit-management/lectureUnit.service'; +import { PdfPreviewComponent } from 'app/lecture/pdf-preview/pdf-preview.component'; +import { signal } from '@angular/core'; +import { AlertService } from 'app/core/util/alert.service'; +import { HttpClientModule, HttpErrorResponse } from '@angular/common/http'; +import { TranslateService } from '@ngx-translate/core'; +import { PDFDocument } from 'pdf-lib'; + +jest.mock('pdf-lib', () => { + const originalModule = jest.requireActual('pdf-lib'); + + return { + ...originalModule, + PDFDocument: { + ...originalModule.PDFDocument, + load: jest.fn(), + create: jest.fn(), + prototype: { + removePage: jest.fn(), + save: jest.fn(), + }, + }, + }; +}); + +jest.mock('pdfjs-dist', () => { + return { + getDocument: jest.fn(() => ({ + promise: Promise.resolve({ + numPages: 1, + getPage: jest.fn(() => + Promise.resolve({ + getViewport: jest.fn(() => ({ width: 600, height: 800, scale: 1 })), + render: jest.fn(() => ({ + promise: Promise.resolve(), + })), + }), + ), + }), + })), + }; +}); + +jest.mock('pdfjs-dist/build/pdf.worker', () => { + return {}; +}); + +describe('PdfPreviewComponent', () => { + let component: PdfPreviewComponent; + let fixture: ComponentFixture; + let attachmentServiceMock: any; + let attachmentUnitServiceMock: any; + let lectureUnitServiceMock: any; + let alertServiceMock: any; + let routeMock: any; + let routerNavigateSpy: any; + + beforeEach(async () => { + attachmentServiceMock = { + getAttachmentFile: jest.fn().mockReturnValue(of(new Blob([''], { type: 'application/pdf' }))), + update: jest.fn().mockReturnValue(of({})), + delete: jest.fn().mockReturnValue(of({})), + }; + attachmentUnitServiceMock = { + getAttachmentFile: jest.fn().mockReturnValue(of(new Blob([''], { type: 'application/pdf' }))), + update: jest.fn().mockReturnValue(of({})), + delete: jest.fn().mockReturnValue(of({})), + }; + lectureUnitServiceMock = { + delete: jest.fn().mockReturnValue(of({})), + }; + routeMock = { + data: of({ + course: { id: 1, name: 'Example Course' }, + attachment: { id: 1, name: 'Example PDF', lecture: { id: 1 } }, + attachmentUnit: { id: 1, name: 'Chapter 1', lecture: { id: 1 } }, + }), + }; + alertServiceMock = { + addAlert: jest.fn(), + error: jest.fn(), + success: jest.fn(), + }; + + await TestBed.configureTestingModule({ + imports: [PdfPreviewComponent, HttpClientModule], + providers: [ + { provide: ActivatedRoute, useValue: routeMock }, + { provide: AttachmentService, useValue: attachmentServiceMock }, + { provide: AttachmentUnitService, useValue: attachmentUnitServiceMock }, + { provide: LectureUnitService, useValue: lectureUnitServiceMock }, + { provide: AlertService, useValue: alertServiceMock }, + { provide: TranslateService, useClass: MockTranslateService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(PdfPreviewComponent); + component = fixture.componentInstance; + + jest.spyOn(component.dialogErrorSource, 'next'); + + global.URL.createObjectURL = jest.fn().mockReturnValue('blob-url'); + + routerNavigateSpy = jest.spyOn(TestBed.inject(Router), 'navigate'); + + fixture.detectChanges(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('Initialization and Data Loading', () => { + it('should load attachment file and verify service calls when attachment data is available', () => { + component.ngOnInit(); + expect(attachmentServiceMock.getAttachmentFile).toHaveBeenCalledWith(1, 1); + expect(attachmentUnitServiceMock.getAttachmentFile).not.toHaveBeenCalled(); + }); + + it('should load attachment unit file and verify service calls when attachment unit data is available', () => { + routeMock.data = of({ + course: { id: 1, name: 'Example Course' }, + attachmentUnit: { id: 1, name: 'Chapter 1' }, + }); + component.ngOnInit(); + expect(attachmentUnitServiceMock.getAttachmentFile).toHaveBeenCalledWith(1, 1); + expect(attachmentServiceMock.getAttachmentFile).toHaveBeenCalled(); + }); + + it('should handle errors and trigger alert when loading an attachment file fails', () => { + const errorResponse = new HttpErrorResponse({ + status: 404, + statusText: 'Not Found', + error: 'File not found', + }); + + const attachmentService = TestBed.inject(AttachmentService); + jest.spyOn(attachmentService, 'getAttachmentFile').mockReturnValue(throwError(() => errorResponse)); + const alertServiceSpy = jest.spyOn(alertServiceMock, 'error'); + + component.ngOnInit(); + fixture.detectChanges(); + + expect(alertServiceSpy).toHaveBeenCalled(); + }); + + it('should handle errors and trigger alert when loading an attachment unit file fails', () => { + routeMock.data = of({ + course: { id: 1, name: 'Example Course' }, + attachmentUnit: { id: 1, name: 'Chapter 1' }, + }); + const errorResponse = new HttpErrorResponse({ + status: 404, + statusText: 'Not Found', + error: 'File not found', + }); + + const attachmentUnitService = TestBed.inject(AttachmentUnitService); + jest.spyOn(attachmentUnitService, 'getAttachmentFile').mockReturnValue(throwError(() => errorResponse)); + const alertServiceSpy = jest.spyOn(alertServiceMock, 'error'); + + component.ngOnInit(); + fixture.detectChanges(); + + expect(alertServiceSpy).toHaveBeenCalled(); + }); + }); + + describe('Unsubscribing from Observables', () => { + it('should unsubscribe attachment subscription during component destruction', () => { + const spySub = jest.spyOn(component.attachmentSub, 'unsubscribe'); + component.ngOnDestroy(); + expect(spySub).toHaveBeenCalled(); + }); + + it('should unsubscribe attachmentUnit subscription during component destruction', () => { + routeMock.data = of({ + course: { id: 1, name: 'Example Course' }, + attachmentUnit: { id: 1, name: 'Chapter 1' }, + }); + component.ngOnInit(); + fixture.detectChanges(); + expect(component.attachmentUnitSub).toBeDefined(); + const spySub = jest.spyOn(component.attachmentUnitSub, 'unsubscribe'); + component.ngOnDestroy(); + expect(spySub).toHaveBeenCalled(); + }); + }); + + describe('File Input Handling', () => { + it('should trigger the file input click event', () => { + const mockFileInput = document.createElement('input'); + mockFileInput.type = 'file'; + component.fileInput = signal({ nativeElement: mockFileInput }); + + const clickSpy = jest.spyOn(component.fileInput()!.nativeElement, 'click'); + component.triggerFileInput(); + expect(clickSpy).toHaveBeenCalled(); + }); + }); + + describe('Attachment Updating', () => { + it('should update attachment successfully and show success alert', () => { + component.attachment.set({ id: 1, version: 1 }); + component.updateAttachmentWithFile(); + + expect(attachmentServiceMock.update).toHaveBeenCalled(); + expect(alertServiceMock.success).toHaveBeenCalledWith('artemisApp.attachment.pdfPreview.attachmentUpdateSuccess'); + }); + + it('should not update attachment if file size exceeds the limit and show an error alert', () => { + const oversizedData = new Uint8Array(MAX_FILE_SIZE + 1).fill(0); + component.currentPdfBlob.set(new Blob([oversizedData], { type: 'application/pdf' })); + + component.updateAttachmentWithFile(); + + expect(attachmentServiceMock.update).not.toHaveBeenCalled(); + expect(alertServiceMock.error).toHaveBeenCalledWith('artemisApp.attachment.pdfPreview.fileSizeError'); + }); + + it('should handle errors when updating an attachment fails', () => { + attachmentServiceMock.update.mockReturnValue(throwError(() => new Error('Update failed'))); + component.attachment.set({ id: 1, version: 1 }); + + component.updateAttachmentWithFile(); + + expect(attachmentServiceMock.update).toHaveBeenCalled(); + expect(alertServiceMock.error).toHaveBeenCalledWith('artemisApp.attachment.pdfPreview.attachmentUpdateError', { error: 'Update failed' }); + }); + + it('should update attachment unit successfully and show success alert', () => { + component.attachment.set(undefined); + component.attachmentUnit.set({ + id: 1, + lecture: { id: 1 }, + attachment: { id: 1, version: 1 }, + }); + attachmentUnitServiceMock.update.mockReturnValue(of({})); + + component.updateAttachmentWithFile(); + + expect(attachmentUnitServiceMock.update).toHaveBeenCalledWith(1, 1, expect.any(FormData)); + expect(alertServiceMock.success).toHaveBeenCalledWith('artemisApp.attachment.pdfPreview.attachmentUpdateSuccess'); + }); + + it('should handle errors when updating an attachment unit fails', () => { + component.attachment.set(undefined); + component.attachmentUnit.set({ + id: 1, + lecture: { id: 1 }, + attachment: { id: 1, version: 1 }, + }); + const errorResponse = { message: 'Update failed' }; + attachmentUnitServiceMock.update.mockReturnValue(throwError(() => errorResponse)); + + component.updateAttachmentWithFile(); + + expect(attachmentUnitServiceMock.update).toHaveBeenCalledWith(1, 1, expect.any(FormData)); + expect(alertServiceMock.error).toHaveBeenCalledWith('artemisApp.attachment.pdfPreview.attachmentUpdateError', { error: 'Update failed' }); + }); + }); + + describe('PDF Merging', () => { + it('should merge PDF files correctly and update the component state', async () => { + const mockFile = new File(['new pdf'], 'test.pdf', { type: 'application/pdf' }); + mockFile.arrayBuffer = jest.fn().mockResolvedValue(new ArrayBuffer(8)); + const mockEvent = { target: { files: [mockFile] } }; + + const existingPdfDoc = { + copyPages: jest.fn().mockResolvedValue(['page']), + addPage: jest.fn(), + save: jest.fn().mockResolvedValue(new Uint8Array([1, 2, 3])), + }; + + const newPdfDoc = { + getPageIndices: jest.fn().mockReturnValue([0]), + }; + + PDFDocument.load = jest + .fn() + .mockImplementationOnce(() => Promise.resolve(existingPdfDoc)) + .mockImplementationOnce(() => Promise.resolve(newPdfDoc)); + + component.currentPdfBlob.set(new Blob(['existing pdf'], { type: 'application/pdf' })); + component.currentPdfBlob()!.arrayBuffer = jest.fn().mockResolvedValue(new ArrayBuffer(8)); + + component.selectedPages.set(new Set([1])); + + await component.mergePDF(mockEvent as any); + + expect(PDFDocument.load).toHaveBeenCalledTimes(2); + expect(existingPdfDoc.copyPages).toHaveBeenCalledWith(newPdfDoc, [0]); + expect(existingPdfDoc.addPage).toHaveBeenCalled(); + expect(existingPdfDoc.save).toHaveBeenCalled(); + expect(component.currentPdfBlob).toBeDefined(); + expect(component.selectedPages()!.size).toBe(0); + expect(component.isPdfLoading()).toBeFalsy(); + expect(URL.createObjectURL).toHaveBeenCalledWith(new Blob([new Uint8Array([1, 2, 3])], { type: 'application/pdf' })); + }); + + it('should handle errors when merging PDFs fails', async () => { + const mockFile = new File(['new pdf'], 'test.pdf', { type: 'application/pdf' }); + + mockFile.arrayBuffer = jest.fn().mockResolvedValue(new ArrayBuffer(8)); + + const mockEvent = { target: { files: [mockFile] } }; + const error = new Error('Error loading PDF'); + + component.currentPdfBlob.set(new Blob(['existing pdf'], { type: 'application/pdf' })); + component.currentPdfBlob()!.arrayBuffer = jest.fn().mockResolvedValue(new ArrayBuffer(8)); + + PDFDocument.load = jest + .fn() + .mockImplementationOnce(() => Promise.reject(error)) + .mockImplementationOnce(() => Promise.resolve({})); + + await component.mergePDF(mockEvent as any); + + expect(alertServiceMock.error).toHaveBeenCalledWith('artemisApp.attachment.pdfPreview.mergeFailedError', { error: error.message }); + expect(component.isPdfLoading()).toBeFalsy(); + }); + }); + + describe('Slide Deletion', () => { + it('should delete selected slides and update the PDF', async () => { + const existingPdfDoc = { + removePage: jest.fn(), + save: jest.fn().mockResolvedValue(new Uint8Array([1, 2, 3])), + }; + + PDFDocument.load = jest.fn().mockResolvedValue(existingPdfDoc); + const mockArrayBuffer = new ArrayBuffer(8); + + component.currentPdfBlob.set(new Blob(['existing pdf'], { type: 'application/pdf' })); + component.currentPdfBlob()!.arrayBuffer = jest.fn().mockResolvedValue(mockArrayBuffer); + component.selectedPages.set(new Set([1, 2])); + + const alertServiceErrorSpy = jest.spyOn(alertServiceMock, 'error'); + + await component.deleteSelectedSlides(); + + expect(PDFDocument.load).toHaveBeenCalledWith(mockArrayBuffer); + expect(existingPdfDoc.removePage).toHaveBeenCalledWith(1); + expect(existingPdfDoc.removePage).toHaveBeenCalledWith(0); + expect(existingPdfDoc.removePage).toHaveBeenCalledTimes(2); + expect(existingPdfDoc.save).toHaveBeenCalled(); + expect(component.currentPdfBlob()).toEqual(new Blob([new Uint8Array([1, 2, 3])], { type: 'application/pdf' })); + expect(component.selectedPages()!.size).toBe(0); + expect(alertServiceErrorSpy).not.toHaveBeenCalled(); + expect(component.isPdfLoading()).toBeFalsy(); + }); + + it('should handle errors when deleting slides', async () => { + component.currentPdfBlob.set(new Blob(['existing pdf'], { type: 'application/pdf' })); + component.currentPdfBlob()!.arrayBuffer = jest.fn().mockRejectedValue(new Error('Failed to load PDF')); + + const alertServiceErrorSpy = jest.spyOn(alertServiceMock, 'error'); + await component.deleteSelectedSlides(); + + expect(alertServiceErrorSpy).toHaveBeenCalledWith('artemisApp.attachment.pdfPreview.pageDeleteError', { error: 'Failed to load PDF' }); + expect(component.isPdfLoading()).toBeFalsy(); + }); + }); + + describe('Attachment Deletion', () => { + it('should delete the attachment and navigate to attachments on success', () => { + component.attachment.set({ id: 1, lecture: { id: 2 } }); + component.course.set({ id: 3 }); + + component.deleteAttachmentFile(); + + expect(attachmentServiceMock.delete).toHaveBeenCalledWith(1); + expect(routerNavigateSpy).toHaveBeenCalledWith(['course-management', 3, 'lectures', 2, 'attachments']); + expect(component.dialogErrorSource.next).toHaveBeenCalledWith(''); + }); + + it('should delete the attachment unit and navigate to unit management on success', () => { + component.attachment.set(undefined); + component.attachmentUnit.set({ id: 4, lecture: { id: 5 } }); + component.course.set({ id: 6 }); + + component.deleteAttachmentFile(); + + expect(lectureUnitServiceMock.delete).toHaveBeenCalledWith(4, 5); + expect(routerNavigateSpy).toHaveBeenCalledWith(['course-management', 6, 'lectures', 5, 'unit-management']); + expect(component.dialogErrorSource.next).toHaveBeenCalledWith(''); + }); + + it('should handle error and call alertService.error if deletion of attachment fails', () => { + const error = { message: 'Deletion failed' }; + attachmentServiceMock.delete.mockReturnValue(throwError(() => error)); + component.attachment.set({ id: 1, lecture: { id: 2 } }); + component.course.set({ id: 3 }); + + component.deleteAttachmentFile(); + + expect(attachmentServiceMock.delete).toHaveBeenCalledWith(1); + expect(alertServiceMock.error).toHaveBeenCalledWith('artemisApp.attachment.pdfPreview.attachmentUpdateError', { error: 'Deletion failed' }); + }); + + it('should handle error and call alertService.error if deletion of attachment unit fails', () => { + const error = { message: 'Deletion failed' }; + lectureUnitServiceMock.delete.mockReturnValue(throwError(() => error)); + component.attachment.set(undefined); + component.attachmentUnit.set({ id: 4, lecture: { id: 5 } }); + component.course.set({ id: 6 }); + + component.deleteAttachmentFile(); + + expect(lectureUnitServiceMock.delete).toHaveBeenCalledWith(4, 5); + expect(alertServiceMock.error).toHaveBeenCalledWith('artemisApp.attachment.pdfPreview.attachmentUpdateError', { error: 'Deletion failed' }); + }); + }); +}); From 04ba6b9fd1b781b4a8b4865fcf46f8e9abdcfe83 Mon Sep 17 00:00:00 2001 From: Simon Entholzer <33342534+SimonEntholzer@users.noreply.github.com> Date: Fri, 29 Nov 2024 20:56:50 +0100 Subject: [PATCH 05/10] Development: Fix failing server style (#9912) --- .../localci/scaparser/format/sarif/Result.java | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/Result.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/Result.java index 49d17e49649a..4230d64dcd9c 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/Result.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/Result.java @@ -111,6 +111,14 @@ public String value() { return this.value; } + /** + * Creates a {@link Kind} instance from a given string value. + *

+ * + * @param value the string representation of the {@link Kind} + * @return the matching {@link Kind} instance + * @throws IllegalArgumentException if the provided value does not correspond to any defined {@link Kind} + */ @JsonCreator public static Kind fromValue(String value) { Kind constant = CONSTANTS.get(value); @@ -121,7 +129,6 @@ public static Kind fromValue(String value) { return constant; } } - } /** @@ -155,6 +162,13 @@ public String value() { return this.value; } + /** + * Creates a {@link Level} instance from a given string value. + * + * @param value the string representation of the {@link Level} + * @return the matching {@link Level} instance + * @throws IllegalArgumentException if the provided value does not correspond to any defined {@link Level} + */ @JsonCreator public static Level fromValue(String value) { Level constant = CONSTANTS.get(value); From 9270bc0553aed216d7d81cfedb8fde385843ccb0 Mon Sep 17 00:00:00 2001 From: Yassine Souissi <74144843+yassinsws@users.noreply.github.com> Date: Fri, 29 Nov 2024 22:53:08 +0100 Subject: [PATCH 06/10] Iris: Display ingestion state for lecture slide upload (#9090) --- .../Artemis__Server__LocalVC___Jenkins_.xml | 2 +- .../Artemis__Server__LocalVC___LocalCI_.xml | 4 +- .../aet/artemis/iris/dto/IngestionState.java | 5 + .../iris/dto/IngestionStateResponseDTO.java | 4 + .../service/pyris/PyrisConnectorService.java | 67 +++++++- .../iris/service/pyris/PyrisJobService.java | 17 +- .../pyris/PyrisStatusUpdateService.java | 4 +- .../service/pyris/PyrisWebhookService.java | 157 +++++++++++++----- .../PyrisLectureIngestionStatusUpdateDTO.java | 2 +- .../PyrisLectureUnitWebhookDTO.java | 4 +- ...risWebhookLectureDeletionExecutionDTO.java | 13 ++ ...isWebhookLectureIngestionExecutionDTO.java | 3 +- .../pyris/job/IngestionWebhookJob.java | 2 +- .../aet/artemis/iris/web/IrisResource.java | 82 ++++++++- .../open/PublicPyrisStatusUpdateResource.java | 1 - .../lecture/repository/LectureRepository.java | 20 +++ .../repository/LectureUnitRepository.java | 10 ++ .../lecture/service/LectureService.java | 8 +- .../lecture/service/LectureUnitService.java | 30 ++++ .../artemis/lecture/web/LectureResource.java | 14 +- .../lecture/web/LectureUnitResource.java | 30 +++- .../scaparser/format/sarif/Result.java | 4 +- .../lecture-unit/attachmentUnit.model.ts | 9 + src/main/webapp/app/entities/lecture.model.ts | 2 + .../app/lecture/lecture-detail.component.ts | 8 +- .../lecture-unit-management.component.html | 60 +++++-- .../lecture-unit-management.component.ts | 101 ++++++++++- .../lectureUnit.service.ts | 26 ++- .../webapp/app/lecture/lecture.component.html | 30 ++++ .../webapp/app/lecture/lecture.component.ts | 48 +++++- .../webapp/app/lecture/lecture.service.ts | 13 +- .../app/overview/course-overview.service.ts | 2 +- src/main/webapp/i18n/de/iris.json | 19 +++ src/main/webapp/i18n/de/lecture.json | 1 + src/main/webapp/i18n/en/iris.json | 19 +++ src/main/webapp/i18n/en/lecture.json | 1 + .../connector/IrisRequestMockProvider.java | 18 ++ .../iris/PyrisConnectorServiceTest.java | 15 +- .../iris/PyrisLectureIngestionTest.java | 149 +++++++++-------- .../lecture-unit-management.component.spec.ts | 88 +++++++++- .../lecture-unit/lecture-unit.service.spec.ts | 42 ++++- .../lecture/lecture-detail.component.spec.ts | 2 +- .../lecture/lecture.component.spec.ts | 57 ++++--- .../spec/service/lecture.service.spec.ts | 19 +++ 44 files changed, 997 insertions(+), 215 deletions(-) create mode 100644 src/main/java/de/tum/cit/aet/artemis/iris/dto/IngestionState.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/iris/dto/IngestionStateResponseDTO.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/lectureingestionwebhook/PyrisWebhookLectureDeletionExecutionDTO.java diff --git a/.idea/runConfigurations/Artemis__Server__LocalVC___Jenkins_.xml b/.idea/runConfigurations/Artemis__Server__LocalVC___Jenkins_.xml index 217477ace79d..2eae7040817e 100644 --- a/.idea/runConfigurations/Artemis__Server__LocalVC___Jenkins_.xml +++ b/.idea/runConfigurations/Artemis__Server__LocalVC___Jenkins_.xml @@ -9,4 +9,4 @@

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

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

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

+ * + * @param courseId the ID of the lecture the unit belongs to + * @param lectureId the ID of the lecture the unit belongs to + * @return a {@link ResponseEntity} containing the {@link IngestionState} of the lecture unit, + */ + @GetMapping("courses/{courseId}/lectures/{lectureId}/lecture-units/ingestion-state") + @EnforceAtLeastInstructorInCourse + public ResponseEntity> getStatusOfLectureUnitsIngestion(@PathVariable long courseId, @PathVariable long lectureId) { + try { + Course course = courseRepository.findByIdElseThrow(courseId); + authorizationCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.INSTRUCTOR, course, null); + return ResponseEntity.ok(pyrisWebhookService.getLectureUnitsIngestionState(courseId, lectureId)); + } + catch (PyrisConnectorException e) { + log.error("Error fetching ingestion state for lecture {}", lectureId, e); + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).build(); + } + } + } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/web/open/PublicPyrisStatusUpdateResource.java b/src/main/java/de/tum/cit/aet/artemis/iris/web/open/PublicPyrisStatusUpdateResource.java index 025a6ce4e897..73e4520c32a6 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/web/open/PublicPyrisStatusUpdateResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/web/open/PublicPyrisStatusUpdateResource.java @@ -171,7 +171,6 @@ public ResponseEntity setStatusOfIngestionJob(@PathVariable String runId, } pyrisStatusUpdateService.handleStatusUpdate(ingestionWebhookJob, statusUpdateDTO); - return ResponseEntity.ok().build(); } } diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/repository/LectureRepository.java b/src/main/java/de/tum/cit/aet/artemis/lecture/repository/LectureRepository.java index d0fd1afbf8a6..33ce07e7b6fb 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/repository/LectureRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/repository/LectureRepository.java @@ -28,6 +28,13 @@ @Repository public interface LectureRepository extends ArtemisJpaRepository { + @Query(""" + SELECT lecture + FROM Lecture lecture + WHERE lecture.course.id = :courseId + """) + Set findAllByCourseId(@Param("courseId") Long courseId); + @Query(""" SELECT lecture FROM Lecture lecture @@ -85,6 +92,14 @@ public interface LectureRepository extends ArtemisJpaRepository { """) Optional findByIdWithLectureUnitsAndCompetencies(@Param("lectureId") Long lectureId); + @Query(""" + SELECT lecture + FROM Lecture lecture + LEFT JOIN FETCH lecture.lectureUnits + WHERE lecture.id = :lectureId + """) + Optional findByIdWithLectureUnits(@Param("lectureId") Long lectureId); + @Query(""" SELECT lecture FROM Lecture lecture @@ -165,6 +180,11 @@ Page findByTitleInLectureOrCourseAndUserHasAccessToCourse(@Param("parti @Cacheable(cacheNames = "lectureTitle", key = "#lectureId", unless = "#result == null") String getLectureTitle(@Param("lectureId") Long lectureId); + @NotNull + default Lecture findByIdWithLectureUnitsElseThrow(Long lectureId) { + return getValueElseThrow(findByIdWithLectureUnits(lectureId), lectureId); + } + @NotNull default Lecture findByIdWithLectureUnitsAndCompetenciesElseThrow(Long lectureId) { return getValueElseThrow(findByIdWithLectureUnitsAndCompetencies(lectureId), lectureId); diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/repository/LectureUnitRepository.java b/src/main/java/de/tum/cit/aet/artemis/lecture/repository/LectureUnitRepository.java index 031fdc300e9d..42807551d308 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/repository/LectureUnitRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/repository/LectureUnitRepository.java @@ -21,6 +21,13 @@ @Repository public interface LectureUnitRepository extends ArtemisJpaRepository { + @Query(""" + SELECT lu + FROM LectureUnit lu + WHERE lu.id = :lectureUnitId + """) + Optional findById(@Param("lectureUnitId") long lectureUnitId); + @Query(""" SELECT lu FROM LectureUnit lu @@ -104,4 +111,7 @@ default LectureUnit findByIdWithCompetenciesAndSlidesElseThrow(long lectureUnitI return getValueElseThrow(findWithCompetenciesAndSlidesById(lectureUnitId), lectureUnitId); } + default LectureUnit findByIdElseThrow(long lectureUnitId) { + return getValueElseThrow(findById(lectureUnitId), lectureUnitId); + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/service/LectureService.java b/src/main/java/de/tum/cit/aet/artemis/lecture/service/LectureService.java index 3095605139a1..7654884f753d 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/service/LectureService.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/service/LectureService.java @@ -177,16 +177,14 @@ public void delete(Lecture lecture, boolean updateCompetencyProgress) { * Ingest the lectures when triggered by the ingest lectures button * * @param lectures set of lectures to be ingested - * @return returns the job token if the operation is successful else it returns null */ - public boolean ingestLecturesInPyris(Set lectures) { + public void ingestLecturesInPyris(Set lectures) { if (pyrisWebhookService.isPresent()) { List attachmentUnitList = lectures.stream().flatMap(lec -> lec.getLectureUnits().stream()).filter(unit -> unit instanceof AttachmentUnit) .map(unit -> (AttachmentUnit) unit).toList(); - if (!attachmentUnitList.isEmpty()) { - return pyrisWebhookService.get().addLectureUnitsToPyrisDB(attachmentUnitList) != null; + for (AttachmentUnit attachmentUnit : attachmentUnitList) { + pyrisWebhookService.get().addLectureUnitToPyrisDB(attachmentUnit); } } - return false; } } diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/service/LectureUnitService.java b/src/main/java/de/tum/cit/aet/artemis/lecture/service/LectureUnitService.java index bda282499738..74ec8a5fa90c 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/service/LectureUnitService.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/service/LectureUnitService.java @@ -19,8 +19,12 @@ import jakarta.ws.rs.BadRequestException; import org.hibernate.Hibernate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Profile; import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import de.tum.cit.aet.artemis.atlas.domain.competency.CompetencyLectureUnitLink; @@ -47,6 +51,8 @@ @Service public class LectureUnitService { + private static final Logger log = LoggerFactory.getLogger(LectureUnitService.class); + private final LectureUnitRepository lectureUnitRepository; private final LectureRepository lectureRepository; @@ -220,6 +226,30 @@ public URL validateUrlStringAndReturnUrl(String urlString) { } } + /** + * This method is responsible for ingesting a specific `LectureUnit` into Pyris, but only if it is an instance of + * `AttachmentUnit`. If the Pyris webhook service is available, it attempts to add the `LectureUnit` to the Pyris + * database. + * The method responds with different HTTP status codes based on the result: + * Returns {OK} if the ingestion is successful. + * Returns {SERVICE_UNAVAILABLE} if the Pyris webhook service is unavailable or if the ingestion fails. + * Returns {400 BAD_REQUEST} if the provided lecture unit is not of type {AttachmentUnit}. + * + * @param lectureUnit the lecture unit to be ingested, which must be an instance of AttachmentUnit. + * @return ResponseEntity representing the outcome of the operation with the appropriate HTTP status. + */ + public ResponseEntity ingestLectureUnitInPyris(LectureUnit lectureUnit) { + if (!(lectureUnit instanceof AttachmentUnit)) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).build(); + } + if (pyrisWebhookService.isEmpty()) { + log.error("Could not send Lecture Unit to Pyris: Pyris webhook service is not available, check if IRIS is enabled."); + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).build(); + } + boolean isIngested = pyrisWebhookService.get().addLectureUnitToPyrisDB((AttachmentUnit) lectureUnit) != null; + return ResponseEntity.status(isIngested ? HttpStatus.OK : HttpStatus.BAD_REQUEST).build(); + } + /** * Disconnects the competency exercise links from the exercise before the cycle is broken by the deserialization. * diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/web/LectureResource.java b/src/main/java/de/tum/cit/aet/artemis/lecture/web/LectureResource.java index baecd6cd7c57..6d0a6f0f33e9 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/web/LectureResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/web/LectureResource.java @@ -276,21 +276,21 @@ public ResponseEntity importLecture(@PathVariable long sourceLectureId, @Profile(PROFILE_IRIS) @PostMapping("courses/{courseId}/ingest") @EnforceAtLeastInstructorInCourse - public ResponseEntity ingestLectures(@PathVariable Long courseId, @RequestParam(required = false) Optional lectureId) { - log.debug("REST request to ingest lectures of course : {}", courseId); + public ResponseEntity ingestLectures(@PathVariable Long courseId, @RequestParam(required = false) Optional lectureId) { Course course = courseRepository.findByIdWithLecturesAndLectureUnitsElseThrow(courseId); + authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.INSTRUCTOR, course, null); if (lectureId.isPresent()) { Optional lectureToIngest = course.getLectures().stream().filter(lecture -> lecture.getId().equals(lectureId.get())).findFirst(); if (lectureToIngest.isPresent()) { Set lecturesToIngest = new HashSet<>(); lecturesToIngest.add(lectureToIngest.get()); - return ResponseEntity.ok().body(lectureService.ingestLecturesInPyris(lecturesToIngest)); + lectureService.ingestLecturesInPyris(lecturesToIngest); + return ResponseEntity.ok().build(); } - return ResponseEntity.badRequest() - .headers(HeaderUtil.createAlert(applicationName, "Could not send lecture to Iris, no lecture found with the provided id.", "idExists")).body(null); - + return ResponseEntity.badRequest().headers(HeaderUtil.createAlert(applicationName, "artemisApp.iris.ingestionAlert.allLecturesError", "idExists")).body(null); } - return ResponseEntity.ok().body(lectureService.ingestLecturesInPyris(course.getLectures())); + lectureService.ingestLecturesInPyris(course.getLectures()); + return ResponseEntity.ok().build(); } /** diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/web/LectureUnitResource.java b/src/main/java/de/tum/cit/aet/artemis/lecture/web/LectureUnitResource.java index b14667346674..78bb7d708f86 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/web/LectureUnitResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/web/LectureUnitResource.java @@ -4,6 +4,7 @@ import java.util.Comparator; import java.util.List; +import java.util.Optional; import jakarta.validation.Valid; @@ -11,6 +12,7 @@ import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -156,7 +158,7 @@ public ResponseEntity completeLectureUnit(@PathVariable Long lectureUnitId */ @DeleteMapping("lectures/{lectureId}/lecture-units/{lectureUnitId}") @EnforceAtLeastInstructor - public ResponseEntity deleteLectureUnit(@PathVariable Long lectureUnitId, @PathVariable Long lectureId) { + public ResponseEntity deleteLectureUnit(@PathVariable long lectureUnitId, @PathVariable Long lectureId) { log.info("REST request to delete lecture unit: {}", lectureUnitId); LectureUnit lectureUnit = lectureUnitRepository.findByIdWithCompetenciesBidirectionalElseThrow(lectureUnitId); if (lectureUnit.getLecture() == null || lectureUnit.getLecture().getCourse() == null) { @@ -200,11 +202,35 @@ public ResponseEntity getLectureUnitFo */ @GetMapping("lecture-units/{lectureUnitId}") @EnforceAtLeastStudent - public ResponseEntity getLectureUnitById(@PathVariable @Valid Long lectureUnitId) { + public ResponseEntity getLectureUnitById(@PathVariable @Valid long lectureUnitId) { log.debug("REST request to get lecture unit with id: {}", lectureUnitId); var lectureUnit = lectureUnitRepository.findByIdWithCompletedUsersElseThrow(lectureUnitId); authorizationCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.STUDENT, lectureUnit.getLecture().getCourse(), null); lectureUnit.setCompleted(lectureUnit.isCompletedFor(userRepository.getUser())); return ResponseEntity.ok(lectureUnit); } + + /** + * This endpoint triggers the ingestion process for a specified lecture unit into Pyris. + * + * @param lectureId the ID of the lecture to which the lecture unit belongs + * @param lectureUnitId the ID of the lecture unit to be ingested + * @return ResponseEntity with the status of the ingestion operation. + * Returns 200 OK if the ingestion is successfully started. + * Returns 400 BAD_REQUEST if the lecture unit cannot be ingested. + * Returns SERVICE_UNAVAILABLE if the Pyris service is unavailable or + * ingestion fails for another reason. + */ + @PostMapping("lectures/{lectureId}/lecture-units/{lectureUnitId}/ingest") + @EnforceAtLeastInstructor + public ResponseEntity ingestLectureUnit(@PathVariable long lectureId, @PathVariable long lectureUnitId) { + Lecture lecture = this.lectureRepository.findByIdWithLectureUnitsElseThrow(lectureId); + Optional lectureUnitOptional = lecture.getLectureUnits().stream().filter(lu -> lu.getId() == lectureUnitId).findFirst(); + if (lectureUnitOptional.isEmpty()) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).build(); + } + LectureUnit lectureUnit = lectureUnitOptional.get(); + authorizationCheckService.checkHasAtLeastRoleForLectureElseThrow(Role.INSTRUCTOR, lectureUnit.getLecture(), null); + return lectureUnitService.ingestLectureUnitInPyris(lectureUnit); + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/Result.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/Result.java index 4230d64dcd9c..5b280e65e9c0 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/Result.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/format/sarif/Result.java @@ -89,7 +89,7 @@ public enum Kind { private final String value; - private final static Map CONSTANTS = new HashMap<>(); + private static final Map CONSTANTS = new HashMap<>(); static { for (Kind c : values()) { @@ -140,7 +140,7 @@ public enum Level { private final String value; - private final static Map CONSTANTS = new HashMap<>(); + private static final Map CONSTANTS = new HashMap<>(); static { for (Level c : values()) { diff --git a/src/main/webapp/app/entities/lecture-unit/attachmentUnit.model.ts b/src/main/webapp/app/entities/lecture-unit/attachmentUnit.model.ts index 9d1e9986329e..d83c834fe83a 100644 --- a/src/main/webapp/app/entities/lecture-unit/attachmentUnit.model.ts +++ b/src/main/webapp/app/entities/lecture-unit/attachmentUnit.model.ts @@ -6,8 +6,17 @@ export class AttachmentUnit extends LectureUnit { public description?: string; public attachment?: Attachment; public slides?: Slide[]; + public pyrisIngestionState?: IngestionState; constructor() { super(LectureUnitType.ATTACHMENT); } } + +export enum IngestionState { + NOT_STARTED = 'NOT_STARTED', + IN_PROGRESS = 'IN_PROGRESS', + DONE = 'DONE', + ERROR = 'ERROR', + PARTIALLY_INGESTED = 'PARTIALLY_INGESTED', +} diff --git a/src/main/webapp/app/entities/lecture.model.ts b/src/main/webapp/app/entities/lecture.model.ts index 3f039edafe40..d213ed53de1d 100644 --- a/src/main/webapp/app/entities/lecture.model.ts +++ b/src/main/webapp/app/entities/lecture.model.ts @@ -4,6 +4,7 @@ import { Attachment } from 'app/entities/attachment.model'; import { Post } from 'app/entities/metis/post.model'; import { Course } from 'app/entities/course.model'; import { LectureUnit } from 'app/entities/lecture-unit/lectureUnit.model'; +import { IngestionState } from 'app/entities/lecture-unit/attachmentUnit.model'; export class Lecture implements BaseEntity { id?: number; @@ -21,6 +22,7 @@ export class Lecture implements BaseEntity { channelName?: string; isAtLeastEditor?: boolean; isAtLeastInstructor?: boolean; + ingested?: IngestionState; constructor() {} } diff --git a/src/main/webapp/app/lecture/lecture-detail.component.ts b/src/main/webapp/app/lecture/lecture-detail.component.ts index ef9e7344b4eb..59692860562f 100644 --- a/src/main/webapp/app/lecture/lecture-detail.component.ts +++ b/src/main/webapp/app/lecture/lecture-detail.component.ts @@ -9,6 +9,7 @@ import { ArtemisMarkdownService } from 'app/shared/markdown.service'; import { LectureService } from 'app/lecture/lecture.service'; import { IrisSettingsService } from 'app/iris/settings/shared/iris-settings.service'; import { Subscription } from 'rxjs'; +import { AlertService } from 'app/core/util/alert.service'; @Component({ selector: 'jhi-lecture-detail', templateUrl: './lecture-detail.component.html', @@ -30,6 +31,7 @@ export class LectureDetailComponent implements OnInit, OnDestroy { constructor( private activatedRoute: ActivatedRoute, private artemisMarkdown: ArtemisMarkdownService, + private alertService: AlertService, protected lectureService: LectureService, private profileService: ProfileService, private irisSettingsService: IrisSettingsService, @@ -93,7 +95,11 @@ export class LectureDetailComponent implements OnInit, OnDestroy { */ ingestLectureInPyris() { this.lectureService.ingestLecturesInPyris(this.lecture.course!.id!, this.lecture.id).subscribe({ - error: (error) => console.error(`Failed to send Ingestion request`, error), + next: () => this.alertService.success('artemisApp.iris.ingestionAlert.lectureSuccess'), + error: (error) => { + this.alertService.error('artemisApp.iris.ingestionAlert.lectureError'); + console.error('Failed to send Ingestion request', error); + }, }); } } diff --git a/src/main/webapp/app/lecture/lecture-unit/lecture-unit-management/lecture-unit-management.component.html b/src/main/webapp/app/lecture/lecture-unit/lecture-unit-management/lecture-unit-management.component.html index 677b82c94a9b..d13e7e6ba09d 100644 --- a/src/main/webapp/app/lecture/lecture-unit/lecture-unit-management/lecture-unit-management.component.html +++ b/src/main/webapp/app/lecture/lecture-unit/lecture-unit-management/lecture-unit-management.component.html @@ -68,27 +68,51 @@
+ } @else { +
+ @if (lecture.course?.id && showCompetencies) { + + } +
+ } + @if (viewButtonAvailable[lectureUnit.id!]) { + + + + }
@if (this.emitEditEvents) { @if (editButtonAvailable(lectureUnit)) { diff --git a/src/main/webapp/app/lecture/lecture-unit/lecture-unit-management/lecture-unit-management.component.ts b/src/main/webapp/app/lecture/lecture-unit/lecture-unit-management/lecture-unit-management.component.ts index dd3897bf0318..6ca545a7881c 100644 --- a/src/main/webapp/app/lecture/lecture-unit/lecture-unit-management/lecture-unit-management.component.ts +++ b/src/main/webapp/app/lecture/lecture-unit/lecture-unit-management/lecture-unit-management.component.ts @@ -10,10 +10,13 @@ import { onError } from 'app/shared/util/global.utils'; import { Subject, Subscription } from 'rxjs'; import { LectureUnitService } from 'app/lecture/lecture-unit/lecture-unit-management/lectureUnit.service'; import { ActionType } from 'app/shared/delete-dialog/delete-dialog.model'; -import { AttachmentUnit } from 'app/entities/lecture-unit/attachmentUnit.model'; +import { AttachmentUnit, IngestionState } from 'app/entities/lecture-unit/attachmentUnit.model'; import { ExerciseUnit } from 'app/entities/lecture-unit/exerciseUnit.model'; -import { faEye, faPencilAlt, faTrash } from '@fortawesome/free-solid-svg-icons'; +import { IconDefinition, faCheckCircle, faEye, faFileExport, faPencilAlt, faRepeat, faSpinner, faTrash } from '@fortawesome/free-solid-svg-icons'; import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'; +import { PROFILE_IRIS } from 'app/app.constants'; +import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; +import { IrisSettingsService } from 'app/iris/settings/shared/iris-settings.service'; @Component({ selector: 'jhi-lecture-unit-management', @@ -43,7 +46,9 @@ export class LectureUnitManagementComponent implements OnInit, OnDestroy { readonly ActionType = ActionType; private dialogErrorSource = new Subject(); dialogError$ = this.dialogErrorSource.asObservable(); - + private profileInfoSubscription: Subscription; + irisEnabled = false; + lectureIngestionEnabled = false; routerEditLinksBase: { [key: string]: string } = { [LectureUnitType.ATTACHMENT]: 'attachment-units', [LectureUnitType.VIDEO]: 'video-units', @@ -52,9 +57,13 @@ export class LectureUnitManagementComponent implements OnInit, OnDestroy { }; // Icons - faTrash = faTrash; - faPencilAlt = faPencilAlt; - faEye = faEye; + readonly faTrash = faTrash; + readonly faPencilAlt = faPencilAlt; + readonly faEye = faEye; + readonly faFileExport = faFileExport; + readonly faRepeat = faRepeat; + readonly faCheckCircle = faCheckCircle; + readonly faSpinner = faSpinner; constructor( private activatedRoute: ActivatedRoute, @@ -62,6 +71,8 @@ export class LectureUnitManagementComponent implements OnInit, OnDestroy { private lectureService: LectureService, private alertService: AlertService, public lectureUnitService: LectureUnitService, + private profileService: ProfileService, + private irisSettingsService: IrisSettingsService, ) {} ngOnDestroy(): void { @@ -69,6 +80,7 @@ export class LectureUnitManagementComponent implements OnInit, OnDestroy { this.updateOrderSubjectSubscription.unsubscribe(); this.dialogErrorSource.unsubscribe(); this.navigationEndSubscription.unsubscribe(); + this.profileInfoSubscription?.unsubscribe(); } ngOnInit(): void { @@ -111,6 +123,10 @@ export class LectureUnitManagementComponent implements OnInit, OnDestroy { this.lectureUnits.forEach((lectureUnit) => { this.viewButtonAvailable[lectureUnit.id!] = this.isViewButtonAvailable(lectureUnit); }); + this.initializeProfileInfo(); + if (this.lectureIngestionEnabled) { + this.updateIngestionStates(); + } } else { this.lectureUnits = []; } @@ -132,6 +148,17 @@ export class LectureUnitManagementComponent implements OnInit, OnDestroy { }); } + initializeProfileInfo() { + this.profileInfoSubscription = this.profileService.getProfileInfo().subscribe(async (profileInfo) => { + this.irisEnabled = profileInfo.activeProfiles.includes(PROFILE_IRIS); + if (this.irisEnabled && this.lecture.course && this.lecture.course.id) { + this.irisSettingsService.getCombinedCourseSettings(this.lecture.course.id).subscribe((settings) => { + this.lectureIngestionEnabled = settings?.irisLectureIngestionSettings?.enabled || false; + }); + } + }); + } + drop(event: CdkDragDrop) { moveItemInArray(this.lectureUnits, event.previousIndex, event.currentIndex); this.updateOrderSubject.next(''); @@ -239,4 +266,66 @@ export class LectureUnitManagementComponent implements OnInit, OnDestroy { return undefined; } } + + /** + * Fetches the ingestion state for each lecture unit asynchronously and updates the lecture unit object. + */ + updateIngestionStates() { + this.lectureUnitService.getIngestionState(this.lecture.course!.id!, this.lecture.id!).subscribe({ + next: (res: HttpResponse>) => { + if (res.body) { + const ingestionStatesMap = res.body; + this.lectureUnits.forEach((lectureUnit) => { + if (lectureUnit.id) { + const ingestionState = ingestionStatesMap[lectureUnit.id]; + if (ingestionState !== undefined) { + (lectureUnit).pyrisIngestionState = ingestionState; + } + } + }); + } + }, + error: (err: HttpErrorResponse) => { + console.error(`Error fetching ingestion states for lecture ${this.lecture.id}`, err); + this.alertService.error('artemisApp.iris.ingestionAlert.pyrisError'); + }, + }); + } + + onIngestButtonClicked(lectureUnitId: number) { + const unitIndex: number = this.lectureUnits.findIndex((unit) => unit.id === lectureUnitId); + if (unitIndex > -1) { + const unit: AttachmentUnit = this.lectureUnits[unitIndex]; + unit.pyrisIngestionState = IngestionState.IN_PROGRESS; + this.lectureUnits[unitIndex] = unit; + } + this.lectureUnitService.ingestLectureUnitInPyris(lectureUnitId, this.lecture.id!).subscribe({ + next: () => this.alertService.success('artemisApp.iris.ingestionAlert.lectureUnitSuccess'), + error: (error) => { + if (error.status === 400) { + this.alertService.error('artemisApp.iris.ingestionAlert.lectureUnitError'); + } else if (error.status === 503) { + this.alertService.error('artemisApp.iris.ingestionAlert.pyrisUnavailable'); + } else { + this.alertService.error('artemisApp.iris.ingestionAlert.pyrisError'); + } + console.error('Failed to send lecture unit ingestion request', error); + }, + }); + } + + getIcon(attachmentUnit: AttachmentUnit): IconDefinition { + switch (attachmentUnit.pyrisIngestionState) { + case IngestionState.NOT_STARTED: + return this.faFileExport; + case IngestionState.IN_PROGRESS: + return this.faSpinner; + case IngestionState.DONE: + return this.faCheckCircle; + case IngestionState.ERROR: + return this.faRepeat; + default: + return this.faFileExport; + } + } } diff --git a/src/main/webapp/app/lecture/lecture-unit/lecture-unit-management/lectureUnit.service.ts b/src/main/webapp/app/lecture/lecture-unit/lecture-unit-management/lectureUnit.service.ts index 2825fed950e2..053b29704369 100644 --- a/src/main/webapp/app/lecture/lecture-unit/lecture-unit-management/lectureUnit.service.ts +++ b/src/main/webapp/app/lecture/lecture-unit/lecture-unit-management/lectureUnit.service.ts @@ -7,7 +7,7 @@ import { onError } from 'app/shared/util/global.utils'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { Injectable } from '@angular/core'; -import { AttachmentUnit } from 'app/entities/lecture-unit/attachmentUnit.model'; +import { AttachmentUnit, IngestionState } from 'app/entities/lecture-unit/attachmentUnit.model'; import { AttachmentService } from 'app/lecture/attachment.service'; import { ExerciseUnit } from 'app/entities/lecture-unit/exerciseUnit.model'; import { ExerciseService } from 'app/exercises/shared/exercise/exercise.service'; @@ -167,4 +167,28 @@ export class LectureUnitService { getLectureUnitById(lectureUnitId: number): Observable { return this.httpClient.get(`${this.resourceURL}/lecture-units/${lectureUnitId}`); } + /** + * Fetch the actual ingestion state for all lecture units from an external service (e.g., Pyris). + * @param courseId + * @param lectureId ID of the lecture + * @returns Observable with the ingestion state + */ + getIngestionState(courseId: number, lectureId: number): Observable>> { + return this.httpClient.get>(`${this.resourceURL}/iris/courses/${courseId}/lectures/${lectureId}/lecture-units/ingestion-state`, { + observe: 'response', + }); + } + + /** + * Triggers the ingestion of one lecture unit. + * + * @param lectureUnitId - The ID of the lecture unit to be ingested. + * @param lectureId - The ID of the lecture to which the unit belongs. + * @returns An Observable with an HttpResponse 200 if the request was successful . + */ + ingestLectureUnitInPyris(lectureUnitId: number, lectureId: number): Observable> { + return this.httpClient.post(`${this.resourceURL}/lectures/${lectureId}/lecture-units/${lectureUnitId}/ingest`, null, { + observe: 'response', + }); + } } diff --git a/src/main/webapp/app/lecture/lecture.component.html b/src/main/webapp/app/lecture/lecture.component.html index 68a82448b85e..51e581861e0f 100644 --- a/src/main/webapp/app/lecture/lecture.component.html +++ b/src/main/webapp/app/lecture/lecture.component.html @@ -118,6 +118,12 @@

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

{{ lecture.visibleDate | artemisDate }} {{ lecture.startDate | artemisDate }} {{ lecture.endDate | artemisDate }} + @if (lectureIngestionEnabled) { + + @switch (lecture.ingested) { + @case (IngestionState.NOT_STARTED) { + + } + @case (IngestionState.IN_PROGRESS) { + + } + @case (IngestionState.PARTIALLY_INGESTED) { + + } + @case (IngestionState.DONE) { + + } + @case (IngestionState.ERROR) { + + } + @default { + + } + } + + }
diff --git a/src/main/webapp/app/lecture/lecture.component.ts b/src/main/webapp/app/lecture/lecture.component.ts index 6f4d832c5167..19c4dc417eb1 100644 --- a/src/main/webapp/app/lecture/lecture.component.ts +++ b/src/main/webapp/app/lecture/lecture.component.ts @@ -16,6 +16,7 @@ import { Subject, Subscription } from 'rxjs'; import { DocumentationType } from 'app/shared/components/documentation-button/documentation-button.component'; import { SortService } from 'app/shared/service/sort.service'; import { IrisSettingsService } from 'app/iris/settings/shared/iris-settings.service'; +import { IngestionState } from 'app/entities/lecture-unit/attachmentUnit.model'; export enum LectureDateFilter { PAST = 'filterPast', @@ -44,6 +45,7 @@ export class LectureComponent implements OnInit, OnDestroy { readonly filterType = LectureDateFilter; readonly documentationType: DocumentationType = 'Lecture'; + readonly ingestionState: IngestionState; // Icons faPlus = faPlus; @@ -57,6 +59,8 @@ export class LectureComponent implements OnInit, OnDestroy { faSort = faSort; lectureIngestionEnabled = false; + protected readonly IngestionState = IngestionState; + private profileInfoSubscription: Subscription; constructor( @@ -75,7 +79,6 @@ export class LectureComponent implements OnInit, OnDestroy { ngOnInit() { this.courseId = Number(this.route.snapshot.paramMap.get('courseId')); - this.loadAll(); this.profileInfoSubscription = this.profileService.getProfileInfo().subscribe(async (profileInfo) => { this.irisEnabled = profileInfo.activeProfiles.includes(PROFILE_IRIS); if (this.irisEnabled) { @@ -84,6 +87,7 @@ export class LectureComponent implements OnInit, OnDestroy { }); } }); + this.loadAll(); } ngOnDestroy(): void { @@ -150,14 +154,21 @@ export class LectureComponent implements OnInit, OnDestroy { private loadAll() { this.lectureService - .findAllByCourseId(this.courseId) + .findAllByCourseIdWithSlides(this.courseId) .pipe( filter((res: HttpResponse) => res.ok), map((res: HttpResponse) => res.body), ) .subscribe({ next: (res: Lecture[]) => { - this.lectures = res; + this.lectures = res.map((lectureData) => { + const lecture = new Lecture(); + Object.assign(lecture, lectureData); + return lecture; + }); + if (this.lectureIngestionEnabled) { + this.updateIngestionStates(); + } this.applyFilters(); }, error: (res: HttpErrorResponse) => onError(this.alertService, res), @@ -208,8 +219,37 @@ export class LectureComponent implements OnInit, OnDestroy { ingestLecturesInPyris() { if (this.lectures.first()) { this.lectureService.ingestLecturesInPyris(this.lectures.first()!.course!.id!).subscribe({ - error: (error) => console.error('Failed to send Ingestion request', error), + next: () => this.alertService.success('artemisApp.iris.ingestionAlert.allLecturesSuccess'), + error: (error) => { + this.alertService.error('artemisApp.iris.ingestionAlert.allLecturesError'); + console.error('Failed to send Lectures Ingestion request', error); + }, }); } } + + /** + * Fetches the ingestion state for all lecture asynchronously and updates all the lectures ingestion state. + */ + updateIngestionStates() { + this.lectureService.getIngestionState(this.courseId).subscribe({ + next: (res: HttpResponse>) => { + if (res.body) { + const ingestionStatesMap = res.body; + this.lectures.forEach((lecture) => { + if (lecture.id) { + const ingestionState = ingestionStatesMap[lecture.id]; + if (ingestionState !== undefined) { + lecture.ingested = ingestionState; + } + } + }); + } + }, + error: (err: HttpErrorResponse) => { + console.error(`Error fetching ingestion state for lecture in course ${this.courseId}`, err); + this.alertService.error('artemisApp.iris.ingestionAlert.pyrisError'); + }, + }); + } } diff --git a/src/main/webapp/app/lecture/lecture.service.ts b/src/main/webapp/app/lecture/lecture.service.ts index 7e1259860fa3..e6b508e6695a 100644 --- a/src/main/webapp/app/lecture/lecture.service.ts +++ b/src/main/webapp/app/lecture/lecture.service.ts @@ -8,6 +8,7 @@ import { AccountService } from 'app/core/auth/account.service'; import { LectureUnitService } from 'app/lecture/lecture-unit/lecture-unit-management/lectureUnit.service'; import { convertDateFromClient, convertDateFromServer } from 'app/utils/date.utils'; import { EntityTitleService, EntityType } from 'app/shared/layouts/navbar/entity-title.service'; +import { IngestionState } from 'app/entities/lecture-unit/attachmentUnit.model'; type EntityResponseType = HttpResponse; type EntityArrayResponseType = HttpResponse; @@ -117,17 +118,23 @@ export class LectureService { * @param courseId Course containing the lecture(s) * @param lectureId The lecture to be ingested in pyris */ - ingestLecturesInPyris(courseId: number, lectureId?: number): Observable> { + ingestLecturesInPyris(courseId: number, lectureId?: number): Observable> { let params = new HttpParams(); if (lectureId !== undefined) { params = params.set('lectureId', lectureId.toString()); } - - return this.http.post(`api/courses/${courseId}/ingest`, null, { + return this.http.post(`api/courses/${courseId}/ingest`, null, { params: params, observe: 'response', }); } + /** + * Fetch the ingestion state of all the lectures inside the course specified + * @param courseId + */ + getIngestionState(courseId: number): Observable>> { + return this.http.get>(`api/iris/courses/${courseId}/lectures/ingestion-state`, { observe: 'response' }); + } /** * Clones and imports the lecture to the course * diff --git a/src/main/webapp/app/overview/course-overview.service.ts b/src/main/webapp/app/overview/course-overview.service.ts index 1aeb84cf5045..97b42ff6dc3f 100644 --- a/src/main/webapp/app/overview/course-overview.service.ts +++ b/src/main/webapp/app/overview/course-overview.service.ts @@ -262,7 +262,7 @@ export class CourseOverviewService { mapLecturesToSidebarCardElements(lectures: Lecture[]) { return lectures.map((lecture) => this.mapLectureToSidebarCardElement(lecture)); } - mapTutorialGroupsToSidebarCardElements(tutorialGroups: Lecture[]) { + mapTutorialGroupsToSidebarCardElements(tutorialGroups: TutorialGroup[]) { return tutorialGroups.map((tutorialGroup) => this.mapTutorialGroupToSidebarCardElement(tutorialGroup)); } diff --git a/src/main/webapp/i18n/de/iris.json b/src/main/webapp/i18n/de/iris.json index 70627d2052c0..1f302a833001 100644 --- a/src/main/webapp/i18n/de/iris.json +++ b/src/main/webapp/i18n/de/iris.json @@ -81,6 +81,25 @@ }, "chat": { "helpOffer": "Wie kann ich Dir helfen?" + }, + "ingestionStates": { + "loading": "Wird geladen...", + "notStarted": "Nicht gestartet", + "inProgress": "In Bearbeitung", + "done": "Abgeschlossen", + "error": "Fehler", + "partiallyIngested": "Teilweise abgeschlossen" + }, + "ingestionAlert": { + "lectureUnitSuccess": "Vorlesungseinheit Ingestion in Pyris erfolgreich gestartet", + "lectureUnitError": "Fehler beim Senden der Vorlesungseinheit an Pyris", + "lectureSuccess": "Vorlesung Ingestion in Pyris erfolgreich gestartet", + "lectureError": "Fehler beim Senden der Vorlesung an Pyris", + "allLecturesSuccess": "Alle Vorlesungen Ingestion in Pyris erfolgreich gestartet", + "allLecturesError": "Fehler beim Senden aller Vorlesungen an Pyris", + "lectureNotFound": "Vorlesung kann nicht an Pyris gesendet werden, keine Vorlesung mit der angegebenen ID gefunden.", + "pyrisUnavailable": "Pyris ist derzeit nicht verfügbar.", + "pyrisError": "Ein Fehler ist bei der Kommunikation mit Pyris aufgetreten." } } } diff --git a/src/main/webapp/i18n/de/lecture.json b/src/main/webapp/i18n/de/lecture.json index cfe674a84872..e5ecd6649b38 100644 --- a/src/main/webapp/i18n/de/lecture.json +++ b/src/main/webapp/i18n/de/lecture.json @@ -31,6 +31,7 @@ "endDate": "Ende", "visibleDate": "Sichtbar ab", "course": "Kurs", + "ingestionState": "Vorlesungsimport Zustand in Pyris", "detail": { "title": "Vorlesung", "sections": { diff --git a/src/main/webapp/i18n/en/iris.json b/src/main/webapp/i18n/en/iris.json index d3ba981c4419..65f153b89c54 100644 --- a/src/main/webapp/i18n/en/iris.json +++ b/src/main/webapp/i18n/en/iris.json @@ -81,6 +81,25 @@ }, "chat": { "helpOffer": "How can I help you today?" + }, + "ingestionStates": { + "loading": "Loading", + "notStarted": "Not started", + "inProgress": "In progress", + "done": "Done", + "error": "Error", + "partiallyIngested": "Partially ingested" + }, + "ingestionAlert": { + "lectureUnitSuccess": "Lecture unit ingestion in Pyris started successfully", + "lectureUnitError": "Error while sending lecture unit to Pyris", + "lectureSuccess": "Lecture ingestion in Pyris started successfully", + "lectureError": "Error while sending lecture to Pyris", + "allLecturesSuccess": "All lectures ingestion in Pyris started successfully", + "allLecturesError": "Error while sending all lectures to Pyris", + "lectureNotFound": "Could not send lecture to Iris, no lecture found with the provided id.", + "pyrisUnavailable": "Pyris is currently unavailable.", + "pyrisError": "An error occurred while getting ingestion state from Pyris." } } } diff --git a/src/main/webapp/i18n/en/lecture.json b/src/main/webapp/i18n/en/lecture.json index 79525529f4c5..284c7d83ace3 100644 --- a/src/main/webapp/i18n/en/lecture.json +++ b/src/main/webapp/i18n/en/lecture.json @@ -31,6 +31,7 @@ "endDate": "End Date", "visibleDate": "Visible from", "course": "Course", + "ingestionState": "Ingestion State", "detail": { "title": "Lecture", "sections": { diff --git a/src/test/java/de/tum/cit/aet/artemis/core/connector/IrisRequestMockProvider.java b/src/test/java/de/tum/cit/aet/artemis/core/connector/IrisRequestMockProvider.java index 3d394d2c9734..4bee55d7481b 100644 --- a/src/test/java/de/tum/cit/aet/artemis/core/connector/IrisRequestMockProvider.java +++ b/src/test/java/de/tum/cit/aet/artemis/core/connector/IrisRequestMockProvider.java @@ -137,6 +137,15 @@ public void mockIngestionWebhookRunResponse(Consumer responseConsumer) { + mockServer.expect(ExpectedCount.once(), requestTo(webhooksApiURL + "/lectures/delete")).andExpect(method(HttpMethod.POST)).andRespond(request -> { + var mockRequest = (MockClientHttpRequest) request; + var dto = mapper.readValue(mockRequest.getBodyAsString(), PyrisWebhookLectureIngestionExecutionDTO.class); + responseConsumer.accept(dto); + return MockRestResponseCreators.withRawStatus(HttpStatus.ACCEPTED.value()).createResponse(request); + }); + } + public void mockRunError(int httpStatus) { // @formatter:off mockServer @@ -155,6 +164,15 @@ public void mockIngestionWebhookRunError(int httpStatus) { // @formatter:on } + public void mockDeletionWebhookRunError(int httpStatus) { + // @formatter:off + mockServer + .expect(ExpectedCount.once(), requestTo(webhooksApiURL + "/lectures/delete")) + .andExpect(method(HttpMethod.POST)) + .andRespond(withStatus(HttpStatus.valueOf(httpStatus))); + // @formatter:on + } + public void mockVariantsResponse(IrisSubSettingsType feature) throws JsonProcessingException { var irisModelDTO = new PyrisVariantDTO("TEST_MODEL", "Test model", "Test description"); var irisModelDTOArray = new PyrisVariantDTO[] { irisModelDTO }; diff --git a/src/test/java/de/tum/cit/aet/artemis/iris/PyrisConnectorServiceTest.java b/src/test/java/de/tum/cit/aet/artemis/iris/PyrisConnectorServiceTest.java index de726729a035..a2a6c28aa6c3 100644 --- a/src/test/java/de/tum/cit/aet/artemis/iris/PyrisConnectorServiceTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/iris/PyrisConnectorServiceTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import java.util.List; import java.util.stream.Stream; import org.junit.jupiter.params.ParameterizedTest; @@ -16,6 +17,8 @@ import de.tum.cit.aet.artemis.iris.exception.IrisInternalPyrisErrorException; import de.tum.cit.aet.artemis.iris.service.pyris.PyrisConnectorException; import de.tum.cit.aet.artemis.iris.service.pyris.PyrisConnectorService; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.lectureingestionwebhook.PyrisLectureUnitWebhookDTO; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.lectureingestionwebhook.PyrisWebhookLectureIngestionExecutionDTO; class PyrisConnectorServiceTest extends AbstractIrisIntegrationTest { @@ -47,7 +50,17 @@ void testExceptionV2(int httpStatus, Class exceptionClass) { @MethodSource("irisExceptions") void testExceptionIngestionV2(int httpStatus, Class exceptionClass) { irisRequestMockProvider.mockIngestionWebhookRunError(httpStatus); - assertThatThrownBy(() -> pyrisConnectorService.executeLectureWebhook("fullIngestion", null)).isInstanceOf(exceptionClass); + PyrisLectureUnitWebhookDTO pyrisLectureUnitWebhookDTO = new PyrisLectureUnitWebhookDTO("example.pdf", 123L, "Lecture Unit Name", 456L, "Lecture Name", 789L, "Course Name", + "Course Description"); + PyrisWebhookLectureIngestionExecutionDTO executionDTO = new PyrisWebhookLectureIngestionExecutionDTO(pyrisLectureUnitWebhookDTO, null, List.of()); + assertThatThrownBy(() -> pyrisConnectorService.executeLectureAddtionWebhook("fullIngestion", executionDTO)).isInstanceOf(exceptionClass); + } + + @ParameterizedTest + @MethodSource("irisExceptions") + void testExceptionLectureDeletionV2(int httpStatus, Class exceptionClass) { + irisRequestMockProvider.mockDeletionWebhookRunError(httpStatus); + assertThatThrownBy(() -> pyrisConnectorService.executeLectureDeletionWebhook(null)).isInstanceOf(exceptionClass); } @ParameterizedTest diff --git a/src/test/java/de/tum/cit/aet/artemis/iris/PyrisLectureIngestionTest.java b/src/test/java/de/tum/cit/aet/artemis/iris/PyrisLectureIngestionTest.java index e7cdc125faa8..636d18003de1 100644 --- a/src/test/java/de/tum/cit/aet/artemis/iris/PyrisLectureIngestionTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/iris/PyrisLectureIngestionTest.java @@ -26,9 +26,12 @@ import de.tum.cit.aet.artemis.iris.service.pyris.dto.lectureingestionwebhook.PyrisLectureIngestionStatusUpdateDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.status.PyrisStageDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.status.PyrisStageState; +import de.tum.cit.aet.artemis.lecture.domain.Attachment; import de.tum.cit.aet.artemis.lecture.domain.AttachmentUnit; import de.tum.cit.aet.artemis.lecture.domain.Lecture; import de.tum.cit.aet.artemis.lecture.repository.LectureRepository; +import de.tum.cit.aet.artemis.lecture.repository.LectureUnitRepository; +import de.tum.cit.aet.artemis.lecture.util.LectureFactory; import de.tum.cit.aet.artemis.lecture.util.LectureUtilService; class PyrisLectureIngestionTest extends AbstractIrisIntegrationTest { @@ -59,6 +62,11 @@ class PyrisLectureIngestionTest extends AbstractIrisIntegrationTest { @Autowired protected IrisSettingsRepository irisSettingsRepository; + @Autowired + protected LectureUnitRepository lectureUnitRepository; + + private Attachment attachment; + private Lecture lecture1; @BeforeEach @@ -83,7 +91,13 @@ void initTestCase() throws Exception { @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void testAutoUpdateAttachmentUnitsWithAutoUpdateEnabled() { + void autoIngestionWhenAttachmentUnitCreatedAndAutoUpdateEnabled() { + this.attachment = LectureFactory.generateAttachment(null); + this.attachment.setName(" LoremIpsum "); + this.attachment.setLink("/api/files/temp/example.txt"); + this.lecture1 = lectureUtilService.createCourseWithLecture(true); + AttachmentUnit attachmentUnit = new AttachmentUnit(); + attachmentUnit.setDescription("Lorem Ipsum"); activateIrisFor(lecture1.getCourse()); IrisCourseSettings courseSettings = irisSettingsService.getRawIrisSettingsFor(lecture1.getCourse()); courseSettings.getIrisLectureIngestionSettings().setAutoIngestOnLectureAttachmentUpload(true); @@ -91,65 +105,40 @@ void testAutoUpdateAttachmentUnitsWithAutoUpdateEnabled() { irisRequestMockProvider.mockIngestionWebhookRunResponse(dto -> { assertThat(dto.settings().authenticationToken()).isNotNull(); }); - assertThat(pyrisWebhookService.autoUpdateAttachmentUnitsInPyris(lecture1.getCourse().getId(), List.of((AttachmentUnit) lecture1.getLectureUnits().getFirst()))).isTrue(); - assertThat(pyrisWebhookService.autoUpdateAttachmentUnitsInPyris(lecture1.getCourse().getId(), List.of((AttachmentUnit) lecture1.getLectureUnits().getLast()))).isFalse(); } @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void testAutoUpdateAttachmentUnitsWithAutoUpdateDisabled() { - activateIrisFor(lecture1.getCourse()); - IrisCourseSettings courseSettings = irisSettingsService.getRawIrisSettingsFor(lecture1.getCourse()); - courseSettings.getIrisLectureIngestionSettings().setAutoIngestOnLectureAttachmentUpload(false); - this.irisSettingsRepository.save(courseSettings); + void noAutoIngestionWhenAttachmentUnitCreatedAndAutoUpdateDisabled() { + this.attachment = LectureFactory.generateAttachment(null); + this.attachment.setName(" LoremIpsum "); + this.attachment.setLink("/api/files/temp/example.txt"); + this.lecture1 = lectureUtilService.createCourseWithLecture(true); + AttachmentUnit attachmentUnit = new AttachmentUnit(); + attachmentUnit.setDescription("Lorem Ipsum"); irisRequestMockProvider.mockIngestionWebhookRunResponse(dto -> { assertThat(dto.settings().authenticationToken()).isNotNull(); }); - assertThat(pyrisWebhookService.autoUpdateAttachmentUnitsInPyris(lecture1.getCourse().getId(), List.of((AttachmentUnit) lecture1.getLectureUnits().getFirst()))).isFalse(); - assertThat(pyrisWebhookService.autoUpdateAttachmentUnitsInPyris(lecture1.getCourse().getId(), List.of((AttachmentUnit) lecture1.getLectureUnits().getLast()))).isFalse(); } @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void testAddLectureToPyrisDatabaseWithCourseSettingsDisabled() { + void testIngestLecturesButtonInPyris() throws Exception { activateIrisFor(lecture1.getCourse()); - IrisCourseSettings courseSettings = irisSettingsService.getRawIrisSettingsFor(lecture1.getCourse()); - courseSettings.getIrisLectureIngestionSettings().setEnabled(false); - this.irisSettingsRepository.save(courseSettings); irisRequestMockProvider.mockIngestionWebhookRunResponse(dto -> { assertThat(dto.settings().authenticationToken()).isNotNull(); }); - String jobToken = pyrisWebhookService.addLectureUnitsToPyrisDB(List.of((AttachmentUnit) lecture1.getLectureUnits().getFirst())); - assertThat(jobToken).isNull(); - jobToken = pyrisWebhookService.addLectureUnitsToPyrisDB(List.of((AttachmentUnit) lecture1.getLectureUnits().getLast())); - assertThat(jobToken).isNull(); + request.postWithResponseBody("/api/courses/" + lecture1.getCourse().getId() + "/ingest", Optional.empty(), boolean.class, HttpStatus.OK); } @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void testDeleteLecturefromPyrisDatabaseWithCourseSettingsDisabled() { + void testIngestLectureUnitButtonInPyris() { activateIrisFor(lecture1.getCourse()); - IrisCourseSettings courseSettings = irisSettingsService.getRawIrisSettingsFor(lecture1.getCourse()); - courseSettings.getIrisLectureIngestionSettings().setEnabled(false); - this.irisSettingsRepository.save(courseSettings); irisRequestMockProvider.mockIngestionWebhookRunResponse(dto -> { assertThat(dto.settings().authenticationToken()).isNotNull(); }); - String jobToken = pyrisWebhookService.deleteLectureFromPyrisDB(List.of((AttachmentUnit) lecture1.getLectureUnits().getFirst())); - assertThat(jobToken).isNotNull(); - jobToken = pyrisWebhookService.deleteLectureFromPyrisDB(List.of((AttachmentUnit) lecture1.getLectureUnits().getLast())); - assertThat(jobToken).isNull(); - } - @Test - @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void testIngestLecturesButtonInPyris() throws Exception { - activateIrisFor(lecture1.getCourse()); - irisRequestMockProvider.mockIngestionWebhookRunResponse(dto -> { - assertThat(dto.settings().authenticationToken()).isNotNull(); - }); - Boolean response = request.postWithResponseBody("/api/courses/" + lecture1.getCourse().getId() + "/ingest", Optional.empty(), boolean.class, HttpStatus.OK); - assertThat(response).isTrue(); } @Test @@ -158,7 +147,7 @@ void testDeleteLecturefromPyrisDatabaseWithCourseSettingsEnabled() { activateIrisFor(lecture1.getCourse()); IrisCourseSettings courseSettings = irisSettingsService.getRawIrisSettingsFor(lecture1.getCourse()); this.irisSettingsRepository.save(courseSettings); - irisRequestMockProvider.mockIngestionWebhookRunResponse(dto -> { + irisRequestMockProvider.mockDeletionWebhookRunResponse(dto -> { assertThat(dto.settings().authenticationToken()).isNotNull(); }); String jobToken = pyrisWebhookService.deleteLectureFromPyrisDB(List.of((AttachmentUnit) lecture1.getLectureUnits().getFirst())); @@ -169,31 +158,28 @@ void testDeleteLecturefromPyrisDatabaseWithCourseSettingsEnabled() { @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void testAddLectureToPyrisDBAddJobWithCourseSettingsEnabled() { + void testAddLectureToPyrisDBAddJobWithCourseSettingsEnabled() throws Exception { activateIrisFor(lecture1.getCourse()); irisRequestMockProvider.mockIngestionWebhookRunResponse(dto -> { assertThat(dto.settings().authenticationToken()).isNotNull(); }); - String jobToken = pyrisWebhookService.addLectureUnitsToPyrisDB(List.of((AttachmentUnit) lecture1.getLectureUnits().getFirst())); - assertThat(jobToken).isNotNull(); - jobToken = pyrisWebhookService.addLectureUnitsToPyrisDB(List.of((AttachmentUnit) lecture1.getLectureUnits().getLast())); - assertThat(jobToken).isNull(); + request.postWithResponseBody("/api/courses/" + lecture1.getCourse().getId() + "/ingest", Optional.empty(), boolean.class, HttpStatus.OK); } @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void testAllStagesDoneRemovesAdditionIngestionJob() throws Exception { + void testAllStagesDoneIngestionStateDone() throws Exception { activateIrisFor(lecture1.getCourse()); irisRequestMockProvider.mockIngestionWebhookRunResponse(dto -> { assertThat(dto.settings().authenticationToken()).isNotNull(); }); - if (lecture1.getLectureUnits().getFirst() instanceof AttachmentUnit attachmentUnit) { - String jobToken = pyrisWebhookService.addLectureUnitsToPyrisDB(List.of(attachmentUnit)); + if (lecture1.getLectureUnits().getFirst() instanceof AttachmentUnit unit) { + String jobToken = pyrisWebhookService.addLectureUnitToPyrisDB(unit); PyrisStageDTO doneStage = new PyrisStageDTO("done", 1, PyrisStageState.DONE, "Stage completed successfully."); - PyrisLectureIngestionStatusUpdateDTO statusUpdate = new PyrisLectureIngestionStatusUpdateDTO("Success", List.of(doneStage)); + PyrisLectureIngestionStatusUpdateDTO statusUpdate = new PyrisLectureIngestionStatusUpdateDTO("Success", List.of(doneStage), + lecture1.getLectureUnits().getFirst().getId()); var headers = new HttpHeaders(new LinkedMultiValueMap<>(Map.of("Authorization", List.of("Bearer " + jobToken)))); request.postWithoutResponseBody("/api/public/pyris/webhooks/ingestion/runs/" + jobToken + "/status", statusUpdate, HttpStatus.OK, headers); - assertThat(pyrisJobService.getJob(jobToken)).isNull(); } } @@ -201,13 +187,14 @@ void testAllStagesDoneRemovesAdditionIngestionJob() throws Exception { @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void testAllStagesDoneRemovesDeletionIngestionJob() throws Exception { activateIrisFor(lecture1.getCourse()); - irisRequestMockProvider.mockIngestionWebhookRunResponse(dto -> { + irisRequestMockProvider.mockDeletionWebhookRunResponse(dto -> { assertThat(dto.settings().authenticationToken()).isNotNull(); }); - if (lecture1.getLectureUnits().getFirst() instanceof AttachmentUnit attachmentUnit) { - String jobToken = pyrisWebhookService.deleteLectureFromPyrisDB(List.of(attachmentUnit)); + if (lecture1.getLectureUnits().getFirst() instanceof AttachmentUnit unit) { + String jobToken = pyrisWebhookService.deleteLectureFromPyrisDB(List.of(unit)); PyrisStageDTO doneStage = new PyrisStageDTO("done", 1, PyrisStageState.DONE, "Stage completed successfully."); - PyrisLectureIngestionStatusUpdateDTO statusUpdate = new PyrisLectureIngestionStatusUpdateDTO("Success", List.of(doneStage)); + PyrisLectureIngestionStatusUpdateDTO statusUpdate = new PyrisLectureIngestionStatusUpdateDTO("Success", List.of(doneStage), + lecture1.getLectureUnits().getFirst().getId()); var headers = new HttpHeaders(new LinkedMultiValueMap<>(Map.of("Authorization", List.of("Bearer " + jobToken)))); request.postWithoutResponseBody("/api/public/pyris/webhooks/ingestion/runs/" + jobToken + "/status", statusUpdate, HttpStatus.OK, headers); assertThat(pyrisJobService.getJob(jobToken)).isNull(); @@ -220,11 +207,12 @@ void testStageNotDoneKeepsAdditionIngestionJob() throws Exception { irisRequestMockProvider.mockIngestionWebhookRunResponse(dto -> { assertThat(dto.settings().authenticationToken()).isNotNull(); }); - if (lecture1.getLectureUnits().getFirst() instanceof AttachmentUnit attachmentUnit) { - String jobToken = pyrisWebhookService.addLectureUnitsToPyrisDB(List.of(attachmentUnit)); + if (lecture1.getLectureUnits().getFirst() instanceof AttachmentUnit unit) { + String jobToken = pyrisWebhookService.addLectureUnitToPyrisDB(unit); PyrisStageDTO doneStage = new PyrisStageDTO("done", 1, PyrisStageState.DONE, "Stage completed successfully."); PyrisStageDTO inProgressStage = new PyrisStageDTO("inProgressStage", 1, PyrisStageState.IN_PROGRESS, "Stage completed successfully."); - PyrisLectureIngestionStatusUpdateDTO statusUpdate = new PyrisLectureIngestionStatusUpdateDTO("Success", List.of(doneStage, inProgressStage)); + PyrisLectureIngestionStatusUpdateDTO statusUpdate = new PyrisLectureIngestionStatusUpdateDTO("Success", List.of(doneStage, inProgressStage), + lecture1.getLectureUnits().getFirst().getId()); var headers = new HttpHeaders(new LinkedMultiValueMap<>(Map.of("Authorization", List.of("Bearer " + jobToken)))); request.postWithoutResponseBody("/api/public/pyris/webhooks/ingestion/runs/" + jobToken + "/status", statusUpdate, HttpStatus.OK, headers); assertThat(pyrisJobService.getJob(jobToken)).isNotNull(); @@ -232,16 +220,17 @@ void testStageNotDoneKeepsAdditionIngestionJob() throws Exception { } @Test - void testStageNotDoneKeepsDeletionIngetionJob() throws Exception { + void testStageNotDoneKeepsDeletionIngestionJob() throws Exception { activateIrisFor(lecture1.getCourse()); - irisRequestMockProvider.mockIngestionWebhookRunResponse(dto -> { + irisRequestMockProvider.mockDeletionWebhookRunResponse(dto -> { assertThat(dto.settings().authenticationToken()).isNotNull(); }); - if (lecture1.getLectureUnits().getFirst() instanceof AttachmentUnit attachmentUnit) { - String jobToken = pyrisWebhookService.deleteLectureFromPyrisDB(List.of(attachmentUnit)); + if (lecture1.getLectureUnits().getFirst() instanceof AttachmentUnit unit) { + String jobToken = pyrisWebhookService.deleteLectureFromPyrisDB(List.of(unit)); PyrisStageDTO doneStage = new PyrisStageDTO("done", 1, PyrisStageState.DONE, "Stage completed successfully."); PyrisStageDTO inProgressStage = new PyrisStageDTO("inProgressStage", 1, PyrisStageState.IN_PROGRESS, "Stage completed successfully."); - PyrisLectureIngestionStatusUpdateDTO statusUpdate = new PyrisLectureIngestionStatusUpdateDTO("Success", List.of(doneStage, inProgressStage)); + PyrisLectureIngestionStatusUpdateDTO statusUpdate = new PyrisLectureIngestionStatusUpdateDTO("Success", List.of(doneStage, inProgressStage), + lecture1.getLectureUnits().getFirst().getId()); var headers = new HttpHeaders(new LinkedMultiValueMap<>(Map.of("Authorization", List.of("Bearer " + jobToken)))); request.postWithoutResponseBody("/api/public/pyris/webhooks/ingestion/runs/" + jobToken + "/status", statusUpdate, HttpStatus.OK, headers); assertThat(pyrisJobService.getJob(jobToken)).isNotNull(); @@ -249,15 +238,16 @@ void testStageNotDoneKeepsDeletionIngetionJob() throws Exception { } @Test - void testErrorStageRemovesDeletionIngetionJob() throws Exception { + void testErrorStageRemovesDeletionIngestionJob() throws Exception { activateIrisFor(lecture1.getCourse()); - irisRequestMockProvider.mockIngestionWebhookRunResponse(dto -> { + irisRequestMockProvider.mockDeletionWebhookRunResponse(dto -> { assertThat(dto.settings().authenticationToken()).isNotNull(); }); - if (lecture1.getLectureUnits().getFirst() instanceof AttachmentUnit attachmentUnit) { - String jobToken = pyrisWebhookService.deleteLectureFromPyrisDB(List.of(attachmentUnit)); + if (lecture1.getLectureUnits().getFirst() instanceof AttachmentUnit unit) { + String jobToken = pyrisWebhookService.deleteLectureFromPyrisDB(List.of(unit)); PyrisStageDTO errorStage = new PyrisStageDTO("error", 1, PyrisStageState.ERROR, "Stage not broke due to error."); - PyrisLectureIngestionStatusUpdateDTO statusUpdate = new PyrisLectureIngestionStatusUpdateDTO("Success", List.of(errorStage)); + PyrisLectureIngestionStatusUpdateDTO statusUpdate = new PyrisLectureIngestionStatusUpdateDTO("Success", List.of(errorStage), + lecture1.getLectureUnits().getFirst().getId()); var headers = new HttpHeaders(new LinkedMultiValueMap<>(Map.of("Authorization", List.of("Bearer " + jobToken)))); request.postWithoutResponseBody("/api/public/pyris/webhooks/ingestion/runs/" + jobToken + "/status", statusUpdate, HttpStatus.OK, headers); assertThat(pyrisJobService.getJob(jobToken)).isNull(); @@ -265,15 +255,16 @@ void testErrorStageRemovesDeletionIngetionJob() throws Exception { } @Test - void testErrorStageRemovesAdditionIngetionJob() throws Exception { + void testErrorStageRemovesAdditionIngestionJob() throws Exception { activateIrisFor(lecture1.getCourse()); irisRequestMockProvider.mockIngestionWebhookRunResponse(dto -> { assertThat(dto.settings().authenticationToken()).isNotNull(); }); - if (lecture1.getLectureUnits().getFirst() instanceof AttachmentUnit attachmentUnit) { - String jobToken = pyrisWebhookService.addLectureUnitsToPyrisDB(List.of(attachmentUnit)); + if (lecture1.getLectureUnits().getFirst() instanceof AttachmentUnit unit) { + String jobToken = pyrisWebhookService.addLectureUnitToPyrisDB(unit); PyrisStageDTO errorStage = new PyrisStageDTO("error", 1, PyrisStageState.ERROR, "Stage not broke due to error."); - PyrisLectureIngestionStatusUpdateDTO statusUpdate = new PyrisLectureIngestionStatusUpdateDTO("Success", List.of(errorStage)); + PyrisLectureIngestionStatusUpdateDTO statusUpdate = new PyrisLectureIngestionStatusUpdateDTO("Success", List.of(errorStage), + lecture1.getLectureUnits().getFirst().getId()); var headers = new HttpHeaders(new LinkedMultiValueMap<>(Map.of("Authorization", List.of("Bearer " + jobToken)))); request.postWithoutResponseBody("/api/public/pyris/webhooks/ingestion/runs/" + jobToken + "/status", statusUpdate, HttpStatus.OK, headers); assertThat(pyrisJobService.getJob(jobToken)).isNull(); @@ -286,10 +277,28 @@ void testRunIdIngestionJob() throws Exception { irisRequestMockProvider.mockIngestionWebhookRunResponse(dto -> { assertThat(dto.settings().authenticationToken()).isNotNull(); }); - String newJobToken = pyrisJobService.addIngestionWebhookJob(); + String newJobToken = pyrisJobService.addIngestionWebhookJob(123L, lecture1.getId(), lecture1.getLectureUnits().getFirst().getId()); + String chatJobToken = pyrisJobService.addCourseChatJob(123L, 123L); + PyrisStageDTO errorStage = new PyrisStageDTO("error", 1, PyrisStageState.ERROR, "Stage not broke due to error."); + PyrisLectureIngestionStatusUpdateDTO statusUpdate = new PyrisLectureIngestionStatusUpdateDTO("Success", List.of(errorStage), lecture1.getLectureUnits().getFirst().getId()); + var headers = new HttpHeaders(new LinkedMultiValueMap<>(Map.of("Authorization", List.of("Bearer " + chatJobToken)))); + MockHttpServletResponse response = request.postWithoutResponseBody("/api/public/pyris/webhooks/ingestion/runs/" + newJobToken + "/status", statusUpdate, + HttpStatus.CONFLICT, headers); + assertThat(response.getContentAsString()).contains("Run ID in URL does not match run ID in request body"); + response = request.postWithoutResponseBody("/api/public/pyris/webhooks/ingestion/runs/" + chatJobToken + "/status", statusUpdate, HttpStatus.CONFLICT, headers); + assertThat(response.getContentAsString()).contains("Run ID is not an ingestion job"); + } + + @Test + void testIngestionJobDone() throws Exception { + activateIrisFor(lecture1.getCourse()); + irisRequestMockProvider.mockIngestionWebhookRunResponse(dto -> { + assertThat(dto.settings().authenticationToken()).isNotNull(); + }); + String newJobToken = pyrisJobService.addIngestionWebhookJob(123L, lecture1.getId(), lecture1.getLectureUnits().getFirst().getId()); String chatJobToken = pyrisJobService.addCourseChatJob(123L, 123L); PyrisStageDTO errorStage = new PyrisStageDTO("error", 1, PyrisStageState.ERROR, "Stage not broke due to error."); - PyrisLectureIngestionStatusUpdateDTO statusUpdate = new PyrisLectureIngestionStatusUpdateDTO("Success", List.of(errorStage)); + PyrisLectureIngestionStatusUpdateDTO statusUpdate = new PyrisLectureIngestionStatusUpdateDTO("Success", List.of(errorStage), lecture1.getLectureUnits().getFirst().getId()); var headers = new HttpHeaders(new LinkedMultiValueMap<>(Map.of("Authorization", List.of("Bearer " + chatJobToken)))); MockHttpServletResponse response = request.postWithoutResponseBody("/api/public/pyris/webhooks/ingestion/runs/" + newJobToken + "/status", statusUpdate, HttpStatus.CONFLICT, headers); diff --git a/src/test/javascript/spec/component/lecture-unit/lecture-unit-management.component.spec.ts b/src/test/javascript/spec/component/lecture-unit/lecture-unit-management.component.spec.ts index bad4cf68f981..1be7baf22145 100644 --- a/src/test/javascript/spec/component/lecture-unit/lecture-unit-management.component.spec.ts +++ b/src/test/javascript/spec/component/lecture-unit/lecture-unit-management.component.spec.ts @@ -1,6 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { LectureUnitManagementComponent } from 'app/lecture/lecture-unit/lecture-unit-management/lecture-unit-management.component'; -import { AttachmentUnit } from 'app/entities/lecture-unit/attachmentUnit.model'; +import { AttachmentUnit, IngestionState } from 'app/entities/lecture-unit/attachmentUnit.model'; import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; import { ExerciseUnit } from 'app/entities/lecture-unit/exerciseUnit.model'; import { MockComponent, MockDirective, MockPipe, MockProvider } from 'ng-mocks'; @@ -21,10 +21,10 @@ import { Lecture } from 'app/entities/lecture.model'; import { HttpResponse } from '@angular/common/http'; import { DeleteButtonDirective } from 'app/shared/delete-dialog/delete-button.directive'; import { HasAnyAuthorityDirective } from 'app/shared/auth/has-any-authority.directive'; -import { of } from 'rxjs'; +import { of, throwError } from 'rxjs'; import { By } from '@angular/platform-browser'; import { ActionType } from 'app/shared/delete-dialog/delete-dialog.model'; -import { Competency } from 'app/entities/competency.model'; +import { CompetencyLectureUnitLink } from 'app/entities/competency.model'; import { UnitCreationCardComponent } from 'app/lecture/lecture-unit/lecture-unit-management/unit-creation-card/unit-creation-card.component'; import { ArtemisDatePipe } from 'app/shared/pipes/artemis-date.pipe'; import { MockRouterLinkDirective } from '../../helpers/mocks/directive/mock-router-link.directive'; @@ -32,13 +32,20 @@ import { LectureUnit, LectureUnitType } from 'app/entities/lecture-unit/lectureU import { CdkDragDrop } from '@angular/cdk/drag-drop'; import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; import { OnlineUnit } from 'app/entities/lecture-unit/onlineUnit.model'; +import { IrisSettingsService } from 'app/iris/settings/shared/iris-settings.service'; +import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; +import { PROFILE_IRIS } from 'app/app.constants'; +import { ProfileInfo } from 'app/shared/layouts/profiles/profile-info.model'; +import { IrisCourseSettings } from 'app/entities/iris/settings/iris-settings.model'; +import { IrisLectureIngestionSubSettings } from 'app/entities/iris/settings/iris-sub-settings.model'; +import { Course } from 'app/entities/course.model'; @Component({ selector: 'jhi-competencies-popover', template: '' }) class CompetenciesPopoverStubComponent { @Input() courseId: number; @Input() - competencies: Competency[] = []; + competencyLinks: CompetencyLectureUnitLink[] = []; @Input() navigateTo: 'competencyManagement' | 'courseStatistics' = 'courseStatistics'; } @@ -49,16 +56,21 @@ describe('LectureUnitManagementComponent', () => { let lectureService: LectureService; let lectureUnitService: LectureUnitService; + let profileService: ProfileService; + let irisSettingsService: IrisSettingsService; let findLectureSpy: jest.SpyInstance; let findLectureWithDetailsSpy: jest.SpyInstance; let deleteLectureUnitSpy: jest.SpyInstance; let updateOrderSpy: jest.SpyInstance; + let getProfileInfo: jest.SpyInstance; + let getCombinedCourseSettings: jest.SpyInstance; let attachmentUnit: AttachmentUnit; let exerciseUnit: ExerciseUnit; let textUnit: TextUnit; let videoUnit: VideoUnit; let lecture: Lecture; + let course: Course; beforeEach(() => { return TestBed.configureTestingModule({ @@ -82,6 +94,8 @@ describe('LectureUnitManagementComponent', () => { MockProvider(LectureUnitService), MockProvider(LectureService), MockProvider(AlertService), + MockProvider(ProfileService), + MockProvider(IrisSettingsService), { provide: Router, useClass: MockRouter }, { provide: ActivatedRoute, @@ -105,11 +119,15 @@ describe('LectureUnitManagementComponent', () => { lectureUnitManagementComponent = lectureUnitManagementComponentFixture.componentInstance; lectureService = TestBed.inject(LectureService); lectureUnitService = TestBed.inject(LectureUnitService); + profileService = TestBed.inject(ProfileService); + irisSettingsService = TestBed.inject(IrisSettingsService); findLectureSpy = jest.spyOn(lectureService, 'find'); findLectureWithDetailsSpy = jest.spyOn(lectureService, 'findWithDetails'); deleteLectureUnitSpy = jest.spyOn(lectureUnitService, 'delete'); updateOrderSpy = jest.spyOn(lectureUnitService, 'updateOrder'); + getProfileInfo = jest.spyOn(profileService, 'getProfileInfo'); + getCombinedCourseSettings = jest.spyOn(irisSettingsService, 'getCombinedCourseSettings'); textUnit = new TextUnit(); textUnit.id = 0; @@ -119,9 +137,12 @@ describe('LectureUnitManagementComponent', () => { exerciseUnit.id = 2; attachmentUnit = new AttachmentUnit(); attachmentUnit.id = 3; + course = new Course(); + course.id = 99; lecture = new Lecture(); lecture.id = 0; + lecture.course = course; lecture.lectureUnits = [textUnit, videoUnit, exerciseUnit, attachmentUnit]; const returnValue = of(new HttpResponse({ body: lecture, status: 200 })); @@ -129,6 +150,12 @@ describe('LectureUnitManagementComponent', () => { findLectureWithDetailsSpy.mockReturnValue(returnValue); updateOrderSpy.mockReturnValue(returnValue); deleteLectureUnitSpy.mockReturnValue(of(new HttpResponse({ body: videoUnit, status: 200 }))); + const profileInfo = { activeProfiles: [PROFILE_IRIS] } as ProfileInfo; + getProfileInfo.mockReturnValue(of(profileInfo)); + const irisCourseSettings = new IrisCourseSettings(); + irisCourseSettings.irisLectureIngestionSettings = new IrisLectureIngestionSubSettings(); + irisCourseSettings.irisLectureIngestionSettings.enabled = true; + getCombinedCourseSettings.mockReturnValue(of(irisCourseSettings)); lectureUnitManagementComponentFixture.detectChanges(); }); @@ -201,6 +228,59 @@ describe('LectureUnitManagementComponent', () => { expect(lectureUnitManagementComponent.getActionType(new OnlineUnit())).toEqual(ActionType.Delete); }); + it('should call onIngestButtonClicked when button is clicked', () => { + const ingestLectureUnitInPyris = jest.spyOn(lectureUnitService, 'ingestLectureUnitInPyris'); + const returnValue = of(new HttpResponse({ status: 200 })); + ingestLectureUnitInPyris.mockReturnValue(returnValue); + const lectureUnitId = 1; + lectureUnitManagementComponent.lecture = { id: 2 } as any; + lectureUnitManagementComponent.onIngestButtonClicked(lectureUnitId); + expect(lectureUnitService.ingestLectureUnitInPyris).toHaveBeenCalledWith(lectureUnitId, lectureUnitManagementComponent.lecture.id); + }); + + it('should initialize profile info and check for Iris settings', () => { + lectureUnitManagementComponent.lecture = lecture; + lectureUnitManagementComponent.initializeProfileInfo(); + expect(profileService.getProfileInfo).toHaveBeenCalled(); + expect(irisSettingsService.getCombinedCourseSettings).toHaveBeenCalledWith(lecture.course!.id); + expect(lectureUnitManagementComponent.irisEnabled).toBeTrue(); + expect(lectureUnitManagementComponent.lectureIngestionEnabled).toBeTrue(); + }); + + it('should update ingestion states correctly when getIngestionState returns data', () => { + lectureUnitManagementComponent.lecture = lecture; + const mockIngestionStates = { + 3: IngestionState.DONE, + }; + + jest.spyOn(lectureUnitService, 'getIngestionState').mockReturnValue( + of( + new HttpResponse({ + body: mockIngestionStates, + status: 200, + }), + ), + ); + lectureUnitManagementComponent.updateIngestionStates(); + expect(lectureUnitService.getIngestionState).toHaveBeenCalledWith(lecture.course!.id!, lecture.id); + expect(attachmentUnit.pyrisIngestionState).toBe(IngestionState.DONE); + }); + + it('should handle error when ingestLectureUnitInPyris fails', () => { + const ingestLectureUnitInPyris = jest.spyOn(lectureUnitService, 'ingestLectureUnitInPyris'); + const lectureUnitId = 1; + lectureUnitManagementComponent.lecture = { id: 2 } as any; + const error = new Error('Failed to send Ingestion request'); + ingestLectureUnitInPyris.mockReturnValue(throwError(() => error)); + + jest.spyOn(console, 'error').mockImplementation(() => {}); + + lectureUnitManagementComponent.onIngestButtonClicked(lectureUnitId); + + expect(lectureUnitService.ingestLectureUnitInPyris).toHaveBeenCalledWith(lectureUnitId, lectureUnitManagementComponent.lecture.id); + expect(console.error).toHaveBeenCalledWith('Failed to send lecture unit ingestion request', error); + }); + describe('isViewButtonAvailable', () => { it('should return true for an attachment unit with a PDF link', () => { const lectureUnit = { diff --git a/src/test/javascript/spec/component/lecture-unit/lecture-unit.service.spec.ts b/src/test/javascript/spec/component/lecture-unit/lecture-unit.service.spec.ts index df7b3e171889..3a2a4df89376 100644 --- a/src/test/javascript/spec/component/lecture-unit/lecture-unit.service.spec.ts +++ b/src/test/javascript/spec/component/lecture-unit/lecture-unit.service.spec.ts @@ -5,7 +5,7 @@ import { Lecture } from 'app/entities/lecture.model'; import { LectureUnitService } from 'app/lecture/lecture-unit/lecture-unit-management/lectureUnit.service'; import { LectureUnit } from 'app/entities/lecture-unit/lectureUnit.model'; import dayjs from 'dayjs/esm'; -import { AttachmentUnit } from 'app/entities/lecture-unit/attachmentUnit.model'; +import { AttachmentUnit, IngestionState } from 'app/entities/lecture-unit/attachmentUnit.model'; import { TextUnit } from 'app/entities/lecture-unit/textUnit.model'; import { VideoUnit } from 'app/entities/lecture-unit/videoUnit.model'; import { ExerciseUnit } from 'app/entities/lecture-unit/exerciseUnit.model'; @@ -154,4 +154,44 @@ describe('LectureUnitService', () => { expect(convertDateFromServerEntitySpy).not.toHaveBeenCalled(); expect(result).toBe(emptyResponse); })); + + it('should fetch the ingestion state for lecture units and return an OK response', () => { + const courseId = 123; + const lectureId = 456; + const expectedUrl = `api/iris/courses/${courseId}/lectures/${lectureId}/lecture-units/ingestion-state`; + + const expectedResponse: Record = { + 1: IngestionState.DONE, + 2: IngestionState.NOT_STARTED, + }; + + service.getIngestionState(courseId, lectureId).subscribe((response) => { + expect(response.body).toEqual(expectedResponse); + }); + + const req = httpMock.expectOne({ + url: expectedUrl, + method: 'GET', + }); + + req.flush(expectedResponse, { status: 200, statusText: 'OK' }); + + expect(req.request.method).toBe('GET'); + }); + + it('should send a POST request to ingest a lecture unit and return an OK response', () => { + const lectureUnitId = 123; + const lectureId = 456; + const expectedUrl = `api/lectures/${lectureId}/lecture-units/${lectureUnitId}/ingest`; + + service.ingestLectureUnitInPyris(lectureUnitId, lectureId).subscribe((response) => { + expect(response.status).toBe(200); + }); + const req = httpMock.expectOne({ + url: expectedUrl, + method: 'POST', + }); + req.flush(null, { status: 200, statusText: 'OK' }); + expect(req.request.method).toBe('POST'); + }); }); diff --git a/src/test/javascript/spec/component/lecture/lecture-detail.component.spec.ts b/src/test/javascript/spec/component/lecture/lecture-detail.component.spec.ts index 367f3d916eff..c4cf5ce89d01 100644 --- a/src/test/javascript/spec/component/lecture/lecture-detail.component.spec.ts +++ b/src/test/javascript/spec/component/lecture/lecture-detail.component.spec.ts @@ -101,7 +101,7 @@ describe('LectureDetailComponent', () => { }); it('should call the service to ingest lectures when ingestLecturesInPyris is called', () => { component.lecture = mockLecture; - const ingestSpy = jest.spyOn(lectureService, 'ingestLecturesInPyris').mockImplementation(() => of(new HttpResponse({ status: 200, body: true }))); + const ingestSpy = jest.spyOn(lectureService, 'ingestLecturesInPyris').mockImplementation(() => of(new HttpResponse({ status: 200 }))); component.ingestLectureInPyris(); expect(ingestSpy).toHaveBeenCalledWith(mockLecture.course?.id, mockLecture.id); expect(ingestSpy).toHaveBeenCalledOnce(); diff --git a/src/test/javascript/spec/component/lecture/lecture.component.spec.ts b/src/test/javascript/spec/component/lecture/lecture.component.spec.ts index 62d315dd67e7..da714bd7059e 100644 --- a/src/test/javascript/spec/component/lecture/lecture.component.spec.ts +++ b/src/test/javascript/spec/component/lecture/lecture.component.spec.ts @@ -23,18 +23,15 @@ import { LectureImportComponent } from 'app/lecture/lecture-import.component'; import { DocumentationButtonComponent } from 'app/shared/components/documentation-button/documentation-button.component'; import { SortDirective } from 'app/shared/sort/sort.directive'; import { Course } from 'app/entities/course.model'; -import { IrisSettingsService } from 'app/iris/settings/shared/iris-settings.service'; -import { IrisCourseSettings } from 'app/entities/iris/settings/iris-settings.model'; -import { PROFILE_IRIS } from 'app/app.constants'; import { ProfileInfo } from 'app/shared/layouts/profiles/profile-info.model'; import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; +import { IngestionState } from 'app/entities/lecture-unit/attachmentUnit.model'; describe('Lecture', () => { let lectureComponentFixture: ComponentFixture; let lectureComponent: LectureComponent; let lectureService: LectureService; let profileService: ProfileService; - let irisSettingsService: IrisSettingsService; let modalService: NgbModal; let pastLecture: Lecture; @@ -100,6 +97,7 @@ describe('Lecture', () => { lectureToIngest.id = 1; lectureToIngest.title = 'machine Learning'; lectureToIngest.course = new Course(); + lectureToIngest.course.id = 99; consoleSpy = jest.spyOn(console, 'error').mockImplementation(); const profileInfo = { @@ -134,7 +132,7 @@ describe('Lecture', () => { }, }, MockProvider(LectureService, { - findAllByCourseId: () => { + findAllByCourseIdWithSlides: () => { return of( new HttpResponse({ body: [pastLecture, pastLecture2, currentLecture, currentLecture2, currentLecture3, futureLecture, futureLecture2, unspecifiedLecture], @@ -172,7 +170,7 @@ describe('Lecture', () => { }); it('should fetch lectures when initialized', () => { - const findAllSpy = jest.spyOn(lectureService, 'findAllByCourseId'); + const findAllSpy = jest.spyOn(lectureService, 'findAllByCourseIdWithSlides'); lectureComponentFixture.detectChanges(); @@ -265,15 +263,39 @@ describe('Lecture', () => { it('should call the service to ingest lectures when ingestLecturesInPyris is called', () => { lectureComponent.lectures = [lectureToIngest]; - const ingestSpy = jest.spyOn(lectureService, 'ingestLecturesInPyris').mockImplementation(() => of(new HttpResponse({ status: 200, body: true }))); + const ingestSpy = jest.spyOn(lectureService, 'ingestLecturesInPyris').mockImplementation(() => of(new HttpResponse({ status: 200 }))); lectureComponent.ingestLecturesInPyris(); expect(ingestSpy).toHaveBeenCalledWith(lectureToIngest.course?.id); expect(ingestSpy).toHaveBeenCalledOnce(); }); + it('should update ingestion states correctly when getIngestionState returns data', () => { + lectureComponent.courseId = 99; + lectureComponent.lectures = [lectureToIngest, pastLecture]; + const mockIngestionStates = { + 1: IngestionState.DONE, + 6: IngestionState.PARTIALLY_INGESTED, + }; + + jest.spyOn(lectureService, 'getIngestionState').mockReturnValue( + of( + new HttpResponse({ + body: mockIngestionStates, + status: 200, + }), + ), + ); + + lectureComponent.updateIngestionStates(); + + expect(lectureService.getIngestionState).toHaveBeenCalledWith(lectureToIngest.course!.id!); + expect(lectureToIngest.ingested).toBe(IngestionState.DONE); + expect(pastLecture.ingested).toBe(IngestionState.PARTIALLY_INGESTED); + }); + it('should not call the service if the first lecture does not exist', () => { lectureComponent.lectures = []; - const ingestSpy = jest.spyOn(lectureService, 'ingestLecturesInPyris').mockImplementation(() => of(new HttpResponse({ status: 200, body: true }))); + const ingestSpy = jest.spyOn(lectureService, 'ingestLecturesInPyris').mockImplementation(() => of(new HttpResponse({ status: 200 }))); lectureComponent.ingestLecturesInPyris(); expect(ingestSpy).not.toHaveBeenCalled(); }); @@ -281,23 +303,6 @@ describe('Lecture', () => { lectureComponent.lectures = [lectureToIngest]; jest.spyOn(lectureService, 'ingestLecturesInPyris').mockReturnValue(throwError(() => new Error('Error while ingesting'))); lectureComponent.ingestLecturesInPyris(); - expect(consoleSpy).toHaveBeenCalledWith('Failed to send Ingestion request', expect.any(Error)); - }); - it('should set lectureIngestionEnabled based on service response', () => { - irisSettingsService = TestBed.inject(IrisSettingsService); - profileService = TestBed.inject(ProfileService); - const profileInfoResponse = { - activeProfiles: [PROFILE_IRIS], - } as ProfileInfo; - const irisSettingsResponse = { - irisLectureIngestionSettings: { - enabled: true, - }, - } as IrisCourseSettings; - jest.spyOn(profileService, 'getProfileInfo').mockReturnValue(of(profileInfoResponse)); - jest.spyOn(irisSettingsService, 'getCombinedCourseSettings').mockImplementation(() => of(irisSettingsResponse)); - lectureComponent.ngOnInit(); - expect(irisSettingsService.getCombinedCourseSettings).toHaveBeenCalledWith(lectureComponent.courseId); - expect(lectureComponent.lectureIngestionEnabled).toBeTrue(); + expect(consoleSpy).toHaveBeenCalledWith('Failed to send Lectures Ingestion request', expect.any(Error)); }); }); diff --git a/src/test/javascript/spec/service/lecture.service.spec.ts b/src/test/javascript/spec/service/lecture.service.spec.ts index 681d13f11509..57e6e27c7d64 100644 --- a/src/test/javascript/spec/service/lecture.service.spec.ts +++ b/src/test/javascript/spec/service/lecture.service.spec.ts @@ -11,6 +11,7 @@ import { LectureService } from 'app/lecture/lecture.service'; import { Lecture } from 'app/entities/lecture.model'; import { Course } from 'app/entities/course.model'; import dayjs from 'dayjs/esm'; +import { IngestionState } from 'app/entities/lecture-unit/attachmentUnit.model'; describe('Lecture Service', () => { let httpMock: HttpTestingController; @@ -213,6 +214,24 @@ describe('Lecture Service', () => { expect(results).toEqual([elemDefault, elemDefault]); }); + it('should fetch ingestion state for a course', () => { + const courseId = 123; + const expectedUrl = `api/iris/courses/${courseId}/lectures/ingestion-state`; + const expectedResponse = { 1: IngestionState.DONE, 2: IngestionState.NOT_STARTED }; + + service.getIngestionState(courseId).subscribe((resp) => { + expect(resp.body).toEqual(expectedResponse); + }); + + const req = httpMock.expectOne({ + url: expectedUrl, + method: 'GET', + }); + + req.flush(expectedResponse); + expect(req.request.method).toBe('GET'); + }); + it('should send a POST request to ingest lectures and return an OK response', () => { const courseId = 123; const lectureId = 456; From e5ca593fbf9888eca54e1da600aa2a776b0ce290 Mon Sep 17 00:00:00 2001 From: Asli Aykan <56061820+asliayk@users.noreply.github.com> Date: Sat, 30 Nov 2024 09:15:52 +0100 Subject: [PATCH 07/10] Communication: Fix dropdown menu behavior for links to allow default browser options (#9832) --- .../answer-post/answer-post.component.ts | 55 ++++++++++++++----- .../app/shared/metis/post/post.component.ts | 33 ++++++----- .../answer-post/answer-post.component.spec.ts | 40 ++++++++++++++ .../shared/metis/post/post.component.spec.ts | 38 +++++++++++++ 4 files changed, 137 insertions(+), 29 deletions(-) diff --git a/src/main/webapp/app/shared/metis/answer-post/answer-post.component.ts b/src/main/webapp/app/shared/metis/answer-post/answer-post.component.ts index 0c16f1be06ce..ab9e1881dccc 100644 --- a/src/main/webapp/app/shared/metis/answer-post/answer-post.component.ts +++ b/src/main/webapp/app/shared/metis/answer-post/answer-post.component.ts @@ -7,6 +7,7 @@ import { Inject, Input, OnChanges, + OnDestroy, OnInit, Output, Renderer2, @@ -36,7 +37,7 @@ import { AnswerPostReactionsBarComponent } from 'app/shared/metis/posting-reacti ]), ], }) -export class AnswerPostComponent extends PostingDirective implements OnInit, OnChanges { +export class AnswerPostComponent extends PostingDirective implements OnInit, OnChanges, OnDestroy { @Input() lastReadDate?: dayjs.Dayjs; @Input() isLastAnswer: boolean; @Output() openPostingCreateEditModal = new EventEmitter(); @@ -119,24 +120,33 @@ export class AnswerPostComponent extends PostingDirective implements } onRightClick(event: MouseEvent) { - event.preventDefault(); - - if (AnswerPostComponent.activeDropdownPost && AnswerPostComponent.activeDropdownPost !== this) { - AnswerPostComponent.activeDropdownPost.showDropdown = false; - AnswerPostComponent.activeDropdownPost.enableBodyScroll(); - AnswerPostComponent.activeDropdownPost.changeDetector.detectChanges(); + const targetElement = event.target as HTMLElement; + let isPointerCursor = false; + try { + isPointerCursor = window.getComputedStyle(targetElement).cursor === 'pointer'; + } catch (error) { + console.error('Failed to compute style:', error); + isPointerCursor = true; } - AnswerPostComponent.activeDropdownPost = this; + if (!isPointerCursor) { + event.preventDefault(); - this.dropdownPosition = { - x: event.clientX, - y: event.clientY, - }; + if (AnswerPostComponent.activeDropdownPost !== this) { + AnswerPostComponent.cleanupActiveDropdown(); + } - this.showDropdown = true; - this.adjustDropdownPosition(); - this.disableBodyScroll(); + AnswerPostComponent.activeDropdownPost = this; + + this.dropdownPosition = { + x: event.clientX, + y: event.clientY, + }; + + this.showDropdown = true; + this.adjustDropdownPosition(); + this.disableBodyScroll(); + } } adjustDropdownPosition() { @@ -148,6 +158,21 @@ export class AnswerPostComponent extends PostingDirective implements } } + private static cleanupActiveDropdown(): void { + if (AnswerPostComponent.activeDropdownPost) { + AnswerPostComponent.activeDropdownPost.showDropdown = false; + AnswerPostComponent.activeDropdownPost.enableBodyScroll(); + AnswerPostComponent.activeDropdownPost.changeDetector.detectChanges(); + AnswerPostComponent.activeDropdownPost = null; + } + } + + ngOnDestroy(): void { + if (AnswerPostComponent.activeDropdownPost === this) { + AnswerPostComponent.cleanupActiveDropdown(); + } + } + private assignPostingToAnswerPost() { // This is needed because otherwise instanceof returns 'object'. if (this.posting && !(this.posting instanceof AnswerPost)) { diff --git a/src/main/webapp/app/shared/metis/post/post.component.ts b/src/main/webapp/app/shared/metis/post/post.component.ts index 5deafdbd287b..feeb30d3ed0d 100644 --- a/src/main/webapp/app/shared/metis/post/post.component.ts +++ b/src/main/webapp/app/shared/metis/post/post.component.ts @@ -128,24 +128,29 @@ export class PostComponent extends PostingDirective implements OnInit, OnC } onRightClick(event: MouseEvent) { - event.preventDefault(); + const targetElement = event.target as HTMLElement; + const isPointerCursor = window.getComputedStyle(targetElement).cursor === 'pointer'; - if (PostComponent.activeDropdownPost && PostComponent.activeDropdownPost !== this) { - PostComponent.activeDropdownPost.showDropdown = false; - PostComponent.activeDropdownPost.enableBodyScroll(); - PostComponent.activeDropdownPost.changeDetector.detectChanges(); - } + if (!isPointerCursor) { + event.preventDefault(); + + if (PostComponent.activeDropdownPost && PostComponent.activeDropdownPost !== this) { + PostComponent.activeDropdownPost.showDropdown = false; + PostComponent.activeDropdownPost.enableBodyScroll(); + PostComponent.activeDropdownPost.changeDetector.detectChanges(); + } - PostComponent.activeDropdownPost = this; + PostComponent.activeDropdownPost = this; - this.dropdownPosition = { - x: event.clientX, - y: event.clientY, - }; + this.dropdownPosition = { + x: event.clientX, + y: event.clientY, + }; - this.showDropdown = true; - this.adjustDropdownPosition(); - this.disableBodyScroll(); + this.showDropdown = true; + this.adjustDropdownPosition(); + this.disableBodyScroll(); + } } adjustDropdownPosition() { diff --git a/src/test/javascript/spec/component/shared/metis/answer-post/answer-post.component.spec.ts b/src/test/javascript/spec/component/shared/metis/answer-post/answer-post.component.spec.ts index 441b3e4ef382..3a84c4f12e87 100644 --- a/src/test/javascript/spec/component/shared/metis/answer-post/answer-post.component.spec.ts +++ b/src/test/javascript/spec/component/shared/metis/answer-post/answer-post.component.spec.ts @@ -52,6 +52,10 @@ describe('AnswerPostComponent', () => { }); }); + afterEach(() => { + jest.restoreAllMocks(); + }); + it('should contain an answer post header when isConsecutive is false', () => { runInInjectionContext(fixture.debugElement.injector, () => { component.isConsecutive = input(false); @@ -178,6 +182,42 @@ describe('AnswerPostComponent', () => { expect(component.posting.reactions).toEqual(updatedReactions); }); + it('should handle onRightClick correctly based on cursor style', () => { + const testCases = [ + { + cursor: 'pointer', + preventDefaultCalled: false, + showDropdown: false, + dropdownPosition: { x: 0, y: 0 }, + }, + { + cursor: 'default', + preventDefaultCalled: true, + showDropdown: true, + dropdownPosition: { x: 100, y: 200 }, + }, + ]; + + testCases.forEach(({ cursor, preventDefaultCalled, showDropdown, dropdownPosition }) => { + const event = new MouseEvent('contextmenu', { clientX: 100, clientY: 200 }); + + const targetElement = document.createElement('div'); + Object.defineProperty(event, 'target', { value: targetElement }); + + jest.spyOn(window, 'getComputedStyle').mockReturnValue({ + cursor, + } as CSSStyleDeclaration); + + const preventDefaultSpy = jest.spyOn(event, 'preventDefault'); + + component.onRightClick(event); + + expect(preventDefaultSpy).toHaveBeenCalledTimes(preventDefaultCalled ? 1 : 0); + expect(component.showDropdown).toBe(showDropdown); + expect(component.dropdownPosition).toEqual(dropdownPosition); + }); + }); + it('should cast the post to answer post on change', () => { const mockPost: Posting = { id: 1, diff --git a/src/test/javascript/spec/component/shared/metis/post/post.component.spec.ts b/src/test/javascript/spec/component/shared/metis/post/post.component.spec.ts index 6d0a1940a7d9..6c0859326aaa 100644 --- a/src/test/javascript/spec/component/shared/metis/post/post.component.spec.ts +++ b/src/test/javascript/spec/component/shared/metis/post/post.component.spec.ts @@ -323,6 +323,44 @@ describe('PostComponent', () => { expect(enableBodyScrollSpy).toHaveBeenCalled(); }); + it('should handle onRightClick correctly based on cursor style', () => { + const testCases = [ + { + cursor: 'pointer', + preventDefaultCalled: false, + showDropdown: false, + dropdownPosition: { x: 0, y: 0 }, + }, + { + cursor: 'default', + preventDefaultCalled: true, + showDropdown: true, + dropdownPosition: { x: 100, y: 200 }, + }, + ]; + + testCases.forEach(({ cursor, preventDefaultCalled, showDropdown, dropdownPosition }) => { + const event = new MouseEvent('contextmenu', { clientX: 100, clientY: 200 }); + + const targetElement = document.createElement('div'); + Object.defineProperty(event, 'target', { value: targetElement }); + + jest.spyOn(window, 'getComputedStyle').mockReturnValue({ + cursor, + } as CSSStyleDeclaration); + + const preventDefaultSpy = jest.spyOn(event, 'preventDefault'); + + component.onRightClick(event); + + expect(preventDefaultSpy).toHaveBeenCalledTimes(preventDefaultCalled ? 1 : 0); + expect(component.showDropdown).toBe(showDropdown); + expect(component.dropdownPosition).toEqual(dropdownPosition); + + jest.restoreAllMocks(); + }); + }); + it('should cast the post to Post on change', () => { const mockPost: Posting = { id: 1, From b03891c3e7e053c6fd828bbc0725f4b23cdf3f6f Mon Sep 17 00:00:00 2001 From: Ole Vester <73833780+ole-ve@users.noreply.github.com> Date: Sat, 30 Nov 2024 10:22:09 +0100 Subject: [PATCH 08/10] Development: Introduce module API for Atlas (#9752) --- .../ParticipantScoreScheduleService.java | 10 +-- .../artemis/atlas/api/AbstractAtlasApi.java | 6 ++ .../aet/artemis/atlas/api/CompetencyApi.java | 24 ++++++ .../atlas/api/CompetencyProgressApi.java | 74 +++++++++++++++++++ .../atlas/api/CompetencyRelationApi.java | 56 ++++++++++++++ .../atlas/api/CourseCompetencyApi.java | 24 ++++++ .../artemis/atlas/api/LearningMetricsApi.java | 24 ++++++ .../artemis/atlas/api/LearningPathApi.java | 31 ++++++++ .../artemis/atlas/api/PrerequisitesApi.java | 29 ++++++++ .../artemis/atlas/api/ScienceEventApi.java | 30 ++++++++ .../service/LearningMetricsService.java | 2 +- .../artemis/atlas/web/MetricsResource.java | 2 +- .../cit/aet/artemis/core/api/AbstractApi.java | 4 + .../MigrationEntry20240614_140000.java | 20 ++--- .../artemis/core/service/CourseService.java | 46 ++++++------ .../export/DataExportScienceEventService.java | 10 +-- .../core/service/user/UserService.java | 10 +-- .../aet/artemis/core/web/CourseResource.java | 12 +-- .../service/ExerciseDeletionService.java | 11 ++- .../exercise/service/ExerciseService.java | 12 +-- .../service/ParticipationService.java | 10 +-- .../FileUploadExerciseImportService.java | 12 +-- .../web/FileUploadExerciseResource.java | 12 +-- .../service/pyris/PyrisPipelineService.java | 10 +-- .../service/AttachmentUnitService.java | 6 +- .../lecture/service/LectureService.java | 19 +++-- .../lecture/service/LectureUnitService.java | 30 ++++---- .../lecture/web/AttachmentUnitResource.java | 12 +-- .../artemis/lecture/web/LectureResource.java | 12 +-- .../lecture/web/LectureUnitResource.java | 10 +-- .../lecture/web/OnlineUnitResource.java | 12 +-- .../artemis/lecture/web/TextUnitResource.java | 12 +-- .../lecture/web/VideoUnitResource.java | 12 +-- .../ModelingExerciseImportService.java | 10 +-- .../web/ModelingExerciseResource.java | 12 +-- .../service/ProgrammingExerciseService.java | 12 +-- ...ogrammingExerciseExportImportResource.java | 10 +-- .../service/QuizExerciseImportService.java | 10 +-- .../quiz/web/QuizExerciseResource.java | 12 +-- .../service/TextExerciseImportService.java | 10 +-- .../text/web/TextExerciseResource.java | 14 ++-- .../AtlasApiArchitectureTest.java | 11 +++ .../FileUploadExerciseIntegrationTest.java | 6 +- .../AttachmentUnitIntegrationTest.java | 6 +- .../lecture/ExerciseUnitIntegrationTest.java | 2 +- .../lecture/LectureIntegrationTest.java | 2 +- .../lecture/OnlineUnitIntegrationTest.java | 6 +- .../lecture/TextUnitIntegrationTest.java | 6 +- .../lecture/VideoUnitIntegrationTest.java | 6 +- .../ModelingExerciseIntegrationTest.java | 7 +- ...ExerciseLocalVCLocalCIIntegrationTest.java | 8 +- .../AbstractModuleAccessArchitectureTest.java | 67 +++++++++++++++++ .../base/AbstractArtemisIntegrationTest.java | 7 +- .../text/TextExerciseIntegrationTest.java | 6 +- 54 files changed, 604 insertions(+), 232 deletions(-) create mode 100644 src/main/java/de/tum/cit/aet/artemis/atlas/api/AbstractAtlasApi.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/atlas/api/CompetencyApi.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/atlas/api/CompetencyProgressApi.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/atlas/api/CompetencyRelationApi.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/atlas/api/CourseCompetencyApi.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/atlas/api/LearningMetricsApi.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/atlas/api/LearningPathApi.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/atlas/api/PrerequisitesApi.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/atlas/api/ScienceEventApi.java rename src/main/java/de/tum/cit/aet/artemis/{exercise => atlas}/service/LearningMetricsService.java (99%) create mode 100644 src/main/java/de/tum/cit/aet/artemis/core/api/AbstractApi.java create mode 100644 src/test/java/de/tum/cit/aet/artemis/atlas/architecture/AtlasApiArchitectureTest.java create mode 100644 src/test/java/de/tum/cit/aet/artemis/shared/architecture/module/AbstractModuleAccessArchitectureTest.java diff --git a/src/main/java/de/tum/cit/aet/artemis/assessment/service/ParticipantScoreScheduleService.java b/src/main/java/de/tum/cit/aet/artemis/assessment/service/ParticipantScoreScheduleService.java index fffd4957b5b2..a3b7521668f4 100644 --- a/src/main/java/de/tum/cit/aet/artemis/assessment/service/ParticipantScoreScheduleService.java +++ b/src/main/java/de/tum/cit/aet/artemis/assessment/service/ParticipantScoreScheduleService.java @@ -37,7 +37,7 @@ import de.tum.cit.aet.artemis.assessment.repository.ResultRepository; import de.tum.cit.aet.artemis.assessment.repository.StudentScoreRepository; import de.tum.cit.aet.artemis.assessment.repository.TeamScoreRepository; -import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyProgressService; +import de.tum.cit.aet.artemis.atlas.api.CompetencyProgressApi; import de.tum.cit.aet.artemis.core.domain.User; import de.tum.cit.aet.artemis.core.repository.UserRepository; import de.tum.cit.aet.artemis.core.security.SecurityUtils; @@ -74,7 +74,7 @@ public class ParticipantScoreScheduleService { private Optional lastScheduledRun = Optional.empty(); - private final CompetencyProgressService competencyProgressService; + private final CompetencyProgressApi competencyProgressApi; private final ParticipantScoreRepository participantScoreRepository; @@ -96,11 +96,11 @@ public class ParticipantScoreScheduleService { */ private final AtomicBoolean isRunning = new AtomicBoolean(false); - public ParticipantScoreScheduleService(@Qualifier("taskScheduler") TaskScheduler scheduler, CompetencyProgressService competencyProgressService, + public ParticipantScoreScheduleService(@Qualifier("taskScheduler") TaskScheduler scheduler, CompetencyProgressApi competencyProgressApi, ParticipantScoreRepository participantScoreRepository, StudentScoreRepository studentScoreRepository, TeamScoreRepository teamScoreRepository, ExerciseRepository exerciseRepository, ResultRepository resultRepository, UserRepository userRepository, TeamRepository teamRepository) { this.scheduler = scheduler; - this.competencyProgressService = competencyProgressService; + this.competencyProgressApi = competencyProgressApi; this.participantScoreRepository = participantScoreRepository; this.studentScoreRepository = studentScoreRepository; this.teamScoreRepository = teamScoreRepository; @@ -336,7 +336,7 @@ private void executeTask(Long exerciseId, Long participantId, Instant resultLast if (scoreParticipant instanceof Team team && !Hibernate.isInitialized(team.getStudents())) { scoreParticipant = teamRepository.findWithStudentsByIdElseThrow(team.getId()); } - competencyProgressService.updateProgressByLearningObjectSync(score.getExercise(), scoreParticipant.getParticipants()); + competencyProgressApi.updateProgressByLearningObjectSync(score.getExercise(), scoreParticipant.getParticipants()); } catch (Exception e) { log.error("Exception while processing participant score for exercise {} and participant {} for participant scores:", exerciseId, participantId, e); diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/api/AbstractAtlasApi.java b/src/main/java/de/tum/cit/aet/artemis/atlas/api/AbstractAtlasApi.java new file mode 100644 index 000000000000..41481096ac68 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/api/AbstractAtlasApi.java @@ -0,0 +1,6 @@ +package de.tum.cit.aet.artemis.atlas.api; + +import de.tum.cit.aet.artemis.core.api.AbstractApi; + +public abstract class AbstractAtlasApi implements AbstractApi { +} diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/api/CompetencyApi.java b/src/main/java/de/tum/cit/aet/artemis/atlas/api/CompetencyApi.java new file mode 100644 index 000000000000..7c4f349d9215 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/api/CompetencyApi.java @@ -0,0 +1,24 @@ +package de.tum.cit.aet.artemis.atlas.api; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Controller; + +import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyService; +import de.tum.cit.aet.artemis.lecture.domain.Lecture; + +@Controller +@Profile(PROFILE_CORE) +public class CompetencyApi extends AbstractAtlasApi { + + private final CompetencyService competencyService; + + public CompetencyApi(CompetencyService competencyService) { + this.competencyService = competencyService; + } + + public void addCompetencyLinksToExerciseUnits(Lecture lecture) { + competencyService.addCompetencyLinksToExerciseUnits(lecture); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/api/CompetencyProgressApi.java b/src/main/java/de/tum/cit/aet/artemis/atlas/api/CompetencyProgressApi.java new file mode 100644 index 000000000000..eddc384ad8c7 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/api/CompetencyProgressApi.java @@ -0,0 +1,74 @@ +package de.tum.cit.aet.artemis.atlas.api; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Controller; + +import de.tum.cit.aet.artemis.atlas.domain.LearningObject; +import de.tum.cit.aet.artemis.atlas.domain.competency.Competency; +import de.tum.cit.aet.artemis.atlas.domain.competency.CourseCompetency; +import de.tum.cit.aet.artemis.atlas.repository.CompetencyRepository; +import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyProgressService; +import de.tum.cit.aet.artemis.core.domain.Course; +import de.tum.cit.aet.artemis.core.domain.User; +import de.tum.cit.aet.artemis.exercise.domain.participation.Participant; + +@Controller +@Profile(PROFILE_CORE) +public class CompetencyProgressApi extends AbstractAtlasApi { + + private final CompetencyProgressService competencyProgressService; + + private final CompetencyRepository competencyRepository; + + public CompetencyProgressApi(CompetencyProgressService competencyProgressService, CompetencyRepository competencyRepository) { + this.competencyProgressService = competencyProgressService; + this.competencyRepository = competencyRepository; + } + + public void updateProgressByLearningObjectForParticipantAsync(LearningObject learningObject, Participant participant) { + competencyProgressService.updateProgressByLearningObjectForParticipantAsync(learningObject, participant); + } + + public void updateProgressByLearningObjectAsync(LearningObject learningObject) { + competencyProgressService.updateProgressByLearningObjectAsync(learningObject); + } + + public void updateProgressByCompetencyAsync(CourseCompetency competency) { + competencyProgressService.updateProgressByCompetencyAsync(competency); + } + + public void updateProgressForUpdatedLearningObjectAsync(LearningObject originalLearningObject, Optional updatedLearningObject) { + competencyProgressService.updateProgressForUpdatedLearningObjectAsync(originalLearningObject, updatedLearningObject); + } + + public void updateProgressByLearningObjectSync(LearningObject learningObject, Set users) { + competencyProgressService.updateProgressByLearningObjectSync(learningObject, users); + } + + public long countByCourse(Course course) { + return competencyRepository.countByCourse(course); + } + + public void deleteAll(Set competencies) { + competencyRepository.deleteAll(competencies); + } + + /** + * Updates the progress for all competencies of the given courses. + * + * @param activeCourses the active courses + */ + public void updateProgressForCoursesAsync(List activeCourses) { + activeCourses.forEach(course -> { + List competencies = competencyRepository.findByCourseIdOrderById(course.getId()); + // Asynchronously update the progress for each competency + competencies.forEach(competencyProgressService::updateProgressByCompetencyAsync); + }); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/api/CompetencyRelationApi.java b/src/main/java/de/tum/cit/aet/artemis/atlas/api/CompetencyRelationApi.java new file mode 100644 index 000000000000..d7857bdcf509 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/api/CompetencyRelationApi.java @@ -0,0 +1,56 @@ +package de.tum.cit.aet.artemis.atlas.api; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import java.util.List; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Controller; + +import de.tum.cit.aet.artemis.atlas.domain.competency.CompetencyExerciseLink; +import de.tum.cit.aet.artemis.atlas.domain.competency.CompetencyLectureUnitLink; +import de.tum.cit.aet.artemis.atlas.repository.CompetencyExerciseLinkRepository; +import de.tum.cit.aet.artemis.atlas.repository.CompetencyLectureUnitLinkRepository; +import de.tum.cit.aet.artemis.atlas.repository.CompetencyRelationRepository; + +@Controller +@Profile(PROFILE_CORE) +public class CompetencyRelationApi extends AbstractAtlasApi { + + private final CompetencyRelationRepository competencyRelationRepository; + + private final CompetencyExerciseLinkRepository competencyExerciseLinkRepository; + + private final CompetencyLectureUnitLinkRepository lectureUnitLinkRepository; + + public CompetencyRelationApi(CompetencyRelationRepository competencyRelationRepository, CompetencyExerciseLinkRepository competencyExerciseLinkRepository, + CompetencyLectureUnitLinkRepository lectureUnitLinkRepository) { + this.competencyRelationRepository = competencyRelationRepository; + this.competencyExerciseLinkRepository = competencyExerciseLinkRepository; + this.lectureUnitLinkRepository = lectureUnitLinkRepository; + } + + public void deleteAllByCourseId(Long courseId) { + competencyRelationRepository.deleteAllByCourseId(courseId); + } + + public void deleteAllExerciseLinks(Iterable competencyExerciseLinks) { + competencyExerciseLinkRepository.deleteAll(competencyExerciseLinks); + } + + public List saveAllExerciseLinks(Iterable competencyExerciseLinks) { + return competencyExerciseLinkRepository.saveAll(competencyExerciseLinks); + } + + public List saveAllLectureUnitLinks(Iterable lectureUnitLinks) { + return lectureUnitLinkRepository.saveAll(lectureUnitLinks); + } + + public void deleteAllLectureUnitLinks(Iterable lectureUnitLinks) { + lectureUnitLinkRepository.deleteAll(lectureUnitLinks); + } + + public void deleteAllLectureUnitLinksByLectureId(Long lectureId) { + lectureUnitLinkRepository.deleteAllByLectureId(lectureId); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/api/CourseCompetencyApi.java b/src/main/java/de/tum/cit/aet/artemis/atlas/api/CourseCompetencyApi.java new file mode 100644 index 000000000000..c970d2f137ff --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/api/CourseCompetencyApi.java @@ -0,0 +1,24 @@ +package de.tum.cit.aet.artemis.atlas.api; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Controller; + +import de.tum.cit.aet.artemis.atlas.domain.competency.CourseCompetency; +import de.tum.cit.aet.artemis.atlas.repository.CourseCompetencyRepository; + +@Controller +@Profile(PROFILE_CORE) +public class CourseCompetencyApi extends AbstractAtlasApi { + + private final CourseCompetencyRepository courseCompetencyRepository; + + public CourseCompetencyApi(CourseCompetencyRepository courseCompetencyRepository) { + this.courseCompetencyRepository = courseCompetencyRepository; + } + + public void save(CourseCompetency courseCompetency) { + courseCompetencyRepository.save(courseCompetency); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/api/LearningMetricsApi.java b/src/main/java/de/tum/cit/aet/artemis/atlas/api/LearningMetricsApi.java new file mode 100644 index 000000000000..cf2a5271cab0 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/api/LearningMetricsApi.java @@ -0,0 +1,24 @@ +package de.tum.cit.aet.artemis.atlas.api; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Controller; + +import de.tum.cit.aet.artemis.atlas.dto.metrics.StudentMetricsDTO; +import de.tum.cit.aet.artemis.atlas.service.LearningMetricsService; + +@Controller +@Profile(PROFILE_CORE) +public class LearningMetricsApi extends AbstractAtlasApi { + + private final LearningMetricsService metricsService; + + public LearningMetricsApi(LearningMetricsService metricsService) { + this.metricsService = metricsService; + } + + public StudentMetricsDTO getStudentCourseMetrics(long userId, long courseId) { + return metricsService.getStudentCourseMetrics(userId, courseId); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/api/LearningPathApi.java b/src/main/java/de/tum/cit/aet/artemis/atlas/api/LearningPathApi.java new file mode 100644 index 000000000000..834297c04288 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/api/LearningPathApi.java @@ -0,0 +1,31 @@ +package de.tum.cit.aet.artemis.atlas.api; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import jakarta.validation.constraints.NotNull; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Controller; + +import de.tum.cit.aet.artemis.atlas.service.learningpath.LearningPathService; +import de.tum.cit.aet.artemis.core.domain.Course; +import de.tum.cit.aet.artemis.core.domain.User; + +@Controller +@Profile(PROFILE_CORE) +public class LearningPathApi extends AbstractAtlasApi { + + private final LearningPathService learningPathService; + + public LearningPathApi(LearningPathService learningPathService) { + this.learningPathService = learningPathService; + } + + public void generateLearningPathForUser(@NotNull Course course, @NotNull User user) { + learningPathService.generateLearningPathForUser(course, user); + } + + public void generateLearningPaths(@NotNull Course course) { + learningPathService.generateLearningPaths(course); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/api/PrerequisitesApi.java b/src/main/java/de/tum/cit/aet/artemis/atlas/api/PrerequisitesApi.java new file mode 100644 index 000000000000..ddcc8fa3c56a --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/api/PrerequisitesApi.java @@ -0,0 +1,29 @@ +package de.tum.cit.aet.artemis.atlas.api; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Controller; + +import de.tum.cit.aet.artemis.atlas.domain.competency.Prerequisite; +import de.tum.cit.aet.artemis.atlas.repository.PrerequisiteRepository; +import de.tum.cit.aet.artemis.core.domain.Course; + +@Controller +@Profile(PROFILE_CORE) +public class PrerequisitesApi extends AbstractAtlasApi { + + private final PrerequisiteRepository prerequisiteRepository; + + public PrerequisitesApi(PrerequisiteRepository prerequisiteRepository) { + this.prerequisiteRepository = prerequisiteRepository; + } + + public long countByCourse(Course course) { + return prerequisiteRepository.countByCourse(course); + } + + public void deleteAll(Iterable prerequisites) { + prerequisiteRepository.deleteAll(prerequisites); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/api/ScienceEventApi.java b/src/main/java/de/tum/cit/aet/artemis/atlas/api/ScienceEventApi.java new file mode 100644 index 000000000000..0da5e4beb0ca --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/api/ScienceEventApi.java @@ -0,0 +1,30 @@ +package de.tum.cit.aet.artemis.atlas.api; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import java.util.Set; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Controller; + +import de.tum.cit.aet.artemis.atlas.domain.science.ScienceEvent; +import de.tum.cit.aet.artemis.atlas.repository.ScienceEventRepository; + +@Controller +@Profile(PROFILE_CORE) +public class ScienceEventApi extends AbstractAtlasApi { + + private final ScienceEventRepository scienceEventRepository; + + public ScienceEventApi(ScienceEventRepository scienceEventRepository) { + this.scienceEventRepository = scienceEventRepository; + } + + public Set findAllByIdentity(String login) { + return scienceEventRepository.findAllByIdentity(login); + } + + public void renameIdentity(String oldIdentity, String newIdentity) { + scienceEventRepository.renameIdentity(oldIdentity, newIdentity); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/exercise/service/LearningMetricsService.java b/src/main/java/de/tum/cit/aet/artemis/atlas/service/LearningMetricsService.java similarity index 99% rename from src/main/java/de/tum/cit/aet/artemis/exercise/service/LearningMetricsService.java rename to src/main/java/de/tum/cit/aet/artemis/atlas/service/LearningMetricsService.java index c939f59367b5..38b4552c19c3 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exercise/service/LearningMetricsService.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/service/LearningMetricsService.java @@ -1,4 +1,4 @@ -package de.tum.cit.aet.artemis.exercise.service; +package de.tum.cit.aet.artemis.atlas.service; import static de.tum.cit.aet.artemis.core.config.Constants.MIN_SCORE_GREEN; import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/web/MetricsResource.java b/src/main/java/de/tum/cit/aet/artemis/atlas/web/MetricsResource.java index ca2fa2f30251..db24d9569c88 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/web/MetricsResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/web/MetricsResource.java @@ -12,9 +12,9 @@ import org.springframework.web.bind.annotation.RestController; import de.tum.cit.aet.artemis.atlas.dto.metrics.StudentMetricsDTO; +import de.tum.cit.aet.artemis.atlas.service.LearningMetricsService; import de.tum.cit.aet.artemis.core.repository.UserRepository; import de.tum.cit.aet.artemis.core.security.annotations.enforceRoleInCourse.EnforceAtLeastStudentInCourse; -import de.tum.cit.aet.artemis.exercise.service.LearningMetricsService; @Profile(PROFILE_CORE) @RestController diff --git a/src/main/java/de/tum/cit/aet/artemis/core/api/AbstractApi.java b/src/main/java/de/tum/cit/aet/artemis/core/api/AbstractApi.java new file mode 100644 index 000000000000..8ca26f995aa0 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/core/api/AbstractApi.java @@ -0,0 +1,4 @@ +package de.tum.cit.aet.artemis.core.api; + +public interface AbstractApi { +} diff --git a/src/main/java/de/tum/cit/aet/artemis/core/config/migration/entries/MigrationEntry20240614_140000.java b/src/main/java/de/tum/cit/aet/artemis/core/config/migration/entries/MigrationEntry20240614_140000.java index 7371408c2df6..b385c3f01053 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/config/migration/entries/MigrationEntry20240614_140000.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/config/migration/entries/MigrationEntry20240614_140000.java @@ -6,9 +6,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import de.tum.cit.aet.artemis.atlas.domain.competency.Competency; -import de.tum.cit.aet.artemis.atlas.repository.CompetencyRepository; -import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyProgressService; +import de.tum.cit.aet.artemis.atlas.api.CompetencyProgressApi; import de.tum.cit.aet.artemis.core.config.migration.MigrationEntry; import de.tum.cit.aet.artemis.core.domain.Course; import de.tum.cit.aet.artemis.core.repository.CourseRepository; @@ -19,14 +17,11 @@ public class MigrationEntry20240614_140000 extends MigrationEntry { private final CourseRepository courseRepository; - private final CompetencyRepository competencyRepository; + private final CompetencyProgressApi competencyProgressApi; - private final CompetencyProgressService competencyProgressService; - - public MigrationEntry20240614_140000(CourseRepository courseRepository, CompetencyRepository competencyRepository, CompetencyProgressService competencyProgressService) { + public MigrationEntry20240614_140000(CourseRepository courseRepository, CompetencyProgressApi competencyProgressApi) { this.courseRepository = courseRepository; - this.competencyRepository = competencyRepository; - this.competencyProgressService = competencyProgressService; + this.competencyProgressApi = competencyProgressApi; } @Override @@ -34,12 +29,7 @@ public void execute() { List activeCourses = courseRepository.findAllActiveWithoutTestCourses(ZonedDateTime.now()); log.info("Updating competency progress for {} active courses", activeCourses.size()); - - activeCourses.forEach(course -> { - List competencies = competencyRepository.findByCourseIdOrderById(course.getId()); - // Asynchronously update the progress for each competency - competencies.forEach(competencyProgressService::updateProgressByCompetencyAsync); - }); + competencyProgressApi.updateProgressForCoursesAsync(activeCourses); } @Override diff --git a/src/main/java/de/tum/cit/aet/artemis/core/service/CourseService.java b/src/main/java/de/tum/cit/aet/artemis/core/service/CourseService.java index f10248c99b81..1b037532e453 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/service/CourseService.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/service/CourseService.java @@ -53,10 +53,10 @@ import de.tum.cit.aet.artemis.assessment.service.ComplaintService; import de.tum.cit.aet.artemis.assessment.service.PresentationPointsCalculationService; import de.tum.cit.aet.artemis.assessment.service.TutorLeaderboardService; -import de.tum.cit.aet.artemis.atlas.repository.CompetencyRelationRepository; -import de.tum.cit.aet.artemis.atlas.repository.CompetencyRepository; -import de.tum.cit.aet.artemis.atlas.repository.PrerequisiteRepository; -import de.tum.cit.aet.artemis.atlas.service.learningpath.LearningPathService; +import de.tum.cit.aet.artemis.atlas.api.CompetencyProgressApi; +import de.tum.cit.aet.artemis.atlas.api.CompetencyRelationApi; +import de.tum.cit.aet.artemis.atlas.api.LearningPathApi; +import de.tum.cit.aet.artemis.atlas.api.PrerequisitesApi; import de.tum.cit.aet.artemis.communication.domain.FaqState; import de.tum.cit.aet.artemis.communication.domain.NotificationType; import de.tum.cit.aet.artemis.communication.domain.Post; @@ -132,7 +132,7 @@ public class CourseService { private final TutorialGroupChannelManagementService tutorialGroupChannelManagementService; - private final CompetencyRelationRepository competencyRelationRepository; + private final CompetencyRelationApi competencyRelationApi; private final ExerciseService exerciseService; @@ -162,9 +162,9 @@ public class CourseService { private final AuditEventRepository auditEventRepository; - private final CompetencyRepository competencyRepository; + private final CompetencyProgressApi competencyProgressApi; - private final PrerequisiteRepository prerequisiteRepository; + private final PrerequisitesApi prerequisitesApi; private final GradingScaleRepository gradingScaleRepository; @@ -200,7 +200,7 @@ public class CourseService { private final ConversationRepository conversationRepository; - private final LearningPathService learningPathService; + private final LearningPathApi learningPathApi; private final Optional irisSettingsService; @@ -217,17 +217,17 @@ public class CourseService { public CourseService(CourseRepository courseRepository, ExerciseService exerciseService, ExerciseDeletionService exerciseDeletionService, AuthorizationCheckService authCheckService, UserRepository userRepository, LectureService lectureService, GroupNotificationRepository groupNotificationRepository, ExerciseGroupRepository exerciseGroupRepository, AuditEventRepository auditEventRepository, UserService userService, ExamDeletionService examDeletionService, - CompetencyRepository competencyRepository, GroupNotificationService groupNotificationService, ExamRepository examRepository, + CompetencyProgressApi competencyProgressApi, GroupNotificationService groupNotificationService, ExamRepository examRepository, CourseExamExportService courseExamExportService, GradingScaleRepository gradingScaleRepository, StatisticsRepository statisticsRepository, StudentParticipationRepository studentParticipationRepository, TutorLeaderboardService tutorLeaderboardService, RatingRepository ratingRepository, ComplaintService complaintService, ComplaintRepository complaintRepository, ResultRepository resultRepository, ComplaintResponseRepository complaintResponseRepository, SubmissionRepository submissionRepository, ProgrammingExerciseRepository programmingExerciseRepository, ExerciseRepository exerciseRepository, ParticipantScoreRepository participantScoreRepository, PresentationPointsCalculationService presentationPointsCalculationService, TutorialGroupRepository tutorialGroupRepository, PlagiarismCaseRepository plagiarismCaseRepository, ConversationRepository conversationRepository, - LearningPathService learningPathService, Optional irisSettingsService, LectureRepository lectureRepository, + LearningPathApi learningPathApi, Optional irisSettingsService, LectureRepository lectureRepository, TutorialGroupNotificationRepository tutorialGroupNotificationRepository, TutorialGroupChannelManagementService tutorialGroupChannelManagementService, - PrerequisiteRepository prerequisiteRepository, CompetencyRelationRepository competencyRelationRepository, PostRepository postRepository, - AnswerPostRepository answerPostRepository, BuildJobRepository buildJobRepository, FaqRepository faqRepository) { + PrerequisitesApi prerequisitesApi, CompetencyRelationApi competencyRelationApi, PostRepository postRepository, AnswerPostRepository answerPostRepository, + BuildJobRepository buildJobRepository, FaqRepository faqRepository) { this.courseRepository = courseRepository; this.exerciseService = exerciseService; this.exerciseDeletionService = exerciseDeletionService; @@ -239,7 +239,7 @@ public CourseService(CourseRepository courseRepository, ExerciseService exercise this.auditEventRepository = auditEventRepository; this.userService = userService; this.examDeletionService = examDeletionService; - this.competencyRepository = competencyRepository; + this.competencyProgressApi = competencyProgressApi; this.groupNotificationService = groupNotificationService; this.examRepository = examRepository; this.courseExamExportService = courseExamExportService; @@ -260,13 +260,13 @@ public CourseService(CourseRepository courseRepository, ExerciseService exercise this.tutorialGroupRepository = tutorialGroupRepository; this.plagiarismCaseRepository = plagiarismCaseRepository; this.conversationRepository = conversationRepository; - this.learningPathService = learningPathService; + this.learningPathApi = learningPathApi; this.irisSettingsService = irisSettingsService; this.lectureRepository = lectureRepository; this.tutorialGroupNotificationRepository = tutorialGroupNotificationRepository; this.tutorialGroupChannelManagementService = tutorialGroupChannelManagementService; - this.prerequisiteRepository = prerequisiteRepository; - this.competencyRelationRepository = competencyRelationRepository; + this.prerequisitesApi = prerequisitesApi; + this.competencyRelationApi = competencyRelationApi; this.buildJobRepository = buildJobRepository; this.postRepository = postRepository; this.answerPostRepository = answerPostRepository; @@ -354,9 +354,9 @@ public Course findOneWithExercisesAndLecturesAndExamsAndCompetenciesAndTutorialG // TODO: in the future, we only want to know if lectures exist, the actual lectures will be loaded when the user navigates into the lecture course.setLectures(lectureService.filterVisibleLecturesWithActiveAttachments(course, course.getLectures(), user)); // NOTE: in this call we only want to know if competencies exist in the course, we will load them when the user navigates into them - course.setNumberOfCompetencies(competencyRepository.countByCourse(course)); + course.setNumberOfCompetencies(competencyProgressApi.countByCourse(course)); // NOTE: in this call we only want to know if prerequisites exist in the course, we will load them when the user navigates into them - course.setNumberOfPrerequisites(prerequisiteRepository.countByCourse(course)); + course.setNumberOfPrerequisites(prerequisitesApi.countByCourse(course)); // NOTE: in this call we only want to know if tutorial groups exist in the course, we will load them when the user navigates into them course.setNumberOfTutorialGroups(tutorialGroupRepository.countByCourse(course)); if (course.isFaqEnabled()) { @@ -578,9 +578,9 @@ private void deleteExercisesOfCourse(Course course) { } private void deleteCompetenciesOfCourse(Course course) { - competencyRelationRepository.deleteAllByCourseId(course.getId()); - prerequisiteRepository.deleteAll(course.getPrerequisites()); - competencyRepository.deleteAll(course.getCompetencies()); + competencyRelationApi.deleteAllByCourseId(course.getId()); + prerequisitesApi.deleteAll(course.getPrerequisites()); + competencyProgressApi.deleteAll(course.getCompetencies()); } private void deleteFaqsOfCourse(Course course) { @@ -618,7 +618,7 @@ public void enrollUserForCourseOrThrow(User user, Course course) { authCheckService.checkUserAllowedToEnrollInCourseElseThrow(user, course); userService.addUserToGroup(user, course.getStudentGroupName()); if (course.getLearningPathsEnabled()) { - learningPathService.generateLearningPathForUser(course, user); + learningPathApi.generateLearningPathForUser(course, user); } final var auditEvent = new AuditEvent(user.getLogin(), Constants.ENROLL_IN_COURSE, "course=" + course.getTitle()); auditEventRepository.add(auditEvent); @@ -651,7 +651,7 @@ public List registerUsersForCourseGroup(Long courseId, List ldapUserService, GuidedTourSettingsRepository guidedTourSettingsRepository, PasswordService passwordService, Optional optionalVcsUserManagementService, Optional optionalCIUserManagementService, - InstanceMessageSendService instanceMessageSendService, FileService fileService, ScienceEventRepository scienceEventRepository, + InstanceMessageSendService instanceMessageSendService, FileService fileService, ScienceEventApi scienceEventApi, ParticipationVcsAccessTokenService participationVCSAccessTokenService, SavedPostRepository savedPostRepository) { this.userCreationService = userCreationService; this.userRepository = userRepository; @@ -134,7 +134,7 @@ public UserService(UserCreationService userCreationService, UserRepository userR this.optionalCIUserManagementService = optionalCIUserManagementService; this.instanceMessageSendService = instanceMessageSendService; this.fileService = fileService; - this.scienceEventRepository = scienceEventRepository; + this.scienceEventApi = scienceEventApi; this.participationVCSAccessTokenService = participationVCSAccessTokenService; this.savedPostRepository = savedPostRepository; } @@ -508,7 +508,7 @@ protected void anonymizeUser(User user) { clearUserCaches(user); userRepository.flush(); - scienceEventRepository.renameIdentity(originalLogin, anonymizedLogin); + scienceEventApi.renameIdentity(originalLogin, anonymizedLogin); if (userImageString != null) { fileService.schedulePathForDeletion(FilePathService.actualPathForPublicPath(URI.create(userImageString)), 0); diff --git a/src/main/java/de/tum/cit/aet/artemis/core/web/CourseResource.java b/src/main/java/de/tum/cit/aet/artemis/core/web/CourseResource.java index e6af8db0cd8a..2545a6ffb192 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/web/CourseResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/web/CourseResource.java @@ -66,7 +66,7 @@ import de.tum.cit.aet.artemis.assessment.service.CourseScoreCalculationService; import de.tum.cit.aet.artemis.assessment.service.GradingScaleService; import de.tum.cit.aet.artemis.athena.service.AthenaModuleService; -import de.tum.cit.aet.artemis.atlas.service.learningpath.LearningPathService; +import de.tum.cit.aet.artemis.atlas.api.LearningPathApi; import de.tum.cit.aet.artemis.communication.service.ConductAgreementService; import de.tum.cit.aet.artemis.core.config.Constants; import de.tum.cit.aet.artemis.core.domain.Course; @@ -180,7 +180,7 @@ public class CourseResource { @Value("${artemis.course-archives-path}") private String courseArchivesDirPath; - private final LearningPathService learningPathService; + private final LearningPathApi learningPathApi; private final ExamRepository examRepository; @@ -193,7 +193,7 @@ public CourseResource(UserRepository userRepository, CourseService courseService TutorParticipationRepository tutorParticipationRepository, SubmissionService submissionService, Optional optionalVcsUserManagementService, AssessmentDashboardService assessmentDashboardService, ExerciseRepository exerciseRepository, Optional optionalCiUserManagementService, FileService fileService, TutorialGroupsConfigurationService tutorialGroupsConfigurationService, GradingScaleService gradingScaleService, - CourseScoreCalculationService courseScoreCalculationService, GradingScaleRepository gradingScaleRepository, LearningPathService learningPathService, + CourseScoreCalculationService courseScoreCalculationService, GradingScaleRepository gradingScaleRepository, LearningPathApi learningPathApi, ConductAgreementService conductAgreementService, Optional athenaModuleService, ExamRepository examRepository, ComplaintService complaintService, TeamRepository teamRepository) { this.courseService = courseService; @@ -213,7 +213,7 @@ public CourseResource(UserRepository userRepository, CourseService courseService this.gradingScaleService = gradingScaleService; this.courseScoreCalculationService = courseScoreCalculationService; this.gradingScaleRepository = gradingScaleRepository; - this.learningPathService = learningPathService; + this.learningPathApi = learningPathApi; this.conductAgreementService = conductAgreementService; this.athenaModuleService = athenaModuleService; this.examRepository = examRepository; @@ -335,7 +335,7 @@ else if (courseUpdate.getCourseIcon() == null && existingCourse.getCourseIcon() // if learning paths got enabled, generate learning paths for students if (existingCourse.getLearningPathsEnabled() != courseUpdate.getLearningPathsEnabled() && courseUpdate.getLearningPathsEnabled()) { Course courseWithCompetencies = courseRepository.findWithEagerCompetenciesAndPrerequisitesByIdElseThrow(result.getId()); - learningPathService.generateLearningPaths(courseWithCompetencies); + learningPathApi.generateLearningPaths(courseWithCompetencies); } // if access to restricted athena modules got disabled for the course, we need to set all exercises that use restricted modules to null @@ -1244,7 +1244,7 @@ public ResponseEntity addUserToCourseGroup(String userLogin, User instruct courseService.addUserToGroup(userToAddToGroup.get(), group); if (role == Role.STUDENT && course.getLearningPathsEnabled()) { Course courseWithCompetencies = courseRepository.findWithEagerCompetenciesAndPrerequisitesByIdElseThrow(course.getId()); - learningPathService.generateLearningPathForUser(courseWithCompetencies, userToAddToGroup.get()); + learningPathApi.generateLearningPathForUser(courseWithCompetencies, userToAddToGroup.get()); } return ResponseEntity.ok().body(null); } diff --git a/src/main/java/de/tum/cit/aet/artemis/exercise/service/ExerciseDeletionService.java b/src/main/java/de/tum/cit/aet/artemis/exercise/service/ExerciseDeletionService.java index 36b7e99fedd0..fd80e0a00fb5 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exercise/service/ExerciseDeletionService.java +++ b/src/main/java/de/tum/cit/aet/artemis/exercise/service/ExerciseDeletionService.java @@ -16,8 +16,8 @@ import de.tum.cit.aet.artemis.assessment.repository.TutorParticipationRepository; import de.tum.cit.aet.artemis.assessment.service.ExampleSubmissionService; +import de.tum.cit.aet.artemis.atlas.api.CompetencyProgressApi; import de.tum.cit.aet.artemis.atlas.domain.competency.CompetencyExerciseLink; -import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyProgressService; import de.tum.cit.aet.artemis.communication.domain.conversation.Channel; import de.tum.cit.aet.artemis.communication.repository.conversation.ChannelRepository; import de.tum.cit.aet.artemis.communication.service.conversation.ChannelService; @@ -78,7 +78,7 @@ public class ExerciseDeletionService { private final ChannelService channelService; - private final CompetencyProgressService competencyProgressService; + private final CompetencyProgressApi competencyProgressApi; private final Optional irisSettingsService; @@ -86,8 +86,7 @@ public ExerciseDeletionService(ExerciseRepository exerciseRepository, ExerciseUn ProgrammingExerciseService programmingExerciseService, ModelingExerciseService modelingExerciseService, QuizExerciseService quizExerciseService, TutorParticipationRepository tutorParticipationRepository, ExampleSubmissionService exampleSubmissionService, StudentExamRepository studentExamRepository, LectureUnitService lectureUnitService, PlagiarismResultRepository plagiarismResultRepository, TextExerciseService textExerciseService, - ChannelRepository channelRepository, ChannelService channelService, CompetencyProgressService competencyProgressService, - Optional irisSettingsService) { + ChannelRepository channelRepository, ChannelService channelService, CompetencyProgressApi competencyProgressApi, Optional irisSettingsService) { this.exerciseRepository = exerciseRepository; this.participationService = participationService; this.programmingExerciseService = programmingExerciseService; @@ -102,7 +101,7 @@ public ExerciseDeletionService(ExerciseRepository exerciseRepository, ExerciseUn this.textExerciseService = textExerciseService; this.channelRepository = channelRepository; this.channelService = channelService; - this.competencyProgressService = competencyProgressService; + this.competencyProgressApi = competencyProgressApi; this.irisSettingsService = irisSettingsService; } @@ -215,7 +214,7 @@ public void delete(long exerciseId, boolean deleteStudentReposBuildPlans, boolea exerciseRepository.delete(exercise); } - competencyLinks.stream().map(CompetencyExerciseLink::getCompetency).forEach(competencyProgressService::updateProgressByCompetencyAsync); + competencyLinks.stream().map(CompetencyExerciseLink::getCompetency).forEach(competencyProgressApi::updateProgressByCompetencyAsync); } /** diff --git a/src/main/java/de/tum/cit/aet/artemis/exercise/service/ExerciseService.java b/src/main/java/de/tum/cit/aet/artemis/exercise/service/ExerciseService.java index 0a355a870620..73fce3b0f9f1 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exercise/service/ExerciseService.java +++ b/src/main/java/de/tum/cit/aet/artemis/exercise/service/ExerciseService.java @@ -45,9 +45,9 @@ import de.tum.cit.aet.artemis.assessment.repository.ResultRepository; import de.tum.cit.aet.artemis.assessment.service.RatingService; import de.tum.cit.aet.artemis.assessment.service.TutorLeaderboardService; +import de.tum.cit.aet.artemis.atlas.api.CompetencyRelationApi; import de.tum.cit.aet.artemis.atlas.domain.competency.CompetencyExerciseLink; import de.tum.cit.aet.artemis.atlas.domain.competency.CourseCompetency; -import de.tum.cit.aet.artemis.atlas.repository.CompetencyExerciseLinkRepository; import de.tum.cit.aet.artemis.communication.service.notifications.GroupNotificationScheduleService; import de.tum.cit.aet.artemis.core.config.Constants; import de.tum.cit.aet.artemis.core.domain.Course; @@ -130,7 +130,7 @@ public class ExerciseService { private final GroupNotificationScheduleService groupNotificationScheduleService; - private final CompetencyExerciseLinkRepository competencyExerciseLinkRepository; + private final CompetencyRelationApi competencyRelationApi; public ExerciseService(ExerciseRepository exerciseRepository, AuthorizationCheckService authCheckService, AuditEventRepository auditEventRepository, TeamRepository teamRepository, ProgrammingExerciseRepository programmingExerciseRepository, Optional lti13ResourceLaunchRepository, @@ -139,7 +139,7 @@ public ExerciseService(ExerciseRepository exerciseRepository, AuthorizationCheck TutorLeaderboardService tutorLeaderboardService, ComplaintResponseRepository complaintResponseRepository, GradingCriterionRepository gradingCriterionRepository, FeedbackRepository feedbackRepository, RatingService ratingService, ExerciseDateService exerciseDateService, ExampleSubmissionRepository exampleSubmissionRepository, QuizBatchService quizBatchService, ExamLiveEventsService examLiveEventsService, GroupNotificationScheduleService groupNotificationScheduleService, - CompetencyExerciseLinkRepository competencyExerciseLinkRepository) { + CompetencyRelationApi competencyRelationApi) { this.exerciseRepository = exerciseRepository; this.resultRepository = resultRepository; this.authCheckService = authCheckService; @@ -162,7 +162,7 @@ public ExerciseService(ExerciseRepository exerciseRepository, AuthorizationCheck this.quizBatchService = quizBatchService; this.examLiveEventsService = examLiveEventsService; this.groupNotificationScheduleService = groupNotificationScheduleService; - this.competencyExerciseLinkRepository = competencyExerciseLinkRepository; + this.competencyRelationApi = competencyRelationApi; } /** @@ -773,7 +773,7 @@ public List getFeedbackToBeDeletedAfterGradingInstructionUpdate(boolea * @param competency competency to remove */ public void removeCompetency(@NotNull Set exerciseLinks, @NotNull CourseCompetency competency) { - competencyExerciseLinkRepository.deleteAll(exerciseLinks); + competencyRelationApi.deleteAllExerciseLinks(exerciseLinks); competency.getExerciseLinks().removeAll(exerciseLinks); } @@ -815,7 +815,7 @@ public T saveWithCompetencyLinks(T exercise, Function if (Hibernate.isInitialized(links) && !links.isEmpty()) { savedExercise.setCompetencyLinks(links); reconnectCompetencyExerciseLinks(savedExercise); - savedExercise.setCompetencyLinks(new HashSet<>(competencyExerciseLinkRepository.saveAll(links))); + savedExercise.setCompetencyLinks(new HashSet<>(competencyRelationApi.saveAllExerciseLinks(links))); } return savedExercise; diff --git a/src/main/java/de/tum/cit/aet/artemis/exercise/service/ParticipationService.java b/src/main/java/de/tum/cit/aet/artemis/exercise/service/ParticipationService.java index 9074ad8ec1f8..a8c4d3507813 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exercise/service/ParticipationService.java +++ b/src/main/java/de/tum/cit/aet/artemis/exercise/service/ParticipationService.java @@ -24,7 +24,7 @@ import de.tum.cit.aet.artemis.assessment.repository.StudentScoreRepository; import de.tum.cit.aet.artemis.assessment.repository.TeamScoreRepository; import de.tum.cit.aet.artemis.assessment.service.ResultService; -import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyProgressService; +import de.tum.cit.aet.artemis.atlas.api.CompetencyProgressApi; import de.tum.cit.aet.artemis.core.domain.User; import de.tum.cit.aet.artemis.core.exception.ContinuousIntegrationException; import de.tum.cit.aet.artemis.core.exception.EntityNotFoundException; @@ -109,7 +109,7 @@ public class ParticipationService { private final ParticipationVcsAccessTokenService participationVCSAccessTokenService; - private final CompetencyProgressService competencyProgressService; + private final CompetencyProgressApi competencyProgressApi; public ParticipationService(GitService gitService, Optional continuousIntegrationService, Optional versionControlService, BuildLogEntryService buildLogEntryService, ParticipationRepository participationRepository, StudentParticipationRepository studentParticipationRepository, @@ -118,7 +118,7 @@ public ParticipationService(GitService gitService, Optional localCISharedBuildJobQueueService, ProfileService profileService, - ParticipationVcsAccessTokenService participationVCSAccessTokenService, CompetencyProgressService competencyProgressService) { + ParticipationVcsAccessTokenService participationVCSAccessTokenService, CompetencyProgressApi competencyProgressApi) { this.gitService = gitService; this.continuousIntegrationService = continuousIntegrationService; this.versionControlService = versionControlService; @@ -139,7 +139,7 @@ public ParticipationService(GitService gitService, Optional createFileUploadExercise(@RequestBody channelService.createExerciseChannel(result, Optional.ofNullable(fileUploadExercise.getChannelName())); groupNotificationScheduleService.checkNotificationsForNewExerciseAsync(fileUploadExercise); - competencyProgressService.updateProgressByLearningObjectAsync(result); + competencyProgressApi.updateProgressByLearningObjectAsync(result); return ResponseEntity.created(new URI("/api/file-upload-exercises/" + result.getId())).body(result); } @@ -287,7 +287,7 @@ public ResponseEntity updateFileUploadExercise(@RequestBody participationRepository.removeIndividualDueDatesIfBeforeDueDate(updatedExercise, fileUploadExerciseBeforeUpdate.getDueDate()); exerciseService.notifyAboutExerciseChanges(fileUploadExerciseBeforeUpdate, updatedExercise, notificationText); - competencyProgressService.updateProgressForUpdatedLearningObjectAsync(fileUploadExerciseBeforeUpdate, Optional.of(fileUploadExercise)); + competencyProgressApi.updateProgressForUpdatedLearningObjectAsync(fileUploadExerciseBeforeUpdate, Optional.of(fileUploadExercise)); return ResponseEntity.ok(updatedExercise); } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisPipelineService.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisPipelineService.java index 08861c06a773..c8851341e29e 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisPipelineService.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisPipelineService.java @@ -17,13 +17,13 @@ import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; +import de.tum.cit.aet.artemis.atlas.api.LearningMetricsApi; import de.tum.cit.aet.artemis.atlas.domain.competency.CompetencyJol; import de.tum.cit.aet.artemis.atlas.dto.CompetencyJolDTO; import de.tum.cit.aet.artemis.core.domain.Course; import de.tum.cit.aet.artemis.core.repository.CourseRepository; import de.tum.cit.aet.artemis.exercise.domain.participation.StudentParticipation; import de.tum.cit.aet.artemis.exercise.repository.StudentParticipationRepository; -import de.tum.cit.aet.artemis.exercise.service.LearningMetricsService; import de.tum.cit.aet.artemis.iris.domain.session.IrisCourseChatSession; import de.tum.cit.aet.artemis.iris.domain.session.IrisExerciseChatSession; import de.tum.cit.aet.artemis.iris.exception.IrisException; @@ -61,20 +61,20 @@ public class PyrisPipelineService { private final StudentParticipationRepository studentParticipationRepository; - private final LearningMetricsService learningMetricsService; + private final LearningMetricsApi learningMetricsApi; @Value("${server.url}") private String artemisBaseUrl; public PyrisPipelineService(PyrisConnectorService pyrisConnectorService, PyrisJobService pyrisJobService, PyrisDTOService pyrisDTOService, - IrisChatWebsocketService irisChatWebsocketService, CourseRepository courseRepository, LearningMetricsService learningMetricsService, + IrisChatWebsocketService irisChatWebsocketService, CourseRepository courseRepository, LearningMetricsApi learningMetricsApi, StudentParticipationRepository studentParticipationRepository) { this.pyrisConnectorService = pyrisConnectorService; this.pyrisJobService = pyrisJobService; this.pyrisDTOService = pyrisDTOService; this.irisChatWebsocketService = irisChatWebsocketService; this.courseRepository = courseRepository; - this.learningMetricsService = learningMetricsService; + this.learningMetricsApi = learningMetricsApi; this.studentParticipationRepository = studentParticipationRepository; } @@ -186,7 +186,7 @@ public void executeCourseChatPipeline(String variant, IrisCourseChatSession sess var fullCourse = loadCourseWithParticipationOfStudent(courseId, studentId); return new PyrisCourseChatPipelineExecutionDTO( PyrisExtendedCourseDTO.of(fullCourse), - learningMetricsService.getStudentCourseMetrics(session.getUser().getId(), courseId), + learningMetricsApi.getStudentCourseMetrics(session.getUser().getId(), courseId), competencyJol == null ? null : CompetencyJolDTO.of(competencyJol), pyrisDTOService.toPyrisMessageDTOList(session.getMessages()), new PyrisUserDTO(session.getUser()), diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/service/AttachmentUnitService.java b/src/main/java/de/tum/cit/aet/artemis/lecture/service/AttachmentUnitService.java index 83d3ffdf6751..342e463b5650 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/service/AttachmentUnitService.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/service/AttachmentUnitService.java @@ -16,8 +16,8 @@ import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; +import de.tum.cit.aet.artemis.atlas.api.CompetencyProgressApi; import de.tum.cit.aet.artemis.atlas.domain.competency.CompetencyLectureUnitLink; -import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyProgressService; import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException; import de.tum.cit.aet.artemis.core.service.FilePathService; import de.tum.cit.aet.artemis.core.service.FileService; @@ -49,13 +49,13 @@ public class AttachmentUnitService { private final Optional irisSettingsRepository; - private final CompetencyProgressService competencyProgressService; + private final CompetencyProgressApi competencyProgressService; private final LectureUnitService lectureUnitService; public AttachmentUnitService(SlideRepository slideRepository, SlideSplitterService slideSplitterService, AttachmentUnitRepository attachmentUnitRepository, AttachmentRepository attachmentRepository, FileService fileService, Optional pyrisWebhookService, - Optional irisSettingsRepository, CompetencyProgressService competencyProgressService, LectureUnitService lectureUnitService) { + Optional irisSettingsRepository, CompetencyProgressApi competencyProgressService, LectureUnitService lectureUnitService) { this.attachmentUnitRepository = attachmentUnitRepository; this.attachmentRepository = attachmentRepository; this.fileService = fileService; diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/service/LectureService.java b/src/main/java/de/tum/cit/aet/artemis/lecture/service/LectureService.java index 7654884f753d..9bf8edba5bea 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/service/LectureService.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/service/LectureService.java @@ -13,8 +13,8 @@ import org.springframework.data.domain.Page; import org.springframework.stereotype.Service; -import de.tum.cit.aet.artemis.atlas.repository.CompetencyLectureUnitLinkRepository; -import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyProgressService; +import de.tum.cit.aet.artemis.atlas.api.CompetencyProgressApi; +import de.tum.cit.aet.artemis.atlas.api.CompetencyRelationApi; import de.tum.cit.aet.artemis.communication.domain.conversation.Channel; import de.tum.cit.aet.artemis.communication.repository.conversation.ChannelRepository; import de.tum.cit.aet.artemis.communication.service.conversation.ChannelService; @@ -46,20 +46,19 @@ public class LectureService { private final Optional pyrisWebhookService; - private final CompetencyProgressService competencyProgressService; + private final CompetencyProgressApi competencyProgressApi; - private final CompetencyLectureUnitLinkRepository competencyLectureUnitLinkRepository; + private final CompetencyRelationApi competencyRelationApi; public LectureService(LectureRepository lectureRepository, AuthorizationCheckService authCheckService, ChannelRepository channelRepository, ChannelService channelService, - Optional pyrisWebhookService, CompetencyProgressService competencyProgressService, - CompetencyLectureUnitLinkRepository competencyLectureUnitLinkRepository) { + Optional pyrisWebhookService, CompetencyProgressApi competencyProgressApi, CompetencyRelationApi competencyRelationApi) { this.lectureRepository = lectureRepository; this.authCheckService = authCheckService; this.channelRepository = channelRepository; this.channelService = channelService; this.pyrisWebhookService = pyrisWebhookService; - this.competencyProgressService = competencyProgressService; - this.competencyLectureUnitLinkRepository = competencyLectureUnitLinkRepository; + this.competencyProgressApi = competencyProgressApi; + this.competencyRelationApi = competencyRelationApi; } /** @@ -162,13 +161,13 @@ public void delete(Lecture lecture, boolean updateCompetencyProgress) { if (updateCompetencyProgress) { lecture.getLectureUnits().stream().filter(lectureUnit -> !(lectureUnit instanceof ExerciseUnit)) - .forEach(lectureUnit -> competencyProgressService.updateProgressForUpdatedLearningObjectAsync(lectureUnit, Optional.empty())); + .forEach(lectureUnit -> competencyProgressApi.updateProgressForUpdatedLearningObjectAsync(lectureUnit, Optional.empty())); } Channel lectureChannel = channelRepository.findChannelByLectureId(lecture.getId()); channelService.deleteChannel(lectureChannel); - competencyLectureUnitLinkRepository.deleteAllByLectureId(lecture.getId()); + competencyRelationApi.deleteAllLectureUnitLinksByLectureId(lecture.getId()); lectureRepository.deleteById(lecture.getId()); } diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/service/LectureUnitService.java b/src/main/java/de/tum/cit/aet/artemis/lecture/service/LectureUnitService.java index 74ec8a5fa90c..b3fba2a6c829 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/service/LectureUnitService.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/service/LectureUnitService.java @@ -27,11 +27,11 @@ import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; +import de.tum.cit.aet.artemis.atlas.api.CompetencyProgressApi; +import de.tum.cit.aet.artemis.atlas.api.CompetencyRelationApi; +import de.tum.cit.aet.artemis.atlas.api.CourseCompetencyApi; import de.tum.cit.aet.artemis.atlas.domain.competency.CompetencyLectureUnitLink; import de.tum.cit.aet.artemis.atlas.domain.competency.CourseCompetency; -import de.tum.cit.aet.artemis.atlas.repository.CompetencyLectureUnitLinkRepository; -import de.tum.cit.aet.artemis.atlas.repository.CourseCompetencyRepository; -import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyProgressService; import de.tum.cit.aet.artemis.core.domain.User; import de.tum.cit.aet.artemis.core.service.FilePathService; import de.tum.cit.aet.artemis.core.service.FileService; @@ -65,24 +65,24 @@ public class LectureUnitService { private final Optional pyrisWebhookService; - private final CompetencyProgressService competencyProgressService; + private final CompetencyProgressApi competencyProgressApi; - private final CourseCompetencyRepository courseCompetencyRepository; + private final CourseCompetencyApi courseCompetencyApi; - private final CompetencyLectureUnitLinkRepository competencyLectureUnitLinkRepository; + private final CompetencyRelationApi competencyRelationApi; public LectureUnitService(LectureUnitRepository lectureUnitRepository, LectureRepository lectureRepository, LectureUnitCompletionRepository lectureUnitCompletionRepository, - FileService fileService, SlideRepository slideRepository, Optional pyrisWebhookService, CompetencyProgressService competencyProgressService, - CourseCompetencyRepository courseCompetencyRepository, CompetencyLectureUnitLinkRepository competencyLectureUnitLinkRepository) { + FileService fileService, SlideRepository slideRepository, Optional pyrisWebhookService, CompetencyProgressApi competencyProgressApi, + CourseCompetencyApi courseCompetencyApi, CompetencyRelationApi competencyRelationApi) { this.lectureUnitRepository = lectureUnitRepository; this.lectureRepository = lectureRepository; this.lectureUnitCompletionRepository = lectureUnitCompletionRepository; this.fileService = fileService; this.slideRepository = slideRepository; this.pyrisWebhookService = pyrisWebhookService; - this.courseCompetencyRepository = courseCompetencyRepository; - this.competencyProgressService = competencyProgressService; - this.competencyLectureUnitLinkRepository = competencyLectureUnitLinkRepository; + this.courseCompetencyApi = courseCompetencyApi; + this.competencyProgressApi = competencyProgressApi; + this.competencyRelationApi = competencyRelationApi; } /** @@ -183,7 +183,7 @@ public void removeLectureUnit(@NotNull LectureUnit lectureUnit) { if (!(lectureUnitToDelete instanceof ExerciseUnit)) { // update associated competency progress objects - competencyProgressService.updateProgressForUpdatedLearningObjectAsync(lectureUnitToDelete, Optional.empty()); + competencyProgressApi.updateProgressForUpdatedLearningObjectAsync(lectureUnitToDelete, Optional.empty()); } } @@ -196,7 +196,7 @@ public void removeLectureUnit(@NotNull LectureUnit lectureUnit) { public void linkLectureUnitsToCompetency(CourseCompetency competency, Set lectureUnitLinks) { lectureUnitLinks.forEach(link -> link.setCompetency(competency)); competency.setLectureUnitLinks(lectureUnitLinks); - courseCompetencyRepository.save(competency); + courseCompetencyApi.save(competency); } /** @@ -206,7 +206,7 @@ public void linkLectureUnitsToCompetency(CourseCompetency competency, Set lectureUnitLinks, CourseCompetency competency) { - competencyLectureUnitLinkRepository.deleteAll(lectureUnitLinks); + competencyRelationApi.deleteAllLectureUnitLinks(lectureUnitLinks); competency.getLectureUnitLinks().removeAll(lectureUnitLinks); } @@ -286,7 +286,7 @@ public T saveWithCompetencyLinks(T lectureUnit, Function if (Hibernate.isInitialized(links) && links != null && !links.isEmpty()) { savedLectureUnit.setCompetencyLinks(links); reconnectCompetencyLectureUnitLinks(savedLectureUnit); - savedLectureUnit.setCompetencyLinks(new HashSet<>(competencyLectureUnitLinkRepository.saveAll(links))); + savedLectureUnit.setCompetencyLinks(new HashSet<>(competencyRelationApi.saveAllLectureUnitLinks(links))); } return savedLectureUnit; diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/web/AttachmentUnitResource.java b/src/main/java/de/tum/cit/aet/artemis/lecture/web/AttachmentUnitResource.java index 83aa9f8ebefb..ec1514ca668b 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/web/AttachmentUnitResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/web/AttachmentUnitResource.java @@ -29,7 +29,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; -import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyProgressService; +import de.tum.cit.aet.artemis.atlas.api.CompetencyProgressApi; import de.tum.cit.aet.artemis.communication.service.notifications.GroupNotificationService; import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException; import de.tum.cit.aet.artemis.core.exception.EntityNotFoundException; @@ -69,7 +69,7 @@ public class AttachmentUnitResource { private final LectureUnitProcessingService lectureUnitProcessingService; - private final CompetencyProgressService competencyProgressService; + private final CompetencyProgressApi competencyProgressApi; private final SlideSplitterService slideSplitterService; @@ -77,14 +77,14 @@ public class AttachmentUnitResource { public AttachmentUnitResource(AttachmentUnitRepository attachmentUnitRepository, LectureRepository lectureRepository, LectureUnitProcessingService lectureUnitProcessingService, AuthorizationCheckService authorizationCheckService, GroupNotificationService groupNotificationService, AttachmentUnitService attachmentUnitService, - CompetencyProgressService competencyProgressService, SlideSplitterService slideSplitterService, FileService fileService) { + CompetencyProgressApi competencyProgressApi, SlideSplitterService slideSplitterService, FileService fileService) { this.attachmentUnitRepository = attachmentUnitRepository; this.lectureUnitProcessingService = lectureUnitProcessingService; this.lectureRepository = lectureRepository; this.authorizationCheckService = authorizationCheckService; this.groupNotificationService = groupNotificationService; this.attachmentUnitService = attachmentUnitService; - this.competencyProgressService = competencyProgressService; + this.competencyProgressApi = competencyProgressApi; this.slideSplitterService = slideSplitterService; this.fileService = fileService; } @@ -173,7 +173,7 @@ public ResponseEntity createAttachmentUnit(@PathVariable Long le slideSplitterService.splitAttachmentUnitIntoSingleSlides(savedAttachmentUnit); } attachmentUnitService.prepareAttachmentUnitForClient(savedAttachmentUnit); - competencyProgressService.updateProgressByLearningObjectAsync(savedAttachmentUnit); + competencyProgressApi.updateProgressByLearningObjectAsync(savedAttachmentUnit); return ResponseEntity.created(new URI("/api/attachment-units/" + savedAttachmentUnit.getId())).body(savedAttachmentUnit); } @@ -228,7 +228,7 @@ public ResponseEntity> createAttachmentUnits(@PathVariable List savedAttachmentUnits = lectureUnitProcessingService.splitAndSaveUnits(lectureUnitSplitInformationDTO, fileBytes, lectureRepository.findByIdWithLectureUnitsAndAttachmentsElseThrow(lectureId)); savedAttachmentUnits.forEach(attachmentUnitService::prepareAttachmentUnitForClient); - savedAttachmentUnits.forEach(competencyProgressService::updateProgressByLearningObjectAsync); + savedAttachmentUnits.forEach(competencyProgressApi::updateProgressByLearningObjectAsync); return ResponseEntity.ok().body(savedAttachmentUnits); } catch (IOException e) { diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/web/LectureResource.java b/src/main/java/de/tum/cit/aet/artemis/lecture/web/LectureResource.java index 6d0a6f0f33e9..deeb3ec3861c 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/web/LectureResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/web/LectureResource.java @@ -27,7 +27,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyService; +import de.tum.cit.aet.artemis.atlas.api.CompetencyApi; import de.tum.cit.aet.artemis.communication.domain.conversation.Channel; import de.tum.cit.aet.artemis.communication.repository.conversation.ChannelRepository; import de.tum.cit.aet.artemis.communication.service.conversation.ChannelService; @@ -67,7 +67,7 @@ public class LectureResource { private static final String ENTITY_NAME = "lecture"; - private final CompetencyService competencyService; + private final CompetencyApi competencyApi; @Value("${jhipster.clientApp.name}") private String applicationName; @@ -92,7 +92,7 @@ public class LectureResource { public LectureResource(LectureRepository lectureRepository, LectureService lectureService, LectureImportService lectureImportService, CourseRepository courseRepository, UserRepository userRepository, AuthorizationCheckService authCheckService, ExerciseService exerciseService, ChannelService channelService, - ChannelRepository channelRepository, CompetencyService competencyService) { + ChannelRepository channelRepository, CompetencyApi competencyApi) { this.lectureRepository = lectureRepository; this.lectureService = lectureService; this.lectureImportService = lectureImportService; @@ -102,7 +102,7 @@ public LectureResource(LectureRepository lectureRepository, LectureService lectu this.exerciseService = exerciseService; this.channelService = channelService; this.channelRepository = channelRepository; - this.competencyService = competencyService; + this.competencyApi = competencyApi; } /** @@ -304,7 +304,7 @@ public ResponseEntity ingestLectures(@PathVariable Long courseId, @Request public ResponseEntity getLectureWithDetails(@PathVariable Long lectureId) { log.debug("REST request to get lecture {} with details", lectureId); Lecture lecture = lectureRepository.findByIdWithAttachmentsAndPostsAndLectureUnitsAndCompetenciesAndCompletionsElseThrow(lectureId); - competencyService.addCompetencyLinksToExerciseUnits(lecture); + competencyApi.addCompetencyLinksToExerciseUnits(lecture); Course course = lecture.getCourse(); if (course == null) { return ResponseEntity.badRequest().build(); @@ -334,7 +334,7 @@ public ResponseEntity getLectureWithDetailsAndSlides(@PathVariable long User user = userRepository.getUserWithGroupsAndAuthorities(); authCheckService.checkIsAllowedToSeeLectureElseThrow(lecture, user); - competencyService.addCompetencyLinksToExerciseUnits(lecture); + competencyApi.addCompetencyLinksToExerciseUnits(lecture); lectureService.filterActiveAttachmentUnits(lecture); lectureService.filterActiveAttachments(lecture, user); return ResponseEntity.ok(lecture); diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/web/LectureUnitResource.java b/src/main/java/de/tum/cit/aet/artemis/lecture/web/LectureUnitResource.java index 78bb7d708f86..2faca61e3854 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/web/LectureUnitResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/web/LectureUnitResource.java @@ -24,7 +24,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyProgressService; +import de.tum.cit.aet.artemis.atlas.api.CompetencyProgressApi; import de.tum.cit.aet.artemis.core.domain.User; import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException; import de.tum.cit.aet.artemis.core.exception.EntityNotFoundException; @@ -65,16 +65,16 @@ public class LectureUnitResource { private final LectureUnitService lectureUnitService; - private final CompetencyProgressService competencyProgressService; + private final CompetencyProgressApi competencyProgressApi; public LectureUnitResource(AuthorizationCheckService authorizationCheckService, UserRepository userRepository, LectureRepository lectureRepository, - LectureUnitRepository lectureUnitRepository, LectureUnitService lectureUnitService, CompetencyProgressService competencyProgressService, UserService userService) { + LectureUnitRepository lectureUnitRepository, LectureUnitService lectureUnitService, CompetencyProgressApi competencyProgressApi, UserService userService) { this.authorizationCheckService = authorizationCheckService; this.userRepository = userRepository; this.lectureUnitRepository = lectureUnitRepository; this.lectureRepository = lectureRepository; this.lectureUnitService = lectureUnitService; - this.competencyProgressService = competencyProgressService; + this.competencyProgressApi = competencyProgressApi; } /** @@ -144,7 +144,7 @@ public ResponseEntity completeLectureUnit(@PathVariable Long lectureUnitId authorizationCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.STUDENT, lectureUnit.getLecture().getCourse(), user); lectureUnitService.setLectureUnitCompletion(lectureUnit, user, completed); - competencyProgressService.updateProgressByLearningObjectForParticipantAsync(lectureUnit, user); + competencyProgressApi.updateProgressByLearningObjectForParticipantAsync(lectureUnit, user); return ResponseEntity.ok().build(); } diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/web/OnlineUnitResource.java b/src/main/java/de/tum/cit/aet/artemis/lecture/web/OnlineUnitResource.java index c5e8bfa600d1..891701b00afc 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/web/OnlineUnitResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/web/OnlineUnitResource.java @@ -28,7 +28,7 @@ import com.google.common.net.InternetDomainName; -import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyProgressService; +import de.tum.cit.aet.artemis.atlas.api.CompetencyProgressApi; import de.tum.cit.aet.artemis.core.dto.OnlineResourceDTO; import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException; import de.tum.cit.aet.artemis.core.exception.InternalServerErrorException; @@ -56,16 +56,16 @@ public class OnlineUnitResource { private final AuthorizationCheckService authorizationCheckService; - private final CompetencyProgressService competencyProgressService; + private final CompetencyProgressApi competencyProgressApi; private final LectureUnitService lectureUnitService; public OnlineUnitResource(LectureRepository lectureRepository, AuthorizationCheckService authorizationCheckService, OnlineUnitRepository onlineUnitRepository, - CompetencyProgressService competencyProgressService, LectureUnitService lectureUnitService) { + CompetencyProgressApi competencyProgressApi, LectureUnitService lectureUnitService) { this.lectureRepository = lectureRepository; this.authorizationCheckService = authorizationCheckService; this.onlineUnitRepository = onlineUnitRepository; - this.competencyProgressService = competencyProgressService; + this.competencyProgressApi = competencyProgressApi; this.lectureUnitService = lectureUnitService; } @@ -110,7 +110,7 @@ public ResponseEntity updateOnlineUnit(@PathVariable Long lectureId, OnlineUnit result = lectureUnitService.saveWithCompetencyLinks(onlineUnit, onlineUnitRepository::save); - competencyProgressService.updateProgressForUpdatedLearningObjectAsync(existingOnlineUnit, Optional.of(onlineUnit)); + competencyProgressApi.updateProgressForUpdatedLearningObjectAsync(existingOnlineUnit, Optional.of(onlineUnit)); return ResponseEntity.ok(result); } @@ -148,7 +148,7 @@ public ResponseEntity createOnlineUnit(@PathVariable Long lectureId, lecture.addLectureUnit(persistedOnlineUnit); lectureRepository.save(lecture); - competencyProgressService.updateProgressByLearningObjectAsync(persistedOnlineUnit); + competencyProgressApi.updateProgressByLearningObjectAsync(persistedOnlineUnit); return ResponseEntity.created(new URI("/api/online-units/" + persistedOnlineUnit.getId())).body(persistedOnlineUnit); } diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/web/TextUnitResource.java b/src/main/java/de/tum/cit/aet/artemis/lecture/web/TextUnitResource.java index b48d11ca87d4..e7c15d7e786f 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/web/TextUnitResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/web/TextUnitResource.java @@ -18,7 +18,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyProgressService; +import de.tum.cit.aet.artemis.atlas.api.CompetencyProgressApi; import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException; import de.tum.cit.aet.artemis.core.exception.EntityNotFoundException; import de.tum.cit.aet.artemis.core.security.Role; @@ -45,16 +45,16 @@ public class TextUnitResource { private final AuthorizationCheckService authorizationCheckService; - private final CompetencyProgressService competencyProgressService; + private final CompetencyProgressApi competencyProgressApi; private final LectureUnitService lectureUnitService; public TextUnitResource(LectureRepository lectureRepository, TextUnitRepository textUnitRepository, AuthorizationCheckService authorizationCheckService, - CompetencyProgressService competencyProgressService, LectureUnitService lectureUnitService) { + CompetencyProgressApi competencyProgressApi, LectureUnitService lectureUnitService) { this.lectureRepository = lectureRepository; this.textUnitRepository = textUnitRepository; this.authorizationCheckService = authorizationCheckService; - this.competencyProgressService = competencyProgressService; + this.competencyProgressApi = competencyProgressApi; this.lectureUnitService = lectureUnitService; } @@ -108,7 +108,7 @@ public ResponseEntity updateTextUnit(@PathVariable Long lectureId, @Re TextUnit result = lectureUnitService.saveWithCompetencyLinks(textUnitForm, textUnitRepository::save); - competencyProgressService.updateProgressForUpdatedLearningObjectAsync(existingTextUnit, Optional.of(textUnitForm)); + competencyProgressApi.updateProgressForUpdatedLearningObjectAsync(existingTextUnit, Optional.of(textUnitForm)); return ResponseEntity.ok(result); } @@ -146,7 +146,7 @@ public ResponseEntity createTextUnit(@PathVariable Long lectureId, @Re Lecture updatedLecture = lectureRepository.save(lecture); TextUnit persistedTextUnit = (TextUnit) updatedLecture.getLectureUnits().getLast(); - competencyProgressService.updateProgressByLearningObjectAsync(persistedTextUnit); + competencyProgressApi.updateProgressByLearningObjectAsync(persistedTextUnit); lectureUnitService.disconnectCompetencyLectureUnitLinks(persistedTextUnit); return ResponseEntity.created(new URI("/api/text-units/" + persistedTextUnit.getId())).body(persistedTextUnit); diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/web/VideoUnitResource.java b/src/main/java/de/tum/cit/aet/artemis/lecture/web/VideoUnitResource.java index e38d2580f83e..ad4e2d75b163 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/web/VideoUnitResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/web/VideoUnitResource.java @@ -20,7 +20,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyProgressService; +import de.tum.cit.aet.artemis.atlas.api.CompetencyProgressApi; import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException; import de.tum.cit.aet.artemis.core.security.Role; import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastEditor; @@ -46,16 +46,16 @@ public class VideoUnitResource { private final AuthorizationCheckService authorizationCheckService; - private final CompetencyProgressService competencyProgressService; + private final CompetencyProgressApi competencyProgressApi; private final LectureUnitService lectureUnitService; public VideoUnitResource(LectureRepository lectureRepository, AuthorizationCheckService authorizationCheckService, VideoUnitRepository videoUnitRepository, - CompetencyProgressService competencyProgressService, LectureUnitService lectureUnitService) { + CompetencyProgressApi competencyProgressApi, LectureUnitService lectureUnitService) { this.lectureRepository = lectureRepository; this.authorizationCheckService = authorizationCheckService; this.videoUnitRepository = videoUnitRepository; - this.competencyProgressService = competencyProgressService; + this.competencyProgressApi = competencyProgressApi; this.lectureUnitService = lectureUnitService; } @@ -99,7 +99,7 @@ public ResponseEntity updateVideoUnit(@PathVariable Long lectureId, @ VideoUnit result = lectureUnitService.saveWithCompetencyLinks(videoUnit, videoUnitRepository::save); - competencyProgressService.updateProgressForUpdatedLearningObjectAsync(existingVideoUnit, Optional.of(videoUnit)); + competencyProgressApi.updateProgressForUpdatedLearningObjectAsync(existingVideoUnit, Optional.of(videoUnit)); return ResponseEntity.ok(result); } @@ -139,7 +139,7 @@ public ResponseEntity createVideoUnit(@PathVariable Long lectureId, @ Lecture updatedLecture = lectureRepository.save(lecture); VideoUnit persistedVideoUnit = (VideoUnit) updatedLecture.getLectureUnits().getLast(); - competencyProgressService.updateProgressByLearningObjectAsync(persistedVideoUnit); + competencyProgressApi.updateProgressByLearningObjectAsync(persistedVideoUnit); lectureUnitService.disconnectCompetencyLectureUnitLinks(persistedVideoUnit); return ResponseEntity.created(new URI("/api/video-units/" + persistedVideoUnit.getId())).body(persistedVideoUnit); diff --git a/src/main/java/de/tum/cit/aet/artemis/modeling/service/ModelingExerciseImportService.java b/src/main/java/de/tum/cit/aet/artemis/modeling/service/ModelingExerciseImportService.java index 3c877ce69465..c418596c3a49 100644 --- a/src/main/java/de/tum/cit/aet/artemis/modeling/service/ModelingExerciseImportService.java +++ b/src/main/java/de/tum/cit/aet/artemis/modeling/service/ModelingExerciseImportService.java @@ -21,7 +21,7 @@ import de.tum.cit.aet.artemis.assessment.repository.ExampleSubmissionRepository; import de.tum.cit.aet.artemis.assessment.repository.ResultRepository; import de.tum.cit.aet.artemis.assessment.service.FeedbackService; -import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyProgressService; +import de.tum.cit.aet.artemis.atlas.api.CompetencyProgressApi; import de.tum.cit.aet.artemis.communication.service.conversation.ChannelService; import de.tum.cit.aet.artemis.exercise.domain.Exercise; import de.tum.cit.aet.artemis.exercise.domain.Submission; @@ -42,17 +42,17 @@ public class ModelingExerciseImportService extends ExerciseImportService { private final ChannelService channelService; - private final CompetencyProgressService competencyProgressService; + private final CompetencyProgressApi competencyProgressApi; private final ExerciseService exerciseService; public ModelingExerciseImportService(ModelingExerciseRepository modelingExerciseRepository, ExampleSubmissionRepository exampleSubmissionRepository, SubmissionRepository submissionRepository, ResultRepository resultRepository, ChannelService channelService, FeedbackService feedbackService, - CompetencyProgressService competencyProgressService, ExerciseService exerciseService) { + CompetencyProgressApi competencyProgressApi, ExerciseService exerciseService) { super(exampleSubmissionRepository, submissionRepository, resultRepository, feedbackService); this.modelingExerciseRepository = modelingExerciseRepository; this.channelService = channelService; - this.competencyProgressService = competencyProgressService; + this.competencyProgressApi = competencyProgressApi; this.exerciseService = exerciseService; } @@ -77,7 +77,7 @@ public ModelingExercise importModelingExercise(ModelingExercise templateExercise channelService.createExerciseChannel(newModelingExercise, Optional.ofNullable(importedExercise.getChannelName())); newModelingExercise.setExampleSubmissions(copyExampleSubmission(templateExercise, newExercise, gradingInstructionCopyTracker)); - competencyProgressService.updateProgressByLearningObjectAsync(newModelingExercise); + competencyProgressApi.updateProgressByLearningObjectAsync(newModelingExercise); return newModelingExercise; } diff --git a/src/main/java/de/tum/cit/aet/artemis/modeling/web/ModelingExerciseResource.java b/src/main/java/de/tum/cit/aet/artemis/modeling/web/ModelingExerciseResource.java index a2739ed28c76..899e16a32127 100644 --- a/src/main/java/de/tum/cit/aet/artemis/modeling/web/ModelingExerciseResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/modeling/web/ModelingExerciseResource.java @@ -29,7 +29,7 @@ import de.tum.cit.aet.artemis.assessment.domain.GradingCriterion; import de.tum.cit.aet.artemis.assessment.repository.GradingCriterionRepository; -import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyProgressService; +import de.tum.cit.aet.artemis.atlas.api.CompetencyProgressApi; import de.tum.cit.aet.artemis.communication.domain.conversation.Channel; import de.tum.cit.aet.artemis.communication.repository.conversation.ChannelRepository; import de.tum.cit.aet.artemis.communication.service.conversation.ChannelService; @@ -81,7 +81,7 @@ public class ModelingExerciseResource { private static final String ENTITY_NAME = "modelingExercise"; - private final CompetencyProgressService competencyProgressService; + private final CompetencyProgressApi competencyProgressApi; @Value("${jhipster.clientApp.name}") private String applicationName; @@ -126,7 +126,7 @@ public ModelingExerciseResource(ModelingExerciseRepository modelingExerciseRepos ModelingExerciseImportService modelingExerciseImportService, SubmissionExportService modelingSubmissionExportService, ExerciseService exerciseService, GroupNotificationScheduleService groupNotificationScheduleService, GradingCriterionRepository gradingCriterionRepository, PlagiarismDetectionService plagiarismDetectionService, ChannelService channelService, ChannelRepository channelRepository, - CompetencyProgressService competencyProgressService) { + CompetencyProgressApi competencyProgressApi) { this.modelingExerciseRepository = modelingExerciseRepository; this.courseService = courseService; this.modelingExerciseService = modelingExerciseService; @@ -144,7 +144,7 @@ public ModelingExerciseResource(ModelingExerciseRepository modelingExerciseRepos this.plagiarismDetectionService = plagiarismDetectionService; this.channelService = channelService; this.channelRepository = channelRepository; - this.competencyProgressService = competencyProgressService; + this.competencyProgressApi = competencyProgressApi; } // TODO: most of these calls should be done in the context of a course @@ -182,7 +182,7 @@ public ResponseEntity createModelingExercise(@RequestBody Mode channelService.createExerciseChannel(result, Optional.ofNullable(modelingExercise.getChannelName())); modelingExerciseService.scheduleOperations(result.getId()); groupNotificationScheduleService.checkNotificationsForNewExerciseAsync(modelingExercise); - competencyProgressService.updateProgressByLearningObjectAsync(result); + competencyProgressApi.updateProgressByLearningObjectAsync(result); return ResponseEntity.created(new URI("/api/modeling-exercises/" + result.getId())).body(result); } @@ -253,7 +253,7 @@ public ResponseEntity updateModelingExercise(@RequestBody Mode exerciseService.notifyAboutExerciseChanges(modelingExerciseBeforeUpdate, updatedModelingExercise, notificationText); - competencyProgressService.updateProgressForUpdatedLearningObjectAsync(modelingExerciseBeforeUpdate, Optional.of(modelingExercise)); + competencyProgressApi.updateProgressForUpdatedLearningObjectAsync(modelingExerciseBeforeUpdate, Optional.of(modelingExercise)); return ResponseEntity.ok(updatedModelingExercise); } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseService.java index 65a12b87be77..20b546ad4a48 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseService.java @@ -45,7 +45,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import de.tum.cit.aet.artemis.assessment.repository.ResultRepository; -import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyProgressService; +import de.tum.cit.aet.artemis.atlas.api.CompetencyProgressApi; import de.tum.cit.aet.artemis.communication.service.conversation.ChannelService; import de.tum.cit.aet.artemis.communication.service.notifications.GroupNotificationScheduleService; import de.tum.cit.aet.artemis.core.domain.Course; @@ -183,7 +183,7 @@ public class ProgrammingExerciseService { private final ExerciseService exerciseService; - private final CompetencyProgressService competencyProgressService; + private final CompetencyProgressApi competencyProgressApi; public ProgrammingExerciseService(ProgrammingExerciseRepository programmingExerciseRepository, GitService gitService, Optional versionControlService, Optional continuousIntegrationService, Optional continuousIntegrationTriggerService, @@ -199,7 +199,7 @@ public ProgrammingExerciseService(ProgrammingExerciseRepository programmingExerc ProgrammingSubmissionService programmingSubmissionService, Optional irisSettingsService, Optional aeolusTemplateService, Optional buildScriptGenerationService, ProgrammingExerciseStudentParticipationRepository programmingExerciseStudentParticipationRepository, ProfileService profileService, ExerciseService exerciseService, - ProgrammingExerciseBuildConfigRepository programmingExerciseBuildConfigRepository, CompetencyProgressService competencyProgressService) { + ProgrammingExerciseBuildConfigRepository programmingExerciseBuildConfigRepository, CompetencyProgressApi competencyProgressApi) { this.programmingExerciseRepository = programmingExerciseRepository; this.gitService = gitService; this.versionControlService = versionControlService; @@ -232,7 +232,7 @@ public ProgrammingExerciseService(ProgrammingExerciseRepository programmingExerc this.profileService = profileService; this.exerciseService = exerciseService; this.programmingExerciseBuildConfigRepository = programmingExerciseBuildConfigRepository; - this.competencyProgressService = competencyProgressService; + this.competencyProgressApi = competencyProgressApi; } /** @@ -339,7 +339,7 @@ public ProgrammingExercise createProgrammingExercise(ProgrammingExercise program // Step 12c: Check notifications for new exercise groupNotificationScheduleService.checkNotificationsForNewExerciseAsync(savedProgrammingExercise); // Step 12d: Update student competency progress - competencyProgressService.updateProgressByLearningObjectAsync(savedProgrammingExercise); + competencyProgressApi.updateProgressByLearningObjectAsync(savedProgrammingExercise); // Step 13: Set Iris settings if (irisSettingsService.isPresent()) { @@ -627,7 +627,7 @@ public ProgrammingExercise updateProgrammingExercise(ProgrammingExercise program exerciseService.notifyAboutExerciseChanges(programmingExerciseBeforeUpdate, updatedProgrammingExercise, notificationText); - competencyProgressService.updateProgressForUpdatedLearningObjectAsync(programmingExerciseBeforeUpdate, Optional.of(updatedProgrammingExercise)); + competencyProgressApi.updateProgressForUpdatedLearningObjectAsync(programmingExerciseBeforeUpdate, Optional.of(updatedProgrammingExercise)); irisSettingsService .ifPresent(settingsService -> settingsService.setEnabledForExerciseByCategories(savedProgrammingExercise, programmingExerciseBeforeUpdate.getCategories())); diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseExportImportResource.java b/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseExportImportResource.java index 1659956ac1f8..748645568dc6 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseExportImportResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseExportImportResource.java @@ -42,7 +42,7 @@ import de.tum.cit.aet.artemis.assessment.domain.Visibility; import de.tum.cit.aet.artemis.athena.service.AthenaModuleService; -import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyProgressService; +import de.tum.cit.aet.artemis.atlas.api.CompetencyProgressApi; import de.tum.cit.aet.artemis.core.domain.Course; import de.tum.cit.aet.artemis.core.domain.User; import de.tum.cit.aet.artemis.core.dto.RepositoryExportOptionsDTO; @@ -95,7 +95,7 @@ public class ProgrammingExerciseExportImportResource { private static final String ENTITY_NAME = "programmingExercise"; - private final CompetencyProgressService competencyProgressService; + private final CompetencyProgressApi competencyProgressApi; @Value("${jhipster.clientApp.name}") private String applicationName; @@ -136,7 +136,7 @@ public ProgrammingExerciseExportImportResource(ProgrammingExerciseRepository pro AuxiliaryRepositoryRepository auxiliaryRepositoryRepository, SubmissionPolicyService submissionPolicyService, ProgrammingExerciseTaskRepository programmingExerciseTaskRepository, ExamAccessService examAccessService, CourseRepository courseRepository, ProgrammingExerciseImportFromFileService programmingExerciseImportFromFileService, ConsistencyCheckService consistencyCheckService, - Optional athenaModuleService, CompetencyProgressService competencyProgressService) { + Optional athenaModuleService, CompetencyProgressApi competencyProgressApi) { this.programmingExerciseRepository = programmingExerciseRepository; this.userRepository = userRepository; this.courseService = courseService; @@ -152,7 +152,7 @@ public ProgrammingExerciseExportImportResource(ProgrammingExerciseRepository pro this.programmingExerciseImportFromFileService = programmingExerciseImportFromFileService; this.consistencyCheckService = consistencyCheckService; this.athenaModuleService = athenaModuleService; - this.competencyProgressService = competencyProgressService; + this.competencyProgressApi = competencyProgressApi; } /** @@ -259,7 +259,7 @@ public ResponseEntity importProgrammingExercise(@PathVariab importedProgrammingExercise.setExerciseHints(null); importedProgrammingExercise.setTasks(null); - competencyProgressService.updateProgressByLearningObjectAsync(importedProgrammingExercise); + competencyProgressApi.updateProgressByLearningObjectAsync(importedProgrammingExercise); return ResponseEntity.ok().headers(HeaderUtil.createEntityCreationAlert(applicationName, true, ENTITY_NAME, importedProgrammingExercise.getTitle())) .body(importedProgrammingExercise); diff --git a/src/main/java/de/tum/cit/aet/artemis/quiz/service/QuizExerciseImportService.java b/src/main/java/de/tum/cit/aet/artemis/quiz/service/QuizExerciseImportService.java index 1b26865c641b..b76d15598f91 100644 --- a/src/main/java/de/tum/cit/aet/artemis/quiz/service/QuizExerciseImportService.java +++ b/src/main/java/de/tum/cit/aet/artemis/quiz/service/QuizExerciseImportService.java @@ -24,7 +24,7 @@ import de.tum.cit.aet.artemis.assessment.repository.ExampleSubmissionRepository; import de.tum.cit.aet.artemis.assessment.repository.ResultRepository; import de.tum.cit.aet.artemis.assessment.service.FeedbackService; -import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyProgressService; +import de.tum.cit.aet.artemis.atlas.api.CompetencyProgressApi; import de.tum.cit.aet.artemis.communication.service.conversation.ChannelService; import de.tum.cit.aet.artemis.core.service.FilePathService; import de.tum.cit.aet.artemis.core.service.FileService; @@ -56,16 +56,16 @@ public class QuizExerciseImportService extends ExerciseImportService { private final ChannelService channelService; - private final CompetencyProgressService competencyProgressService; + private final CompetencyProgressApi competencyProgressApi; public QuizExerciseImportService(QuizExerciseService quizExerciseService, FileService fileService, ExampleSubmissionRepository exampleSubmissionRepository, SubmissionRepository submissionRepository, ResultRepository resultRepository, ChannelService channelService, FeedbackService feedbackService, - CompetencyProgressService competencyProgressService) { + CompetencyProgressApi competencyProgressApi) { super(exampleSubmissionRepository, submissionRepository, resultRepository, feedbackService); this.quizExerciseService = quizExerciseService; this.fileService = fileService; this.channelService = channelService; - this.competencyProgressService = competencyProgressService; + this.competencyProgressApi = competencyProgressApi; } /** @@ -90,7 +90,7 @@ public QuizExercise importQuizExercise(final QuizExercise templateExercise, Quiz channelService.createExerciseChannel(newQuizExercise, Optional.ofNullable(importedExercise.getChannelName())); - competencyProgressService.updateProgressByLearningObjectAsync(newQuizExercise); + competencyProgressApi.updateProgressByLearningObjectAsync(newQuizExercise); if (files != null) { newQuizExercise = quizExerciseService.save(quizExerciseService.uploadNewFilesToNewImportedQuiz(newQuizExercise, files)); } diff --git a/src/main/java/de/tum/cit/aet/artemis/quiz/web/QuizExerciseResource.java b/src/main/java/de/tum/cit/aet/artemis/quiz/web/QuizExerciseResource.java index d9d276bb3bd4..ecb693e49250 100644 --- a/src/main/java/de/tum/cit/aet/artemis/quiz/web/QuizExerciseResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/quiz/web/QuizExerciseResource.java @@ -38,7 +38,7 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; -import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyProgressService; +import de.tum.cit.aet.artemis.atlas.api.CompetencyProgressApi; import de.tum.cit.aet.artemis.communication.domain.conversation.Channel; import de.tum.cit.aet.artemis.communication.repository.conversation.ChannelRepository; import de.tum.cit.aet.artemis.communication.service.conversation.ChannelService; @@ -148,7 +148,7 @@ public class QuizExerciseResource { private final ChannelRepository channelRepository; - private final CompetencyProgressService competencyProgressService; + private final CompetencyProgressApi competencyProgressApi; public QuizExerciseResource(QuizExerciseService quizExerciseService, QuizMessagingService quizMessagingService, QuizExerciseRepository quizExerciseRepository, UserRepository userRepository, CourseService courseService, ExerciseService exerciseService, ExerciseDeletionService exerciseDeletionService, @@ -156,7 +156,7 @@ public QuizExerciseResource(QuizExerciseService quizExerciseService, QuizMessagi QuizExerciseImportService quizExerciseImportService, AuthorizationCheckService authCheckService, GroupNotificationService groupNotificationService, GroupNotificationScheduleService groupNotificationScheduleService, StudentParticipationRepository studentParticipationRepository, QuizBatchService quizBatchService, QuizBatchRepository quizBatchRepository, FileService fileService, ChannelService channelService, ChannelRepository channelRepository, - QuizSubmissionService quizSubmissionService, QuizResultService quizResultService, CompetencyProgressService competencyProgressService) { + QuizSubmissionService quizSubmissionService, QuizResultService quizResultService, CompetencyProgressApi competencyProgressApi) { this.quizExerciseService = quizExerciseService; this.quizMessagingService = quizMessagingService; this.quizExerciseRepository = quizExerciseRepository; @@ -179,7 +179,7 @@ public QuizExerciseResource(QuizExerciseService quizExerciseService, QuizMessagi this.channelRepository = channelRepository; this.quizSubmissionService = quizSubmissionService; this.quizResultService = quizResultService; - this.competencyProgressService = competencyProgressService; + this.competencyProgressApi = competencyProgressApi; } /** @@ -241,7 +241,7 @@ public ResponseEntity createQuizExercise(@RequestPart("exercise") channelService.createExerciseChannel(result, Optional.ofNullable(quizExercise.getChannelName())); - competencyProgressService.updateProgressByLearningObjectAsync(result); + competencyProgressApi.updateProgressByLearningObjectAsync(result); return ResponseEntity.created(new URI("/api/quiz-exercises/" + result.getId())) .headers(HeaderUtil.createEntityCreationAlert(applicationName, true, ENTITY_NAME, result.getId().toString())).body(result); @@ -308,7 +308,7 @@ public ResponseEntity updateQuizExercise(@PathVariable Long exerci if (updatedChannel != null) { quizExercise.setChannelName(updatedChannel.getName()); } - competencyProgressService.updateProgressForUpdatedLearningObjectAsync(originalQuiz, Optional.of(quizExercise)); + competencyProgressApi.updateProgressForUpdatedLearningObjectAsync(originalQuiz, Optional.of(quizExercise)); return ResponseEntity.ok(quizExercise); } diff --git a/src/main/java/de/tum/cit/aet/artemis/text/service/TextExerciseImportService.java b/src/main/java/de/tum/cit/aet/artemis/text/service/TextExerciseImportService.java index 161c6426589b..f45cadcd7ddd 100644 --- a/src/main/java/de/tum/cit/aet/artemis/text/service/TextExerciseImportService.java +++ b/src/main/java/de/tum/cit/aet/artemis/text/service/TextExerciseImportService.java @@ -27,7 +27,7 @@ import de.tum.cit.aet.artemis.assessment.repository.ResultRepository; import de.tum.cit.aet.artemis.assessment.repository.TextBlockRepository; import de.tum.cit.aet.artemis.assessment.service.FeedbackService; -import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyProgressService; +import de.tum.cit.aet.artemis.atlas.api.CompetencyProgressApi; import de.tum.cit.aet.artemis.communication.service.conversation.ChannelService; import de.tum.cit.aet.artemis.exercise.domain.Exercise; import de.tum.cit.aet.artemis.exercise.domain.Submission; @@ -57,13 +57,13 @@ public class TextExerciseImportService extends ExerciseImportService { private final ChannelService channelService; - private final CompetencyProgressService competencyProgressService; + private final CompetencyProgressApi competencyProgressApi; private final ExerciseService exerciseService; public TextExerciseImportService(TextExerciseRepository textExerciseRepository, ExampleSubmissionRepository exampleSubmissionRepository, SubmissionRepository submissionRepository, ResultRepository resultRepository, TextBlockRepository textBlockRepository, FeedbackRepository feedbackRepository, - TextSubmissionRepository textSubmissionRepository, ChannelService channelService, FeedbackService feedbackService, CompetencyProgressService competencyProgressService, + TextSubmissionRepository textSubmissionRepository, ChannelService channelService, FeedbackService feedbackService, CompetencyProgressApi competencyProgressApi, ExerciseService exerciseService) { super(exampleSubmissionRepository, submissionRepository, resultRepository, feedbackService); this.textBlockRepository = textBlockRepository; @@ -71,7 +71,7 @@ public TextExerciseImportService(TextExerciseRepository textExerciseRepository, this.feedbackRepository = feedbackRepository; this.textSubmissionRepository = textSubmissionRepository; this.channelService = channelService; - this.competencyProgressService = competencyProgressService; + this.competencyProgressApi = competencyProgressApi; this.exerciseService = exerciseService; } @@ -100,7 +100,7 @@ public TextExercise importTextExercise(final TextExercise templateExercise, Text channelService.createExerciseChannel(newTextExercise, Optional.ofNullable(importedExercise.getChannelName())); newExercise.setExampleSubmissions(copyExampleSubmission(templateExercise, newExercise, gradingInstructionCopyTracker)); - competencyProgressService.updateProgressByLearningObjectAsync(newTextExercise); + competencyProgressApi.updateProgressByLearningObjectAsync(newTextExercise); return newExercise; } diff --git a/src/main/java/de/tum/cit/aet/artemis/text/web/TextExerciseResource.java b/src/main/java/de/tum/cit/aet/artemis/text/web/TextExerciseResource.java index 0040e183e1e8..0b72f23ba0db 100644 --- a/src/main/java/de/tum/cit/aet/artemis/text/web/TextExerciseResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/text/web/TextExerciseResource.java @@ -41,7 +41,7 @@ import de.tum.cit.aet.artemis.assessment.repository.ResultRepository; import de.tum.cit.aet.artemis.assessment.repository.TextBlockRepository; import de.tum.cit.aet.artemis.athena.service.AthenaModuleService; -import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyProgressService; +import de.tum.cit.aet.artemis.atlas.api.CompetencyProgressApi; import de.tum.cit.aet.artemis.communication.domain.conversation.Channel; import de.tum.cit.aet.artemis.communication.repository.conversation.ChannelRepository; import de.tum.cit.aet.artemis.communication.service.conversation.ChannelService; @@ -154,7 +154,7 @@ public class TextExerciseResource { private final Optional athenaModuleService; - private final CompetencyProgressService competencyProgressService; + private final CompetencyProgressApi competencyProgressApi; private final Optional irisSettingsService; @@ -165,8 +165,8 @@ public TextExerciseResource(TextExerciseRepository textExerciseRepository, TextE TextSubmissionExportService textSubmissionExportService, ExampleSubmissionRepository exampleSubmissionRepository, ExerciseService exerciseService, GradingCriterionRepository gradingCriterionRepository, TextBlockRepository textBlockRepository, GroupNotificationScheduleService groupNotificationScheduleService, InstanceMessageSendService instanceMessageSendService, PlagiarismDetectionService plagiarismDetectionService, CourseRepository courseRepository, - ChannelService channelService, ChannelRepository channelRepository, Optional athenaModuleService, - CompetencyProgressService competencyProgressService, Optional irisSettingsService) { + ChannelService channelService, ChannelRepository channelRepository, Optional athenaModuleService, CompetencyProgressApi competencyProgressApi, + Optional irisSettingsService) { this.feedbackRepository = feedbackRepository; this.exerciseDeletionService = exerciseDeletionService; this.plagiarismResultRepository = plagiarismResultRepository; @@ -191,7 +191,7 @@ public TextExerciseResource(TextExerciseRepository textExerciseRepository, TextE this.channelService = channelService; this.channelRepository = channelRepository; this.athenaModuleService = athenaModuleService; - this.competencyProgressService = competencyProgressService; + this.competencyProgressApi = competencyProgressApi; this.irisSettingsService = irisSettingsService; } @@ -232,7 +232,7 @@ public ResponseEntity createTextExercise(@RequestBody TextExercise channelService.createExerciseChannel(result, Optional.ofNullable(textExercise.getChannelName())); instanceMessageSendService.sendTextExerciseSchedule(result.getId()); groupNotificationScheduleService.checkNotificationsForNewExerciseAsync(textExercise); - competencyProgressService.updateProgressByLearningObjectAsync(result); + competencyProgressApi.updateProgressByLearningObjectAsync(result); irisSettingsService.ifPresent(iss -> iss.setEnabledForExerciseByCategories(result, new HashSet<>())); @@ -294,7 +294,7 @@ public ResponseEntity updateTextExercise(@RequestBody TextExercise exerciseService.checkExampleSubmissions(updatedTextExercise); exerciseService.notifyAboutExerciseChanges(textExerciseBeforeUpdate, updatedTextExercise, notificationText); - competencyProgressService.updateProgressForUpdatedLearningObjectAsync(textExerciseBeforeUpdate, Optional.of(textExercise)); + competencyProgressApi.updateProgressForUpdatedLearningObjectAsync(textExerciseBeforeUpdate, Optional.of(textExercise)); irisSettingsService.ifPresent(iss -> iss.setEnabledForExerciseByCategories(textExercise, textExerciseBeforeUpdate.getCategories())); diff --git a/src/test/java/de/tum/cit/aet/artemis/atlas/architecture/AtlasApiArchitectureTest.java b/src/test/java/de/tum/cit/aet/artemis/atlas/architecture/AtlasApiArchitectureTest.java new file mode 100644 index 000000000000..d35694be19b7 --- /dev/null +++ b/src/test/java/de/tum/cit/aet/artemis/atlas/architecture/AtlasApiArchitectureTest.java @@ -0,0 +1,11 @@ +package de.tum.cit.aet.artemis.atlas.architecture; + +import de.tum.cit.aet.artemis.shared.architecture.module.AbstractModuleAccessArchitectureTest; + +class AtlasApiArchitectureTest extends AbstractModuleAccessArchitectureTest { + + @Override + public String getModulePackage() { + return ARTEMIS_PACKAGE + ".atlas"; + } +} diff --git a/src/test/java/de/tum/cit/aet/artemis/fileupload/FileUploadExerciseIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/fileupload/FileUploadExerciseIntegrationTest.java index 1c6476a486e4..7dfe79d270b1 100644 --- a/src/test/java/de/tum/cit/aet/artemis/fileupload/FileUploadExerciseIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/fileupload/FileUploadExerciseIntegrationTest.java @@ -332,7 +332,7 @@ void testDeleteFileUploadExerciseWithCompetency() throws Exception { competencyExerciseLinkRepository.save(new CompetencyExerciseLink(competency, fileUploadExercise, 1)); request.delete("/api/file-upload-exercises/" + fileUploadExercise.getId(), HttpStatus.OK); - verify(competencyProgressService).updateProgressByCompetencyAsync(eq(competency)); + verify(competencyProgressApi).updateProgressByCompetencyAsync(eq(competency)); } @Test @@ -391,7 +391,7 @@ void updateFileUploadExercise_asInstructor() throws Exception { assertThat(receivedFileUploadExercise.getCourseViaExerciseGroupOrCourseMember().getId()).as("courseId was not updated").isEqualTo(course.getId()); verify(examLiveEventsService, never()).createAndSendProblemStatementUpdateEvent(any(), any(), any()); verify(groupNotificationScheduleService, times(1)).checkAndCreateAppropriateNotificationsWhenUpdatingExercise(any(), any(), any()); - verify(competencyProgressService, timeout(1000).times(1)).updateProgressForUpdatedLearningObjectAsync(eq(fileUploadExercise), eq(Optional.of(fileUploadExercise))); + verify(competencyProgressApi, timeout(1000).times(1)).updateProgressForUpdatedLearningObjectAsync(eq(fileUploadExercise), eq(Optional.of(fileUploadExercise))); } @Test @@ -691,7 +691,7 @@ void testImportFileUploadExerciseFromCourseToCourseAsEditorSuccess() throws Exce Channel channelFromDB = channelRepository.findChannelByExerciseId(importedFileUploadExercise.getId()); assertThat(channelFromDB).isNotNull(); assertThat(channelFromDB.getName()).isEqualTo(uniqueChannelName); - verify(competencyProgressService).updateProgressByLearningObjectAsync(eq(importedFileUploadExercise)); + verify(competencyProgressApi).updateProgressByLearningObjectAsync(eq(importedFileUploadExercise)); } @Test diff --git a/src/test/java/de/tum/cit/aet/artemis/lecture/AttachmentUnitIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/lecture/AttachmentUnitIntegrationTest.java index 929d46b4b5bb..e81d7571973b 100644 --- a/src/test/java/de/tum/cit/aet/artemis/lecture/AttachmentUnitIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/lecture/AttachmentUnitIntegrationTest.java @@ -232,7 +232,7 @@ void createAttachmentUnit_asInstructor_shouldCreateAttachmentUnit() throws Excep assertThat(updatedAttachmentUnit.getAttachment()).isEqualTo(persistedAttachment); assertThat(updatedAttachmentUnit.getAttachment().getName()).isEqualTo("LoremIpsum"); assertThat(updatedAttachmentUnit.getCompetencyLinks()).anyMatch(link -> link.getCompetency().getId().equals(competency.getId())); - verify(competencyProgressService).updateProgressByLearningObjectAsync(eq(updatedAttachmentUnit)); + verify(competencyProgressApi).updateProgressByLearningObjectAsync(eq(updatedAttachmentUnit)); } @Test @@ -279,7 +279,7 @@ void updateAttachmentUnit_asInstructor_shouldUpdateAttachmentUnit() throws Excep assertThat(attachmentUnit2.getAttachment()).isEqualTo(attachment); assertThat(attachment.getAttachmentUnit()).isEqualTo(attachmentUnit2); assertThat(attachmentUnit1.getCompetencyLinks()).anyMatch(link -> link.getCompetency().getId().equals(competency.getId())); - verify(competencyProgressService, timeout(1000).times(1)).updateProgressForUpdatedLearningObjectAsync(eq(attachmentUnit), eq(Optional.of(attachmentUnit))); + verify(competencyProgressApi, timeout(1000).times(1)).updateProgressForUpdatedLearningObjectAsync(eq(attachmentUnit), eq(Optional.of(attachmentUnit))); } @Test @@ -357,6 +357,6 @@ void deleteAttachmentUnit_withAttachment_shouldDeleteAttachment() throws Excepti assertThat(slideRepository.findAllByAttachmentUnitId(persistedAttachmentUnit.getId())).hasSize(0); request.delete("/api/lectures/" + lecture1.getId() + "/lecture-units/" + persistedAttachmentUnit.getId(), HttpStatus.OK); request.get("/api/lectures/" + lecture1.getId() + "/attachment-units/" + persistedAttachmentUnit.getId(), HttpStatus.NOT_FOUND, AttachmentUnit.class); - verify(competencyProgressService, timeout(1000).times(1)).updateProgressForUpdatedLearningObjectAsync(eq(persistedAttachmentUnit), eq(Optional.empty())); + verify(competencyProgressApi, timeout(1000).times(1)).updateProgressForUpdatedLearningObjectAsync(eq(persistedAttachmentUnit), eq(Optional.empty())); } } diff --git a/src/test/java/de/tum/cit/aet/artemis/lecture/ExerciseUnitIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/lecture/ExerciseUnitIntegrationTest.java index 18ffb227b586..e1e9a8e57c4e 100644 --- a/src/test/java/de/tum/cit/aet/artemis/lecture/ExerciseUnitIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/lecture/ExerciseUnitIntegrationTest.java @@ -214,7 +214,7 @@ void deleteExerciseUnit_exerciseConnectedWithExerciseUnit_shouldNOTDeleteExercis request.get("/api/exercises/" + exercise.getId(), HttpStatus.OK, Exercise.class); } - verify(competencyProgressService, never()).updateProgressForUpdatedLearningObjectAsync(any(), any()); + verify(competencyProgressApi, never()).updateProgressForUpdatedLearningObjectAsync(any(), any()); } } diff --git a/src/test/java/de/tum/cit/aet/artemis/lecture/LectureIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/lecture/LectureIntegrationTest.java index dea62c73d76b..310eaee69286 100644 --- a/src/test/java/de/tum/cit/aet/artemis/lecture/LectureIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/lecture/LectureIntegrationTest.java @@ -370,7 +370,7 @@ void deleteLecture_lectureExists_shouldDeleteLecture() throws Exception { assertThat(lectureOptional).isEmpty(); // ExerciseUnits do not have competencies, their exercises do - verify(competencyProgressService, timeout(1000).times(lecture1.getLectureUnits().size() - 1)).updateProgressForUpdatedLearningObjectAsync(any(), eq(Optional.empty())); + verify(competencyProgressApi, timeout(1000).times(lecture1.getLectureUnits().size() - 1)).updateProgressForUpdatedLearningObjectAsync(any(), eq(Optional.empty())); } @Test diff --git a/src/test/java/de/tum/cit/aet/artemis/lecture/OnlineUnitIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/lecture/OnlineUnitIntegrationTest.java index 53f7c139f6d9..952bcd219def 100644 --- a/src/test/java/de/tum/cit/aet/artemis/lecture/OnlineUnitIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/lecture/OnlineUnitIntegrationTest.java @@ -113,7 +113,7 @@ void createOnlineUnit_asInstructor_shouldCreateOnlineUnit() throws Exception { onlineUnit.setSource("https://www.youtube.com/embed/8iU8LPEa4o0"); var persistedOnlineUnit = request.postWithResponseBody("/api/lectures/" + this.lecture1.getId() + "/online-units", onlineUnit, OnlineUnit.class, HttpStatus.CREATED); assertThat(persistedOnlineUnit.getId()).isNotNull(); - verify(competencyProgressService).updateProgressByLearningObjectAsync(eq(persistedOnlineUnit)); + verify(competencyProgressApi).updateProgressByLearningObjectAsync(eq(persistedOnlineUnit)); } @Test @@ -148,7 +148,7 @@ void updateOnlineUnit_asInstructor_shouldUpdateOnlineUnit() throws Exception { this.onlineUnit.setDescription("Changed"); this.onlineUnit = request.putWithResponseBody("/api/lectures/" + lecture1.getId() + "/online-units", this.onlineUnit, OnlineUnit.class, HttpStatus.OK); assertThat(this.onlineUnit.getDescription()).isEqualTo("Changed"); - verify(competencyProgressService, timeout(1000).times(1)).updateProgressForUpdatedLearningObjectAsync(eq(onlineUnit), eq(Optional.of(onlineUnit))); + verify(competencyProgressApi, timeout(1000).times(1)).updateProgressForUpdatedLearningObjectAsync(eq(onlineUnit), eq(Optional.of(onlineUnit))); } @Test @@ -269,7 +269,7 @@ void deleteOnlineUnit_correctId_shouldDeleteOnlineUnit() throws Exception { assertThat(this.onlineUnit.getId()).isNotNull(); request.delete("/api/lectures/" + lecture1.getId() + "/lecture-units/" + this.onlineUnit.getId(), HttpStatus.OK); request.get("/api/lectures/" + lecture1.getId() + "/online-units/" + this.onlineUnit.getId(), HttpStatus.NOT_FOUND, OnlineUnit.class); - verify(competencyProgressService, timeout(1000).times(1)).updateProgressForUpdatedLearningObjectAsync(eq(onlineUnit), eq(Optional.empty())); + verify(competencyProgressApi, timeout(1000).times(1)).updateProgressForUpdatedLearningObjectAsync(eq(onlineUnit), eq(Optional.empty())); } } diff --git a/src/test/java/de/tum/cit/aet/artemis/lecture/TextUnitIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/lecture/TextUnitIntegrationTest.java index 0493cd108136..1d3082359dc5 100644 --- a/src/test/java/de/tum/cit/aet/artemis/lecture/TextUnitIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/lecture/TextUnitIntegrationTest.java @@ -90,7 +90,7 @@ void createTextUnit_asEditor_shouldCreateTextUnitUnit() throws Exception { var persistedTextUnit = request.postWithResponseBody("/api/lectures/" + this.lecture.getId() + "/text-units", textUnit, TextUnit.class, HttpStatus.CREATED); assertThat(persistedTextUnit.getId()).isNotNull(); assertThat(persistedTextUnit.getName()).isEqualTo("LoremIpsum"); - verify(competencyProgressService).updateProgressByLearningObjectAsync(eq(persistedTextUnit)); + verify(competencyProgressApi).updateProgressByLearningObjectAsync(eq(persistedTextUnit)); } @Test @@ -112,7 +112,7 @@ void updateTextUnit_asEditor_shouldUpdateTextUnit() throws Exception { textUnit.setContent("Changed"); TextUnit updatedTextUnit = request.putWithResponseBody("/api/lectures/" + lecture.getId() + "/text-units", textUnit, TextUnit.class, HttpStatus.OK); assertThat(updatedTextUnit.getContent()).isEqualTo("Changed"); - verify(competencyProgressService, timeout(1000).times(1)).updateProgressForUpdatedLearningObjectAsync(eq(textUnit), eq(Optional.of(textUnit))); + verify(competencyProgressApi, timeout(1000).times(1)).updateProgressForUpdatedLearningObjectAsync(eq(textUnit), eq(Optional.of(textUnit))); } @Test @@ -161,7 +161,7 @@ void deleteTextUnit_correctId_shouldDeleteTextUnit() throws Exception { assertThat(this.textUnit.getId()).isNotNull(); request.delete("/api/lectures/" + lecture.getId() + "/lecture-units/" + this.textUnit.getId(), HttpStatus.OK); request.get("/api/lectures/" + lecture.getId() + "/text-units/" + this.textUnit.getId(), HttpStatus.NOT_FOUND, TextUnit.class); - verify(competencyProgressService, timeout(1000).times(1)).updateProgressForUpdatedLearningObjectAsync(eq(textUnit), eq(Optional.empty())); + verify(competencyProgressApi, timeout(1000).times(1)).updateProgressForUpdatedLearningObjectAsync(eq(textUnit), eq(Optional.empty())); } private void persistTextUnitWithLecture() { diff --git a/src/test/java/de/tum/cit/aet/artemis/lecture/VideoUnitIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/lecture/VideoUnitIntegrationTest.java index 2c43ed5c8106..8ef389dfd672 100644 --- a/src/test/java/de/tum/cit/aet/artemis/lecture/VideoUnitIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/lecture/VideoUnitIntegrationTest.java @@ -90,7 +90,7 @@ void createVideoUnit_asInstructor_shouldCreateVideoUnit() throws Exception { videoUnit.setCompetencyLinks(Set.of(new CompetencyLectureUnitLink(competency, videoUnit, 1))); var persistedVideoUnit = request.postWithResponseBody("/api/lectures/" + this.lecture1.getId() + "/video-units", videoUnit, VideoUnit.class, HttpStatus.CREATED); assertThat(persistedVideoUnit.getId()).isNotNull(); - verify(competencyProgressService).updateProgressByLearningObjectAsync(eq(persistedVideoUnit)); + verify(competencyProgressApi).updateProgressByLearningObjectAsync(eq(persistedVideoUnit)); } @Test @@ -125,7 +125,7 @@ void updateVideoUnit_asInstructor_shouldUpdateVideoUnit() throws Exception { this.videoUnit.setDescription("Changed"); this.videoUnit = request.putWithResponseBody("/api/lectures/" + lecture1.getId() + "/video-units", this.videoUnit, VideoUnit.class, HttpStatus.OK); assertThat(this.videoUnit.getDescription()).isEqualTo("Changed"); - verify(competencyProgressService, timeout(1000).times(1)).updateProgressForUpdatedLearningObjectAsync(eq(videoUnit), eq(Optional.of(videoUnit))); + verify(competencyProgressApi, timeout(1000).times(1)).updateProgressForUpdatedLearningObjectAsync(eq(videoUnit), eq(Optional.of(videoUnit))); } @Test @@ -209,7 +209,7 @@ void deleteVideoUnit_correctId_shouldDeleteVideoUnit() throws Exception { assertThat(this.videoUnit.getId()).isNotNull(); request.delete("/api/lectures/" + lecture1.getId() + "/lecture-units/" + this.videoUnit.getId(), HttpStatus.OK); request.get("/api/lectures/" + lecture1.getId() + "/video-units/" + this.videoUnit.getId(), HttpStatus.NOT_FOUND, VideoUnit.class); - verify(competencyProgressService, timeout(1000).times(1)).updateProgressForUpdatedLearningObjectAsync(eq(videoUnit), eq(Optional.empty())); + verify(competencyProgressApi, timeout(1000).times(1)).updateProgressForUpdatedLearningObjectAsync(eq(videoUnit), eq(Optional.empty())); } @Test diff --git a/src/test/java/de/tum/cit/aet/artemis/modeling/ModelingExerciseIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/modeling/ModelingExerciseIntegrationTest.java index 6732ee7c6a2d..2891ce7dc87f 100644 --- a/src/test/java/de/tum/cit/aet/artemis/modeling/ModelingExerciseIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/modeling/ModelingExerciseIntegrationTest.java @@ -242,8 +242,7 @@ void testUpdateModelingExercise_asInstructor() throws Exception { assertThat(returnedModelingExercise.getGradingCriteria()).hasSameSizeAs(gradingCriteria); verify(groupNotificationService).notifyStudentAndEditorAndInstructorGroupAboutExerciseUpdate(returnedModelingExercise, notificationText); verify(examLiveEventsService, never()).createAndSendProblemStatementUpdateEvent(eq(returnedModelingExercise), eq(notificationText), any()); - verify(competencyProgressService, timeout(1000).times(1)).updateProgressForUpdatedLearningObjectAsync(eq(createdModelingExercise), - eq(Optional.of(createdModelingExercise))); + verify(competencyProgressApi, timeout(1000).times(1)).updateProgressForUpdatedLearningObjectAsync(eq(createdModelingExercise), eq(Optional.of(createdModelingExercise))); } @Test @@ -414,7 +413,7 @@ void testDeleteModelingExerciseWithCompetency() throws Exception { request.delete("/api/modeling-exercises/" + classExercise.getId(), HttpStatus.OK); - verify(competencyProgressService).updateProgressByCompetencyAsync(eq(competency)); + verify(competencyProgressApi).updateProgressByCompetencyAsync(eq(competency)); } @Test @@ -485,7 +484,7 @@ void importModelingExerciseFromCourseToCourse() throws Exception { Channel channelFromDB = channelRepository.findChannelByExerciseId(importedExercise.getId()); assertThat(channelFromDB).isNotNull(); assertThat(channelFromDB.getName()).isEqualTo(uniqueChannelName); - verify(competencyProgressService).updateProgressByLearningObjectAsync(eq(importedExercise)); + verify(competencyProgressApi).updateProgressByLearningObjectAsync(eq(importedExercise)); } @Test diff --git a/src/test/java/de/tum/cit/aet/artemis/programming/icl/ProgrammingExerciseLocalVCLocalCIIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/programming/icl/ProgrammingExerciseLocalVCLocalCIIntegrationTest.java index 211d1e9ce30f..fdc6a876eb7d 100644 --- a/src/test/java/de/tum/cit/aet/artemis/programming/icl/ProgrammingExerciseLocalVCLocalCIIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/programming/icl/ProgrammingExerciseLocalVCLocalCIIntegrationTest.java @@ -176,7 +176,7 @@ void testCreateProgrammingExercise() throws Exception { localVCLocalCITestService.testLatestSubmission(createdExercise.getTemplateParticipation().getId(), null, 0, false); localVCLocalCITestService.testLatestSubmission(createdExercise.getSolutionParticipation().getId(), null, 13, false); - verify(competencyProgressService).updateProgressByLearningObjectAsync(eq(createdExercise)); + verify(competencyProgressApi).updateProgressByLearningObjectAsync(eq(createdExercise)); } @Test @@ -202,7 +202,7 @@ void testUpdateProgrammingExercise() throws Exception { ProgrammingExercise updatedExercise = request.putWithResponseBody("/api/programming-exercises", programmingExercise, ProgrammingExercise.class, HttpStatus.OK); assertThat(updatedExercise.getReleaseDate()).isEqualTo(programmingExercise.getReleaseDate()); - verify(competencyProgressService, timeout(1000).times(1)).updateProgressForUpdatedLearningObjectAsync(eq(programmingExercise), eq(Optional.of(programmingExercise))); + verify(competencyProgressApi, timeout(1000).times(1)).updateProgressForUpdatedLearningObjectAsync(eq(programmingExercise), eq(Optional.of(programmingExercise))); } @Test @@ -235,7 +235,7 @@ void testDeleteProgrammingExercise() throws Exception { assertThat(solutionRepositoryUri.getLocalRepositoryPath(localVCBasePath)).doesNotExist(); LocalVCRepositoryUri testsRepositoryUri = new LocalVCRepositoryUri(programmingExercise.getTestRepositoryUri()); assertThat(testsRepositoryUri.getLocalRepositoryPath(localVCBasePath)).doesNotExist(); - verify(competencyProgressService).updateProgressByCompetencyAsync(eq(competency)); + verify(competencyProgressApi).updateProgressByCompetencyAsync(eq(competency)); } @Test @@ -287,7 +287,7 @@ void testImportProgrammingExercise() throws Exception { .orElseThrow(); localVCLocalCITestService.testLatestSubmission(templateParticipation.getId(), null, 0, false); localVCLocalCITestService.testLatestSubmission(solutionParticipation.getId(), null, 13, false); - verify(competencyProgressService).updateProgressByLearningObjectAsync(eq(importedExercise)); + verify(competencyProgressApi).updateProgressByLearningObjectAsync(eq(importedExercise)); } @Nested diff --git a/src/test/java/de/tum/cit/aet/artemis/shared/architecture/module/AbstractModuleAccessArchitectureTest.java b/src/test/java/de/tum/cit/aet/artemis/shared/architecture/module/AbstractModuleAccessArchitectureTest.java new file mode 100644 index 000000000000..dc3b274f146e --- /dev/null +++ b/src/test/java/de/tum/cit/aet/artemis/shared/architecture/module/AbstractModuleAccessArchitectureTest.java @@ -0,0 +1,67 @@ +package de.tum.cit.aet.artemis.shared.architecture.module; + +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage; +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideOutsideOfPackages; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +import org.junit.jupiter.api.Test; +import org.springframework.stereotype.Controller; + +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.domain.JavaModifier; +import com.tngtech.archunit.lang.ArchCondition; +import com.tngtech.archunit.lang.ConditionEvents; +import com.tngtech.archunit.lang.SimpleConditionEvent; + +import de.tum.cit.aet.artemis.core.api.AbstractApi; +import de.tum.cit.aet.artemis.shared.architecture.AbstractArchitectureTest; + +public abstract class AbstractModuleAccessArchitectureTest extends AbstractArchitectureTest implements ModuleArchitectureTest { + + @Test + void shouldOnlyAccessApiDomainDto() { + noClasses().that().resideOutsideOfPackage(getModuleWithSubpackage()).should() + .dependOnClassesThat( + resideInAPackage(getModuleWithSubpackage()).and(resideOutsideOfPackages(getModuleApiSubpackage(), getModuleDomainSubpackage(), getModuleDtoSubpackage()))) + .check(productionClasses); + } + + @Test + void apiClassesShouldInheritFromAbstractApi() { + classes().that().resideInAPackage(getModuleApiSubpackage()).should().beAssignableTo(AbstractApi.class).check(productionClasses); + } + + @Test + void apiClassesShouldBeAbstractOrAnnotatedWithController() { + classes().that().resideInAPackage(getModuleApiSubpackage()).should(beAbstractOrAnnotatedWithController()).check(productionClasses); + } + + protected String getModuleApiSubpackage() { + return getModulePackage() + ".api.."; + } + + protected String getModuleDomainSubpackage() { + return getModulePackage() + ".domain.."; + } + + protected String getModuleDtoSubpackage() { + return getModulePackage() + ".dto.."; + } + + private static ArchCondition beAbstractOrAnnotatedWithController() { + return new ArchCondition<>("be abstract or annotated with @Controller") { + + @Override + public void check(JavaClass javaClass, ConditionEvents events) { + boolean isAbstract = javaClass.getModifiers().contains(JavaModifier.ABSTRACT); + boolean isAnnotatedWithController = javaClass.isAnnotatedWith(Controller.class); + + if (!isAbstract && !isAnnotatedWithController) { + String message = String.format("Class %s is neither abstract nor annotated with @Controller", javaClass.getName()); + events.add(SimpleConditionEvent.violated(javaClass, message)); + } + } + }; + } +} diff --git a/src/test/java/de/tum/cit/aet/artemis/shared/base/AbstractArtemisIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/shared/base/AbstractArtemisIntegrationTest.java index 3252a1afdc8b..769ba99ea29b 100644 --- a/src/test/java/de/tum/cit/aet/artemis/shared/base/AbstractArtemisIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/shared/base/AbstractArtemisIntegrationTest.java @@ -29,6 +29,7 @@ import de.tum.cit.aet.artemis.assessment.service.ParticipantScoreScheduleService; import de.tum.cit.aet.artemis.assessment.test_repository.ResultTestRepository; +import de.tum.cit.aet.artemis.atlas.api.CompetencyProgressApi; import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyProgressService; import de.tum.cit.aet.artemis.communication.service.WebsocketMessagingService; import de.tum.cit.aet.artemis.communication.service.notifications.ConversationNotificationService; @@ -171,6 +172,9 @@ public abstract class AbstractArtemisIntegrationTest implements MockDelegate { @SpyBean protected CompetencyProgressService competencyProgressService; + @SpyBean + protected CompetencyProgressApi competencyProgressApi; + @Autowired protected RequestUtilService request; @@ -227,7 +231,8 @@ void stopRunningTasks() { protected void resetSpyBeans() { Mockito.reset(gitService, groupNotificationService, conversationNotificationService, tutorialGroupNotificationService, singleUserNotificationService, websocketMessagingService, examAccessService, mailService, instanceMessageSendService, programmingExerciseScheduleService, programmingExerciseParticipationService, - uriService, scheduleService, participantScoreScheduleService, javaMailSender, programmingTriggerService, zipFileService, competencyProgressService); + uriService, scheduleService, participantScoreScheduleService, javaMailSender, programmingTriggerService, zipFileService, competencyProgressService, + competencyProgressApi); } @Override diff --git a/src/test/java/de/tum/cit/aet/artemis/text/TextExerciseIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/text/TextExerciseIntegrationTest.java index c635d94e9c6b..9ed51413ea40 100644 --- a/src/test/java/de/tum/cit/aet/artemis/text/TextExerciseIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/text/TextExerciseIntegrationTest.java @@ -200,7 +200,7 @@ void testDeleteTextExerciseWithCompetency() throws Exception { request.delete("/api/text-exercises/" + textExercise.getId(), HttpStatus.OK); - verify(competencyProgressService).updateProgressByCompetencyAsync(eq(competency)); + verify(competencyProgressApi).updateProgressByCompetencyAsync(eq(competency)); } @Test @@ -431,7 +431,7 @@ void updateTextExercise() throws Exception { assertThat(updatedTextExercise.getCourseViaExerciseGroupOrCourseMember().getId()).as("courseId was not updated").isEqualTo(course.getId()); verify(examLiveEventsService, never()).createAndSendProblemStatementUpdateEvent(any(), any(), any()); verify(groupNotificationScheduleService, times(1)).checkAndCreateAppropriateNotificationsWhenUpdatingExercise(any(), any(), any()); - verify(competencyProgressService, timeout(1000).times(1)).updateProgressForUpdatedLearningObjectAsync(eq(textExercise), eq(Optional.of(textExercise))); + verify(competencyProgressApi, timeout(1000).times(1)).updateProgressForUpdatedLearningObjectAsync(eq(textExercise), eq(Optional.of(textExercise))); } @Test @@ -596,7 +596,7 @@ void importTextExerciseFromCourseToCourse() throws Exception { var newTextExercise = request.postWithResponseBody("/api/text-exercises/import/" + textExercise.getId(), textExercise, TextExercise.class, HttpStatus.CREATED); Channel channel = channelRepository.findChannelByExerciseId(newTextExercise.getId()); assertThat(channel).isNotNull(); - verify(competencyProgressService).updateProgressByLearningObjectAsync(eq(newTextExercise)); + verify(competencyProgressApi).updateProgressByLearningObjectAsync(eq(newTextExercise)); } @Test From 2611d04c7bd94d3af37ec6554a1579b96d384933 Mon Sep 17 00:00:00 2001 From: Stephan Krusche Date: Sat, 30 Nov 2024 10:23:34 +0100 Subject: [PATCH 09/10] Development: Remove unused server service dependency in assessment module --- .../aet/artemis/assessment/service/AssessmentService.java | 6 +----- .../programming/service/ProgrammingAssessmentService.java | 2 +- .../cit/aet/artemis/text/service/TextAssessmentService.java | 2 +- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/main/java/de/tum/cit/aet/artemis/assessment/service/AssessmentService.java b/src/main/java/de/tum/cit/aet/artemis/assessment/service/AssessmentService.java index 6b7b45c8c65b..35596c4a4c15 100644 --- a/src/main/java/de/tum/cit/aet/artemis/assessment/service/AssessmentService.java +++ b/src/main/java/de/tum/cit/aet/artemis/assessment/service/AssessmentService.java @@ -17,7 +17,6 @@ import de.tum.cit.aet.artemis.assessment.dto.AssessmentUpdateBaseDTO; import de.tum.cit.aet.artemis.assessment.repository.ComplaintRepository; import de.tum.cit.aet.artemis.assessment.repository.FeedbackRepository; -import de.tum.cit.aet.artemis.assessment.repository.LongFeedbackTextRepository; import de.tum.cit.aet.artemis.assessment.repository.ResultRepository; import de.tum.cit.aet.artemis.assessment.web.ResultWebsocketService; import de.tum.cit.aet.artemis.communication.service.notifications.SingleUserNotificationService; @@ -68,12 +67,10 @@ public class AssessmentService { protected final ResultWebsocketService resultWebsocketService; - private final LongFeedbackTextRepository longFeedbackTextRepository; - public AssessmentService(ComplaintResponseService complaintResponseService, ComplaintRepository complaintRepository, FeedbackRepository feedbackRepository, ResultRepository resultRepository, StudentParticipationRepository studentParticipationRepository, ResultService resultService, SubmissionService submissionService, SubmissionRepository submissionRepository, ExamDateService examDateService, UserRepository userRepository, Optional ltiNewResultService, - SingleUserNotificationService singleUserNotificationService, ResultWebsocketService resultWebsocketService, LongFeedbackTextRepository longFeedbackTextRepository) { + SingleUserNotificationService singleUserNotificationService, ResultWebsocketService resultWebsocketService) { this.complaintResponseService = complaintResponseService; this.complaintRepository = complaintRepository; this.feedbackRepository = feedbackRepository; @@ -87,7 +84,6 @@ public AssessmentService(ComplaintResponseService complaintResponseService, Comp this.ltiNewResultService = ltiNewResultService; this.singleUserNotificationService = singleUserNotificationService; this.resultWebsocketService = resultWebsocketService; - this.longFeedbackTextRepository = longFeedbackTextRepository; } /** diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingAssessmentService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingAssessmentService.java index 03ec7f527429..f62bde6cc957 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingAssessmentService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingAssessmentService.java @@ -52,7 +52,7 @@ public ProgrammingAssessmentService(ComplaintResponseService complaintResponseSe ProgrammingExerciseParticipationService programmingExerciseParticipationService, Optional athenaFeedbackSendingService, LongFeedbackTextRepository longFeedbackTextRepository) { super(complaintResponseService, complaintRepository, feedbackRepository, resultRepository, studentParticipationRepository, resultService, submissionService, - submissionRepository, examDateService, userRepository, ltiNewResultService, singleUserNotificationService, resultWebsocketService, longFeedbackTextRepository); + submissionRepository, examDateService, userRepository, ltiNewResultService, singleUserNotificationService, resultWebsocketService); this.programmingExerciseParticipationService = programmingExerciseParticipationService; this.athenaFeedbackSendingService = athenaFeedbackSendingService; } diff --git a/src/main/java/de/tum/cit/aet/artemis/text/service/TextAssessmentService.java b/src/main/java/de/tum/cit/aet/artemis/text/service/TextAssessmentService.java index ad1e367c2735..774e3655e752 100644 --- a/src/main/java/de/tum/cit/aet/artemis/text/service/TextAssessmentService.java +++ b/src/main/java/de/tum/cit/aet/artemis/text/service/TextAssessmentService.java @@ -44,7 +44,7 @@ public TextAssessmentService(UserRepository userRepository, ComplaintResponseSer SubmissionService submissionService, Optional ltiNewResultService, SingleUserNotificationService singleUserNotificationService, ResultWebsocketService resultWebsocketService, LongFeedbackTextRepository longFeedbackTextRepository) { super(complaintResponseService, complaintRepository, feedbackRepository, resultRepository, studentParticipationRepository, resultService, submissionService, - submissionRepository, examDateService, userRepository, ltiNewResultService, singleUserNotificationService, resultWebsocketService, longFeedbackTextRepository); + submissionRepository, examDateService, userRepository, ltiNewResultService, singleUserNotificationService, resultWebsocketService); this.textBlockService = textBlockService; } From dece904bb8def9d3f9748a81c88a87b2dfa60958 Mon Sep 17 00:00:00 2001 From: Florian Glombik <63976129+florian-glombik@users.noreply.github.com> Date: Sat, 30 Nov 2024 22:41:26 +0100 Subject: [PATCH 10/10] Lectures: Improve lecture attachment validation (#9893) --- .../lecture-attachments.component.html | 50 ++++--- .../lecture-attachments.component.scss | 4 + .../lecture/lecture-attachments.component.ts | 124 +++++++++++++----- .../lecture-attachments.component.spec.ts | 82 +++++++----- 4 files changed, 169 insertions(+), 91 deletions(-) diff --git a/src/main/webapp/app/lecture/lecture-attachments.component.html b/src/main/webapp/app/lecture/lecture-attachments.component.html index 6192f270d64e..82050a7c3efe 100644 --- a/src/main/webapp/app/lecture/lecture-attachments.component.html +++ b/src/main/webapp/app/lecture/lecture-attachments.component.html @@ -44,7 +44,6 @@

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

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

}

@if (lecture().isAtLeastInstructor) {

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

}

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

} @else {

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

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

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

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

-
- } - @if (!attachmentToBeCreated) { + + } @else {