diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/domain/profile/LearnerProfile.java b/src/main/java/de/tum/cit/aet/artemis/atlas/domain/profile/LearnerProfile.java index 3c8a596474c2..330e0f27cc81 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/domain/profile/LearnerProfile.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/domain/profile/LearnerProfile.java @@ -5,11 +5,14 @@ import java.util.Set; import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.OneToMany; import jakarta.persistence.OneToOne; import jakarta.persistence.Table; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; import org.hibernate.annotations.Cache; import org.hibernate.annotations.CacheConcurrencyStrategy; @@ -26,6 +29,8 @@ @JsonInclude(JsonInclude.Include.NON_EMPTY) public class LearnerProfile extends DomainObject { + public static final String ENTITY_NAME = "learnerProfile"; + @JsonIgnoreProperties("learnerProfile") @OneToOne(mappedBy = "learnerProfile", cascade = CascadeType.PERSIST) private User user; @@ -34,6 +39,26 @@ public class LearnerProfile extends DomainObject { @JsonIgnoreProperties("learnerProfile") private Set courseLearnerProfiles = new HashSet<>(); + @Column(name = "feedback_practical_theoretical") + @Min(1) + @Max(5) + private int feedbackPracticalTheoretical; + + @Column(name = "feedback_creative_guidance") + @Min(1) + @Max(5) + private int feedbackCreativeGuidance; + + @Column(name = "feedback_followup_summary") + @Min(1) + @Max(5) + private int feedbackFollowupSummary; + + @Column(name = "feedback_brief_detailed") + @Min(1) + @Max(5) + private int feedbackBriefDetailed; + public void setUser(User user) { this.user = user; } @@ -61,4 +86,36 @@ public boolean addAllCourseLearnerProfiles(Collection MAX_PROFILE_VALUE) { + throw new BadRequestAlertException(fieldName + " field is outside valid bounds", LearnerProfile.ENTITY_NAME, fieldName.toLowerCase() + "OutOfBounds", true); + } + } + + @GetMapping("learner-profiles") + @EnforceAtLeastStudent + public ResponseEntity getLearnerProfile() { + User user = userRepository.getUser(); + LearnerProfile profile = learnerProfileRepository.findByUserElseThrow(user); + return ResponseEntity.ok(LearnerProfileDTO.of(profile)); + } + + /** + * PUT /learner-profiles/{learnerProfileId} : update fields in a {@link LearnerProfile}. + * + * @param learnerProfileId ID of the LearnerProfile + * @param learnerProfileDTO {@link LearnerProfileDTO} object from the request body. + * @return A ResponseEntity with a status matching the validity of the request containing the updated profile. + */ + @PutMapping(value = "learner-profiles/{learnerProfileId}") + @EnforceAtLeastStudent + public ResponseEntity updateLearnerProfile(@PathVariable long learnerProfileId, @RequestBody LearnerProfileDTO learnerProfileDTO) { + User user = userRepository.getUser(); + + if (learnerProfileDTO.id() != learnerProfileId) { + throw new BadRequestAlertException("Provided learnerProfileId does not match learnerProfile.", LearnerProfile.ENTITY_NAME, "objectDoesNotMatchId", true); + } + + LearnerProfile updateProfile = learnerProfileRepository.findByUserElseThrow(user); + + validateProfileField(learnerProfileDTO.feedbackPracticalTheoretical(), "FeedbackPracticalTheoretical"); + validateProfileField(learnerProfileDTO.feedbackCreativeGuidance(), "FeedbackCreativeGuidance"); + validateProfileField(learnerProfileDTO.feedbackFollowupSummary(), "FeedbackFollowupSummary"); + validateProfileField(learnerProfileDTO.feedbackBriefDetailed(), "FeedbackBriefDetailed"); + + updateProfile.setFeedbackPracticalTheoretical(learnerProfileDTO.feedbackPracticalTheoretical()); + updateProfile.setFeedbackCreativeGuidance(learnerProfileDTO.feedbackCreativeGuidance()); + updateProfile.setFeedbackFollowupSummary(learnerProfileDTO.feedbackFollowupSummary()); + updateProfile.setFeedbackBriefDetailed(learnerProfileDTO.feedbackBriefDetailed()); + + LearnerProfile result = learnerProfileRepository.save(updateProfile); + return ResponseEntity.ok(LearnerProfileDTO.of(result)); + } +} diff --git a/src/main/resources/config/liquibase/changelog/20250409191359_changelog.xml b/src/main/resources/config/liquibase/changelog/20250409191359_changelog.xml new file mode 100644 index 000000000000..08fb568bf76b --- /dev/null +++ b/src/main/resources/config/liquibase/changelog/20250409191359_changelog.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + \ 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 bfb20900b248..a2cc9983c3fc 100644 --- a/src/main/resources/config/liquibase/master.xml +++ b/src/main/resources/config/liquibase/master.xml @@ -71,6 +71,7 @@ + diff --git a/src/main/webapp/app/core/user/settings/learner-profile/learner-profile.component.html b/src/main/webapp/app/core/user/settings/learner-profile/learner-profile.component.html new file mode 100644 index 000000000000..8173edb0c744 --- /dev/null +++ b/src/main/webapp/app/core/user/settings/learner-profile/learner-profile.component.html @@ -0,0 +1,67 @@ +
+
+

