diff --git a/src/main/java/de/tum/in/www1/artemis/domain/ConductAgreement.java b/src/main/java/de/tum/in/www1/artemis/domain/ConductAgreement.java new file mode 100644 index 000000000000..bc95a6c3a5e0 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/domain/ConductAgreement.java @@ -0,0 +1,65 @@ +package de.tum.in.www1.artemis.domain; + +import java.util.Objects; + +import javax.persistence.*; + +import com.fasterxml.jackson.annotation.JsonInclude; + +/** + * A user's agreement of a course's code of conduct. + */ +@Entity +@Table(name = "conduct_agreement") +@JsonInclude(JsonInclude.Include.NON_EMPTY) +@IdClass(ConductAgreementId.class) +public class ConductAgreement { + + @Id + @ManyToOne + @JoinColumn(name = "course_id") + private Course course; + + @Id + @ManyToOne + @JoinColumn(name = "user_id") + private User user; + + public Course getCourse() { + return course; + } + + public void setCourse(Course course) { + this.course = course; + } + + public User getUser() { + return user; + } + + public void setUser(User user) { + this.user = user; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ConductAgreement that = (ConductAgreement) o; + return course.equals(that.course) && user.equals(that.user); + } + + @Override + public int hashCode() { + return Objects.hash(course, user); + } + + @Override + public String toString() { + return "ConductAgreement{" + "course=" + course + ", user=" + user + '}'; + } +} diff --git a/src/main/java/de/tum/in/www1/artemis/domain/ConductAgreementId.java b/src/main/java/de/tum/in/www1/artemis/domain/ConductAgreementId.java new file mode 100644 index 000000000000..930de471cea1 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/domain/ConductAgreementId.java @@ -0,0 +1,56 @@ +package de.tum.in.www1.artemis.domain; + +import java.io.Serializable; +import java.util.Objects; + +/** + * The primary key for ConductAgreement + */ +public class ConductAgreementId implements Serializable { + + private Long course; + + private Long user; + + ConductAgreementId(Long course, Long user) { + this.course = course; + this.user = user; + } + + ConductAgreementId() { + // Needed for JPA + } + + public Long getCourse() { + return course; + } + + public void setCourse(Long course) { + this.course = course; + } + + public Long getUser() { + return user; + } + + public void setUser(Long user) { + this.user = user; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ConductAgreementId that = (ConductAgreementId) o; + return course.equals(that.course) && user.equals(that.user); + } + + @Override + public int hashCode() { + return Objects.hash(course, user); + } +} diff --git a/src/main/java/de/tum/in/www1/artemis/repository/ConductAgreementRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/ConductAgreementRepository.java new file mode 100644 index 000000000000..5db886d67638 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/repository/ConductAgreementRepository.java @@ -0,0 +1,36 @@ +package de.tum.in.www1.artemis.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import de.tum.in.www1.artemis.domain.ConductAgreement; +import de.tum.in.www1.artemis.domain.ConductAgreementId; + +/** + * Spring Data repository for the Code of Conduct Agreement entity. + */ +@Repository +public interface ConductAgreementRepository extends JpaRepository { + + /** + * Find the user's agreement to a course's code of conduct. + * + * @param courseId the ID of the code of conduct's course + * @param userId the user's ID + * @return the user's agreement to the course's code of conduct + */ + Optional findByCourseIdAndUserId(Long courseId, Long userId); + + /** + * Delete all users' agreements to a course's code of conduct. + * + * @param courseId the ID of the code of conduct's course + */ + @Transactional // ok because of delete + @Modifying + void deleteByCourseId(Long courseId); +} diff --git a/src/main/java/de/tum/in/www1/artemis/service/ConductAgreementService.java b/src/main/java/de/tum/in/www1/artemis/service/ConductAgreementService.java new file mode 100644 index 000000000000..694b2a9c2531 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/service/ConductAgreementService.java @@ -0,0 +1,58 @@ +package de.tum.in.www1.artemis.service; + +import org.springframework.stereotype.Service; + +import de.tum.in.www1.artemis.domain.ConductAgreement; +import de.tum.in.www1.artemis.domain.Course; +import de.tum.in.www1.artemis.domain.User; +import de.tum.in.www1.artemis.repository.ConductAgreementRepository; + +/** + * Service Implementation for managing a user's agreement to a course's code of conduct. + */ +@Service +public class ConductAgreementService { + + private final ConductAgreementRepository conductAgreementRepository; + + ConductAgreementService(ConductAgreementRepository conductAgreementRepository) { + this.conductAgreementRepository = conductAgreementRepository; + } + + /** + * Fetches if a user agreed to a course's code of conduct. + * + * @param user the user in the course + * @param course the code of conduct's course + * @return if the user agreed to the course's code of conduct + */ + public boolean fetchUserAgreesToCodeOfConductInCourse(User user, Course course) { + var codeOfConduct = course.getCourseInformationSharingMessagingCodeOfConduct(); + if (codeOfConduct == null || codeOfConduct.isEmpty()) { + return true; + } + return conductAgreementRepository.findByCourseIdAndUserId(course.getId(), user.getId()).isPresent(); + } + + /** + * A user agrees to a course's code of conduct. + * + * @param user the user in the course + * @param course the code of conduct's course + */ + public void setUserAgreesToCodeOfConductInCourse(User user, Course course) { + ConductAgreement conductAgreement = new ConductAgreement(); + conductAgreement.setCourse(course); + conductAgreement.setUser(user); + conductAgreementRepository.save(conductAgreement); + } + + /** + * Reset all agreements to a course's code of conduct. + * + * @param course the code of conduct's course + */ + public void resetUsersAgreeToCodeOfConductInCourse(Course course) { + conductAgreementRepository.deleteByCourseId(course.getId()); + } +} diff --git a/src/main/java/de/tum/in/www1/artemis/service/dto/ResponsibleUserDTO.java b/src/main/java/de/tum/in/www1/artemis/service/dto/ResponsibleUserDTO.java new file mode 100644 index 000000000000..59bf474332ca --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/service/dto/ResponsibleUserDTO.java @@ -0,0 +1,7 @@ +package de.tum.in.www1.artemis.service.dto; + +/** + * A DTO representing a course's responsible user, i.e., a person to report misconduct to. + */ +public record ResponsibleUserDTO(String name, String email) { +} diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/CourseResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/CourseResource.java index db78ea4d77b8..8419621e6dcd 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/CourseResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/CourseResource.java @@ -106,6 +106,8 @@ public class CourseResource { private final GradingScaleRepository gradingScaleRepository; + private final ConductAgreementService conductAgreementService; + @Value("${artemis.course-archives-path}") private String courseArchivesDirPath; @@ -116,7 +118,8 @@ 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, LearningPathService learningPathService, + ConductAgreementService conductAgreementService) { this.courseService = courseService; this.courseRepository = courseRepository; this.exerciseService = exerciseService; @@ -136,6 +139,7 @@ public CourseResource(UserRepository userRepository, CourseService courseService this.courseScoreCalculationService = courseScoreCalculationService; this.gradingScaleRepository = gradingScaleRepository; this.learningPathService = learningPathService; + this.conductAgreementService = conductAgreementService; } /** @@ -234,6 +238,10 @@ public ResponseEntity updateCourse(@PathVariable Long courseId, @Request } } + if (!Objects.equals(courseUpdate.getCourseInformationSharingMessagingCodeOfConduct(), existingCourse.getCourseInformationSharingMessagingCodeOfConduct())) { + conductAgreementService.resetUsersAgreeToCodeOfConductInCourse(existingCourse); + } + courseUpdate.setId(courseId); // Don't persist a wrong ID Course result = courseRepository.save(courseUpdate); diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/FileResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/FileResource.java index 2464683b3a36..6c3e87d73ad3 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/FileResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/FileResource.java @@ -308,6 +308,20 @@ public ResponseEntity getCourseIcon(@PathVariable Long courseId) { return responseEntityForFilePath(filePathService.actualPathForPublicPath(URI.create(course.getCourseIcon()))); } + /** + * GET /files/templates/code-of-conduct : Get the Code of Conduct template + * + * @return The requested file, 403 if the logged-in user is not allowed to access it, or 404 if the file doesn't exist + */ + @GetMapping("files/templates/code-of-conduct") + @EnforceAtLeastInstructor + public ResponseEntity getCourseCodeOfConduct() throws IOException { + var templatePath = Path.of("templates", "codeofconduct", "README.md"); + log.debug("REST request to get template : {}", templatePath); + var resource = resourceLoaderService.getResource(templatePath); + return ResponseEntity.ok(resource.getInputStream().readAllBytes()); + } + /** * GET /files/exam-user/signatures/:examUserId/:filename : Get the exam user signature * diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/metis/conversation/ConversationResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/metis/conversation/ConversationResource.java index 80829e9ed7e6..4d057cc015aa 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/metis/conversation/ConversationResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/metis/conversation/ConversationResource.java @@ -1,8 +1,6 @@ package de.tum.in.www1.artemis.web.rest.metis.conversation; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; +import java.util.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -22,6 +20,8 @@ import de.tum.in.www1.artemis.security.Role; import de.tum.in.www1.artemis.security.annotations.EnforceAtLeastStudent; import de.tum.in.www1.artemis.service.AuthorizationCheckService; +import de.tum.in.www1.artemis.service.ConductAgreementService; +import de.tum.in.www1.artemis.service.dto.ResponsibleUserDTO; import de.tum.in.www1.artemis.service.dto.UserPublicInfoDTO; import de.tum.in.www1.artemis.service.metis.conversation.ConversationService; import de.tum.in.www1.artemis.service.metis.conversation.ConversationService.ConversationMemberSearchFilters; @@ -46,13 +46,17 @@ public class ConversationResource extends ConversationManagementResource { private final UserRepository userRepository; + private final ConductAgreementService conductAgreementService; + public ConversationResource(ConversationService conversationService, ChannelAuthorizationService channelAuthorizationService, - AuthorizationCheckService authorizationCheckService, UserRepository userRepository, CourseRepository courseRepository) { + AuthorizationCheckService authorizationCheckService, UserRepository userRepository, CourseRepository courseRepository, + ConductAgreementService conductAgreementService) { super(courseRepository); this.conversationService = conversationService; this.channelAuthorizationService = channelAuthorizationService; this.authorizationCheckService = authorizationCheckService; this.userRepository = userRepository; + this.conductAgreementService = conductAgreementService; } /** @@ -124,6 +128,61 @@ public ResponseEntity hasUnreadMessages(@PathVariable Long courseId) { return ResponseEntity.ok(conversationService.userHasUnreadMessages(courseId, requestingUser)); } + /** + * GET /api/courses/:courseId/code-of-conduct/agreement : Checks if the user agrees to the code of conduct + * + * @param courseId the course's ID + * @return ResponseEntity with status 200 (Ok) and body is true if the user agreed to the course's code of conduct + */ + @GetMapping("/{courseId}/code-of-conduct/agreement") + @EnforceAtLeastStudent + public ResponseEntity isCodeOfConductAccepted(@PathVariable Long courseId) { + checkMessagingEnabledElseThrow(courseId); + var course = courseRepository.findByIdElseThrow(courseId); + var requestingUser = userRepository.getUserWithGroupsAndAuthorities(); + authorizationCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.STUDENT, course, requestingUser); + return ResponseEntity.ok(conductAgreementService.fetchUserAgreesToCodeOfConductInCourse(requestingUser, course)); + } + + /** + * PATCH /api/courses/:courseId/code-of-conduct/agreement : Accept the course's code of conduct + * + * @param courseId the course's ID + * @return ResponseEntity with status 200 (Ok) + */ + @PatchMapping("/{courseId}/code-of-conduct/agreement") + @EnforceAtLeastStudent + public ResponseEntity acceptCodeOfConduct(@PathVariable Long courseId) { + checkMessagingEnabledElseThrow(courseId); + var course = courseRepository.findByIdElseThrow(courseId); + var requestingUser = userRepository.getUserWithGroupsAndAuthorities(); + authorizationCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.STUDENT, course, requestingUser); + conductAgreementService.setUserAgreesToCodeOfConductInCourse(requestingUser, course); + return ResponseEntity.ok().build(); + } + + /** + * GET /api/courses/:courseId/code-of-conduct/responsible-users : Users responsible for the course + * + * @param courseId the course's ID + * @return ResponseEntity with the status 200 (Ok) and a list of users responsible for the course + */ + @GetMapping("/{courseId}/code-of-conduct/responsible-users") + @EnforceAtLeastStudent + public ResponseEntity> getResponsibleUsersForCodeOfConduct(@PathVariable Long courseId) { + checkMessagingEnabledElseThrow(courseId); + + var requestingUser = userRepository.getUserWithGroupsAndAuthorities(); + + var course = courseRepository.findByIdElseThrow(courseId); + authorizationCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.STUDENT, course, requestingUser); + + var responsibleUsers = userRepository.searchAllByLoginOrNameInGroups(Pageable.unpaged(), "", Set.of(course.getInstructorGroupName())) + .map((user) -> new ResponsibleUserDTO(user.getName(), user.getEmail())).toList(); + + return ResponseEntity.ok(responsibleUsers); + } + /** * GET /api/courses/:courseId/conversations/:conversationId/members/search: Searches for members of a conversation * diff --git a/src/main/resources/config/liquibase/changelog/20230927125606_changelog.xml b/src/main/resources/config/liquibase/changelog/20230927125606_changelog.xml new file mode 100644 index 000000000000..e611258fad91 --- /dev/null +++ b/src/main/resources/config/liquibase/changelog/20230927125606_changelog.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + diff --git a/src/main/resources/config/liquibase/master.xml b/src/main/resources/config/liquibase/master.xml index 4126937b405d..2d7584cbf99f 100644 --- a/src/main/resources/config/liquibase/master.xml +++ b/src/main/resources/config/liquibase/master.xml @@ -62,6 +62,7 @@ + diff --git a/src/main/resources/templates/codeofconduct/README.md b/src/main/resources/templates/codeofconduct/README.md new file mode 100644 index 000000000000..b71b797ae668 --- /dev/null +++ b/src/main/resources/templates/codeofconduct/README.md @@ -0,0 +1,34 @@ + + +We as students, tutors, and instructors pledge to make participation in our course a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +### Examples of behavior that contributes to a positive environment for our community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +- Focusing on what is best not just for us as individuals, but for the overall community + +### Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or advances of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a professional setting + +## Scope + +This Code of Conduct applies within all messages channels. + +## Reporting + +Each course is represented by instructors. If you see inappropriate behavior or content, please report it. +You may find a list of contacts responsible for this course below. + + diff --git a/src/main/webapp/app/core/user/user.model.ts b/src/main/webapp/app/core/user/user.model.ts index cb34cc387861..1599dd14d74c 100644 --- a/src/main/webapp/app/core/user/user.model.ts +++ b/src/main/webapp/app/core/user/user.model.ts @@ -56,6 +56,7 @@ export class UserPublicInfoDTO { public name?: string; public firstName?: string; public lastName?: string; + public email?: string; public isInstructor?: boolean; public isEditor?: boolean; public isTeachingAssistant?: boolean; diff --git a/src/main/webapp/app/course/manage/course-update.component.html b/src/main/webapp/app/course/manage/course-update.component.html index 040fc4f8768b..f0e593c668e8 100644 --- a/src/main/webapp/app/course/manage/course-update.component.html +++ b/src/main/webapp/app/course/manage/course-update.component.html @@ -337,10 +337,8 @@
>
- - + + 0 && this.course.maxComplaintResponseTextLimit! > 0; this.requestMoreFeedbackEnabled = this.course.maxRequestMoreFeedbackTimeDays! > 0; + } else { + this.fileService.getTemplateCodeOfCondcut().subscribe({ + next: (res: HttpResponse) => { + if (res.body) { + this.course.courseInformationSharingMessagingCodeOfConduct = res.body; + } + }, + error: (res: HttpErrorResponse) => onError(this.alertService, res), + }); } }); diff --git a/src/main/webapp/app/overview/course-conversations/code-of-conduct/course-conversations-code-of-conduct.component.html b/src/main/webapp/app/overview/course-conversations/code-of-conduct/course-conversations-code-of-conduct.component.html new file mode 100644 index 000000000000..944577d640b4 --- /dev/null +++ b/src/main/webapp/app/overview/course-conversations/code-of-conduct/course-conversations-code-of-conduct.component.html @@ -0,0 +1,8 @@ +

