Merge pull request #289 from SELab-2/fix/questions-toon-enkel-groep

fix: Questions, submissions & discussions
This commit is contained in:
Gabriellvl 2025-05-20 20:57:19 +02:00 committed by GitHub
commit d68564c953
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 280 additions and 188 deletions

View file

@ -1,12 +1,25 @@
import { EntityRepository, FilterQuery } from '@mikro-orm/core'; import { EntityRepository, FilterQuery, SyntaxErrorException } from '@mikro-orm/core';
import { EntityAlreadyExistsException } from '../exceptions/entity-already-exists-exception.js'; import { EntityAlreadyExistsException } from '../exceptions/entity-already-exists-exception.js';
import { getLogger } from '../logging/initalize.js';
export abstract class DwengoEntityRepository<T extends object> extends EntityRepository<T> { export abstract class DwengoEntityRepository<T extends object> extends EntityRepository<T> {
public async save(entity: T, options?: { preventOverwrite?: boolean }): Promise<void> { public async save(entity: T, options?: { preventOverwrite?: boolean }): Promise<void> {
if (options?.preventOverwrite && (await this.findOne(entity))) { if (options?.preventOverwrite && (await this.findOne(entity))) {
throw new EntityAlreadyExistsException(`A ${this.getEntityName()} with this identifier already exists.`); throw new EntityAlreadyExistsException(`A ${this.getEntityName()} with this identifier already exists.`);
} }
await this.getEntityManager().persistAndFlush(entity); try {
await this.getEntityManager().persistAndFlush(entity);
} catch (e: unknown) {
// Workaround for MikroORM bug: Sometimes, queries are generated with random syntax errors.
// The faulty query is then retried everytime something is persisted. By clearing the entity
// Manager in that case, we make sure that future queries will work.
if (e instanceof SyntaxErrorException) {
getLogger().error('SyntaxErrorException caught => entity manager cleared.');
this.em.clear();
} else {
throw e;
}
}
} }
public async deleteWhere(query: FilterQuery<T>): Promise<void> { public async deleteWhere(query: FilterQuery<T>): Promise<void> {
const toDelete = await this.findOne(query); const toDelete = await this.findOne(query);

View file

@ -18,13 +18,8 @@ export class QuestionRepository extends DwengoEntityRepository<Question> {
content: question.content, content: question.content,
timestamp: new Date(), timestamp: new Date(),
}); });
await this.insert(questionEntity); // Don't check for overwrite since this is impossible anyway due to autoincrement.
questionEntity.learningObjectHruid = question.loId.hruid; await this.save(questionEntity, { preventOverwrite: false });
questionEntity.learningObjectLanguage = question.loId.language;
questionEntity.learningObjectVersion = question.loId.version;
questionEntity.author = question.author;
questionEntity.inGroup = question.inGroup;
questionEntity.content = question.content;
return questionEntity; return questionEntity;
} }
public async findAllQuestionsAboutLearningObject(loId: LearningObjectIdentifier): Promise<Question[]> { public async findAllQuestionsAboutLearningObject(loId: LearningObjectIdentifier): Promise<Question[]> {

View file

@ -6,6 +6,9 @@ import { Group } from '../assignments/group.entity.js';
@Entity({ repository: () => QuestionRepository }) @Entity({ repository: () => QuestionRepository })
export class Question { export class Question {
@PrimaryKey({ type: 'integer', autoincrement: true })
sequenceNumber?: number;
@PrimaryKey({ type: 'string' }) @PrimaryKey({ type: 'string' })
learningObjectHruid!: string; learningObjectHruid!: string;
@ -18,9 +21,6 @@ export class Question {
@PrimaryKey({ type: 'number' }) @PrimaryKey({ type: 'number' })
learningObjectVersion = 1; learningObjectVersion = 1;
@PrimaryKey({ type: 'integer', autoincrement: true })
sequenceNumber?: number;
@ManyToOne({ entity: () => Group }) @ManyToOne({ entity: () => Group })
inGroup!: Group; inGroup!: Group;

View file

@ -34,15 +34,15 @@ export const onlyAllowIfHasAccessToSubmission = authorize(async (auth: Authentic
}); });
export const onlyAllowIfHasAccessToSubmissionFromParams = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => { export const onlyAllowIfHasAccessToSubmissionFromParams = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => {
const { classId, assignmentId, groupId } = req.query; const { classId, assignmentId, forGroup } = req.query;
requireFields({ classId, assignmentId, groupId }); requireFields({ classId, assignmentId, forGroup });
if (auth.accountType === AccountType.Teacher) { if (auth.accountType === AccountType.Teacher) {
const cls = await fetchClass(classId as string); const cls = await fetchClass(classId as string);
return cls.teachers.map(mapToUsername).includes(auth.username); return cls.teachers.map(mapToUsername).includes(auth.username);
} }
const group = await fetchGroup(classId as string, Number(assignmentId as string), Number(groupId as string)); const group = await fetchGroup(classId as string, Number(assignmentId as string), Number(forGroup as string));
return group.members.map(mapToUsername).includes(auth.username); return group.members.map(mapToUsername).includes(auth.username);
}); });

View file

@ -110,17 +110,26 @@ export async function putAssignment(classid: string, id: number, assignmentData:
const studentLists = await Promise.all((assignmentData.groups as string[][]).map(async (group) => await fetchStudents(group))); const studentLists = await Promise.all((assignmentData.groups as string[][]).map(async (group) => await fetchStudents(group)));
const groupRepository = getGroupRepository(); try {
await groupRepository.deleteAllByAssignment(assignment); const groupRepository = getGroupRepository();
await Promise.all( await groupRepository.deleteAllByAssignment(assignment);
studentLists.map(async (students) => {
const newGroup = groupRepository.create({ await Promise.all(
assignment: assignment, studentLists.map(async (students) => {
members: students, const newGroup = groupRepository.create({
}); assignment: assignment,
await groupRepository.save(newGroup); members: students,
}) });
); await groupRepository.save(newGroup);
})
);
} catch (e: unknown) {
if (e instanceof ForeignKeyConstraintViolationException || e instanceof PostgreSqlExceptionConverter) {
throw new ConflictException('Cannot update assigment with questions or submissions');
} else {
throw e;
}
}
delete assignmentData.groups; delete assignmentData.groups;
} }

