diff --git a/backend/.env-old b/backend/.env-old new file mode 100644 index 00000000..bedfb0b7 --- /dev/null +++ b/backend/.env-old @@ -0,0 +1,21 @@ +PORT=3000 +DWENGO_DB_HOST=db +DWENGO_DB_PORT=5432 +DWENGO_DB_USERNAME=postgres +DWENGO_DB_PASSWORD=postgres +DWENGO_DB_UPDATE=false + +DWENGO_AUTH_STUDENT_URL=http://localhost/idp/realms/student +DWENGO_AUTH_STUDENT_CLIENT_ID=dwengo +DWENGO_AUTH_STUDENT_JWKS_ENDPOINT=http://idp:7080/idp/realms/student/protocol/openid-connect/certs +DWENGO_AUTH_TEACHER_URL=http://localhost/idp/realms/teacher +DWENGO_AUTH_TEACHER_CLIENT_ID=dwengo +DWENGO_AUTH_TEACHER_JWKS_ENDPOINT=http://idp:7080/idp/realms/teacher/protocol/openid-connect/certs + +# Allow Vite dev-server to access the backend (for testing purposes). Don't forget to remove this in production! +#DWENGO_CORS_ALLOWED_ORIGINS=http://localhost/,127.0.0.1:80,http://127.0.0.1,http://localhost:80,http://127.0.0.1:80,localhost +DWENGO_CORS_ALLOWED_ORIGINS=http://localhost/*,http://idp:7080,https://idp:7080 + +# Logging and monitoring + +LOKI_HOST=http://logging:3102 diff --git a/backend/Dockerfile b/backend/Dockerfile index bb3464c3..1d82a484 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -6,8 +6,9 @@ WORKDIR /app/dwengo COPY package*.json ./ COPY backend/package.json ./backend/ -# Backend depends on common +# Backend depends on common and docs COPY common/package.json ./common/ +COPY docs/package.json ./docs/ RUN npm install --silent @@ -34,6 +35,7 @@ COPY ./backend/i18n ./i18n COPY --from=build-stage /app/dwengo/common/dist ./common/dist COPY --from=build-stage /app/dwengo/backend/dist ./backend/dist +COPY --from=build-stage /app/dwengo/docs/api/swagger.json ./docs/api/swagger.json COPY package*.json ./ COPY backend/package.json ./backend/ @@ -42,7 +44,6 @@ COPY common/package.json ./common/ RUN npm install --silent --only=production -COPY ./docs ./docs COPY ./backend/i18n ./backend/i18n EXPOSE 3000 diff --git a/backend/package.json b/backend/package.json index 31e00f15..275bad7d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -12,7 +12,7 @@ "format": "prettier --write src/", "format-check": "prettier --check src/", "lint": "eslint . --fix", - "pretest:unit": "npm run build", + "pretest:unit": "tsx ../docs/api/generate.ts && npm run build", "test:unit": "vitest --run" }, "dependencies": { diff --git a/backend/src/controllers/questions.ts b/backend/src/controllers/questions.ts index dfb3ef6c..3240aa71 100644 --- a/backend/src/controllers/questions.ts +++ b/backend/src/controllers/questions.ts @@ -1,8 +1,15 @@ import { Request, Response } from 'express'; -import { createQuestion, deleteQuestion, getAllQuestions, getQuestion, updateQuestion } from '../services/questions.js'; +import { + createQuestion, + deleteQuestion, + getAllQuestions, + getQuestion, + getQuestionsAboutLearningObjectInAssignment, + updateQuestion, +} from '../services/questions.js'; import { FALLBACK_LANG, FALLBACK_SEQ_NUM, FALLBACK_VERSION_NUM } from '../config.js'; import { LearningObjectIdentifier } from '../entities/content/learning-object-identifier.js'; -import { QuestionData, QuestionId } from '@dwengo-1/common/interfaces/question'; +import { QuestionData, QuestionDTO, QuestionId } from '@dwengo-1/common/interfaces/question'; import { Language } from '@dwengo-1/common/util/language'; import { requireFields } from './error-helper.js'; @@ -30,7 +37,18 @@ export async function getAllQuestionsHandler(req: Request, res: Response): Promi const learningObjectId = getLearningObjectId(hruid, version, language); - const questions = await getAllQuestions(learningObjectId, full); + let questions: QuestionDTO[] | QuestionId[]; + if (req.query.classId && req.query.assignmentId) { + questions = await getQuestionsAboutLearningObjectInAssignment( + learningObjectId, + req.query.classId as string, + parseInt(req.query.assignmentId as string), + full ?? false, + req.query.forStudent as string | undefined + ); + } else { + questions = await getAllQuestions(learningObjectId, full ?? false); + } res.json({ questions }); } @@ -60,7 +78,8 @@ export async function createQuestionHandler(req: Request, res: Response): Promis const author = req.body.author as string; const content = req.body.content as string; - requireFields({ author, content }); + const inGroup = req.body.inGroup; + requireFields({ author, content, inGroup }); const questionData = req.body as QuestionData; diff --git a/backend/src/controllers/submissions.ts b/backend/src/controllers/submissions.ts index fb681656..92cf84c1 100644 --- a/backend/src/controllers/submissions.ts +++ b/backend/src/controllers/submissions.ts @@ -1,10 +1,32 @@ import { Request, Response } from 'express'; -import { createSubmission, deleteSubmission, getAllSubmissions, getSubmission } from '../services/submissions.js'; -import { BadRequestException } from '../exceptions/bad-request-exception.js'; -import { LearningObjectIdentifier } from '../entities/content/learning-object-identifier.js'; -import { Language, languageMap } from '@dwengo-1/common/util/language'; +import { + createSubmission, + deleteSubmission, + getAllSubmissions, + getSubmission, + getSubmissionsForLearningObjectAndAssignment, +} from '../services/submissions.js'; import { SubmissionDTO } from '@dwengo-1/common/interfaces/submission'; +import { Language, languageMap } from '@dwengo-1/common/util/language'; +import { BadRequestException } from '../exceptions/bad-request-exception.js'; import { requireFields } from './error-helper.js'; +import { LearningObjectIdentifier } from '../entities/content/learning-object-identifier.js'; + +export async function getSubmissionsHandler(req: Request, res: Response): Promise { + const loHruid = req.params.hruid; + const lang = languageMap[req.query.language as string] || Language.Dutch; + const version = parseInt(req.query.version as string) ?? 1; + + const submissions = await getSubmissionsForLearningObjectAndAssignment( + loHruid, + lang, + version, + req.query.classId as string, + parseInt(req.query.assignmentId as string) + ); + + res.json(submissions); +} export async function getSubmissionHandler(req: Request, res: Response): Promise { const lohruid = req.params.hruid; diff --git a/backend/src/controllers/teacher-invitations.ts b/backend/src/controllers/teacher-invitations.ts new file mode 100644 index 00000000..5f003f7f --- /dev/null +++ b/backend/src/controllers/teacher-invitations.ts @@ -0,0 +1,66 @@ +import { Request, Response } from 'express'; +import { requireFields } from './error-helper.js'; +import { createInvitation, deleteInvitation, getAllInvitations, getInvitation, updateInvitation } from '../services/teacher-invitations.js'; +import { TeacherInvitationData } from '@dwengo-1/common/interfaces/teacher-invitation'; + +export async function getAllInvitationsHandler(req: Request, res: Response): Promise { + const username = req.params.username; + const by = req.query.sent === 'true'; + requireFields({ username }); + + const invitations = await getAllInvitations(username, by); + + res.json({ invitations }); +} + +export async function getInvitationHandler(req: Request, res: Response): Promise { + const sender = req.params.sender; + const receiver = req.params.receiver; + const classId = req.params.classId; + requireFields({ sender, receiver, classId }); + + const invitation = await getInvitation(sender, receiver, classId); + + res.json({ invitation }); +} + +export async function createInvitationHandler(req: Request, res: Response): Promise { + const sender = req.body.sender; + const receiver = req.body.receiver; + const classId = req.body.class; + requireFields({ sender, receiver, classId }); + + const data = req.body as TeacherInvitationData; + const invitation = await createInvitation(data); + + res.json({ invitation }); +} + +export async function updateInvitationHandler(req: Request, res: Response): Promise { + const sender = req.body.sender; + const receiver = req.body.receiver; + const classId = req.body.class; + req.body.accepted = req.body.accepted !== 'false'; + requireFields({ sender, receiver, classId }); + + const data = req.body as TeacherInvitationData; + const invitation = await updateInvitation(data); + + res.json({ invitation }); +} + +export async function deleteInvitationHandler(req: Request, res: Response): Promise { + const sender = req.params.sender; + const receiver = req.params.receiver; + const classId = req.params.classId; + requireFields({ sender, receiver, classId }); + + const data: TeacherInvitationData = { + sender, + receiver, + class: classId, + }; + const invitation = await deleteInvitation(data); + + res.json({ invitation }); +} diff --git a/backend/src/data/assignments/assignment-repository.ts b/backend/src/data/assignments/assignment-repository.ts index 8b9ebe22..bdfa9fa7 100644 --- a/backend/src/data/assignments/assignment-repository.ts +++ b/backend/src/data/assignments/assignment-repository.ts @@ -6,6 +6,22 @@ export class AssignmentRepository extends DwengoEntityRepository { public async findByClassAndId(within: Class, id: number): Promise { return this.findOne({ within: within, id: id }, { populate: [ "groups", "groups.members" ]}); } + public async findByClassIdAndAssignmentId(withinClass: string, id: number): Promise { + return this.findOne({ within: { classId: withinClass }, id: id }); + } + public async findAllByResponsibleTeacher(teacherUsername: string): Promise { + return this.findAll({ + where: { + within: { + teachers: { + $some: { + username: teacherUsername, + }, + }, + }, + }, + }); + } public async findAllAssignmentsInClass(within: Class): Promise { return this.findAll({ where: { within: within }, populate: [ "groups", "groups.members" ] }); } diff --git a/backend/src/data/assignments/submission-repository.ts b/backend/src/data/assignments/submission-repository.ts index 2e673210..93089e3b 100644 --- a/backend/src/data/assignments/submission-repository.ts +++ b/backend/src/data/assignments/submission-repository.ts @@ -3,6 +3,7 @@ import { Group } from '../../entities/assignments/group.entity.js'; import { Submission } from '../../entities/assignments/submission.entity.js'; import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js'; import { Student } from '../../entities/users/student.entity.js'; +import { Assignment } from '../../entities/assignments/assignment.entity'; export class SubmissionRepository extends DwengoEntityRepository { public async findSubmissionByLearningObjectAndSubmissionNumber( @@ -50,11 +51,58 @@ export class SubmissionRepository extends DwengoEntityRepository { } public async findAllSubmissionsForGroup(group: Group): Promise { - return this.find({ onBehalfOf: group }); + return this.find( + { onBehalfOf: group }, + { + populate: ['onBehalfOf.members'], + } + ); + } + + /** + * Looks up all submissions for the given learning object which were submitted as part of the given assignment. + * When forStudentUsername is set, only the submissions of the given user's group are shown. + */ + public async findAllSubmissionsForLearningObjectAndAssignment( + loId: LearningObjectIdentifier, + assignment: Assignment, + forStudentUsername?: string + ): Promise { + const onBehalfOf = forStudentUsername + ? { + assignment, + members: { + $some: { + username: forStudentUsername, + }, + }, + } + : { + assignment, + }; + + return this.findAll({ + where: { + learningObjectHruid: loId.hruid, + learningObjectLanguage: loId.language, + learningObjectVersion: loId.version, + onBehalfOf, + }, + }); } public async findAllSubmissionsForStudent(student: Student): Promise { - return this.find({ submitter: student }); + const result = await this.find( + { submitter: student }, + { + populate: ['onBehalfOf.members'], + } + ); + + // Workaround: For some reason, without this MikroORM generates an UPDATE query with a syntax error in some tests + this.em.clear(); + + return result; } public async deleteSubmissionByLearningObjectAndSubmissionNumber(loId: LearningObjectIdentifier, submissionNumber: number): Promise { diff --git a/backend/src/data/classes/class-join-request-repository.ts b/backend/src/data/classes/class-join-request-repository.ts index 0d9ab6e1..8bd0f81e 100644 --- a/backend/src/data/classes/class-join-request-repository.ts +++ b/backend/src/data/classes/class-join-request-repository.ts @@ -2,14 +2,14 @@ import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; import { Class } from '../../entities/classes/class.entity.js'; import { ClassJoinRequest } from '../../entities/classes/class-join-request.entity.js'; import { Student } from '../../entities/users/student.entity.js'; -import { ClassJoinRequestStatus } from '@dwengo-1/common/util/class-join-request'; +import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; export class ClassJoinRequestRepository extends DwengoEntityRepository { public async findAllRequestsBy(requester: Student): Promise { return this.findAll({ where: { requester: requester } }); } public async findAllOpenRequestsTo(clazz: Class): Promise { - return this.findAll({ where: { class: clazz, status: ClassJoinRequestStatus.Open } }); // TODO check if works like this + return this.findAll({ where: { class: clazz, status: ClassStatus.Open } }); // TODO check if works like this } public async findByStudentAndClass(requester: Student, clazz: Class): Promise { return this.findOne({ requester, class: clazz }); diff --git a/backend/src/data/classes/teacher-invitation-repository.ts b/backend/src/data/classes/teacher-invitation-repository.ts index ce059ca8..c9442e29 100644 --- a/backend/src/data/classes/teacher-invitation-repository.ts +++ b/backend/src/data/classes/teacher-invitation-repository.ts @@ -2,6 +2,7 @@ import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; import { Class } from '../../entities/classes/class.entity.js'; import { TeacherInvitation } from '../../entities/classes/teacher-invitation.entity.js'; import { Teacher } from '../../entities/users/teacher.entity.js'; +import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; export class TeacherInvitationRepository extends DwengoEntityRepository { public async findAllInvitationsForClass(clazz: Class): Promise { @@ -11,7 +12,7 @@ export class TeacherInvitationRepository extends DwengoEntityRepository { - return this.findAll({ where: { receiver: receiver } }); + return this.findAll({ where: { receiver: receiver, status: ClassStatus.Open } }); } public async deleteBy(clazz: Class, sender: Teacher, receiver: Teacher): Promise { return this.deleteWhere({ @@ -20,4 +21,11 @@ export class TeacherInvitationRepository extends DwengoEntityRepository { + return this.findOne({ + sender: sender, + receiver: receiver, + class: clazz, + }); + } } diff --git a/backend/src/data/questions/question-repository.ts b/backend/src/data/questions/question-repository.ts index 14db8c88..362ec4c9 100644 --- a/backend/src/data/questions/question-repository.ts +++ b/backend/src/data/questions/question-repository.ts @@ -5,15 +5,16 @@ import { Student } from '../../entities/users/student.entity.js'; import { LearningObject } from '../../entities/content/learning-object.entity.js'; import { Assignment } from '../../entities/assignments/assignment.entity.js'; import { Loaded } from '@mikro-orm/core'; -import {Group} from "../../entities/assignments/group.entity"; +import { Group } from '../../entities/assignments/group.entity'; export class QuestionRepository extends DwengoEntityRepository { - public async createQuestion(question: { loId: LearningObjectIdentifier; author: Student; content: string }): Promise { + public async createQuestion(question: { loId: LearningObjectIdentifier; author: Student; inGroup: Group; content: string }): Promise { const questionEntity = this.create({ learningObjectHruid: question.loId.hruid, learningObjectLanguage: question.loId.language, learningObjectVersion: question.loId.version, author: question.author, + inGroup: question.inGroup, content: question.content, timestamp: new Date(), }); @@ -21,6 +22,7 @@ export class QuestionRepository extends DwengoEntityRepository { questionEntity.learningObjectLanguage = question.loId.language; questionEntity.learningObjectVersion = question.loId.version; questionEntity.author = question.author; + questionEntity.inGroup = question.inGroup; questionEntity.content = question.content; return this.insert(questionEntity); } @@ -60,7 +62,9 @@ export class QuestionRepository extends DwengoEntityRepository { public async findAllByAssignment(assignment: Assignment): Promise { return this.find({ - author: assignment.groups.toArray().flatMap((group) => group.members), + inGroup: { + $contained: assignment.groups, + }, learningObjectHruid: assignment.learningPathHruid, learningObjectLanguage: assignment.learningPathLanguage, }); @@ -73,6 +77,38 @@ export class QuestionRepository extends DwengoEntityRepository { }); } + /** + * Looks up all questions for the given learning object which were asked as part of the given assignment. + * When forStudentUsername is set, only the questions within the given user's group are shown. + */ + public async findAllQuestionsAboutLearningObjectInAssignment( + loId: LearningObjectIdentifier, + assignment: Assignment, + forStudentUsername?: string + ): Promise { + const inGroup = forStudentUsername + ? { + assignment, + members: { + $some: { + username: forStudentUsername, + }, + }, + } + : { + assignment, + }; + + return this.findAll({ + where: { + learningObjectHruid: loId.hruid, + learningObjectLanguage: loId.language, + learningObjectVersion: loId.version, + inGroup, + }, + }); + } + public async findByLearningObjectAndSequenceNumber(loId: LearningObjectIdentifier, sequenceNumber: number): Promise | null> { return this.findOne({ learningObjectHruid: loId.hruid, diff --git a/backend/src/entities/assignments/submission.entity.ts b/backend/src/entities/assignments/submission.entity.ts index 80b9a8fb..82d49a40 100644 --- a/backend/src/entities/assignments/submission.entity.ts +++ b/backend/src/entities/assignments/submission.entity.ts @@ -21,6 +21,11 @@ export class Submission { @PrimaryKey({ type: 'integer', autoincrement: true }) submissionNumber?: number; + @ManyToOne({ + entity: () => Group, + }) + onBehalfOf!: Group; + @ManyToOne({ entity: () => Student, }) @@ -29,12 +34,6 @@ export class Submission { @Property({ type: 'datetime' }) submissionTime!: Date; - @ManyToOne({ - entity: () => Group, - nullable: true, - }) - onBehalfOf?: Group; - @Property({ type: 'json' }) content!: string; } diff --git a/backend/src/entities/classes/class-join-request.entity.ts b/backend/src/entities/classes/class-join-request.entity.ts index 907c0199..548968a6 100644 --- a/backend/src/entities/classes/class-join-request.entity.ts +++ b/backend/src/entities/classes/class-join-request.entity.ts @@ -2,7 +2,7 @@ import { Entity, Enum, ManyToOne } from '@mikro-orm/core'; import { Student } from '../users/student.entity.js'; import { Class } from './class.entity.js'; import { ClassJoinRequestRepository } from '../../data/classes/class-join-request-repository.js'; -import { ClassJoinRequestStatus } from '@dwengo-1/common/util/class-join-request'; +import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; @Entity({ repository: () => ClassJoinRequestRepository, @@ -20,6 +20,6 @@ export class ClassJoinRequest { }) class!: Class; - @Enum(() => ClassJoinRequestStatus) - status!: ClassJoinRequestStatus; + @Enum(() => ClassStatus) + status!: ClassStatus; } diff --git a/backend/src/entities/classes/teacher-invitation.entity.ts b/backend/src/entities/classes/teacher-invitation.entity.ts index 668a0a1c..6059f155 100644 --- a/backend/src/entities/classes/teacher-invitation.entity.ts +++ b/backend/src/entities/classes/teacher-invitation.entity.ts @@ -1,7 +1,8 @@ -import { Entity, ManyToOne } from '@mikro-orm/core'; +import { Entity, Enum, ManyToOne } from '@mikro-orm/core'; import { Teacher } from '../users/teacher.entity.js'; import { Class } from './class.entity.js'; import { TeacherInvitationRepository } from '../../data/classes/teacher-invitation-repository.js'; +import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; /** * Invitation of a teacher into a class (in order to teach it). @@ -25,4 +26,7 @@ export class TeacherInvitation { primary: true, }) class!: Class; + + @Enum(() => ClassStatus) + status!: ClassStatus; } diff --git a/backend/src/entities/questions/question.entity.ts b/backend/src/entities/questions/question.entity.ts index 5e691f70..44ccfbd3 100644 --- a/backend/src/entities/questions/question.entity.ts +++ b/backend/src/entities/questions/question.entity.ts @@ -2,6 +2,7 @@ import { Entity, Enum, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'; import { Student } from '../users/student.entity.js'; import { QuestionRepository } from '../../data/questions/question-repository.js'; import { Language } from '@dwengo-1/common/util/language'; +import { Group } from '../assignments/group.entity.js'; @Entity({ repository: () => QuestionRepository }) export class Question { @@ -20,6 +21,9 @@ export class Question { @PrimaryKey({ type: 'integer', autoincrement: true }) sequenceNumber?: number; + @ManyToOne({ entity: () => Group }) + inGroup!: Group; + @ManyToOne({ entity: () => Student, }) diff --git a/backend/src/interfaces/group.ts b/backend/src/interfaces/group.ts index 2d7f9afa..1d15b459 100644 --- a/backend/src/interfaces/group.ts +++ b/backend/src/interfaces/group.ts @@ -1,17 +1,31 @@ import { Group } from '../entities/assignments/group.entity.js'; -import { Class } from '../entities/classes/class.entity.js'; +import { mapToAssignment } from './assignment.js'; +import { mapToStudent } from './student.js'; +import { mapToAssignmentDTO } from './assignment.js'; import { mapToStudentDTO } from './student.js'; import { GroupDTO, GroupDTOId } from '@dwengo-1/common/interfaces/group'; +import { getGroupRepository } from '../data/repositories.js'; +import { AssignmentDTO } from '@dwengo-1/common/interfaces/assignment'; +import { Class } from '../entities/classes/class.entity.js'; +import { StudentDTO } from '@dwengo-1/common/interfaces/student'; +import { mapToClassDTO } from './class.js'; -export function mapToGroupDTO(group: Group, cls: Class, options?: { expandStudents: boolean }): GroupDTO { +export function mapToGroup(groupDto: GroupDTO, clazz: Class): Group { + const assignmentDto = groupDto.assignment as AssignmentDTO; + + return getGroupRepository().create({ + groupNumber: groupDto.groupNumber, + assignment: mapToAssignment(assignmentDto, clazz), + members: groupDto.members!.map((studentDto) => mapToStudent(studentDto as StudentDTO)), + }); +} + +export function mapToGroupDTO(group: Group, cls: Class): GroupDTO { return { class: cls.classId!, assignment: group.assignment.id!, groupNumber: group.groupNumber!, - members: - options?.expandStudents - ? group.members.map(mapToStudentDTO) - : group.members.map((student) => student.username) + members: group.members.map(mapToStudentDTO) }; } @@ -23,3 +37,15 @@ export function mapToGroupDTOId(group: Group, cls: Class): GroupDTOId { groupNumber: group.groupNumber!, }; } + +/** + * Map to group DTO where other objects are only referenced by their id. + */ +export function mapToShallowGroupDTO(group: Group): GroupDTO { + return { + class: group.assignment.within.classId!, + assignment: group.assignment.id!, + groupNumber: group.groupNumber!, + members: group.members.map((member) => member.username), + }; +} diff --git a/backend/src/interfaces/question.ts b/backend/src/interfaces/question.ts index 40642ae7..50a61301 100644 --- a/backend/src/interfaces/question.ts +++ b/backend/src/interfaces/question.ts @@ -3,6 +3,7 @@ import { mapToStudentDTO } from './student.js'; import { QuestionDTO, QuestionId } from '@dwengo-1/common/interfaces/question'; import { LearningObjectIdentifierDTO } from '@dwengo-1/common/interfaces/learning-content'; import { LearningObjectIdentifier } from '../entities/content/learning-object-identifier.js'; +import { mapToGroupDTOId } from './group.js'; function getLearningObjectIdentifier(question: Question): LearningObjectIdentifierDTO { return { @@ -30,6 +31,7 @@ export function mapToQuestionDTO(question: Question): QuestionDTO { learningObjectIdentifier, sequenceNumber: question.sequenceNumber!, author: mapToStudentDTO(question.author), + inGroup: mapToGroupDTOId(question.inGroup), timestamp: question.timestamp.toISOString(), content: question.content, }; diff --git a/backend/src/interfaces/student-request.ts b/backend/src/interfaces/student-request.ts index d97f5eb5..a4d3b31b 100644 --- a/backend/src/interfaces/student-request.ts +++ b/backend/src/interfaces/student-request.ts @@ -4,7 +4,7 @@ import { getClassJoinRequestRepository } from '../data/repositories.js'; import { Student } from '../entities/users/student.entity.js'; import { Class } from '../entities/classes/class.entity.js'; import { ClassJoinRequestDTO } from '@dwengo-1/common/interfaces/class-join-request'; -import { ClassJoinRequestStatus } from '@dwengo-1/common/util/class-join-request'; +import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; export function mapToStudentRequestDTO(request: ClassJoinRequest): ClassJoinRequestDTO { return { @@ -18,6 +18,6 @@ export function mapToStudentRequest(student: Student, cls: Class): ClassJoinRequ return getClassJoinRequestRepository().create({ requester: student, class: cls, - status: ClassJoinRequestStatus.Open, + status: ClassStatus.Open, }); } diff --git a/backend/src/interfaces/submission.ts b/backend/src/interfaces/submission.ts index b9eb0802..20a89665 100644 --- a/backend/src/interfaces/submission.ts +++ b/backend/src/interfaces/submission.ts @@ -1,10 +1,10 @@ -import { getSubmissionRepository } from '../data/repositories.js'; -import { Group } from '../entities/assignments/group.entity.js'; import { Submission } from '../entities/assignments/submission.entity.js'; -import { Student } from '../entities/users/student.entity.js'; import { mapToGroupDTO } from './group.js'; import { mapToStudentDTO } from './student.js'; import { SubmissionDTO, SubmissionDTOId } from '@dwengo-1/common/interfaces/submission'; +import { getSubmissionRepository } from '../data/repositories.js'; +import { Student } from '../entities/users/student.entity.js'; +import { Group } from '../entities/assignments/group.entity.js'; export function mapToSubmissionDTO(submission: Submission): SubmissionDTO { return { @@ -32,7 +32,7 @@ export function mapToSubmissionDTOId(submission: Submission): SubmissionDTOId { }; } -export function mapToSubmission(submissionDTO: SubmissionDTO, submitter: Student, onBehalfOf: Group | undefined): Submission { +export function mapToSubmission(submissionDTO: SubmissionDTO, submitter: Student, onBehalfOf: Group): Submission { return getSubmissionRepository().create({ learningObjectHruid: submissionDTO.learningObjectIdentifier.hruid, learningObjectLanguage: submissionDTO.learningObjectIdentifier.language, diff --git a/backend/src/interfaces/teacher-invitation.ts b/backend/src/interfaces/teacher-invitation.ts index d9cb9915..88b66f7a 100644 --- a/backend/src/interfaces/teacher-invitation.ts +++ b/backend/src/interfaces/teacher-invitation.ts @@ -1,13 +1,17 @@ import { TeacherInvitation } from '../entities/classes/teacher-invitation.entity.js'; -import { mapToClassDTO } from './class.js'; import { mapToUserDTO } from './user.js'; import { TeacherInvitationDTO } from '@dwengo-1/common/interfaces/teacher-invitation'; +import { getTeacherInvitationRepository } from '../data/repositories.js'; +import { Teacher } from '../entities/users/teacher.entity.js'; +import { Class } from '../entities/classes/class.entity.js'; +import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; export function mapToTeacherInvitationDTO(invitation: TeacherInvitation): TeacherInvitationDTO { return { sender: mapToUserDTO(invitation.sender), receiver: mapToUserDTO(invitation.receiver), - class: mapToClassDTO(invitation.class), + classId: invitation.class.classId!, + status: invitation.status, }; } @@ -15,6 +19,16 @@ export function mapToTeacherInvitationDTOIds(invitation: TeacherInvitation): Tea return { sender: invitation.sender.username, receiver: invitation.receiver.username, - class: invitation.class.classId!, + classId: invitation.class.classId!, + status: invitation.status, }; } + +export function mapToInvitation(sender: Teacher, receiver: Teacher, cls: Class): TeacherInvitation { + return getTeacherInvitationRepository().create({ + sender, + receiver, + class: cls, + status: ClassStatus.Open, + }); +} diff --git a/backend/src/routes/submissions.ts b/backend/src/routes/submissions.ts index 7c91de52..492b6439 100644 --- a/backend/src/routes/submissions.ts +++ b/backend/src/routes/submissions.ts @@ -1,9 +1,9 @@ import express from 'express'; -import { createSubmissionHandler, deleteSubmissionHandler, getAllSubmissionsHandler, getSubmissionHandler } from '../controllers/submissions.js'; +import { createSubmissionHandler, deleteSubmissionHandler, getSubmissionHandler, getSubmissionsHandler } from '../controllers/submissions.js'; const router = express.Router({ mergeParams: true }); // Root endpoint used to search objects -router.get('/', getAllSubmissionsHandler); +router.get('/', getSubmissionsHandler); router.post('/:id', createSubmissionHandler); diff --git a/backend/src/routes/teacher-invitations.ts b/backend/src/routes/teacher-invitations.ts new file mode 100644 index 00000000..23b943d0 --- /dev/null +++ b/backend/src/routes/teacher-invitations.ts @@ -0,0 +1,22 @@ +import express from 'express'; +import { + createInvitationHandler, + deleteInvitationHandler, + getAllInvitationsHandler, + getInvitationHandler, + updateInvitationHandler, +} from '../controllers/teacher-invitations.js'; + +const router = express.Router({ mergeParams: true }); + +router.get('/:username', getAllInvitationsHandler); + +router.get('/:sender/:receiver/:classId', getInvitationHandler); + +router.post('/', createInvitationHandler); + +router.put('/', updateInvitationHandler); + +router.delete('/:sender/:receiver/:classId', deleteInvitationHandler); + +export default router; diff --git a/backend/src/routes/teachers.ts b/backend/src/routes/teachers.ts index a6106a80..801eaee8 100644 --- a/backend/src/routes/teachers.ts +++ b/backend/src/routes/teachers.ts @@ -10,6 +10,8 @@ import { getTeacherStudentHandler, updateStudentJoinRequestHandler, } from '../controllers/teachers.js'; +import invitationRouter from './teacher-invitations.js'; + const router = express.Router(); // Root endpoint used to search objects @@ -32,10 +34,6 @@ router.get('/:username/joinRequests/:classId', getStudentJoinRequestHandler); router.put('/:username/joinRequests/:classId/:studentUsername', updateStudentJoinRequestHandler); // Invitations to other classes a teacher received -router.get('/:id/invitations', (_req, res) => { - res.json({ - invitations: ['0'], - }); -}); +router.get('/invitations', invitationRouter); export default router; diff --git a/backend/src/services/groups.ts b/backend/src/services/groups.ts index f9889416..0bda8237 100644 --- a/backend/src/services/groups.ts +++ b/backend/src/services/groups.ts @@ -1,7 +1,7 @@ import { EntityDTO } from '@mikro-orm/core'; import { getGroupRepository, getSubmissionRepository } from '../data/repositories.js'; import { Group } from '../entities/assignments/group.entity.js'; -import { mapToGroupDTO, mapToGroupDTOId } from '../interfaces/group.js'; +import { mapToGroupDTO, mapToGroupDTOId, mapToShallowGroupDTO } from '../interfaces/group.js'; import { mapToSubmissionDTO, mapToSubmissionDTOId } from '../interfaces/submission.js'; import { GroupDTO, GroupDTOId } from '@dwengo-1/common/interfaces/group'; import { SubmissionDTO, SubmissionDTOId } from '@dwengo-1/common/interfaces/submission'; diff --git a/backend/src/services/questions.ts b/backend/src/services/questions.ts index 2dd7da1c..a16c277b 100644 --- a/backend/src/services/questions.ts +++ b/backend/src/services/questions.ts @@ -1,12 +1,34 @@ -import { getQuestionRepository } from '../data/repositories.js'; +import { getAnswerRepository, getAssignmentRepository, getClassRepository, getGroupRepository, getQuestionRepository } from '../data/repositories.js'; import { mapToLearningObjectID, mapToQuestionDTO, mapToQuestionDTOId } from '../interfaces/question.js'; import { Question } from '../entities/questions/question.entity.js'; +import { Answer } from '../entities/questions/answer.entity.js'; +import { mapToAnswerDTO, mapToAnswerDTOId } from '../interfaces/answer.js'; import { QuestionRepository } from '../data/questions/question-repository.js'; import { LearningObjectIdentifier } from '../entities/content/learning-object-identifier.js'; import { QuestionData, QuestionDTO, QuestionId } from '@dwengo-1/common/interfaces/question'; +import { AnswerDTO, AnswerId } from '@dwengo-1/common/interfaces/answer'; +import { mapToAssignment } from '../interfaces/assignment.js'; +import { AssignmentDTO } from '@dwengo-1/common/interfaces/assignment'; +import { fetchStudent } from './students.js'; import { NotFoundException } from '../exceptions/not-found-exception.js'; import { FALLBACK_VERSION_NUM } from '../config.js'; -import { fetchStudent } from './students.js'; + +export async function getQuestionsAboutLearningObjectInAssignment( + loId: LearningObjectIdentifier, + classId: string, + assignmentId: number, + full: boolean, + studentUsername?: string +): Promise { + const assignment = await getAssignmentRepository().findByClassIdAndAssignmentId(classId, assignmentId); + + const questions = await getQuestionRepository().findAllQuestionsAboutLearningObjectInAssignment(loId, assignment!, studentUsername); + + if (full) { + return questions.map((q) => mapToQuestionDTO(q)); + } + return questions.map((q) => mapToQuestionDTOId(q)); +} export async function getAllQuestions(id: LearningObjectIdentifier, full: boolean): Promise { const questionRepository: QuestionRepository = getQuestionRepository(); @@ -38,14 +60,40 @@ export async function getQuestion(questionId: QuestionId): Promise return mapToQuestionDTO(question); } +export async function getAnswersByQuestion(questionId: QuestionId, full: boolean): Promise { + const answerRepository = getAnswerRepository(); + const question = await fetchQuestion(questionId); + + if (!question) { + return []; + } + + const answers: Answer[] = await answerRepository.findAllAnswersToQuestion(question); + + if (!answers) { + return []; + } + + if (full) { + return answers.map(mapToAnswerDTO); + } + + return answers.map(mapToAnswerDTOId); +} + export async function createQuestion(loId: LearningObjectIdentifier, questionData: QuestionData): Promise { const questionRepository = getQuestionRepository(); const author = await fetchStudent(questionData.author!); const content = questionData.content; + const clazz = await getClassRepository().findById((questionData.inGroup.assignment as AssignmentDTO).within); + const assignment = mapToAssignment(questionData.inGroup.assignment as AssignmentDTO, clazz!); + const inGroup = await getGroupRepository().findByAssignmentAndGroupNumber(assignment, questionData.inGroup.groupNumber); + const question = await questionRepository.createQuestion({ loId, author, + inGroup: inGroup!, content, }); diff --git a/backend/src/services/students.ts b/backend/src/services/students.ts index d8a06514..c0c64400 100644 --- a/backend/src/services/students.ts +++ b/backend/src/services/students.ts @@ -7,7 +7,7 @@ import { getSubmissionRepository, } from '../data/repositories.js'; import { mapToClassDTO } from '../interfaces/class.js'; -import { mapToGroupDTO, mapToGroupDTOId } from '../interfaces/group.js'; +import { mapToGroupDTO, mapToGroupDTOId, mapToShallowGroupDTO } from '../interfaces/group.js'; import { mapToStudent, mapToStudentDTO } from '../interfaces/student.js'; import { mapToSubmissionDTO, mapToSubmissionDTOId } from '../interfaces/submission.js'; import { getAllAssignments } from './assignments.js'; @@ -24,6 +24,7 @@ import { SubmissionDTO, SubmissionDTOId } from '@dwengo-1/common/interfaces/subm import { QuestionDTO, QuestionId } from '@dwengo-1/common/interfaces/question'; import { ClassJoinRequestDTO } from '@dwengo-1/common/interfaces/class-join-request'; import { ConflictException } from '../exceptions/conflict-exception.js'; +import { Submission } from '../entities/assignments/submission.entity'; export async function getAllStudents(full: boolean): Promise { const studentRepository = getStudentRepository(); @@ -113,7 +114,8 @@ export async function getStudentSubmissions(username: string, full: boolean): Pr const student = await fetchStudent(username); const submissionRepository = getSubmissionRepository(); - const submissions = await submissionRepository.findAllSubmissionsForStudent(student); + + const submissions: Submission[] = await submissionRepository.findAllSubmissionsForStudent(student); if (full) { return submissions.map(mapToSubmissionDTO); diff --git a/backend/src/services/submissions.ts b/backend/src/services/submissions.ts index eb7b9a3b..1061df09 100644 --- a/backend/src/services/submissions.ts +++ b/backend/src/services/submissions.ts @@ -1,4 +1,4 @@ -import { getSubmissionRepository } from '../data/repositories.js'; +import { getAssignmentRepository, getSubmissionRepository } from '../data/repositories.js'; import { LearningObjectIdentifier } from '../entities/content/learning-object-identifier.js'; import { NotFoundException } from '../exceptions/not-found-exception.js'; import { mapToSubmission, mapToSubmissionDTO } from '../interfaces/submission.js'; @@ -6,6 +6,7 @@ import { SubmissionDTO } from '@dwengo-1/common/interfaces/submission'; import { fetchStudent } from './students.js'; import { getExistingGroupFromGroupDTO } from './groups.js'; import { Submission } from '../entities/assignments/submission.entity.js'; +import { Language } from '@dwengo-1/common/util/language'; export async function fetchSubmission(loId: LearningObjectIdentifier, submissionNumber: number): Promise { const submissionRepository = getSubmissionRepository(); @@ -32,9 +33,7 @@ export async function getAllSubmissions(loId: LearningObjectIdentifier): Promise export async function createSubmission(submissionDTO: SubmissionDTO): Promise { const submitter = await fetchStudent(submissionDTO.submitter.username); - // TODO: fix - // const group = submissionDTO.group ? await getExistingGroupFromGroupDTO(submissionDTO.group) : undefined; - const group = undefined; + const group = await getExistingGroupFromGroupDTO(submissionDTO.group!); const submissionRepository = getSubmissionRepository(); const submission = mapToSubmission(submissionDTO, submitter, group); @@ -51,3 +50,22 @@ export async function deleteSubmission(loId: LearningObjectIdentifier, submissio return mapToSubmissionDTO(submission); } + +/** + * Returns all the submissions made by on behalf of any group the given student is in. + */ +export async function getSubmissionsForLearningObjectAndAssignment( + learningObjectHruid: string, + language: Language, + version: number, + classId: string, + assignmentId: number, + studentUsername?: string +): Promise { + const loId = new LearningObjectIdentifier(learningObjectHruid, language, version); + const assignment = await getAssignmentRepository().findByClassIdAndAssignmentId(classId, assignmentId); + + const submissions = await getSubmissionRepository().findAllSubmissionsForLearningObjectAndAssignment(loId, assignment!, studentUsername); + + return submissions.map((s) => mapToSubmissionDTO(s)); +} diff --git a/backend/src/services/teacher-invitations.ts b/backend/src/services/teacher-invitations.ts new file mode 100644 index 00000000..aead8715 --- /dev/null +++ b/backend/src/services/teacher-invitations.ts @@ -0,0 +1,87 @@ +import { fetchTeacher } from './teachers.js'; +import { getTeacherInvitationRepository } from '../data/repositories.js'; +import { mapToInvitation, mapToTeacherInvitationDTO } from '../interfaces/teacher-invitation.js'; +import { addClassTeacher, fetchClass } from './classes.js'; +import { TeacherInvitationData, TeacherInvitationDTO } from '@dwengo-1/common/interfaces/teacher-invitation'; +import { ConflictException } from '../exceptions/conflict-exception.js'; +import { NotFoundException } from '../exceptions/not-found-exception.js'; +import { TeacherInvitation } from '../entities/classes/teacher-invitation.entity.js'; +import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; + +export async function getAllInvitations(username: string, sent: boolean): Promise { + const teacher = await fetchTeacher(username); + const teacherInvitationRepository = getTeacherInvitationRepository(); + + let invitations; + if (sent) { + invitations = await teacherInvitationRepository.findAllInvitationsBy(teacher); + } else { + invitations = await teacherInvitationRepository.findAllInvitationsFor(teacher); + } + return invitations.map(mapToTeacherInvitationDTO); +} + +export async function createInvitation(data: TeacherInvitationData): Promise { + const teacherInvitationRepository = getTeacherInvitationRepository(); + const sender = await fetchTeacher(data.sender); + const receiver = await fetchTeacher(data.receiver); + + const cls = await fetchClass(data.class); + + if (!cls.teachers.contains(sender)) { + throw new ConflictException('The teacher sending the invite is not part of the class'); + } + + const newInvitation = mapToInvitation(sender, receiver, cls); + await teacherInvitationRepository.save(newInvitation, { preventOverwrite: true }); + + return mapToTeacherInvitationDTO(newInvitation); +} + +async function fetchInvitation(usernameSender: string, usernameReceiver: string, classId: string): Promise { + const sender = await fetchTeacher(usernameSender); + const receiver = await fetchTeacher(usernameReceiver); + const cls = await fetchClass(classId); + + const teacherInvitationRepository = getTeacherInvitationRepository(); + const invite = await teacherInvitationRepository.findBy(cls, sender, receiver); + + if (!invite) { + throw new NotFoundException('Teacher invite not found'); + } + + return invite; +} + +export async function getInvitation(sender: string, receiver: string, classId: string): Promise { + const invitation = await fetchInvitation(sender, receiver, classId); + return mapToTeacherInvitationDTO(invitation); +} + +export async function updateInvitation(data: TeacherInvitationData): Promise { + const invitation = await fetchInvitation(data.sender, data.receiver, data.class); + invitation.status = ClassStatus.Declined; + + if (data.accepted) { + invitation.status = ClassStatus.Accepted; + await addClassTeacher(data.class, data.receiver); + } + + const teacherInvitationRepository = getTeacherInvitationRepository(); + await teacherInvitationRepository.save(invitation); + + return mapToTeacherInvitationDTO(invitation); +} + +export async function deleteInvitation(data: TeacherInvitationData): Promise { + const invitation = await fetchInvitation(data.sender, data.receiver, data.class); + + const sender = await fetchTeacher(data.sender); + const receiver = await fetchTeacher(data.receiver); + const cls = await fetchClass(data.class); + + const teacherInvitationRepository = getTeacherInvitationRepository(); + await teacherInvitationRepository.deleteBy(cls, sender, receiver); + + return mapToTeacherInvitationDTO(invitation); +} diff --git a/backend/src/services/teachers.ts b/backend/src/services/teachers.ts index e6596f9e..982b657b 100644 --- a/backend/src/services/teachers.ts +++ b/backend/src/services/teachers.ts @@ -28,7 +28,7 @@ import { ClassDTO } from '@dwengo-1/common/interfaces/class'; import { StudentDTO } from '@dwengo-1/common/interfaces/student'; import { QuestionDTO, QuestionId } from '@dwengo-1/common/interfaces/question'; import { ClassJoinRequestDTO } from '@dwengo-1/common/interfaces/class-join-request'; -import { ClassJoinRequestStatus } from '@dwengo-1/common/util/class-join-request'; +import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; import { ConflictException } from '../exceptions/conflict-exception.js'; export async function getAllTeachers(full: boolean): Promise { @@ -160,10 +160,10 @@ export async function updateClassJoinRequestStatus(studentUsername: string, clas throw new NotFoundException('Join request not found'); } - request.status = ClassJoinRequestStatus.Declined; + request.status = ClassStatus.Declined; if (accepted) { - request.status = ClassJoinRequestStatus.Accepted; + request.status = ClassStatus.Accepted; await addClassStudent(classId, studentUsername); } diff --git a/backend/tests/controllers/teacher-invitations.test.ts b/backend/tests/controllers/teacher-invitations.test.ts new file mode 100644 index 00000000..ed2f5ebf --- /dev/null +++ b/backend/tests/controllers/teacher-invitations.test.ts @@ -0,0 +1,123 @@ +import { beforeAll, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; +import { Request, Response } from 'express'; +import { setupTestApp } from '../setup-tests.js'; +import { + createInvitationHandler, + deleteInvitationHandler, + getAllInvitationsHandler, + getInvitationHandler, + updateInvitationHandler, +} from '../../src/controllers/teacher-invitations'; +import { TeacherInvitationData } from '@dwengo-1/common/interfaces/teacher-invitation'; +import { getClassHandler } from '../../src/controllers/classes'; +import { BadRequestException } from '../../src/exceptions/bad-request-exception'; +import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; + +describe('Teacher controllers', () => { + let req: Partial; + let res: Partial; + + let jsonMock: Mock; + + beforeAll(async () => { + await setupTestApp(); + }); + + beforeEach(() => { + jsonMock = vi.fn(); + res = { + json: jsonMock, + }; + }); + + it('Get teacher invitations by', async () => { + req = { params: { username: 'LimpBizkit' }, query: { sent: 'true' } }; + + await getAllInvitationsHandler(req as Request, res as Response); + + expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ invitations: expect.anything() })); + + const result = jsonMock.mock.lastCall?.[0]; + // Console.log(result.invitations); + expect(result.invitations).to.have.length.greaterThan(0); + }); + + it('Get teacher invitations for', async () => { + req = { params: { username: 'FooFighters' }, query: { by: 'false' } }; + + await getAllInvitationsHandler(req as Request, res as Response); + + expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ invitations: expect.anything() })); + + const result = jsonMock.mock.lastCall?.[0]; + expect(result.invitations).to.have.length.greaterThan(0); + }); + + it('Create and delete invitation', async () => { + const body = { + sender: 'LimpBizkit', + receiver: 'testleerkracht1', + class: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89', + } as TeacherInvitationData; + req = { body }; + + await createInvitationHandler(req as Request, res as Response); + + req = { + params: { + sender: 'LimpBizkit', + receiver: 'testleerkracht1', + classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89', + }, + body: { accepted: 'false' }, + }; + + await deleteInvitationHandler(req as Request, res as Response); + }); + + it('Get invitation', async () => { + req = { + params: { + sender: 'LimpBizkit', + receiver: 'FooFighters', + classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89', + }, + }; + await getInvitationHandler(req as Request, res as Response); + + expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ invitation: expect.anything() })); + }); + + it('Get invitation error', async () => { + req = { + params: { no: 'no params' }, + }; + + await expect(async () => getInvitationHandler(req as Request, res as Response)).rejects.toThrowError(BadRequestException); + }); + + it('Accept invitation', async () => { + const body = { + sender: 'LimpBizkit', + receiver: 'FooFighters', + class: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89', + } as TeacherInvitationData; + req = { body }; + + await updateInvitationHandler(req as Request, res as Response); + + const result1 = jsonMock.mock.lastCall?.[0]; + expect(result1.invitation.status).toEqual(ClassStatus.Accepted); + + req = { + params: { + id: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89', + }, + }; + + await getClassHandler(req as Request, res as Response); + + const result = jsonMock.mock.lastCall?.[0]; + expect(result.class.teachers).toContain('FooFighters'); + }); +}); diff --git a/backend/tests/data/assignments/assignments.test.ts b/backend/tests/data/assignments/assignments.test.ts index c2bdbeef..1fe52523 100644 --- a/backend/tests/data/assignments/assignments.test.ts +++ b/backend/tests/data/assignments/assignments.test.ts @@ -31,6 +31,13 @@ describe('AssignmentRepository', () => { expect(assignments[0].title).toBe('tool'); }); + it('should find all by username of the responsible teacher', async () => { + const result = await assignmentRepository.findAllByResponsibleTeacher('testleerkracht1'); + const resultIds = result.map((it) => it.id).sort((a, b) => (a ?? 0) - (b ?? 0)); + + expect(resultIds).toEqual([1, 3, 4]); + }); + it('should not find removed assignment', async () => { const class_ = await classRepository.findById('id01'); await assignmentRepository.deleteByClassAndId(class_!, 3); diff --git a/backend/tests/data/assignments/submissions.test.ts b/backend/tests/data/assignments/submissions.test.ts index 37d0f2ee..31aafc1d 100644 --- a/backend/tests/data/assignments/submissions.test.ts +++ b/backend/tests/data/assignments/submissions.test.ts @@ -14,6 +14,9 @@ import { StudentRepository } from '../../../src/data/users/student-repository'; import { GroupRepository } from '../../../src/data/assignments/group-repository'; import { AssignmentRepository } from '../../../src/data/assignments/assignment-repository'; import { ClassRepository } from '../../../src/data/classes/class-repository'; +import { Submission } from '../../../src/entities/assignments/submission.entity'; +import { Class } from '../../../src/entities/classes/class.entity'; +import { Assignment } from '../../../src/entities/assignments/assignment.entity'; describe('SubmissionRepository', () => { let submissionRepository: SubmissionRepository; @@ -59,6 +62,49 @@ describe('SubmissionRepository', () => { expect(submission?.submissionTime.getDate()).toBe(25); }); + let clazz: Class | null; + let assignment: Assignment | null; + let loId: LearningObjectIdentifier; + it('should find all submissions for a certain learning object and assignment', async () => { + clazz = await classRepository.findById('8764b861-90a6-42e5-9732-c0d9eb2f55f9'); + assignment = await assignmentRepository.findByClassAndId(clazz!, 1); + loId = { + hruid: 'id02', + language: Language.English, + version: 1, + }; + const result = await submissionRepository.findAllSubmissionsForLearningObjectAndAssignment(loId, assignment!); + sortSubmissions(result); + + expect(result).toHaveLength(3); + + // Submission3 should be found (for learning object 'id02' by group #1 for Assignment #1 in class 'id01') + expect(result[0].learningObjectHruid).toBe(loId.hruid); + expect(result[0].submissionNumber).toBe(1); + + // Submission4 should be found (for learning object 'id02' by group #1 for Assignment #1 in class 'id01') + expect(result[1].learningObjectHruid).toBe(loId.hruid); + expect(result[1].submissionNumber).toBe(2); + + // Submission8 should be found (for learning object 'id02' by group #2 for Assignment #1 in class 'id01') + expect(result[2].learningObjectHruid).toBe(loId.hruid); + expect(result[2].submissionNumber).toBe(3); + }); + + it("should find only the submissions for a certain learning object and assignment made for the user's group", async () => { + const result = await submissionRepository.findAllSubmissionsForLearningObjectAndAssignment(loId, assignment!, 'Tool'); + // (student Tool is in group #2) + + expect(result).toHaveLength(1); + + // Submission8 should be found (for learning object 'id02' by group #2 for Assignment #1 in class 'id01') + expect(result[0].learningObjectHruid).toBe(loId.hruid); + expect(result[0].submissionNumber).toBe(3); + + // The other submissions found in the previous test case should not be found anymore as they were made on + // Behalf of group #1 which Tool is no member of. + }); + it('should not find a deleted submission', async () => { const id = new LearningObjectIdentifier('id01', Language.English, 1); await submissionRepository.deleteSubmissionByLearningObjectAndSubmissionNumber(id, 1); @@ -68,3 +114,15 @@ describe('SubmissionRepository', () => { expect(submission).toBeNull(); }); }); + +function sortSubmissions(submissions: Submission[]): void { + submissions.sort((a, b) => { + if (a.learningObjectHruid < b.learningObjectHruid) { + return -1; + } + if (a.learningObjectHruid > b.learningObjectHruid) { + return 1; + } + return a.submissionNumber! - b.submissionNumber!; + }); +} diff --git a/backend/tests/data/questions/questions.test.ts b/backend/tests/data/questions/questions.test.ts index 055a9d79..9565e71d 100644 --- a/backend/tests/data/questions/questions.test.ts +++ b/backend/tests/data/questions/questions.test.ts @@ -1,10 +1,19 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { setupTestApp } from '../../setup-tests'; import { QuestionRepository } from '../../../src/data/questions/question-repository'; -import { getQuestionRepository, getStudentRepository } from '../../../src/data/repositories'; +import { + getAssignmentRepository, + getClassRepository, + getGroupRepository, + getQuestionRepository, + getStudentRepository, +} from '../../../src/data/repositories'; import { StudentRepository } from '../../../src/data/users/student-repository'; import { LearningObjectIdentifier } from '../../../src/entities/content/learning-object-identifier'; import { Language } from '@dwengo-1/common/util/language'; +import { Question } from '../../../src/entities/questions/question.entity'; +import { Class } from '../../../src/entities/classes/class.entity'; +import { Assignment } from '../../../src/entities/assignments/assignment.entity'; describe('QuestionRepository', () => { let questionRepository: QuestionRepository; @@ -21,14 +30,19 @@ describe('QuestionRepository', () => { const questions = await questionRepository.findAllQuestionsAboutLearningObject(id); expect(questions).toBeTruthy(); - expect(questions).toHaveLength(2); + expect(questions).toHaveLength(4); }); it('should create new question', async () => { const id = new LearningObjectIdentifier('id03', Language.English, 1); const student = await studentRepository.findByUsername('Noordkaap'); + + const clazz = await getClassRepository().findById('8764b861-90a6-42e5-9732-c0d9eb2f55f9'); + const assignment = await getAssignmentRepository().findByClassAndId(clazz!, 1); + const group = await getGroupRepository().findByAssignmentAndGroupNumber(assignment!, 1); await questionRepository.createQuestion({ loId: id, + inGroup: group!, author: student!, content: 'question?', }); @@ -38,6 +52,52 @@ describe('QuestionRepository', () => { expect(question).toHaveLength(1); }); + let clazz: Class | null; + let assignment: Assignment | null; + let loId: LearningObjectIdentifier; + it('should find all questions for a certain learning object and assignment', async () => { + clazz = await getClassRepository().findById('8764b861-90a6-42e5-9732-c0d9eb2f55f9'); + assignment = await getAssignmentRepository().findByClassAndId(clazz!, 1); + loId = { + hruid: 'id05', + language: Language.English, + version: 1, + }; + const result = await questionRepository.findAllQuestionsAboutLearningObjectInAssignment(loId, assignment!); + sortQuestions(result); + + expect(result).toHaveLength(3); + + // Question01: About learning object 'id05', in group #1 for Assignment #1 in class 'id01' + expect(result[0].learningObjectHruid).toEqual(loId.hruid); + expect(result[0].sequenceNumber).toEqual(1); + + // Question02: About learning object 'id05', in group #1 for Assignment #1 in class 'id01' + expect(result[1].learningObjectHruid).toEqual(loId.hruid); + expect(result[1].sequenceNumber).toEqual(2); + + // Question05: About learning object 'id05', in group #2 for Assignment #1 in class 'id01' + expect(result[2].learningObjectHruid).toEqual(loId.hruid); + expect(result[2].sequenceNumber).toEqual(3); + + // Question06: About learning object 'id05', but for Assignment #2 in class 'id01' => not expected. + }); + + it("should find only the questions for a certain learning object and assignment asked by the user's group", async () => { + const result = await questionRepository.findAllQuestionsAboutLearningObjectInAssignment(loId, assignment!, 'Tool'); + // (student Tool is in group #2) + + expect(result).toHaveLength(1); + + // Question01 and question02 are in group #1 => not displayed. + + // Question05: About learning object 'id05', in group #2 for Assignment #1 in class 'id01' + expect(result[0].learningObjectHruid).toEqual(loId.hruid); + expect(result[0].sequenceNumber).toEqual(3); + + // Question06: About learning object 'id05', but for Assignment #2 in class 'id01' => not expected. + }); + it('should not find removed question', async () => { const id = new LearningObjectIdentifier('id04', Language.English, 1); await questionRepository.removeQuestionByLearningObjectAndSequenceNumber(id, 1); @@ -47,3 +107,14 @@ describe('QuestionRepository', () => { expect(question).toHaveLength(0); }); }); + +function sortQuestions(questions: Question[]): void { + questions.sort((a, b) => { + if (a.learningObjectHruid < b.learningObjectHruid) { + return -1; + } else if (a.learningObjectHruid > b.learningObjectHruid) { + return 1; + } + return a.sequenceNumber! - b.sequenceNumber!; + }); +} diff --git a/backend/tests/services/learning-path/database-learning-path-provider.test.ts b/backend/tests/services/learning-path/database-learning-path-provider.test.ts index b8a733e7..0a0370a3 100644 --- a/backend/tests/services/learning-path/database-learning-path-provider.test.ts +++ b/backend/tests/services/learning-path/database-learning-path-provider.test.ts @@ -3,6 +3,9 @@ import { LearningObject } from '../../../src/entities/content/learning-object.en import { setupTestApp } from '../../setup-tests.js'; import { LearningPath } from '../../../src/entities/content/learning-path.entity.js'; import { + getAssignmentRepository, + getClassRepository, + getGroupRepository, getLearningObjectRepository, getLearningPathRepository, getStudentRepository, @@ -22,6 +25,10 @@ import { Student } from '../../../src/entities/users/student.entity.js'; import { LearningObjectNode, LearningPathResponse } from '@dwengo-1/common/interfaces/learning-content'; +const STUDENT_A_USERNAME = 'student_a'; +const STUDENT_B_USERNAME = 'student_b'; +const CLASS_NAME = 'test_class'; + async function initExampleData(): Promise<{ learningObject: LearningObject; learningPath: LearningPath }> { const learningObjectRepo = getLearningObjectRepository(); const learningPathRepo = getLearningPathRepository(); @@ -38,6 +45,9 @@ async function initPersonalizationTestData(): Promise<{ studentB: Student; }> { const studentRepo = getStudentRepository(); + const classRepo = getClassRepository(); + const assignmentRepo = getAssignmentRepository(); + const groupRepo = getGroupRepository(); const submissionRepo = getSubmissionRepository(); const learningPathRepo = getLearningPathRepository(); const learningObjectRepo = getLearningObjectRepository(); @@ -47,32 +57,69 @@ async function initPersonalizationTestData(): Promise<{ await learningObjectRepo.save(learningContent.extraExerciseObject); await learningPathRepo.save(learningContent.learningPath); + // Create students const studentA = studentRepo.create({ - username: 'student_a', + username: STUDENT_A_USERNAME, firstName: 'Aron', lastName: 'Student', }); await studentRepo.save(studentA); + + const studentB = studentRepo.create({ + username: STUDENT_B_USERNAME, + firstName: 'Bill', + lastName: 'Student', + }); + await studentRepo.save(studentB); + + // Create class for students + const testClass = classRepo.create({ + classId: CLASS_NAME, + displayName: 'Test class', + }); + await classRepo.save(testClass); + + // Create assignment for students and assign them to different groups + const assignment = assignmentRepo.create({ + id: 0, + title: 'Test assignment', + description: 'Test description', + learningPathHruid: learningContent.learningPath.hruid, + learningPathLanguage: learningContent.learningPath.language, + within: testClass, + }); + + const groupA = groupRepo.create({ + groupNumber: 0, + members: [studentA], + assignment, + }); + await groupRepo.save(groupA); + + const groupB = groupRepo.create({ + groupNumber: 1, + members: [studentB], + assignment, + }); + await groupRepo.save(groupB); + + // Let each of the students make a submission in his own group. const submissionA = submissionRepo.create({ learningObjectHruid: learningContent.branchingObject.hruid, learningObjectLanguage: learningContent.branchingObject.language, learningObjectVersion: learningContent.branchingObject.version, + onBehalfOf: groupA, submitter: studentA, submissionTime: new Date(), content: '[0]', }); await submissionRepo.save(submissionA); - const studentB = studentRepo.create({ - username: 'student_b', - firstName: 'Bill', - lastName: 'Student', - }); - await studentRepo.save(studentB); const submissionB = submissionRepo.create({ learningObjectHruid: learningContent.branchingObject.hruid, learningObjectLanguage: learningContent.branchingObject.language, learningObjectVersion: learningContent.branchingObject.version, + onBehalfOf: groupA, submitter: studentB, submissionTime: new Date(), content: '[1]', diff --git a/backend/tests/setup-tests.ts b/backend/tests/setup-tests.ts index 016099f3..699e081b 100644 --- a/backend/tests/setup-tests.ts +++ b/backend/tests/setup-tests.ts @@ -13,6 +13,7 @@ import { makeTestAttachments } from './test_assets/content/attachments.testdata. import { makeTestQuestions } from './test_assets/questions/questions.testdata.js'; import { makeTestAnswers } from './test_assets/questions/answers.testdata.js'; import { makeTestSubmissions } from './test_assets/assignments/submission.testdata.js'; +import { Collection } from '@mikro-orm/core'; export async function setupTestApp(): Promise { dotenv.config({ path: '.env.test' }); @@ -28,8 +29,8 @@ export async function setupTestApp(): Promise { const assignments = makeTestAssignemnts(em, classes); const groups = makeTestGroups(em, students, assignments); - assignments[0].groups = groups.slice(0, 3); - assignments[1].groups = groups.slice(3, 4); + assignments[0].groups = new Collection(groups.slice(0, 3)); + assignments[1].groups = new Collection(groups.slice(3, 4)); const teacherInvitations = makeTestTeacherInvitations(em, teachers, classes); const classJoinRequests = makeTestClassJoinRequests(em, students, classes); @@ -37,7 +38,7 @@ export async function setupTestApp(): Promise { learningObjects[1].attachments = attachments; - const questions = makeTestQuestions(em, students); + const questions = makeTestQuestions(em, students, groups); const answers = makeTestAnswers(em, teachers, questions); const submissions = makeTestSubmissions(em, students, groups); diff --git a/backend/tests/test_assets/assignments/assignments.testdata.ts b/backend/tests/test_assets/assignments/assignments.testdata.ts index b0da638f..14253c0a 100644 --- a/backend/tests/test_assets/assignments/assignments.testdata.ts +++ b/backend/tests/test_assets/assignments/assignments.testdata.ts @@ -34,5 +34,15 @@ export function makeTestAssignemnts(em: EntityManager, classes: Class[]): Assign groups: [], }); - return [assignment01, assignment02, assignment03]; + const assignment04 = em.create(Assignment, { + within: classes[0], + id: 4, + title: 'another assignment', + description: 'with a description', + learningPathHruid: 'id01', + learningPathLanguage: Language.English, + groups: [], + }); + + return [assignment01, assignment02, assignment03, assignment04]; } diff --git a/backend/tests/test_assets/assignments/groups.testdata.ts b/backend/tests/test_assets/assignments/groups.testdata.ts index a8ff8380..c82887bb 100644 --- a/backend/tests/test_assets/assignments/groups.testdata.ts +++ b/backend/tests/test_assets/assignments/groups.testdata.ts @@ -4,29 +4,55 @@ import { Assignment } from '../../../src/entities/assignments/assignment.entity' import { Student } from '../../../src/entities/users/student.entity'; export function makeTestGroups(em: EntityManager, students: Student[], assignments: Assignment[]): Group[] { + /* + * Group #1 for Assignment #1 in class 'id01' + * => Assigned to do learning path 'id02' + */ const group01 = em.create(Group, { assignment: assignments[0], groupNumber: 1, members: students.slice(0, 2), }); + /* + * Group #2 for Assignment #1 in class 'id01' + * => Assigned to do learning path 'id02' + */ const group02 = em.create(Group, { assignment: assignments[0], groupNumber: 2, members: students.slice(2, 4), }); + /* + * Group #3 for Assignment #1 in class 'id01' + * => Assigned to do learning path 'id02' + */ const group03 = em.create(Group, { assignment: assignments[0], groupNumber: 3, members: students.slice(4, 6), }); + /* + * Group #4 for Assignment #2 in class 'id02' + * => Assigned to do learning path 'id01' + */ const group04 = em.create(Group, { assignment: assignments[1], groupNumber: 4, members: students.slice(3, 4), }); - return [group01, group02, group03, group04]; + /* + * Group #5 for Assignment #4 in class 'id01' + * => Assigned to do learning path 'id01' + */ + const group05 = em.create(Group, { + assignment: assignments[3], + groupNumber: 1, + members: students.slice(0, 2), + }); + + return [group01, group02, group03, group04, group05]; } diff --git a/backend/tests/test_assets/assignments/submission.testdata.ts b/backend/tests/test_assets/assignments/submission.testdata.ts index f454b133..81db2229 100644 --- a/backend/tests/test_assets/assignments/submission.testdata.ts +++ b/backend/tests/test_assets/assignments/submission.testdata.ts @@ -12,7 +12,7 @@ export function makeTestSubmissions(em: EntityManager, students: Student[], grou submissionNumber: 1, submitter: students[0], submissionTime: new Date(2025, 2, 20), - onBehalfOf: groups[0], + onBehalfOf: groups[0], // Group #1 for Assignment #1 in class 'id01' content: 'sub1', }); @@ -23,7 +23,7 @@ export function makeTestSubmissions(em: EntityManager, students: Student[], grou submissionNumber: 2, submitter: students[0], submissionTime: new Date(2025, 2, 25), - onBehalfOf: groups[0], + onBehalfOf: groups[0], // Group #1 for Assignment #1 in class 'id01' content: '', }); @@ -34,6 +34,7 @@ export function makeTestSubmissions(em: EntityManager, students: Student[], grou submissionNumber: 1, submitter: students[0], submissionTime: new Date(2025, 2, 20), + onBehalfOf: groups[0], // Group #1 for Assignment #1 in class 'id01' content: '', }); @@ -44,6 +45,7 @@ export function makeTestSubmissions(em: EntityManager, students: Student[], grou submissionNumber: 2, submitter: students[0], submissionTime: new Date(2025, 2, 25), + onBehalfOf: groups[0], // Group #1 for Assignment #1 in class 'id01' content: '', }); @@ -54,8 +56,42 @@ export function makeTestSubmissions(em: EntityManager, students: Student[], grou submissionNumber: 1, submitter: students[1], submissionTime: new Date(2025, 2, 20), + onBehalfOf: groups[1], // Group #2 for Assignment #1 in class 'id01' content: '', }); - return [submission01, submission02, submission03, submission04, submission05]; + const submission06 = em.create(Submission, { + learningObjectHruid: 'id01', + learningObjectLanguage: Language.English, + learningObjectVersion: 1, + submissionNumber: 2, + submitter: students[1], + submissionTime: new Date(2025, 2, 25), + onBehalfOf: groups[4], // Group #5 for Assignment #4 in class 'id01' + content: '', + }); + + const submission07 = em.create(Submission, { + learningObjectHruid: 'id01', + learningObjectLanguage: Language.English, + learningObjectVersion: 1, + submissionNumber: 3, + submitter: students[3], + submissionTime: new Date(2025, 3, 25), + onBehalfOf: groups[3], // Group #4 for Assignment #2 in class 'id02' + content: '', + }); + + const submission08 = em.create(Submission, { + learningObjectHruid: 'id02', + learningObjectLanguage: Language.English, + learningObjectVersion: 1, + submissionNumber: 3, + submitter: students[1], + submissionTime: new Date(2025, 4, 7), + onBehalfOf: groups[1], // Group #2 for Assignment #1 in class 'id01' + content: '', + }); + + return [submission01, submission02, submission03, submission04, submission05, submission06, submission07, submission08]; } diff --git a/backend/tests/test_assets/classes/class-join-requests.testdata.ts b/backend/tests/test_assets/classes/class-join-requests.testdata.ts index 32337b19..63319dc4 100644 --- a/backend/tests/test_assets/classes/class-join-requests.testdata.ts +++ b/backend/tests/test_assets/classes/class-join-requests.testdata.ts @@ -2,31 +2,31 @@ import { EntityManager } from '@mikro-orm/core'; import { ClassJoinRequest } from '../../../src/entities/classes/class-join-request.entity'; import { Student } from '../../../src/entities/users/student.entity'; import { Class } from '../../../src/entities/classes/class.entity'; -import { ClassJoinRequestStatus } from '@dwengo-1/common/util/class-join-request'; +import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; export function makeTestClassJoinRequests(em: EntityManager, students: Student[], classes: Class[]): ClassJoinRequest[] { const classJoinRequest01 = em.create(ClassJoinRequest, { requester: students[4], class: classes[1], - status: ClassJoinRequestStatus.Open, + status: ClassStatus.Open, }); const classJoinRequest02 = em.create(ClassJoinRequest, { requester: students[2], class: classes[1], - status: ClassJoinRequestStatus.Open, + status: ClassStatus.Open, }); const classJoinRequest03 = em.create(ClassJoinRequest, { requester: students[4], class: classes[2], - status: ClassJoinRequestStatus.Open, + status: ClassStatus.Open, }); const classJoinRequest04 = em.create(ClassJoinRequest, { requester: students[3], class: classes[2], - status: ClassJoinRequestStatus.Open, + status: ClassStatus.Open, }); return [classJoinRequest01, classJoinRequest02, classJoinRequest03, classJoinRequest04]; diff --git a/backend/tests/test_assets/classes/teacher-invitations.testdata.ts b/backend/tests/test_assets/classes/teacher-invitations.testdata.ts index 68204a57..6337a513 100644 --- a/backend/tests/test_assets/classes/teacher-invitations.testdata.ts +++ b/backend/tests/test_assets/classes/teacher-invitations.testdata.ts @@ -2,30 +2,35 @@ import { EntityManager } from '@mikro-orm/core'; import { TeacherInvitation } from '../../../src/entities/classes/teacher-invitation.entity'; import { Teacher } from '../../../src/entities/users/teacher.entity'; import { Class } from '../../../src/entities/classes/class.entity'; +import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; export function makeTestTeacherInvitations(em: EntityManager, teachers: Teacher[], classes: Class[]): TeacherInvitation[] { const teacherInvitation01 = em.create(TeacherInvitation, { sender: teachers[1], receiver: teachers[0], class: classes[1], + status: ClassStatus.Open, }); const teacherInvitation02 = em.create(TeacherInvitation, { sender: teachers[1], receiver: teachers[2], class: classes[1], + status: ClassStatus.Open, }); const teacherInvitation03 = em.create(TeacherInvitation, { sender: teachers[2], receiver: teachers[0], class: classes[2], + status: ClassStatus.Open, }); const teacherInvitation04 = em.create(TeacherInvitation, { sender: teachers[0], receiver: teachers[1], class: classes[0], + status: ClassStatus.Open, }); return [teacherInvitation01, teacherInvitation02, teacherInvitation03, teacherInvitation04]; diff --git a/backend/tests/test_assets/questions/questions.testdata.ts b/backend/tests/test_assets/questions/questions.testdata.ts index dff742bb..10a04571 100644 --- a/backend/tests/test_assets/questions/questions.testdata.ts +++ b/backend/tests/test_assets/questions/questions.testdata.ts @@ -2,12 +2,14 @@ import { EntityManager } from '@mikro-orm/core'; import { Question } from '../../../src/entities/questions/question.entity'; import { Language } from '@dwengo-1/common/util/language'; import { Student } from '../../../src/entities/users/student.entity'; +import { Group } from '../../../src/entities/assignments/group.entity'; -export function makeTestQuestions(em: EntityManager, students: Student[]): Question[] { +export function makeTestQuestions(em: EntityManager, students: Student[], groups: Group[]): Question[] { const question01 = em.create(Question, { learningObjectLanguage: Language.English, learningObjectVersion: 1, learningObjectHruid: 'id05', + inGroup: groups[0], // Group #1 for Assignment #1 in class 'id01' sequenceNumber: 1, author: students[0], timestamp: new Date(), @@ -18,6 +20,7 @@ export function makeTestQuestions(em: EntityManager, students: Student[]): Quest learningObjectLanguage: Language.English, learningObjectVersion: 1, learningObjectHruid: 'id05', + inGroup: groups[0], // Group #1 for Assignment #1 in class 'id01' sequenceNumber: 2, author: students[2], timestamp: new Date(), @@ -30,6 +33,7 @@ export function makeTestQuestions(em: EntityManager, students: Student[]): Quest learningObjectHruid: 'id04', sequenceNumber: 1, author: students[0], + inGroup: groups[0], // Group #1 for Assignment #1 in class 'id01' timestamp: new Date(), content: 'question', }); @@ -40,9 +44,32 @@ export function makeTestQuestions(em: EntityManager, students: Student[]): Quest learningObjectHruid: 'id01', sequenceNumber: 1, author: students[1], + inGroup: groups[1], // Group #2 for Assignment #1 in class 'id01' timestamp: new Date(), content: 'question', }); - return [question01, question02, question03, question04]; + const question05 = em.create(Question, { + learningObjectLanguage: Language.English, + learningObjectVersion: 1, + learningObjectHruid: 'id05', + sequenceNumber: 3, + author: students[1], + inGroup: groups[1], // Group #2 for Assignment #1 in class 'id01' + timestamp: new Date(), + content: 'question', + }); + + const question06 = em.create(Question, { + learningObjectLanguage: Language.English, + learningObjectVersion: 1, + learningObjectHruid: 'id05', + sequenceNumber: 4, + author: students[2], + inGroup: groups[3], // Group #4 for Assignment #2 in class 'id02' + timestamp: new Date(), + content: 'question', + }); + + return [question01, question02, question03, question04, question05, question06]; } diff --git a/backend/tool/seed.ts b/backend/tool/seed.ts index 33344234..3ded9379 100644 --- a/backend/tool/seed.ts +++ b/backend/tool/seed.ts @@ -14,6 +14,8 @@ import { makeTestQuestions } from '../tests/test_assets/questions/questions.test import { makeTestStudents } from '../tests/test_assets/users/students.testdata.js'; import { makeTestTeachers } from '../tests/test_assets/users/teachers.testdata.js'; import { getLogger, Logger } from '../src/logging/initalize.js'; +import { Collection } from '@mikro-orm/core'; +import { Group } from '../dist/entities/assignments/group.entity.js'; const logger: Logger = getLogger(); @@ -34,8 +36,8 @@ export async function seedDatabase(): Promise { const assignments = makeTestAssignemnts(em, classes); const groups = makeTestGroups(em, students, assignments); - assignments[0].groups = groups.slice(0, 3); - assignments[1].groups = groups.slice(3, 4); + assignments[0].groups = new Collection(groups.slice(0, 3)); + assignments[1].groups = new Collection(groups.slice(3, 4)); const teacherInvitations = makeTestTeacherInvitations(em, teachers, classes); const classJoinRequests = makeTestClassJoinRequests(em, students, classes); @@ -43,7 +45,7 @@ export async function seedDatabase(): Promise { learningObjects[1].attachments = attachments; - const questions = makeTestQuestions(em, students); + const questions = makeTestQuestions(em, students, groups); const answers = makeTestAnswers(em, teachers, questions); const submissions = makeTestSubmissions(em, students, groups); diff --git a/common/src/interfaces/class-join-request.ts b/common/src/interfaces/class-join-request.ts index 6787998b..5e8b2683 100644 --- a/common/src/interfaces/class-join-request.ts +++ b/common/src/interfaces/class-join-request.ts @@ -1,8 +1,8 @@ import { StudentDTO } from './student'; -import { ClassJoinRequestStatus } from '../util/class-join-request'; +import { ClassStatus } from '../util/class-join-request'; export interface ClassJoinRequestDTO { requester: StudentDTO; class: string; - status: ClassJoinRequestStatus; + status: ClassStatus; } diff --git a/common/src/interfaces/group.ts b/common/src/interfaces/group.ts index a9f68398..cd6e9a3a 100644 --- a/common/src/interfaces/group.ts +++ b/common/src/interfaces/group.ts @@ -6,7 +6,7 @@ export interface GroupDTO { class: string | ClassDTO; assignment: number | AssignmentDTO; groupNumber: number; - members: string[] | StudentDTO[]; + members?: string[] | StudentDTO[]; } export interface GroupDTOId { diff --git a/common/src/interfaces/question.ts b/common/src/interfaces/question.ts index 9866fbef..172d14b7 100644 --- a/common/src/interfaces/question.ts +++ b/common/src/interfaces/question.ts @@ -1,10 +1,12 @@ import { LearningObjectIdentifierDTO } from './learning-content'; import { StudentDTO } from './student'; +import { GroupDTO } from './group'; export interface QuestionDTO { learningObjectIdentifier: LearningObjectIdentifierDTO; sequenceNumber?: number; author: StudentDTO; + inGroup: GroupDTO; timestamp: string; content: string; } @@ -12,6 +14,7 @@ export interface QuestionDTO { export interface QuestionData { author?: string; content: string; + inGroup: GroupDTO; } export interface QuestionId { diff --git a/common/src/interfaces/teacher-invitation.ts b/common/src/interfaces/teacher-invitation.ts index 13709322..f878b741 100644 --- a/common/src/interfaces/teacher-invitation.ts +++ b/common/src/interfaces/teacher-invitation.ts @@ -1,8 +1,16 @@ import { UserDTO } from './user'; -import { ClassDTO } from './class'; +import { ClassStatus } from '../util/class-join-request'; export interface TeacherInvitationDTO { sender: string | UserDTO; receiver: string | UserDTO; - class: string | ClassDTO; + classId: string; + status: ClassStatus; +} + +export interface TeacherInvitationData { + sender: string; + receiver: string; + class: string; + accepted?: boolean; // Use for put requests, else skip } diff --git a/common/src/util/class-join-request.ts b/common/src/util/class-join-request.ts index 5f9410f0..2049a16d 100644 --- a/common/src/util/class-join-request.ts +++ b/common/src/util/class-join-request.ts @@ -1,4 +1,4 @@ -export enum ClassJoinRequestStatus { +export enum ClassStatus { Open = 'open', Accepted = 'accepted', Declined = 'declined', diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 00000000..ca73ebc9 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1 @@ +api/swagger.json diff --git a/docs/api/generate.ts b/docs/api/generate.ts index 24d3a6cb..796369d1 100644 --- a/docs/api/generate.ts +++ b/docs/api/generate.ts @@ -15,6 +15,10 @@ const doc = { url: 'http://localhost:3000/', description: 'Development server', }, + { + url: 'http://localhost/', + description: 'Staging server', + }, { url: 'https://sel2-1.ugent.be/', description: 'Production server', @@ -55,4 +59,4 @@ const doc = { const outputFile = './swagger.json'; const routes = ['../../backend/src/app.ts']; -await swaggerAutogen({ openapi: '3.1.0' })(outputFile, routes, doc); +void swaggerAutogen({ openapi: '3.1.0' })(outputFile, routes, doc); diff --git a/docs/api/swagger.json b/docs/api/swagger.json deleted file mode 100644 index 911839d0..00000000 --- a/docs/api/swagger.json +++ /dev/null @@ -1,1964 +0,0 @@ -{ - "openapi": "3.1.0", - "info": { - "version": "0.1.0", - "title": "Dwengo-1 Backend API", - "description": "Dwengo-1 Backend API using Express, based on VZW Dwengo", - "license": { - "name": "MIT", - "url": "https://github.com/SELab-2/Dwengo-1/blob/336496ab6352ee3f8bf47490c90b5cf81526cef6/LICENSE" - } - }, - "servers": [ - { - "url": "http://localhost:3000/", - "description": "Development server" - }, - { - "url": "https://sel2-1.ugent.be/", - "description": "Production server" - } - ], - "paths": { - "/api/": { - "get": { - "description": "", - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/student/": { - "get": { - "tags": ["Student"], - "description": "", - "parameters": [ - { - "name": "full", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "201": { - "description": "Created" - }, - "404": { - "description": "Not Found" - } - } - }, - "post": { - "tags": ["Student"], - "description": "", - "responses": { - "201": { - "description": "Created" - }, - "400": { - "description": "Bad Request" - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "username": { - "example": "any" - }, - "firstName": { - "example": "any" - }, - "lastName": { - "example": "any" - } - } - } - } - } - } - }, - "delete": { - "tags": ["Student"], - "description": "", - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - }, - "404": { - "description": "Not Found" - } - } - } - }, - "/api/student/{username}": { - "delete": { - "tags": ["Student"], - "description": "", - "parameters": [ - { - "name": "username", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - }, - "404": { - "description": "Not Found" - } - } - }, - "get": { - "tags": ["Student"], - "description": "", - "parameters": [ - { - "name": "username", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "201": { - "description": "Created" - }, - "400": { - "description": "Bad Request" - }, - "404": { - "description": "Not Found" - } - } - } - }, - "/api/student/{id}/classes": { - "get": { - "tags": ["Student"], - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "full", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/student/{id}/submissions": { - "get": { - "tags": ["Student"], - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/student/{id}/assignments": { - "get": { - "tags": ["Student"], - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "full", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/student/{id}/groups": { - "get": { - "tags": ["Student"], - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "full", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/student/{id}/questions": { - "get": { - "tags": ["Student"], - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/group/": { - "get": { - "tags": ["Group"], - "description": "", - "parameters": [ - { - "name": "full", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - } - } - }, - "post": { - "tags": ["Group"], - "description": "", - "responses": { - "201": { - "description": "Created" - }, - "400": { - "description": "Bad Request" - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/group/{groupid}": { - "get": { - "tags": ["Group"], - "description": "", - "parameters": [ - { - "name": "groupid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - } - } - } - }, - "/api/group/{id}/questions": { - "get": { - "tags": ["Group"], - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/assignment/": { - "get": { - "tags": ["Assignment"], - "description": "", - "parameters": [ - { - "name": "full", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - }, - "post": { - "tags": ["Assignment"], - "description": "", - "responses": { - "201": { - "description": "Created" - }, - "400": { - "description": "Bad Request" - }, - "500": { - "description": "Internal Server Error" - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "description": { - "example": "any" - }, - "language": { - "example": "any" - }, - "learningPath": { - "example": "any" - }, - "title": { - "example": "any" - } - } - } - } - } - } - } - }, - "/api/assignment/{id}": { - "get": { - "tags": ["Assignment"], - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - }, - "404": { - "description": "Not Found" - } - } - } - }, - "/api/assignment/{id}/submissions": { - "get": { - "tags": ["Assignment"], - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - } - } - } - }, - "/api/assignment/{id}/questions": { - "get": { - "tags": ["Assignment"], - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/assignment/{assignmentid}/groups/": { - "get": { - "tags": ["Assignment"], - "description": "", - "parameters": [ - { - "name": "assignmentid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "full", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - } - } - }, - "post": { - "tags": ["Assignment"], - "description": "", - "parameters": [ - { - "name": "assignmentid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "201": { - "description": "Created" - }, - "400": { - "description": "Bad Request" - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/assignment/{assignmentid}/groups/{groupid}": { - "get": { - "tags": ["Assignment"], - "description": "", - "parameters": [ - { - "name": "assignmentid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "groupid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - } - } - } - }, - "/api/assignment/{assignmentid}/groups/{id}/questions": { - "get": { - "tags": ["Assignment"], - "description": "", - "parameters": [ - { - "name": "assignmentid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/submission/": { - "get": { - "tags": ["Submission"], - "description": "", - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/submission/{id}": { - "post": { - "tags": ["Submission"], - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "404": { - "description": "Not Found" - } - } - }, - "get": { - "tags": ["Submission"], - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "language", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "version", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - }, - "404": { - "description": "Not Found" - } - } - }, - "delete": { - "tags": ["Submission"], - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "language", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "version", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "404": { - "description": "Not Found" - } - } - } - }, - "/api/class/": { - "get": { - "tags": ["Class"], - "description": "", - "parameters": [ - { - "name": "full", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - }, - "post": { - "tags": ["Class"], - "description": "", - "responses": { - "201": { - "description": "Created" - }, - "400": { - "description": "Bad Request" - }, - "500": { - "description": "Internal Server Error" - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "displayName": { - "example": "any" - } - } - } - } - } - } - } - }, - "/api/class/{id}": { - "get": { - "tags": ["Class"], - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "404": { - "description": "Not Found" - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/class/{id}/teacher-invitations": { - "get": { - "tags": ["Class"], - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "full", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/class/{id}/students": { - "get": { - "tags": ["Class"], - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "full", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/class/{classid}/assignments/": { - "get": { - "tags": ["Class"], - "description": "", - "parameters": [ - { - "name": "classid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "full", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - }, - "post": { - "tags": ["Class"], - "description": "", - "parameters": [ - { - "name": "classid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "201": { - "description": "Created" - }, - "400": { - "description": "Bad Request" - }, - "500": { - "description": "Internal Server Error" - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "description": { - "example": "any" - }, - "language": { - "example": "any" - }, - "learningPath": { - "example": "any" - }, - "title": { - "example": "any" - } - } - } - } - } - } - } - }, - "/api/class/{classid}/assignments/{id}": { - "get": { - "tags": ["Class"], - "description": "", - "parameters": [ - { - "name": "classid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - }, - "404": { - "description": "Not Found" - } - } - } - }, - "/api/class/{classid}/assignments/{id}/submissions": { - "get": { - "tags": ["Class"], - "description": "", - "parameters": [ - { - "name": "classid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - } - } - } - }, - "/api/class/{classid}/assignments/{id}/questions": { - "get": { - "tags": ["Class"], - "description": "", - "parameters": [ - { - "name": "classid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/class/{classid}/assignments/{assignmentid}/groups/": { - "get": { - "tags": ["Class"], - "description": "", - "parameters": [ - { - "name": "classid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "assignmentid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "full", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - } - } - }, - "post": { - "tags": ["Class"], - "description": "", - "parameters": [ - { - "name": "classid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "assignmentid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "201": { - "description": "Created" - }, - "400": { - "description": "Bad Request" - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/class/{classid}/assignments/{assignmentid}/groups/{groupid}": { - "get": { - "tags": ["Class"], - "description": "", - "parameters": [ - { - "name": "classid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "assignmentid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "groupid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - } - } - } - }, - "/api/class/{classid}/assignments/{assignmentid}/groups/{id}/questions": { - "get": { - "tags": ["Class"], - "description": "", - "parameters": [ - { - "name": "classid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "assignmentid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/question/": { - "get": { - "tags": ["Question"], - "description": "", - "parameters": [ - { - "name": "full", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "404": { - "description": "Not Found" - } - } - }, - "post": { - "tags": ["Question"], - "description": "", - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "learningObjectIdentifier": { - "example": "any" - }, - "author": { - "example": "any" - }, - "content": { - "example": "any" - } - } - } - } - } - } - } - }, - "/api/question/{seq}": { - "delete": { - "tags": ["Question"], - "description": "", - "parameters": [ - { - "name": "seq", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - } - } - }, - "get": { - "tags": ["Question"], - "description": "", - "parameters": [ - { - "name": "seq", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "404": { - "description": "Not Found" - } - } - } - }, - "/api/question/answers/{seq}": { - "get": { - "tags": ["Question"], - "description": "", - "parameters": [ - { - "name": "seq", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "full", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "404": { - "description": "Not Found" - } - } - } - }, - "/api/auth/config": { - "get": { - "tags": ["Auth"], - "description": "", - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/auth/testAuthenticatedOnly": { - "get": { - "tags": ["Auth"], - "description": "", - "responses": { - "200": { - "description": "OK" - } - }, - "security": [ - { - "student": [] - }, - { - "teacher": [] - } - ] - } - }, - "/api/auth/testStudentsOnly": { - "get": { - "tags": ["Auth"], - "description": "", - "responses": { - "200": { - "description": "OK" - } - }, - "security": [ - { - "student": [] - } - ] - } - }, - "/api/auth/testTeachersOnly": { - "get": { - "tags": ["Auth"], - "description": "", - "responses": { - "200": { - "description": "OK" - } - }, - "security": [ - { - "teacher": [] - } - ] - } - }, - "/api/theme/": { - "get": { - "tags": ["Theme"], - "description": "", - "parameters": [ - { - "name": "language", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/theme/{theme}": { - "get": { - "tags": ["Theme"], - "description": "", - "parameters": [ - { - "name": "theme", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "404": { - "description": "Not Found" - } - } - } - }, - "/api/learningPath/": { - "get": { - "tags": ["Learning Path"], - "description": "", - "parameters": [ - { - "name": "hruid", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "theme", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "search", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "language", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "forStudent", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "forGroup", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "assignmentNo", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "classId", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/learningObject/": { - "get": { - "tags": ["Learning Object"], - "description": "", - "parameters": [ - { - "name": "full", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/learningObject/{hruid}": { - "get": { - "tags": ["Learning Object"], - "description": "", - "parameters": [ - { - "name": "hruid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/learningObject/{hruid}/html": { - "get": { - "tags": ["Learning Object"], - "description": "", - "parameters": [ - { - "name": "hruid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/learningObject/{hruid}/html/{attachmentName}": { - "get": { - "tags": ["Learning Object"], - "description": "", - "parameters": [ - { - "name": "hruid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "attachmentName", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "default": { - "description": "" - } - } - } - }, - "/api/learningObject/{hruid}/submissions/": { - "get": { - "tags": ["Learning Object"], - "description": "", - "parameters": [ - { - "name": "hruid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/learningObject/{hruid}/submissions/{id}": { - "post": { - "tags": ["Learning Object"], - "description": "", - "parameters": [ - { - "name": "hruid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "404": { - "description": "Not Found" - } - } - }, - "get": { - "tags": ["Learning Object"], - "description": "", - "parameters": [ - { - "name": "hruid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "language", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "version", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - }, - "404": { - "description": "Not Found" - } - } - }, - "delete": { - "tags": ["Learning Object"], - "description": "", - "parameters": [ - { - "name": "hruid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "language", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "version", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "404": { - "description": "Not Found" - } - } - } - }, - "/api/learningObject/{hruid}/{version}/questions/": { - "get": { - "tags": ["Learning Object"], - "description": "", - "parameters": [ - { - "name": "hruid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "version", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "full", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "404": { - "description": "Not Found" - } - } - }, - "post": { - "tags": ["Learning Object"], - "description": "", - "parameters": [ - { - "name": "hruid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "version", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "learningObjectIdentifier": { - "example": "any" - }, - "author": { - "example": "any" - }, - "content": { - "example": "any" - } - } - } - } - } - } - } - }, - "/api/learningObject/{hruid}/{version}/questions/{seq}": { - "delete": { - "tags": ["Learning Object"], - "description": "", - "parameters": [ - { - "name": "hruid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "version", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "seq", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - } - } - }, - "get": { - "tags": ["Learning Object"], - "description": "", - "parameters": [ - { - "name": "hruid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "version", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "seq", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "404": { - "description": "Not Found" - } - } - } - }, - "/api/learningObject/{hruid}/{version}/questions/answers/{seq}": { - "get": { - "tags": ["Learning Object"], - "description": "", - "parameters": [ - { - "name": "hruid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "version", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "seq", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "full", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "404": { - "description": "Not Found" - } - } - } - } - }, - "components": { - "securitySchemes": { - "student": { - "type": "oauth2", - "flows": { - "implicit": { - "authorizationUrl": "https://sel2-1.ugent.be/idp/realms/student/protocol/openid-connect/auth", - "scopes": { - "openid": "openid", - "profile": "profile", - "email": "email" - } - } - } - }, - "teacher": { - "type": "oauth2", - "flows": { - "implicit": { - "authorizationUrl": "https://sel2-1.ugent.be/idp/realms/teacher/protocol/openid-connect/auth", - "scopes": { - "openid": "openid", - "profile": "profile", - "email": "email" - } - } - } - } - } - } -} diff --git a/eslint.config.ts b/eslint.config.ts index fb19e5c4..30e8fe2f 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -44,16 +44,16 @@ export default [ // All @typescript-eslint configuration options are listed. // If the rules are commented, they are configured by the inherited configurations. - '@typescript-eslint/adjacent-overload-signatures': 'warn', - '@typescript-eslint/array-type': 'warn', + '@typescript-eslint/adjacent-overload-signatures': 'error', + '@typescript-eslint/array-type': 'error', '@typescript-eslint/await-thenable': 'error', '@typescript-eslint/ban-ts-comment': ['error', { minimumDescriptionLength: 10 }], '@typescript-eslint/ban-tslint-comment': 'error', camelcase: 'off', - '@typescript-eslint/class-literal-property-style': 'warn', + '@typescript-eslint/class-literal-property-style': 'error', 'class-methods-use-this': 'off', '@typescript-eslint/class-methods-use-this': ['error', { ignoreOverrideMethods: true }], - '@typescript-eslint/consistent-generic-constructors': 'warn', + '@typescript-eslint/consistent-generic-constructors': 'error', '@typescript-eslint/consistent-indexed-object-style': 'error', 'consistent-return': 'off', '@typescript-eslint/consistent-return': 'off', @@ -64,18 +64,18 @@ export default [ 'default-param-last': 'off', '@typescript-eslint/default-param-last': 'error', 'dot-notation': 'off', - '@typescript-eslint/dot-notation': 'warn', - '@typescript-eslint/explicit-function-return-type': 'warn', + '@typescript-eslint/dot-notation': 'error', + '@typescript-eslint/explicit-function-return-type': 'error', '@typescript-eslint/explicit-member-accessibility': 'off', - '@typescript-eslint/explicit-module-boundary-types': 'warn', + '@typescript-eslint/explicit-module-boundary-types': 'error', 'init-declarations': 'off', '@typescript-eslint/init-declarations': 'off', 'max-params': 'off', '@typescript-eslint/max-params': ['error', { max: 6 }], - '@typescript-eslint/member-ordering': 'warn', + '@typescript-eslint/member-ordering': 'error', '@typescript-eslint/method-signature-style': 'off', // Don't care about TypeScript strict mode. '@typescript-eslint/naming-convention': [ - 'warn', + 'error', { // Enforce that all variables, functions and properties are camelCase selector: 'variableLike', @@ -113,7 +113,7 @@ export default [ '@typescript-eslint/no-empty-function': 'error', '@typescript-eslint/no-empty-interface': 'off', '@typescript-eslint/no-empty-object-type': 'error', - '@typescript-eslint/no-explicit-any': 'warn', // Once in production, this should be an error. + '@typescript-eslint/no-explicit-any': 'error', // Once in production, this should be an error. '@typescript-eslint/no-extra-non-null-assertion': 'error', '@typescript-eslint/no-extraneous-class': 'error', '@typescript-eslint/no-floating-promises': 'error', @@ -121,7 +121,7 @@ export default [ 'no-implied-eval': 'off', '@typescript-eslint/no-implied-eval': 'error', '@typescript-eslint/no-import-type-side-effects': 'error', - '@typescript-eslint/no-inferrable-types': 'warn', + '@typescript-eslint/no-inferrable-types': 'error', 'no-invalid-this': 'off', '@typescript-eslint/no-invalid-this': 'off', '@typescript-eslint/no-invalid-void-type': 'error', @@ -146,10 +146,10 @@ export default [ '@typescript-eslint/no-unsafe-function-type': 'error', 'no-unused-expressions': 'off', - '@typescript-eslint/no-unused-expressions': 'warn', + '@typescript-eslint/no-unused-expressions': 'error', 'no-unused-vars': 'off', '@typescript-eslint/no-unused-vars': [ - 'warn', + 'error', { args: 'all', argsIgnorePattern: '^_', @@ -164,53 +164,53 @@ export default [ '@typescript-eslint/parameter-properties': 'off', - '@typescript-eslint/prefer-find': 'warn', + '@typescript-eslint/prefer-find': 'error', '@typescript-eslint/prefer-function-type': 'error', '@typescript-eslint/prefer-readonly-parameter-types': 'off', '@typescript-eslint/prefer-reduce-type-parameter': 'error', - '@typescript-eslint/promise-function-async': 'warn', + '@typescript-eslint/promise-function-async': 'error', - '@typescript-eslint/require-array-sort-compare': 'warn', + '@typescript-eslint/require-array-sort-compare': 'error', - 'no-await-in-loop': 'warn', + 'no-await-in-loop': 'error', 'no-constructor-return': 'error', 'no-inner-declarations': 'error', 'no-self-compare': 'error', 'no-template-curly-in-string': 'error', - 'no-unmodified-loop-condition': 'warn', - 'no-unreachable-loop': 'warn', + 'no-unmodified-loop-condition': 'error', + 'no-unreachable-loop': 'error', 'no-useless-assignment': 'error', - 'arrow-body-style': ['warn', 'as-needed'], - 'block-scoped-var': 'warn', - 'capitalized-comments': 'warn', + 'arrow-body-style': ['error', 'as-needed'], + 'block-scoped-var': 'error', + 'capitalized-comments': 'error', 'consistent-this': 'error', curly: 'error', 'default-case': 'error', 'default-case-last': 'error', eqeqeq: 'error', - 'func-names': 'warn', - 'func-style': ['warn', 'declaration'], - 'grouped-accessor-pairs': ['warn', 'getBeforeSet'], - 'guard-for-in': 'warn', - 'logical-assignment-operators': 'warn', - 'max-classes-per-file': 'warn', + 'func-names': 'error', + 'func-style': ['error', 'declaration'], + 'grouped-accessor-pairs': ['error', 'getBeforeSet'], + 'guard-for-in': 'error', + 'logical-assignment-operators': 'error', + 'max-classes-per-file': 'error', 'no-alert': 'error', - 'no-bitwise': 'warn', - 'no-console': 'warn', - 'no-continue': 'warn', - 'no-else-return': 'warn', + 'no-bitwise': 'error', + 'no-console': 'error', + 'no-continue': 'error', + 'no-else-return': 'error', 'no-eq-null': 'error', 'no-eval': 'error', 'no-extend-native': 'error', 'no-extra-label': 'error', - 'no-implicit-coercion': 'warn', + 'no-implicit-coercion': 'error', 'no-iterator': 'error', - 'no-label-var': 'warn', - 'no-labels': 'warn', + 'no-label-var': 'error', + 'no-labels': 'error', 'no-multi-assign': 'error', 'no-nested-ternary': 'error', 'no-object-constructor': 'error', diff --git a/frontend/eslint.config.ts b/frontend/eslint.config.ts index 9ca1a08b..b7f15371 100644 --- a/frontend/eslint.config.ts +++ b/frontend/eslint.config.ts @@ -21,7 +21,14 @@ const vueConfig = defineConfigWithVueTs( { name: "app/files-to-ignore", - ignores: ["**/dist/**", "**/dist-ssr/**", "**/coverage/**", "prettier.config.js"], + ignores: [ + "**/dist/**", + "**/dist-ssr/**", + "**/coverage/**", + "prettier.config.js", + "**/test-results/**", + "**/playwright-report/**", + ], }, pluginVue.configs["flat/essential"], diff --git a/frontend/package.json b/frontend/package.json index e6ce1426..b6bd5deb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,6 +20,7 @@ "@tanstack/vue-query": "^5.69.0", "axios": "^1.8.2", "oidc-client-ts": "^3.1.0", + "uuid": "^11.1.0", "vue": "^3.5.13", "vue-i18n": "^11.1.2", "vue-router": "^4.5.0", diff --git a/frontend/src/components/MenuBar.vue b/frontend/src/components/MenuBar.vue index 328d607a..847dca84 100644 --- a/frontend/src/components/MenuBar.vue +++ b/frontend/src/components/MenuBar.vue @@ -80,13 +80,14 @@ > {{ t("classes") }} - - {{ t("discussions") }} - + + + + + + + +