{{ 'artemisApp.codeOfConduct.title' | artemisTranslate }}

+
+ diff --git a/src/main/webapp/app/overview/course-conversations/code-of-conduct/course-conversations-code-of-conduct.component.ts b/src/main/webapp/app/overview/course-conversations/code-of-conduct/course-conversations-code-of-conduct.component.ts new file mode 100644 index 000000000000..55d32ade492a --- /dev/null +++ b/src/main/webapp/app/overview/course-conversations/code-of-conduct/course-conversations-code-of-conduct.component.ts @@ -0,0 +1,36 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { User } from 'app/core/user/user.model'; +import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; +import { onError } from 'app/shared/util/global.utils'; +import { AlertService } from 'app/core/util/alert.service'; +import { Course } from 'app/entities/course.model'; +import { ConversationService } from 'app/shared/metis/conversations/conversation.service'; + +@Component({ + selector: 'jhi-course-conversations-code-of-conduct', + templateUrl: './course-conversations-code-of-conduct.component.html', +}) +export class CourseConversationsCodeOfConductComponent implements OnInit { + @Input() + course: Course; + + responsibleContacts: User[] = []; + + constructor( + private alertService: AlertService, + private conversationService: ConversationService, + ) {} + + ngOnInit() { + if (this.course.id) { + this.conversationService.getResponsibleUsersForCodeOfConduct(this.course.id).subscribe({ + next: (res: HttpResponse) => { + if (res.body) { + this.responsibleContacts = res.body; + } + }, + error: (res: HttpErrorResponse) => onError(this.alertService, res), + }); + } + } +} diff --git a/src/main/webapp/app/overview/course-conversations/course-conversations.component.html b/src/main/webapp/app/overview/course-conversations/course-conversations.component.html index 06764d4c21ec..0de393cd8600 100644 --- a/src/main/webapp/app/overview/course-conversations/course-conversations.component.html +++ b/src/main/webapp/app/overview/course-conversations/course-conversations.component.html @@ -1,23 +1,31 @@ -
+
+ + +
+
- + +
+
+
- +
-
+
- + >
diff --git a/src/main/webapp/app/overview/course-conversations/course-conversations.component.ts b/src/main/webapp/app/overview/course-conversations/course-conversations.component.ts index b4ae773043ae..10c2c93b54bb 100644 --- a/src/main/webapp/app/overview/course-conversations/course-conversations.component.ts +++ b/src/main/webapp/app/overview/course-conversations/course-conversations.component.ts @@ -24,6 +24,10 @@ export class CourseConversationsComponent implements OnInit, OnDestroy { postInThread?: Post; activeConversation?: ConversationDto = undefined; conversationsOfUser: ConversationDto[] = []; + + isCodeOfConductAccepted: boolean = false; + isCodeOfConductPresented: boolean = false; + // MetisConversationService is created in course overview, so we can use it here constructor( private router: Router, @@ -56,10 +60,13 @@ export class CourseConversationsComponent implements OnInit, OnDestroy { this.subscribeToQueryParameter(); // service is fully set up, now we can subscribe to the respective observables this.subscribeToActiveConversation(); + this.subscribeToIsCodeOfConductAccepted(); + this.subscribeToIsCodeOfConductPresented(); this.subscribeToConversationsOfUser(); this.subscribeToLoading(); this.isServiceSetUp = true; this.updateQueryParameters(); + this.metisConversationService.checkIsCodeOfConductAccepted(this.course!); } }); } @@ -96,6 +103,18 @@ export class CourseConversationsComponent implements OnInit, OnDestroy { }); } + private subscribeToIsCodeOfConductAccepted() { + this.metisConversationService.isCodeOfConductAccepted$.pipe(takeUntil(this.ngUnsubscribe)).subscribe((isCodeOfConductAccepted: boolean) => { + this.isCodeOfConductAccepted = isCodeOfConductAccepted; + }); + } + + private subscribeToIsCodeOfConductPresented() { + this.metisConversationService.isCodeOfConductPresented$.pipe(takeUntil(this.ngUnsubscribe)).subscribe((isCodeOfConductPresented: boolean) => { + this.isCodeOfConductPresented = isCodeOfConductPresented; + }); + } + private subscribeToConversationsOfUser() { this.metisConversationService.conversationsOfUser$.pipe(takeUntil(this.ngUnsubscribe)).subscribe((conversations: ConversationDto[]) => { this.conversationsOfUser = conversations ?? []; @@ -107,4 +126,10 @@ export class CourseConversationsComponent implements OnInit, OnDestroy { this.isLoading = isLoading; }); } + + acceptCodeOfConduct() { + if (this.course) { + this.metisConversationService.acceptCodeOfConduct(this.course); + } + } } diff --git a/src/main/webapp/app/overview/course-conversations/course-conversations.module.ts b/src/main/webapp/app/overview/course-conversations/course-conversations.module.ts index 5aac80556033..fc83a7f3a396 100644 --- a/src/main/webapp/app/overview/course-conversations/course-conversations.module.ts +++ b/src/main/webapp/app/overview/course-conversations/course-conversations.module.ts @@ -30,6 +30,8 @@ import { ConversationSidebarEntryComponent } from './layout/conversation-selecti import { OneToOneChatCreateDialogComponent } from './dialogs/one-to-one-chat-create-dialog/one-to-one-chat-create-dialog.component'; import { GroupChatCreateDialogComponent } from './dialogs/group-chat-create-dialog/group-chat-create-dialog.component'; import { GroupChatIconComponent } from './other/group-chat-icon/group-chat-icon.component'; +import { ArtemisMarkdownModule } from 'app/shared/markdown.module'; +import { CourseConversationsCodeOfConductComponent } from 'app/overview/course-conversations/code-of-conduct/course-conversations-code-of-conduct.component'; const routes: Routes = [ { @@ -46,6 +48,7 @@ const routes: Routes = [ imports: [ RouterModule.forChild(routes), MetisModule, + ArtemisMarkdownModule, ArtemisSharedModule, ArtemisSharedComponentModule, ArtemisDataTableModule, @@ -54,6 +57,7 @@ const routes: Routes = [ ], declarations: [ CourseConversationsComponent, + CourseConversationsCodeOfConductComponent, ConversationSelectionSidebarComponent, ConversationThreadSidebarComponent, ConversationMessagesComponent, diff --git a/src/main/webapp/app/overview/course-conversations/layout/conversation-selection-sidebar/conversation-selection-sidebar.component.html b/src/main/webapp/app/overview/course-conversations/layout/conversation-selection-sidebar/conversation-selection-sidebar.component.html index e26e347181d5..68bbd0a9a920 100644 --- a/src/main/webapp/app/overview/course-conversations/layout/conversation-selection-sidebar/conversation-selection-sidebar.component.html +++ b/src/main/webapp/app/overview/course-conversations/layout/conversation-selection-sidebar/conversation-selection-sidebar.component.html @@ -198,6 +198,10 @@

+ +
diff --git a/src/main/webapp/app/overview/course-conversations/layout/conversation-selection-sidebar/conversation-selection-sidebar.component.ts b/src/main/webapp/app/overview/course-conversations/layout/conversation-selection-sidebar/conversation-selection-sidebar.component.ts index 1d583d42d5cc..7020f2d02509 100644 --- a/src/main/webapp/app/overview/course-conversations/layout/conversation-selection-sidebar/conversation-selection-sidebar.component.ts +++ b/src/main/webapp/app/overview/course-conversations/layout/conversation-selection-sidebar/conversation-selection-sidebar.component.ts @@ -354,4 +354,8 @@ export class ConversationSelectionSidebarComponent implements AfterViewInit, OnI private filterChannelsOfType(subType: ChannelSubType): ChannelDTO[] { return this.displayedChannelConversations.filter((channel) => channel.subType === subType); } + + openCodeOfConduct() { + this.metisConversationService.setCodeOfConduct(); + } } diff --git a/src/main/webapp/app/shared/http/file.service.ts b/src/main/webapp/app/shared/http/file.service.ts index b1013c9dd322..45de081ecf95 100644 --- a/src/main/webapp/app/shared/http/file.service.ts +++ b/src/main/webapp/app/shared/http/file.service.ts @@ -1,5 +1,6 @@ -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpResponse } from '@angular/common/http'; import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; import { ProgrammingLanguage, ProjectType } from 'app/entities/programming-exercise.model'; @@ -15,7 +16,7 @@ export class FileService { * @param {ProjectType} projectType (if available) * @returns json test file */ - getTemplateFile(language: ProgrammingLanguage, projectType?: ProjectType) { + getTemplateFile(language: ProgrammingLanguage, projectType?: ProjectType): Observable { const urlParts: string[] = [language]; if (projectType) { urlParts.push(projectType); @@ -23,6 +24,14 @@ export class FileService { return this.http.get(`${this.resourceUrl}/templates/` + urlParts.join('/'), { responseType: 'text' as 'json' }); } + /** + * Fetches the template code of conduct + * @returns markdown file + */ + getTemplateCodeOfCondcut(): Observable> { + return this.http.get(`api/files/templates/code-of-conduct`, { observe: 'response', responseType: 'text' as 'json' }); + } + /** * Downloads the file from the provided downloadUrl. * diff --git a/src/main/webapp/app/shared/metis/conversations/conversation.service.ts b/src/main/webapp/app/shared/metis/conversations/conversation.service.ts index 2e03a5d2c00c..ce3ff8d49a3b 100644 --- a/src/main/webapp/app/shared/metis/conversations/conversation.service.ts +++ b/src/main/webapp/app/shared/metis/conversations/conversation.service.ts @@ -122,6 +122,18 @@ export class ConversationService { return this.http.get(`${this.resourceUrl}${courseId}/unread-messages`, { observe: 'response' }); } + acceptCodeOfConduct(courseId: number): Observable> { + return this.http.patch(`${this.resourceUrl}${courseId}/code-of-conduct/agreement`, null, { observe: 'response' }); + } + + checkIsCodeOfConductAccepted(courseId: number): Observable> { + return this.http.get(`${this.resourceUrl}${courseId}/code-of-conduct/agreement`, { observe: 'response' }); + } + + getResponsibleUsersForCodeOfConduct(courseId: number): Observable> { + return this.http.get(`${this.resourceUrl}${courseId}/code-of-conduct/responsible-users`, { observe: 'response' }); + } + public convertDateFromClient = (conversation: Conversation) => ({ ...conversation, creationDate: convertDateFromClient(conversation.creationDate), diff --git a/src/main/webapp/app/shared/metis/metis-conversation.service.ts b/src/main/webapp/app/shared/metis/metis-conversation.service.ts index c0ecdce30b5d..8f9f864bbc38 100644 --- a/src/main/webapp/app/shared/metis/metis-conversation.service.ts +++ b/src/main/webapp/app/shared/metis/metis-conversation.service.ts @@ -30,6 +30,10 @@ export class MetisConversationService implements OnDestroy { // Stores the currently selected conversation private activeConversation: ConversationDto | undefined = undefined; _activeConversation$: ReplaySubject = new ReplaySubject(1); + private isCodeOfConductAccepted: boolean = false; + _isCodeOfConductAccepted$: ReplaySubject = new ReplaySubject(1); + private isCodeOfConductPresented: boolean = false; + _isCodeOfConductPresented$: ReplaySubject = new ReplaySubject(1); private hasUnreadMessages = false; _hasUnreadMessages$: Subject = new ReplaySubject(1); // Stores the course for which the service is setup -> should not change during the lifetime of the service @@ -72,6 +76,12 @@ export class MetisConversationService implements OnDestroy { get activeConversation$(): Observable { return this._activeConversation$.asObservable(); } + get isCodeOfConductAccepted$(): Observable { + return this._isCodeOfConductAccepted$.asObservable(); + } + get isCodeOfConductPresented$(): Observable { + return this._isCodeOfConductPresented$.asObservable(); + } get hasUnreadMessages$(): Observable { return this._hasUnreadMessages$.asObservable(); } @@ -88,7 +98,7 @@ export class MetisConversationService implements OnDestroy { return this._isLoading$.asObservable(); } - public setActiveConversation = (conversationIdentifier: ConversationDto | number | undefined) => { + public setActiveConversation(conversationIdentifier: ConversationDto | number | undefined) { this.updateLastReadDateAndNumberOfUnreadMessages(); let cachedConversation = undefined; if (conversationIdentifier) { @@ -102,7 +112,19 @@ export class MetisConversationService implements OnDestroy { } this.activeConversation = cachedConversation; this._activeConversation$.next(this.activeConversation); - }; + this.isCodeOfConductPresented = false; + this._isCodeOfConductPresented$.next(this.isCodeOfConductPresented); + } + + /** + * Set the course conversation component to the contents of the code of conduct. + */ + public setCodeOfConduct() { + this.activeConversation = undefined; + this._activeConversation$.next(this.activeConversation); + this.isCodeOfConductPresented = true; + this._isCodeOfConductPresented$.next(this.isCodeOfConductPresented); + } private updateLastReadDateAndNumberOfUnreadMessages() { // update last read date and number of unread messages of the conversation that is currently active before switching to another conversation @@ -237,6 +259,40 @@ export class MetisConversationService implements OnDestroy { }); }; + acceptCodeOfConduct(course: Course) { + if (!course?.id) { + return; + } + + this.conversationService.acceptCodeOfConduct(course.id).subscribe({ + next: () => { + this.isCodeOfConductAccepted = true; + this._isCodeOfConductAccepted$.next(true); + }, + error: (errorResponse: HttpErrorResponse) => { + onError(this.alertService, errorResponse); + }, + }); + } + + checkIsCodeOfConductAccepted(course: Course) { + if (!course?.id) { + return; + } + + this.conversationService.checkIsCodeOfConductAccepted(course.id).subscribe({ + next: (response) => { + if (response.body !== null) { + this.isCodeOfConductAccepted = response.body; + this._isCodeOfConductAccepted$.next(this.isCodeOfConductAccepted); + } + }, + error: (errorResponse: HttpErrorResponse) => { + onError(this.alertService, errorResponse); + }, + }); + } + private hasUnreadMessagesCheck = (): void => { const hasNewMessages = this.conversationsOfUser.some((conversation) => { return conversation?.unreadMessagesCount && conversation.unreadMessagesCount > 0; diff --git a/src/main/webapp/i18n/de/codeOfConduct.json b/src/main/webapp/i18n/de/codeOfConduct.json new file mode 100644 index 000000000000..81514c7421f8 --- /dev/null +++ b/src/main/webapp/i18n/de/codeOfConduct.json @@ -0,0 +1,9 @@ +{ + "artemisApp": { + "codeOfConduct": { + "accept": "Akzeptieren", + "title": "Verhaltenskodex", + "tooltip": "Der Verhaltenskodex gibt Nutzer:innen an, wie sie miteinander kommunizieren sollen und welche Konsequenzen bei Fehlverhalten drohen können, sowie einen Kontakt zum Melden von Verstößen." + } + } +} diff --git a/src/main/webapp/i18n/de/course.json b/src/main/webapp/i18n/de/course.json index 3390637f6d32..642561a24be8 100644 --- a/src/main/webapp/i18n/de/course.json +++ b/src/main/webapp/i18n/de/course.json @@ -93,11 +93,7 @@ }, "messagingEnabled": { "label": "Nachrichten aktiviert", - "tooltip": "Ermöglicht den Nachrichtenaustausch zwischen Nutzer:innen in privaten oder öffentlichen Kanälen, Gruppenchats oder Direktnachrichten. Kanäle können nur von Lehrenden und Tutor:innen erstellt werden. Nutzer:innen können selbst öffentlichen Kanälen beitreten und müssen zu privaten Kanälen hinzugefügt werden. Alle Nutzer:innen können einen privaten Gruppenchat starten und andere Nutzer:innen hinzufügen. Ein Gruppenchat ist auf zehn Mitglieder:innen begrenzt. Alle Nutzer:innen können Direktnachrichten an andere Nutzer:innen senden. Die Chats finden im Nachrichtenbereich des Kurses statt.", - "codeOfConduct": { - "label": "Verhaltenskodex", - "tooltip": "Der Verhaltenskodex gibt Nutzer:innen an, wie sie miteinander kommunizieren sollen und welche Konsequenzen bei Fehlverhalten drohen können, sowie einen Kontakt zur Berichterstattung." - } + "tooltip": "Ermöglicht den Nachrichtenaustausch zwischen Nutzer:innen in privaten oder öffentlichen Kanälen, Gruppenchats oder Direktnachrichten. Kanäle können nur von Lehrenden und Tutor:innen erstellt werden. Nutzer:innen können selbst öffentlichen Kanälen beitreten und müssen zu privaten Kanälen hinzugefügt werden. Alle Nutzer:innen können einen privaten Gruppenchat starten und andere Nutzer:innen hinzufügen. Ein Gruppenchat ist auf zehn Mitglieder:innen begrenzt. Alle Nutzer:innen können Direktnachrichten an andere Nutzer:innen senden. Die Chats finden im Nachrichtenbereich des Kurses statt." } }, "registrationEnabled": { diff --git a/src/main/webapp/i18n/en/codeOfConduct.json b/src/main/webapp/i18n/en/codeOfConduct.json new file mode 100644 index 000000000000..fb4aaa24e7ae --- /dev/null +++ b/src/main/webapp/i18n/en/codeOfConduct.json @@ -0,0 +1,9 @@ +{ + "artemisApp": { + "codeOfConduct": { + "accept": "Accept", + "title": "Code of Conduct", + "tooltip": "The Code of Conduct describes to users how best to communicate and which consequences might be raised if there is misconduct, as well as, contact information for reporting." + } + } +} diff --git a/src/main/webapp/i18n/en/course.json b/src/main/webapp/i18n/en/course.json index 6e8695f31d42..978785393729 100644 --- a/src/main/webapp/i18n/en/course.json +++ b/src/main/webapp/i18n/en/course.json @@ -93,11 +93,7 @@ }, "messagingEnabled": { "label": "Messaging Enabled", - "tooltip": "Enables messaging between course users in private or public channels, group chats or direct messages. Channels can only be created by instructors and tutors. Users can self-join public channels and must be invited to private channels. Every user can start a private group chat and add other users. A group chat is limited to 10 members. Every user can start a private one-to-one chat with another user. The chats happens in the messaging space of the course.", - "codeOfConduct": { - "label": "Code of Conduct", - "tooltip": "The Code of Conduct describes to users how best to communicate and which consequences might be raised if there is misconduct, as well as, contact information for reporting." - } + "tooltip": "Enables messaging between course users in private or public channels, group chats or direct messages. Channels can only be created by instructors and tutors. Users can self-join public channels and must be invited to private channels. Every user can start a private group chat and add other users. A group chat is limited to 10 members. Every user can start a private one-to-one chat with another user. The chats happens in the messaging space of the course." } }, "registrationEnabled": { diff --git a/src/test/cypress/e2e/course/CourseMessages.cy.ts b/src/test/cypress/e2e/course/CourseMessages.cy.ts index b08f6f670181..05b2e29aba82 100644 --- a/src/test/cypress/e2e/course/CourseMessages.cy.ts +++ b/src/test/cypress/e2e/course/CourseMessages.cy.ts @@ -20,6 +20,17 @@ describe('Course messages', () => { }); }); + it('accepts code of conduct', () => { + cy.login(instructor, `/courses/${course.id}/messages`); + courseMessages.acceptCodeOfConductButton(); + cy.login(studentOne, `/courses/${course.id}/messages`); + courseMessages.acceptCodeOfConductButton(); + cy.login(studentTwo, `/courses/${course.id}/messages`); + courseMessages.acceptCodeOfConductButton(); + cy.login(tutor, `/courses/${course.id}/messages`); + courseMessages.acceptCodeOfConductButton(); + }); + describe('Channel messages', () => { describe('Create channel', () => { it('check for pre-created channels', () => { @@ -223,7 +234,7 @@ describe('Course messages', () => { }); }); - it('student should be able to delete his message in channel', () => { + it('student should be able to delete message in channel', () => { cy.login(studentOne, `/courses/${course.id}/messages?conversationId=${channel.id}`); const messageText = 'Student Edit Test Message'; communicationAPIRequest.createCourseMessage(course, channel.id!, 'channel', messageText).then((response) => { @@ -398,7 +409,7 @@ describe('Course messages', () => { }); }); - it('student should be able to delete his message in group chat', () => { + it('student should be able to delete message in group chat', () => { cy.login(studentOne, `/courses/${course.id}/messages?conversationId=${groupChat.id}`); const messageText = 'Student Edit Test Message'; communicationAPIRequest.createCourseMessage(course, groupChat.id!, 'groupChat', messageText).then((response) => { diff --git a/src/test/cypress/support/pageobjects/course/CourseCommunication.ts b/src/test/cypress/support/pageobjects/course/CourseCommunication.ts index cec35cf7fd45..ae7b46fe2fc5 100644 --- a/src/test/cypress/support/pageobjects/course/CourseCommunication.ts +++ b/src/test/cypress/support/pageobjects/course/CourseCommunication.ts @@ -2,7 +2,7 @@ import { BASE_API, CourseWideContext, POST, PUT } from '../../constants'; import { titleCaseWord } from '../../utils'; /** - * A class which encapsulates UI selectors and actions for the course creation page. + * A class which encapsulates UI selectors and actions for the course communication page. */ export class CourseCommunicationPage { newPost() { diff --git a/src/test/cypress/support/pageobjects/course/CourseMessages.ts b/src/test/cypress/support/pageobjects/course/CourseMessages.ts index df690bbd5638..d83fa487f11b 100644 --- a/src/test/cypress/support/pageobjects/course/CourseMessages.ts +++ b/src/test/cypress/support/pageobjects/course/CourseMessages.ts @@ -1,7 +1,7 @@ import { BASE_API, DELETE, POST, PUT } from '../../constants'; /** - * A class which encapsulates UI selectors and actions for the course creation page. + * A class which encapsulates UI selectors and actions for the course messages page. */ export class CourseMessagesPage { createChannelButton() { @@ -213,4 +213,8 @@ export class CourseMessagesPage { cy.get('.conversation-list').should('not.contain.text', name); } } + + acceptCodeOfConductButton() { + cy.get('#acceptCodeOfConductButton').click(); + } } diff --git a/src/test/cypress/support/requests/CourseManagementAPIRequests.ts b/src/test/cypress/support/requests/CourseManagementAPIRequests.ts index bc4104fb61f0..b13aa1d8201b 100644 --- a/src/test/cypress/support/requests/CourseManagementAPIRequests.ts +++ b/src/test/cypress/support/requests/CourseManagementAPIRequests.ts @@ -59,10 +59,12 @@ export class CourseManagementAPIRequests { if (allowCommunication && allowMessaging) { course.courseInformationSharingConfiguration = CourseInformationSharingConfiguration.COMMUNICATION_AND_MESSAGING; + course.courseInformationSharingMessagingCodeOfConduct = 'Code of Conduct'; } else if (allowCommunication) { course.courseInformationSharingConfiguration = CourseInformationSharingConfiguration.COMMUNICATION_ONLY; } else if (allowMessaging) { course.courseInformationSharingConfiguration = CourseInformationSharingConfiguration.MESSAGING_ONLY; + course.courseInformationSharingMessagingCodeOfConduct = 'Code of Conduct'; } else { course.courseInformationSharingConfiguration = CourseInformationSharingConfiguration.DISABLED; } diff --git a/src/test/java/de/tum/in/www1/artemis/FileIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/FileIntegrationTest.java index ef099f89b9e7..ee0dbbd43010 100644 --- a/src/test/java/de/tum/in/www1/artemis/FileIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/FileIntegrationTest.java @@ -143,6 +143,13 @@ void testGetCourseIcon() throws Exception { assertThat(receivedIcon).isEqualTo("some data"); } + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testGetCodeOfConductTemplate() throws Exception { + var template = request.get("/api/files/templates/code-of-conduct", HttpStatus.OK, String.class); + assertThat(template).startsWith("