View file

@ -1,11 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import UsingQueryResult from "@/components/UsingQueryResult.vue"; import UsingQueryResult from "@/components/UsingQueryResult.vue";
import { useAssignmentSubmissionsQuery } from "@/queries/assignments.ts";
import type { SubmissionsResponse } from "@/controllers/submissions.ts"; import type { SubmissionsResponse } from "@/controllers/submissions.ts";
import { watch } from "vue"; import { ref, watch } from "vue";
import { useGetLearningPathQuery } from "@/queries/learning-paths.ts";
const props = defineProps<{ const props = defineProps<{
learningPathHruid: string;
language: string;
group: object; group: object;
assignmentId: number; assignmentId: number;
classId: string; classId: string;
@ -15,18 +17,24 @@
const emit = defineEmits<(e: "update:hasSubmission", hasSubmission: boolean) => void>(); const emit = defineEmits<(e: "update:hasSubmission", hasSubmission: boolean) => void>();
const { t } = useI18n(); const { t } = useI18n();
const submissionsQuery = useAssignmentSubmissionsQuery( const hasMadeProgress = ref(false);
() => props.classId,
() => props.assignmentId, const getLearningPathQuery = useGetLearningPathQuery(
() => props.group.originalGroupNo, () => props.learningPathHruid,
() => true, () => props.language,
() => ({
forGroup: props.group.originalGroupNo,
assignmentNo: props.assignmentId,
classId: props.classId,
}),
); );
watch( watch(
() => submissionsQuery.data.value, () => getLearningPathQuery.data.value,
(data) => { (learningPath) => {
if (data) { if (learningPath) {
emit("update:hasSubmission", data.submissions.length > 0); hasMadeProgress.value = learningPath.amountOfNodes !== learningPath.amountOfNodesLeft;
emit("update:hasSubmission", hasMadeProgress.value);
} }
}, },
{ immediate: true }, { immediate: true },
@ -35,16 +43,16 @@
<template> <template>
<using-query-result <using-query-result
:query-result="submissionsQuery" :query-result="getLearningPathQuery"
v-slot="{ data }: { data: SubmissionsResponse }" v-slot="{ data }: { data: SubmissionsResponse }"
> >
<v-btn <v-btn
:color="data?.submissions?.length > 0 ? 'green' : 'red'" :color="hasMadeProgress ? 'green' : 'red'"
variant="text" variant="text"
:to="data.submissions.length > 0 ? goToGroupSubmissionLink(props.group.groupNo) : undefined" :to="hasMadeProgress ? goToGroupSubmissionLink(props.group.originalGroupNo) : undefined"
:disabled="data.submissions.length === 0" :disabled="!hasMadeProgress"
> >
{{ data.submissions.length > 0 ? t("submission") : t("noSubmissionsYet") }} {{ hasMadeProgress ? t("submission") : t("noSubmissionsYet") }}
</v-btn> </v-btn>
</using-query-result> </using-query-result>
</template> </template>

View file

@ -1,92 +1,47 @@
<script setup lang="ts"> <script setup lang="ts">
import authService from "@/services/auth/auth-service.ts"; import authService from "@/services/auth/auth-service.ts";
import { Language } from "@/data-objects/language.ts";
import { computed, type ComputedRef, ref } from "vue"; import { computed, type ComputedRef, ref } from "vue";
import type { AssignmentDTO } from "@dwengo-1/common/interfaces/assignment"; import type { GroupDTOId } from "@dwengo-1/common/interfaces/group";
import { useStudentAssignmentsQuery, useStudentGroupsQuery } from "@/queries/students.ts";
import type { GroupDTO, GroupDTOId } from "@dwengo-1/common/interfaces/group";
import type { QuestionData } from "@dwengo-1/common/interfaces/question"; import type { QuestionData } from "@dwengo-1/common/interfaces/question";
import type { LearningObjectIdentifierDTO } from "@dwengo-1/interfaces/learning-content"; import type { LearningObjectIdentifierDTO } from "@dwengo-1/interfaces/learning-content";
import { useCreateQuestionMutation, useQuestionsQuery } from "@/queries/questions.ts"; import { useCreateQuestionMutation } from "@/queries/questions.ts";
import { LearningPathNode } from "@/data-objects/learning-paths/learning-path-node.ts";
import { useGetLearningPathQuery } from "@/queries/learning-paths.ts";
import { useLearningObjectListForPathQuery } from "@/queries/learning-objects.ts";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { AccountType } from "@dwengo-1/common/util/account-types.ts"; import { AccountType } from "@dwengo-1/common/util/account-types.ts";
const props = defineProps<{ const props = defineProps<{
hruid: string; learningObjectHruid: string;
language: Language; learningObjectLanguage: string;
learningObjectHruid?: string; learningObjectVersion: number;
forGroup?: GroupDTOId | undefined; forGroup?: GroupDTOId | undefined;
withTitle?: boolean; withTitle?: boolean;
}>(); }>();
const { t } = useI18n(); const { t } = useI18n();
const studentAssignmentsQueryResult = useStudentAssignmentsQuery( const emit = defineEmits(["updated"]);
authService.authState.user?.profile.preferred_username,
);
const learningPathQueryResult = useGetLearningPathQuery(props.hruid, props.language, props.forGroup);
const learningObjectListQueryResult = useLearningObjectListForPathQuery(learningPathQueryResult.data);
const pathIsAssignment = computed(() => {
const assignments = (studentAssignmentsQueryResult.data.value?.assignments as AssignmentDTO[]) || [];
return assignments.some(
(assignment) => assignment.learningPath === props.hruid && assignment.language === props.language,
);
});
const nodesList: ComputedRef<LearningPathNode[] | null> = computed(
() => learningPathQueryResult.data.value?.nodesAsList ?? null,
);
const currentNode = computed(() => {
const currentHruid = props.learningObjectHruid;
return nodesList.value?.find((it) => it.learningobjectHruid === currentHruid);
});
const getQuestionsQuery = useQuestionsQuery(
computed(
() =>
({
language: currentNode.value?.language,
hruid: currentNode.value?.learningobjectHruid,
version: currentNode.value?.version,
}) as LearningObjectIdentifierDTO,
),
);
const questionInput = ref(""); const questionInput = ref("");
const loID: ComputedRef<LearningObjectIdentifierDTO> = computed(() => ({ const loID: ComputedRef<LearningObjectIdentifierDTO> = computed(() => ({
hruid: props.learningObjectHruid as string, hruid: props.learningObjectHruid as string,
language: props.language, language: props.learningObjectLanguage,
version: props.learningObjectVersion,
})); }));
const createQuestionMutation = useCreateQuestionMutation(loID); const createQuestionMutation = useCreateQuestionMutation(loID);
const groupsQueryResult = useStudentGroupsQuery(authService.authState.user?.profile.preferred_username);
const showQuestionBox = computed( const showQuestionBox = computed(() => authService.authState.activeRole === AccountType.Student && props.forGroup);
() => authService.authState.activeRole === AccountType.Student && pathIsAssignment.value,
);
function submitQuestion(): void { function submitQuestion(): void {
const assignments = studentAssignmentsQueryResult.data.value?.assignments as AssignmentDTO[]; if (props.forGroup && questionInput.value !== "") {
const assignment = assignments.find( const questionData: QuestionData = {
(assignment) => assignment.learningPath === props.hruid && assignment.language === props.language, author: authService.authState.user?.profile.preferred_username,
); content: questionInput.value,
const groups = groupsQueryResult.data.value?.groups as GroupDTO[]; inGroup: props.forGroup,
const group = groups?.find((group) => group.assignment === assignment?.id) as GroupDTO; };
const questionData: QuestionData = {
author: authService.authState.user?.profile.preferred_username,
content: questionInput.value,
inGroup: group,
};
if (questionInput.value !== "") {
createQuestionMutation.mutate(questionData, { createQuestionMutation.mutate(questionData, {
onSuccess: async () => { onSuccess: async () => {
questionInput.value = ""; // Clear the input field after submission questionInput.value = ""; // Clear the input field after submission
await getQuestionsQuery.refetch(); // Reload the questions emit("updated");
}, },
onError: (_) => { onError: (_) => {
// TODO Handle error // TODO Handle error

View file

@ -48,4 +48,12 @@ export class AssignmentController extends BaseController {
async getGroups(assignmentNumber: number, full = true): Promise<GroupsResponse> { async getGroups(assignmentNumber: number, full = true): Promise<GroupsResponse> {
return this.get<GroupsResponse>(`/${assignmentNumber}/groups`, { full }); return this.get<GroupsResponse>(`/${assignmentNumber}/groups`, { full });
} }
async getSubmissionsByGroup(
assignmentNumber: number,
groupNumber: number,
full = true,
): Promise<SubmissionsResponse> {
return this.get<SubmissionsResponse>(`/${assignmentNumber}/groups/${groupNumber}/submissions`, { full });
}
} }

View file

@ -18,6 +18,15 @@ export class QuestionController extends BaseController {
this.loId = loId; this.loId = loId;
} }
async getAllGroup(
classId: string,
assignmentId: string,
forStudent: string,
full = true,
): Promise<QuestionsResponse> {
return this.get<QuestionsResponse>("/", { lang: this.loId.language, full, classId, assignmentId, forStudent });
}
async getAll(full = true): Promise<QuestionsResponse> { async getAll(full = true): Promise<QuestionsResponse> {
return this.get<QuestionsResponse>("/", { lang: this.loId.language, full }); return this.get<QuestionsResponse>("/", { lang: this.loId.language, full });
} }

View file

@ -23,7 +23,14 @@ export class SubmissionController extends BaseController {
groupId?: number, groupId?: number,
full = true, full = true,
): Promise<SubmissionsResponse> { ): Promise<SubmissionsResponse> {
return this.get<SubmissionsResponse>(`/`, { language, version, classId, assignmentId, groupId, full }); return this.get<SubmissionsResponse>(`/`, {
language,
version,
classId,
assignmentId,
forGroup: groupId,
full,
});
} }
async getByNumber( async getByNumber(
@ -39,7 +46,7 @@ export class SubmissionController extends BaseController {
version, version,
classId, classId,
assignmentId, assignmentId,
groupId, forGroup: groupId,
}); });
} }

View file

@ -181,7 +181,7 @@ export function useAssignmentSubmissionsQuery(
return useQuery({ return useQuery({
queryKey: computed(() => assignmentSubmissionsQueryKey(cid!, an!, f)), queryKey: computed(() => assignmentSubmissionsQueryKey(cid!, an!, f)),
queryFn: async () => new AssignmentController(cid!).getSubmissions(an!, f), queryFn: async () => new AssignmentController(cid!).getSubmissionsByGroup(an!, gn!, f),
enabled: () => checkEnabled(cid, an, gn), enabled: () => checkEnabled(cid, an, gn),
}); });
} }

View file

@ -9,6 +9,7 @@ import {
useQueryClient, useQueryClient,
type UseQueryReturnType, type UseQueryReturnType,
} from "@tanstack/vue-query"; } from "@tanstack/vue-query";
import type { Language } from "@dwengo-1/common/util/language";
export function questionsQueryKey( export function questionsQueryKey(
loId: LearningObjectIdentifierDTO, loId: LearningObjectIdentifierDTO,
@ -17,6 +18,16 @@ export function questionsQueryKey(
return ["questions", loId.hruid, loId.version!, loId.language, full]; return ["questions", loId.hruid, loId.version!, loId.language, full];
} }
export function questionsGroupQueryKey(
loId: LearningObjectIdentifierDTO,
classId: string,
assignmentId: string,
student: string,
full: boolean,
): [string, string, number, Language, boolean, string, string, string] {
return ["questions", loId.hruid, loId.version!, loId.language, full, classId, assignmentId, student];
}
export function questionQueryKey(questionId: QuestionId): [string, string, number, string, number] { export function questionQueryKey(questionId: QuestionId): [string, string, number, string, number] {
const loId = questionId.learningObjectIdentifier; const loId = questionId.learningObjectIdentifier;
return ["question", loId.hruid, loId.version!, loId.language, questionId.sequenceNumber]; return ["question", loId.hruid, loId.version!, loId.language, questionId.sequenceNumber];
@ -33,6 +44,34 @@ export function useQuestionsQuery(
}); });
} }
export function useQuestionsGroupQuery(
loId: MaybeRefOrGetter<LearningObjectIdentifierDTO>,
classId: MaybeRefOrGetter<string>,
assignmentId: MaybeRefOrGetter<string>,
student: MaybeRefOrGetter<string>,
full: MaybeRefOrGetter<boolean> = true,
): UseQueryReturnType<QuestionsResponse, Error> {
return useQuery({
queryKey: computed(() =>
questionsGroupQueryKey(
toValue(loId),
toValue(classId),
toValue(assignmentId),
toValue(student),
toValue(full),
),
),
queryFn: async () =>
new QuestionController(toValue(loId)).getAllGroup(
toValue(classId),
toValue(assignmentId),
toValue(student),
toValue(full),
),
enabled: () => Boolean(toValue(loId)),
});
}
export function useQuestionQuery( export function useQuestionQuery(
questionId: MaybeRefOrGetter<QuestionId>, questionId: MaybeRefOrGetter<QuestionId>,
): UseQueryReturnType<QuestionResponse, Error> { ): UseQueryReturnType<QuestionResponse, Error> {

View file

@ -62,7 +62,10 @@
const { valid } = await form.value.validate(); const { valid } = await form.value.validate();
if (!valid) return; if (!valid) return;
const lp = lpIsSelected.value ? route.query.hruid?.toString() : selectedLearningPath.value?.hruid; const lp = lpIsSelected.value
? { hruid: route.query.hruid!.toString(), language: language.value }
: { hruid: selectedLearningPath.value!.hruid, language: selectedLearningPath.value!.language };
if (!lp) { if (!lp) {
return; return;
} }
@ -72,8 +75,8 @@
within: selectedClass.value?.id || "", within: selectedClass.value?.id || "",
title: assignmentTitle.value, title: assignmentTitle.value,
description: "", description: "",
learningPath: lp, learningPath: lp.hruid,
language: language.value, language: lp.language,
deadline: null, deadline: null,
groups: [], groups: [],
}; };

View file

@ -1,18 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watchEffect } from "vue"; import { computed, type ComputedRef, ref, watchEffect } from "vue";
import auth from "@/services/auth/auth-service.ts"; import auth from "@/services/auth/auth-service.ts";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import UsingQueryResult from "@/components/UsingQueryResult.vue"; import UsingQueryResult from "@/components/UsingQueryResult.vue";
import { asyncComputed } from "@vueuse/core"; import { asyncComputed } from "@vueuse/core";
import { import { useStudentsByUsernamesQuery } from "@/queries/students.ts";
useStudentAssignmentsQuery,
useStudentGroupsQuery,
useStudentsByUsernamesQuery,
} from "@/queries/students.ts";
import { useGetLearningPathQuery } from "@/queries/learning-paths.ts"; import { useGetLearningPathQuery } from "@/queries/learning-paths.ts";
import type { Language } from "@/data-objects/language.ts"; import type { Language } from "@/data-objects/language.ts";
import { calculateProgress } from "@/utils/assignment-utils.ts"; import { calculateProgress } from "@/utils/assignment-utils.ts";
import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts";
import type { GroupDTO } from "@dwengo-1/common/interfaces/group";
import { useAssignmentQuery } from "@/queries/assignments.ts";
import type { AssignmentDTO } from "@dwengo-1/common/interfaces/assignment";
import type { StudentDTO } from "@dwengo-1/common/interfaces/student";
const props = defineProps<{ const props = defineProps<{
classId: string; classId: string;
@ -28,32 +28,24 @@
return user?.profile?.preferred_username ?? undefined; return user?.profile?.preferred_username ?? undefined;
}); });
const assignmentsQueryResult = useStudentAssignmentsQuery(username, true); const assignmentQueryResult = useAssignmentQuery(props.classId, props.assignmentId);
const assignment = computed(() => { const assignment: ComputedRef<AssignmentDTO | undefined> = computed(
const assignments = assignmentsQueryResult.data.value?.assignments; () => assignmentQueryResult.data.value?.assignment,
if (!assignments) return undefined; );
return assignments.find((a) => a.id === props.assignmentId && a.within === props.classId);
});
learningPath.value = assignment.value?.learningPath; learningPath.value = assignment.value?.learningPath;
const groupsQueryResult = useStudentGroupsQuery(username, true);
const group = computed(() => { const group = computed(() => {
const groups = groupsQueryResult.data.value?.groups as GroupDTO[]; const groups = assignment.value?.groups as GroupDTO[];
if (!groups) return undefined; if (!groups) return undefined;
// Sort by original groupNumber // To "normalize" the group numbers, sort the groups and then renumber them
const sortedGroups = [...groups].sort((a, b) => a.groupNumber - b.groupNumber); const renumbered = [...groups]
.sort((a, b) => a.groupNumber - b.groupNumber)
return sortedGroups .map((group, index) => ({ ...group, groupNo: index + 1 }));
.map((group, index) => ({ return renumbered.find((group) => group.members?.some((m) => (m as StudentDTO).username === username.value));
...group,
groupNo: index + 1, // Renumbered index
}))
.find((group) => group.members?.some((m) => m.username === username.value));
}); });
watchEffect(() => { watchEffect(() => {
@ -89,7 +81,7 @@
<template> <template>
<div class="container"> <div class="container">
<using-query-result :query-result="assignmentsQueryResult"> <using-query-result :query-result="assignmentQueryResult">
<v-card <v-card
v-if="assignment" v-if="assignment"
class="assignment-card" class="assignment-card"

View file

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch, watchEffect } from "vue"; import { computed, ref, watchEffect } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { import {
useAssignmentQuery, useAssignmentQuery,
@ -14,7 +14,7 @@
import type { GroupDTO, GroupDTOId } from "@dwengo-1/common/interfaces/group"; import type { GroupDTO, GroupDTOId } from "@dwengo-1/common/interfaces/group";
import GroupSubmissionStatus from "@/components/GroupSubmissionStatus.vue"; import GroupSubmissionStatus from "@/components/GroupSubmissionStatus.vue";
import GroupProgressRow from "@/components/GroupProgressRow.vue"; import GroupProgressRow from "@/components/GroupProgressRow.vue";
import type { AssignmentDTO } from "@dwengo-1/common/dist/interfaces/assignment.ts"; import type { AssignmentDTO } from "@dwengo-1/common/interfaces/assignment";
import GroupSelector from "@/components/assignments/GroupSelector.vue"; import GroupSelector from "@/components/assignments/GroupSelector.vue";
import DeadlineSelector from "@/components/assignments/DeadlineSelector.vue"; import DeadlineSelector from "@/components/assignments/DeadlineSelector.vue";
@ -130,13 +130,28 @@
return `/learningPath/${lp.hruid}/${lp.language}/${lp.startNode.learningobjectHruid}?forGroup=${groupNo}&assignmentNo=${props.assignmentId}&classId=${props.classId}`; return `/learningPath/${lp.hruid}/${lp.language}/${lp.startNode.learningobjectHruid}?forGroup=${groupNo}&assignmentNo=${props.assignmentId}&classId=${props.classId}`;
} }
const { mutate, data, isSuccess } = useUpdateAssignmentMutation(); const updateAssignmentMutate = useUpdateAssignmentMutation();
watch([isSuccess, data], async ([success, newData]) => { function updateAssignment(assignmentDTO): void {
if (success && newData?.assignment) { updateAssignmentMutate.mutate(
await assignmentQueryResult.refetch(); {
} cid: assignmentQueryResult.data.value?.assignment.within,
}); an: assignmentQueryResult.data.value?.assignment.id,
data: assignmentDTO,
},
{
onSuccess: async (newData) => {
if (newData?.assignment) {
await assignmentQueryResult.refetch();
}
},
onError: (err: any) => {
const message = err.response?.data?.error || err.message || t("unknownError");
showSnackbar(t("failed") + ": " + message, "error");
},
},
);
}
async function saveChanges(): Promise<void> { async function saveChanges(): Promise<void> {
const { valid } = await form.value.validate(); const { valid } = await form.value.validate();
@ -149,22 +164,14 @@
deadline: deadline.value ?? null, deadline: deadline.value ?? null,
}; };
mutate({ updateAssignment(assignmentDTO);
cid: assignmentQueryResult.data.value?.assignment.within,
an: assignmentQueryResult.data.value?.assignment.id,
data: assignmentDTO,
});
} }
async function handleGroupsUpdated(updatedGroups: string[][]): Promise<void> { async function handleGroupsUpdated(updatedGroups: string[][]): Promise<void> {
const assignmentDTO: AssignmentDTO = { const assignmentDTO: AssignmentDTO = {
groups: updatedGroups, groups: updatedGroups,
}; };
mutate({ updateAssignment(assignmentDTO);
cid: assignmentQueryResult.data.value?.assignment.within,
an: assignmentQueryResult.data.value?.assignment.id,
data: assignmentDTO,
});
} }
</script> </script>
@ -401,13 +408,15 @@
<td> <td>
<GroupSubmissionStatus <GroupSubmissionStatus
:learning-path-hruid="learningPath.hruid"
:language="lang"
:group="g" :group="g"
:assignment-id="assignmentId" :assignment-id="assignmentId"
:class-id="classId" :class-id="classId"
:language="lang"
:go-to-group-submission-link="goToGroupSubmissionLink" :go-to-group-submission-link="goToGroupSubmissionLink"
@update:hasSubmission=" @update:hasSubmission="
(hasSubmission) => (hasSubmissions = hasSubmission) (hasSubmission) =>
(hasSubmissions = hasSubmissions || hasSubmission)
" "
/> />
</td> </td>

View file

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, watch } from "vue"; import { ref, computed, onMounted } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import authState from "@/services/auth/auth-service.ts"; import authState from "@/services/auth/auth-service.ts";
@ -101,9 +101,9 @@
deleteAssignmentMutation.mutate( deleteAssignmentMutation.mutate(
{ cid: clsId, an: num }, { cid: clsId, an: num },
{ {
onSuccess: (data) => { onSuccess: async (data) => {
if (data?.assignment) { if (data?.assignment) {
window.location.reload(); await assignmentsQueryResult.refetch();
} }
showSnackbar(t("success"), "success"); showSnackbar(t("success"), "success");
}, },

View file

@ -16,18 +16,14 @@
const groupsQuery = useGroupsQuery(props.classId, props.assignmentNumber, true); const groupsQuery = useGroupsQuery(props.classId, props.assignmentNumber, true);
interface GroupSelectorOption { function sortedGroups(groups: GroupDTO[]): GroupDTO[] {
groupNumber: number | undefined; return [...groups].sort((a, b) => a.groupNumber - b.groupNumber);
label: string;
} }
function groupOptions(groups: GroupDTO[]): number[] {
function groupOptions(groups: GroupDTO[]): GroupSelectorOption[] { return sortedGroups(groups).map((group) => group.groupNumber);
return [...groups] }
.sort((a, b) => a.groupNumber - b.groupNumber) function labelForGroup(groups: GroupDTO[], groupId: number): string {
.map((group, index) => ({ return `${sortedGroups(groups).findIndex((group) => group.groupNumber === groupId) + 1}`;
groupNumber: group.groupNumber,
label: `${index + 1}`,
}));
} }
</script> </script>
@ -40,7 +36,8 @@
:label="t('viewAsGroup')" :label="t('viewAsGroup')"
:items="groupOptions(data.groups)" :items="groupOptions(data.groups)"
v-model="model" v-model="model"
item-title="label" :item-title="(item) => labelForGroup(data.groups, parseInt(`${item}`))"
:item-value="(item) => item"
class="group-selector-cb" class="group-selector-cb"
variant="outlined" variant="outlined"
clearable clearable

View file

@ -13,7 +13,7 @@
import authService from "@/services/auth/auth-service.ts"; import authService from "@/services/auth/auth-service.ts";
import { LearningPathNode } from "@/data-objects/learning-paths/learning-path-node.ts"; import { LearningPathNode } from "@/data-objects/learning-paths/learning-path-node.ts";
import LearningPathGroupSelector from "@/views/learning-paths/LearningPathGroupSelector.vue"; import LearningPathGroupSelector from "@/views/learning-paths/LearningPathGroupSelector.vue";
import { useQuestionsQuery } from "@/queries/questions"; import { useQuestionsGroupQuery, useQuestionsQuery } from "@/queries/questions";
import type { QuestionsResponse } from "@/controllers/questions"; import type { QuestionsResponse } from "@/controllers/questions";
import type { LearningObjectIdentifierDTO } from "@dwengo-1/common/interfaces/learning-content"; import type { LearningObjectIdentifierDTO } from "@dwengo-1/common/interfaces/learning-content";
import QandA from "@/components/QandA.vue"; import QandA from "@/components/QandA.vue";
@ -56,7 +56,12 @@
const learningObjectListQueryResult = useLearningObjectListForPathQuery(learningPathQueryResult.data); const learningObjectListQueryResult = useLearningObjectListForPathQuery(learningPathQueryResult.data);
const nodesList: ComputedRef<LearningPathNode[] | null> = computed( const nodesList: ComputedRef<LearningPathNode[] | null> = computed(
() => learningPathQueryResult.data.value?.nodesAsList ?? null, () =>
learningPathQueryResult.data.value?.nodesAsList.filter(
(node) =>
authService.authState.activeRole === AccountType.Teacher ||
!getLearningObjectForNode(node)?.teacherExclusive,
) ?? null,
); );
const currentNode = computed(() => { const currentNode = computed(() => {
@ -76,19 +81,44 @@
return currentIndex < nodesList.value?.length ? nodesList.value?.[currentIndex - 1] : undefined; return currentIndex < nodesList.value?.length ? nodesList.value?.[currentIndex - 1] : undefined;
}); });
const getQuestionsQuery = useQuestionsQuery( let getQuestionsQuery;
computed(
() => if (authService.authState.activeRole === AccountType.Student) {
({ getQuestionsQuery = useQuestionsGroupQuery(
language: currentNode.value?.language, computed(
hruid: currentNode.value?.learningobjectHruid, () =>
version: currentNode.value?.version, ({
}) as LearningObjectIdentifierDTO, language: currentNode.value?.language,
), hruid: currentNode.value?.learningobjectHruid,
); version: currentNode.value?.version,
}) as LearningObjectIdentifierDTO,
),
computed(() => query.value.classId ?? ""),
computed(() => query.value.assignmentNo ?? ""),
computed(() => authService.authState.user?.profile.preferred_username ?? ""),
);
} else {
getQuestionsQuery = useQuestionsQuery(
computed(
() =>
({
language: currentNode.value?.language,
hruid: currentNode.value?.learningobjectHruid,
version: currentNode.value?.version,
}) as LearningObjectIdentifierDTO,
),
);
}
const navigationDrawerShown = ref(true); const navigationDrawerShown = ref(true);
function getLearningObjectForNode(node: LearningPathNode): LearningObject | undefined {
return learningObjectListQueryResult.data.value?.find(
(obj) =>
obj.key === node.learningobjectHruid && obj.language === node.language && obj.version === node.version,
);
}
function isLearningObjectCompleted(learningObject: LearningObject): boolean { function isLearningObjectCompleted(learningObject: LearningObject): boolean {
if (learningObjectListQueryResult.isSuccess) { if (learningObjectListQueryResult.isSuccess) {
return ( return (
@ -155,6 +185,20 @@
"/" + "/" +
currentNode.value?.learningobjectHruid, currentNode.value?.learningobjectHruid,
); );
/**
* Filter the given list of questions such that only the questions for the assignment and group specified
* in the query parameters are shown. This is relevant for teachers since they can view questions of all groups.
*/
function filterQuestions(questions?: QuestionDTO[]): QuestionDTO[] {
return (
questions?.filter(
(q) =>
q.inGroup.groupNumber === forGroup.value?.forGroup &&
q.inGroup.assignment === forGroup.value?.assignmentNo,
) ?? []
);
}
</script> </script>
<template> <template>
@ -265,7 +309,7 @@
</v-list-item> </v-list-item>
<v-list-item> <v-list-item>
<div <div
v-if="authService.authState.activeRole === AccountType.Student && pathIsAssignment" v-if="authService.authState.activeRole === AccountType.Student && forGroup"
class="assignment-indicator" class="assignment-indicator"
> >
{{ t("assignmentIndicator") }} {{ t("assignmentIndicator") }}
@ -312,7 +356,7 @@
</v-btn> </v-btn>
</div> </div>
<using-query-result <using-query-result
v-if="forGroup" v-if="currentNode && forGroup"
:query-result="getQuestionsQuery" :query-result="getQuestionsQuery"
v-slot="questionsResponse: { data: QuestionsResponse }" v-slot="questionsResponse: { data: QuestionsResponse }"
> >
@ -327,12 +371,16 @@
</span> </span>
</div> </div>
<QuestionBox <QuestionBox
:hruid="props.hruid" :learningObjectHruid="currentNode.learningobjectHruid"
:language="props.language" :learningObjectLanguage="currentNode.language"
:learningObjectHruid="props.learningObjectHruid" :learningObjectVersion="currentNode.version"
:forGroup="forGroup" :forGroup="{
assignment: forGroup.assignmentNo,
class: forGroup.classId,
groupNumber: forGroup.forGroup,
}"
/> />
<QandA :questions="(questionsResponse.data.questions as QuestionDTO[]) ?? []" /> <QandA :questions="filterQuestions(questionsResponse.data.questions as QuestionDTO[])" />
</using-query-result> </using-query-result>
</using-query-result> </using-query-result>
</template> </template>