+
+ + +
+
+ @if (settingsChanged) { +
+ +
+ } +
+ +
+
+

+
+
+

+ + + +
+ + +
+
+
+
+

+ + + +
+ + +
+
+
+
+

+ + + +
+ + +
+
+
+
+

+ + + +
+ + +
+
+
+
diff --git a/src/main/webapp/app/core/user/settings/learner-profile/learner-profile.component.scss b/src/main/webapp/app/core/user/settings/learner-profile/learner-profile.component.scss new file mode 100644 index 000000000000..ab649b1d0fab --- /dev/null +++ b/src/main/webapp/app/core/user/settings/learner-profile/learner-profile.component.scss @@ -0,0 +1,64 @@ +.preference-section { + margin-bottom: 32px; + + .text-muted { + font-size: 0.9rem; + margin-bottom: 16px; + line-height: 1.4; + } + + mat-slider { + width: 98%; + margin-bottom: 8px; + + ::ng-deep { + .mdc-slider__thumb { + border-radius: 50% !important; + width: 24px !important; + height: 24px !important; + background-color: var(--bs-primary) !important; + top: 8px !important; + } + + .mdc-slider__track { + height: 8px !important; + border-radius: 5px !important; + margin: 0 -12px !important; + } + + .mdc-slider__track--inactive { + background-color: #e0e0e0 !important; + border-radius: 5px !important; + } + + .mdc-slider__track--active { + background-color: var(--bs-primary) !important; + border-radius: 5px !important; + } + + .mdc-slider__thumb-knob { + border-radius: 100% !important; + width: 24px !important; + height: 24px !important; + } + } + } + + .labels { + display: flex; + justify-content: space-between; + color: #666; + font-size: 0.9rem; + } +} + +.btn-primary { + padding: 8px 24px; + border-radius: 4px; + font-weight: 500; + transition: background-color 0.2s; + + &:hover { + background-color: darken(#007bff, 5%); + } +} diff --git a/src/main/webapp/app/core/user/settings/learner-profile/learner-profile.component.ts b/src/main/webapp/app/core/user/settings/learner-profile/learner-profile.component.ts new file mode 100644 index 000000000000..bef445e625d2 --- /dev/null +++ b/src/main/webapp/app/core/user/settings/learner-profile/learner-profile.component.ts @@ -0,0 +1,75 @@ +import { LearnerProfileApiService } from 'app/learner-profile/service/learner-profile-api.service'; +import { Component, OnDestroy, OnInit, inject } from '@angular/core'; +import { LearnerProfileDTO } from 'app/learner-profile/shared/entities/learner-profile.model'; +import { FormsModule } from '@angular/forms'; +import { TranslateDirective } from 'app/shared/language/translate.directive'; +import { MatSliderModule } from '@angular/material/slider'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { faInfoCircle, faSave } from '@fortawesome/free-solid-svg-icons'; +import { AlertService } from 'app/shared/service/alert.service'; +import { Subject, takeUntil } from 'rxjs'; + +@Component({ + selector: 'jhi-learner-profile', + templateUrl: './learner-profile.component.html', + styleUrls: ['./learner-profile.component.scss', '../user-settings.scss'], + standalone: true, + imports: [FormsModule, TranslateDirective, MatSliderModule, FontAwesomeModule], +}) +export class LearnerProfileComponent implements OnInit, OnDestroy { + learnerProfile: LearnerProfileDTO = new LearnerProfileDTO(); + settingsChanged = false; + isSaving = false; + private readonly learnerProfileApiService = inject(LearnerProfileApiService); + private readonly alertService = inject(AlertService); + private destroy$ = new Subject(); + + // Icons + faInfoCircle = faInfoCircle; + faSave = faSave; + + hideTooltip = () => ''; + + ngOnInit(): void { + this.learnerProfileApiService + .getLearnerProfileForCurrentUser() + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (learnerProfile: LearnerProfileDTO) => { + this.learnerProfile = learnerProfile; + }, + error: () => { + this.alertService.error('artemis.learnerProfile.error.loading'); + }, + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + onSliderChange() { + this.settingsChanged = true; + } + + save(): void { + if (this.isSaving) { + return; + } + + this.isSaving = true; + this.learnerProfileApiService.putUpdatedLearnerProfile(this.learnerProfile).subscribe({ + next: (updatedProfile: LearnerProfileDTO) => { + this.learnerProfile = updatedProfile; + this.settingsChanged = false; + this.isSaving = false; + this.alertService.success('artemis.learnerProfile.success.saved'); + }, + error: () => { + this.alertService.error('artemis.learnerProfile.error.saving'); + this.isSaving = false; + }, + }); + } +} diff --git a/src/main/webapp/app/core/user/settings/user-settings-container/user-settings-container.component.html b/src/main/webapp/app/core/user/settings/user-settings-container/user-settings-container.component.html index e049cbc93080..649243db6b3e 100644 --- a/src/main/webapp/app/core/user/settings/user-settings-container/user-settings-container.component.html +++ b/src/main/webapp/app/core/user/settings/user-settings-container/user-settings-container.component.html @@ -22,6 +22,7 @@