Merged in feature/VBV-459-refactoring-course-completion-rebase-2 (pull request #156)

Feature/VBV-459 refactoring course completion rebase 2

Approved-by: Elia Bieri
This commit is contained in:
Daniel Egger 2023-07-12 13:49:44 +00:00
commit 24d57577cc
53 changed files with 1102 additions and 230 deletions

View File

@ -19,10 +19,10 @@ const props = withDefaults(defineProps<Props>(), {
<div class="flex flex-row items-center"> <div class="flex flex-row items-center">
<div v-if="showState" class="mr-4 h-8 w-8"> <div v-if="showState" class="mr-4 h-8 w-8">
<it-icon-smiley-happy <it-icon-smiley-happy
v-if="criteria.completion_status === 'success'" v-if="criteria.completion_status === 'SUCCESS'"
></it-icon-smiley-happy> ></it-icon-smiley-happy>
<it-icon-smiley-thinking <it-icon-smiley-thinking
v-else-if="criteria.completion_status === 'fail'" v-else-if="criteria.completion_status === 'FAIL'"
></it-icon-smiley-thinking> ></it-icon-smiley-thinking>
<it-icon-smiley-neutral v-else></it-icon-smiley-neutral> <it-icon-smiley-neutral v-else></it-icon-smiley-neutral>
</div> </div>

View File

@ -17,9 +17,9 @@ type Story = StoryObj<typeof ItProgress>;
export const NoProgress: Story = { export const NoProgress: Story = {
args: { args: {
statusCount: { statusCount: {
fail: 0, FAIL: 0,
success: 0, SUCCESS: 0,
unknown: 10, UNKNOWN: 10,
}, },
}, },
}; };
@ -27,9 +27,9 @@ export const NoProgress: Story = {
export const FiftyPrecentSuccessProgress: Story = { export const FiftyPrecentSuccessProgress: Story = {
args: { args: {
statusCount: { statusCount: {
fail: 0, FAIL: 0,
success: 5, SUCCESS: 5,
unknown: 5, UNKNOWN: 5,
}, },
}, },
}; };
@ -37,9 +37,9 @@ export const FiftyPrecentSuccessProgress: Story = {
export const FiftyPrecentFailProgress: Story = { export const FiftyPrecentFailProgress: Story = {
args: { args: {
statusCount: { statusCount: {
fail: 5, FAIL: 5,
success: 0, SUCCESS: 0,
unknown: 5, UNKNOWN: 5,
}, },
}, },
}; };

View File

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue"; import { computed } from "vue";
export type StatusCountKey = "fail" | "success" | "unknown"; export type StatusCountKey = "FAIL" | "SUCCESS" | "UNKNOWN";
export type StatusCount = Record<StatusCountKey, number>; export type StatusCount = Record<StatusCountKey, number>;
const props = defineProps<{ const props = defineProps<{
@ -10,9 +10,9 @@ const props = defineProps<{
const total = computed(() => { const total = computed(() => {
return ( return (
(props.statusCount?.fail || 0) + (props.statusCount?.FAIL || 0) +
(props.statusCount?.success || 0) + (props.statusCount?.SUCCESS || 0) +
(props.statusCount?.unknown || 0) (props.statusCount?.UNKNOWN || 0)
); );
}); });
@ -21,7 +21,7 @@ const done = computed(() => {
return 0; return 0;
} }
return ((props.statusCount?.success || 0) / total.value) * 100; return ((props.statusCount?.SUCCESS || 0) / total.value) * 100;
}); });
const notDone = computed(() => { const notDone = computed(() => {
@ -29,7 +29,7 @@ const notDone = computed(() => {
return 0; return 0;
} }
return ((props.statusCount?.fail || 0) / total.value) * 100 + done.value; return ((props.statusCount?.FAIL || 0) / total.value) * 100 + done.value;
}); });
</script> </script>

View File

@ -1,5 +1,5 @@
import type { CourseCompletionStatus } from "@/types"; import type { CourseCompletionStatus } from "@/types";
export const COMPLETION_SUCCESS: CourseCompletionStatus = "success"; export const COMPLETION_SUCCESS: CourseCompletionStatus = "SUCCESS";
export const COMPLETION_FAILURE: CourseCompletionStatus = "fail"; export const COMPLETION_FAILURE: CourseCompletionStatus = "FAIL";
export const COMPLETION_UNKNOWN: CourseCompletionStatus = "unknown"; export const COMPLETION_UNKNOWN: CourseCompletionStatus = "UNKNOWN";

View File

@ -108,6 +108,30 @@ export type AssignmentObjectType = CoursePageInterface & {
translation_key?: Maybe<Scalars['String']['output']>; translation_key?: Maybe<Scalars['String']['output']>;
}; };
export type AttendanceCourseUserMutation = {
__typename?: 'AttendanceCourseUserMutation';
course_session_attendance_course?: Maybe<CourseSessionAttendanceCourseType>;
};
export type AttendanceUserInputType = {
status: AttendanceUserStatus;
user_id: Scalars['ID']['input'];
};
/** An enumeration. */
export type AttendanceUserStatus =
| 'ABSENT'
| 'PRESENT';
export type AttendanceUserType = {
__typename?: 'AttendanceUserType';
email?: Maybe<Scalars['String']['output']>;
first_name?: Maybe<Scalars['String']['output']>;
last_name?: Maybe<Scalars['String']['output']>;
status: AttendanceUserStatus;
user_id: Scalars['ID']['output'];
};
/** An enumeration. */ /** An enumeration. */
export type CoreUserLanguageChoices = export type CoreUserLanguageChoices =
/** Deutsch */ /** Deutsch */
@ -127,6 +151,19 @@ export type CoursePageInterface = {
translation_key?: Maybe<Scalars['String']['output']>; translation_key?: Maybe<Scalars['String']['output']>;
}; };
export type CourseSessionAttendanceCourseType = {
__typename?: 'CourseSessionAttendanceCourseType';
attendance_user_list?: Maybe<Array<Maybe<AttendanceUserType>>>;
course_session_id?: Maybe<Scalars['ID']['output']>;
due_date_id?: Maybe<Scalars['ID']['output']>;
end?: Maybe<Scalars['DateTime']['output']>;
id: Scalars['ID']['output'];
learning_content_id?: Maybe<Scalars['ID']['output']>;
location: Scalars['String']['output'];
start?: Maybe<Scalars['DateTime']['output']>;
trainer: Scalars['String']['output'];
};
export type CourseType = { export type CourseType = {
__typename?: 'CourseType'; __typename?: 'CourseType';
category_name: Scalars['String']['output']; category_name: Scalars['String']['output'];
@ -186,6 +223,7 @@ export type LearningPathType = CoursePageInterface & {
export type Mutation = { export type Mutation = {
__typename?: 'Mutation'; __typename?: 'Mutation';
send_feedback?: Maybe<SendFeedbackPayload>; send_feedback?: Maybe<SendFeedbackPayload>;
update_course_session_attendance_course_users?: Maybe<AttendanceCourseUserMutation>;
upsert_assignment_completion?: Maybe<AssignmentCompletionMutation>; upsert_assignment_completion?: Maybe<AssignmentCompletionMutation>;
}; };
@ -195,6 +233,12 @@ export type MutationSendFeedbackArgs = {
}; };
export type MutationUpdateCourseSessionAttendanceCourseUsersArgs = {
attendance_user_list: Array<InputMaybe<AttendanceUserInputType>>;
id: Scalars['ID']['input'];
};
export type MutationUpsertAssignmentCompletionArgs = { export type MutationUpsertAssignmentCompletionArgs = {
assignment_id: Scalars['ID']['input']; assignment_id: Scalars['ID']['input'];
assignment_user_id?: InputMaybe<Scalars['ID']['input']>; assignment_user_id?: InputMaybe<Scalars['ID']['input']>;
@ -216,6 +260,7 @@ export type Query = {
assignment?: Maybe<AssignmentObjectType>; assignment?: Maybe<AssignmentObjectType>;
assignment_completion?: Maybe<AssignmentCompletionObjectType>; assignment_completion?: Maybe<AssignmentCompletionObjectType>;
course?: Maybe<CourseType>; course?: Maybe<CourseType>;
course_session_attendance_course?: Maybe<CourseSessionAttendanceCourseType>;
}; };
@ -236,6 +281,12 @@ export type QueryCourseArgs = {
id?: InputMaybe<Scalars['Int']['input']>; id?: InputMaybe<Scalars['Int']['input']>;
}; };
export type QueryCourseSessionAttendanceCourseArgs = {
assignment_user_id?: InputMaybe<Scalars['ID']['input']>;
id: Scalars['ID']['input'];
};
export type SendFeedbackInput = { export type SendFeedbackInput = {
clientMutationId?: InputMaybe<Scalars['String']['input']>; clientMutationId?: InputMaybe<Scalars['String']['input']>;
course_session: Scalars['Int']['input']; course_session: Scalars['Int']['input'];

View File

@ -1,9 +1,43 @@
type Query { type Query {
course_session_attendance_course(id: ID!, assignment_user_id: ID): CourseSessionAttendanceCourseType
course(id: Int): CourseType course(id: Int): CourseType
assignment(id: ID, slug: String): AssignmentObjectType assignment(id: ID, slug: String): AssignmentObjectType
assignment_completion(assignment_id: ID!, course_session_id: ID!, assignment_user_id: ID): AssignmentCompletionObjectType assignment_completion(assignment_id: ID!, course_session_id: ID!, assignment_user_id: ID): AssignmentCompletionObjectType
} }
type CourseSessionAttendanceCourseType {
id: ID!
location: String!
trainer: String!
course_session_id: ID
learning_content_id: ID
due_date_id: ID
end: DateTime
start: DateTime
attendance_user_list: [AttendanceUserType]
}
"""
The `DateTime` scalar type represents a DateTime
value as specified by
[iso8601](https://en.wikipedia.org/wiki/ISO_8601).
"""
scalar DateTime
type AttendanceUserType {
user_id: ID!
status: AttendanceUserStatus!
first_name: String
last_name: String
email: String
}
"""An enumeration."""
enum AttendanceUserStatus {
PRESENT
ABSENT
}
type CourseType { type CourseType {
id: ID! id: ID!
title: String! title: String!
@ -63,13 +97,6 @@ interface CoursePageInterface {
frontend_url: String frontend_url: String
} }
"""
The `DateTime` scalar type represents a DateTime
value as specified by
[iso8601](https://en.wikipedia.org/wiki/ISO_8601).
"""
scalar DateTime
type UserType { type UserType {
id: ID! id: ID!
@ -184,6 +211,7 @@ scalar JSONString
type Mutation { type Mutation {
send_feedback(input: SendFeedbackInput!): SendFeedbackPayload send_feedback(input: SendFeedbackInput!): SendFeedbackPayload
update_course_session_attendance_course_users(attendance_user_list: [AttendanceUserInputType]!, id: ID!): AttendanceCourseUserMutation
upsert_assignment_completion(assignment_id: ID!, assignment_user_id: ID, completion_data_string: String, completion_status: AssignmentCompletionStatus, course_session_id: ID!, evaluation_grade: Float, evaluation_points: Float): AssignmentCompletionMutation upsert_assignment_completion(assignment_id: ID!, assignment_user_id: ID, completion_data_string: String, completion_status: AssignmentCompletionStatus, course_session_id: ID!, evaluation_grade: Float, evaluation_points: Float): AssignmentCompletionMutation
} }
@ -220,6 +248,15 @@ input SendFeedbackInput {
clientMutationId: String clientMutationId: String
} }
type AttendanceCourseUserMutation {
course_session_attendance_course: CourseSessionAttendanceCourseType
}
input AttendanceUserInputType {
user_id: ID!
status: AttendanceUserStatus!
}
type AssignmentCompletionMutation { type AssignmentCompletionMutation {
assignment_completion: AssignmentCompletionObjectType assignment_completion: AssignmentCompletionObjectType
} }

View File

@ -4,9 +4,14 @@ export const AssignmentCompletionMutation = "AssignmentCompletionMutation";
export const AssignmentCompletionObjectType = "AssignmentCompletionObjectType"; export const AssignmentCompletionObjectType = "AssignmentCompletionObjectType";
export const AssignmentCompletionStatus = "AssignmentCompletionStatus"; export const AssignmentCompletionStatus = "AssignmentCompletionStatus";
export const AssignmentObjectType = "AssignmentObjectType"; export const AssignmentObjectType = "AssignmentObjectType";
export const AttendanceCourseUserMutation = "AttendanceCourseUserMutation";
export const AttendanceUserInputType = "AttendanceUserInputType";
export const AttendanceUserStatus = "AttendanceUserStatus";
export const AttendanceUserType = "AttendanceUserType";
export const Boolean = "Boolean"; export const Boolean = "Boolean";
export const CoreUserLanguageChoices = "CoreUserLanguageChoices"; export const CoreUserLanguageChoices = "CoreUserLanguageChoices";
export const CoursePageInterface = "CoursePageInterface"; export const CoursePageInterface = "CoursePageInterface";
export const CourseSessionAttendanceCourseType = "CourseSessionAttendanceCourseType";
export const CourseType = "CourseType"; export const CourseType = "CourseType";
export const DateTime = "DateTime"; export const DateTime = "DateTime";
export const ErrorType = "ErrorType"; export const ErrorType = "ErrorType";

View File

@ -129,7 +129,7 @@ const assignmentDetail = computed(() =>
</template> </template>
<template #link> <template #link>
<router-link <router-link
v-if="submissionStatusForUser(csu.user_id)?.progressStatus === 'success'" v-if="submissionStatusForUser(csu.user_id)?.progressStatus === 'SUCCESS'"
:to="`/course/${props.courseSession.course.slug}/cockpit/assignment/${assignment.assignmentId}/${csu.user_id}`" :to="`/course/${props.courseSession.course.slug}/cockpit/assignment/${assignment.assignmentId}/${csu.user_id}`"
class="w-full text-right underline" class="w-full text-right underline"
data-cy="show-results" data-cy="show-results"

View File

@ -45,10 +45,10 @@ onMounted(async () => {
</div> </div>
<div><ItProgress :status-count="state.progressStatusCount" /></div> <div><ItProgress :status-count="state.progressStatusCount" /></div>
<div class="text-gray-900" :class="{ 'text-gray-900': showTitle }"> <div class="text-gray-900" :class="{ 'text-gray-900': showTitle }">
{{ state.progressStatusCount.success || 0 }} von {{ state.progressStatusCount.SUCCESS || 0 }} von
{{ {{
(state.progressStatusCount.success || 0) + (state.progressStatusCount.SUCCESS || 0) +
(state.progressStatusCount.unknown || 0) (state.progressStatusCount.UNKNOWN || 0)
}} }}
Lernenden haben ihre Ergebnisse eingereicht. Lernenden haben ihre Ergebnisse eingereicht.
</div> </div>

View File

@ -174,7 +174,7 @@ function setActiveClasses(translationKey: string) {
class="mr-2 inline-block h-8 w-8" class="mr-2 inline-block h-8 w-8"
></it-icon-smiley-thinking> ></it-icon-smiley-thinking>
<p class="text-bold inline-block"> <p class="text-bold inline-block">
{{ userCountStatusForCircle(csu.user_id, circle).fail }} {{ userCountStatusForCircle(csu.user_id, circle).FAIL }}
</p> </p>
</div> </div>
<li class="mr-6 flex flex-row items-center"> <li class="mr-6 flex flex-row items-center">
@ -182,7 +182,7 @@ function setActiveClasses(translationKey: string) {
class="mr-2 inline-block h-8 w-8" class="mr-2 inline-block h-8 w-8"
></it-icon-smiley-happy> ></it-icon-smiley-happy>
<p class="text-bold inline-block"> <p class="text-bold inline-block">
{{ userCountStatusForCircle(csu.user_id, circle).success }} {{ userCountStatusForCircle(csu.user_id, circle).SUCCESS }}
</p> </p>
</li> </li>
<li class="flex flex-row items-center"> <li class="flex flex-row items-center">
@ -190,7 +190,7 @@ function setActiveClasses(translationKey: string) {
class="mr-2 inline-block h-8 w-8" class="mr-2 inline-block h-8 w-8"
></it-icon-smiley-neutral> ></it-icon-smiley-neutral>
<p class="text-bold inline-block"> <p class="text-bold inline-block">
{{ userCountStatusForCircle(csu.user_id, circle).unknown }} {{ userCountStatusForCircle(csu.user_id, circle).UNKNOWN }}
</p> </p>
</li> </li>
</div> </div>

View File

@ -22,7 +22,7 @@ const failedCriteria = computed(() => {
return competenceStore return competenceStore
.flatPerformanceCriteria() .flatPerformanceCriteria()
.filter((criteria) => { .filter((criteria) => {
return criteria.completion_status === "fail"; return criteria.completion_status === "FAIL";
}) })
.slice(0, 3); .slice(0, 3);
}); });
@ -111,7 +111,7 @@ const countStatus = computed(() => {
<h5 class="mb-4 text-gray-700">«{{ $t("selfEvaluation.no") }}»</h5> <h5 class="mb-4 text-gray-700">«{{ $t("selfEvaluation.no") }}»</h5>
<div class="flex flex-row items-center"> <div class="flex flex-row items-center">
<it-icon-smiley-thinking class="h-16 w-16"></it-icon-smiley-thinking> <it-icon-smiley-thinking class="h-16 w-16"></it-icon-smiley-thinking>
<p class="ml-4 inline-block text-7xl font-bold">{{ countStatus.fail }}</p> <p class="ml-4 inline-block text-7xl font-bold">{{ countStatus.FAIL }}</p>
</div> </div>
</li> </li>
<li <li
@ -121,7 +121,7 @@ const countStatus = computed(() => {
<div class="flex flex-row items-center"> <div class="flex flex-row items-center">
<it-icon-smiley-happy class="h-16 w-16"></it-icon-smiley-happy> <it-icon-smiley-happy class="h-16 w-16"></it-icon-smiley-happy>
<p class="ml-4 inline-block text-7xl font-bold"> <p class="ml-4 inline-block text-7xl font-bold">
{{ countStatus.success }} {{ countStatus.SUCCESS }}
</p> </p>
</div> </div>
</li> </li>
@ -130,7 +130,7 @@ const countStatus = computed(() => {
<div class="flex flex-row items-center"> <div class="flex flex-row items-center">
<it-icon-smiley-neutral class="h-16 w-16"></it-icon-smiley-neutral> <it-icon-smiley-neutral class="h-16 w-16"></it-icon-smiley-neutral>
<p class="ml-4 inline-block text-7xl font-bold"> <p class="ml-4 inline-block text-7xl font-bold">
{{ countStatus.unknown }} {{ countStatus.UNKNOWN }}
</p> </p>
</div> </div>
</li> </li>

View File

@ -32,17 +32,17 @@ const { t } = useTranslation();
const mobileMenuItems: MenuItem[] = [ const mobileMenuItems: MenuItem[] = [
{ {
id: "fail", id: "FAIL",
name: `«${t("selfEvaluation.no")}»`, name: `«${t("selfEvaluation.no")}»`,
iconName: "it-icon-smiley-thinking", iconName: "it-icon-smiley-thinking",
}, },
{ {
id: "success", id: "SUCCESS",
name: `«${t("selfEvaluation.yes")}»`, name: `«${t("selfEvaluation.yes")}»`,
iconName: "it-icon-smiley-happy", iconName: "it-icon-smiley-happy",
}, },
{ {
id: "unknown", id: "UNKNOWN",
name: t("competences.notAssessed"), name: t("competences.notAssessed"),
iconName: "it-icon-smiley-neutral", iconName: "it-icon-smiley-neutral",
}, },
@ -91,7 +91,7 @@ function updateActiveState(status: CourseCompletionStatus) {
:key="item.id" :key="item.id"
:class="{ :class="{
'bg-gray-200': activeMenuItem.id === item.id, 'bg-gray-200': activeMenuItem.id === item.id,
'mr-6': item.id !== 'unknown', 'mr-6': item.id !== 'UNKNOWN',
}" }"
class="mr-6 inline-block px-2 py-4" class="mr-6 inline-block px-2 py-4"
@click="updateActiveState(item.id)" @click="updateActiveState(item.id)"

View File

@ -39,11 +39,11 @@ const singleCriteria = computed(() => {
<button <button
class="inline-flex flex-1 items-center border p-4 text-left" class="inline-flex flex-1 items-center border p-4 text-left"
:class="{ :class="{
'border-green-500': singleCriteria.completion_status === 'success', 'border-green-500': singleCriteria.completion_status === 'SUCCESS',
'border-2': singleCriteria.completion_status === 'success', 'border-2': singleCriteria.completion_status === 'SUCCESS',
}" }"
data-cy="success" data-cy="success"
@click="circleStore.markCompletion(singleCriteria, 'success')" @click="circleStore.markCompletion(singleCriteria, 'SUCCESS')"
> >
<it-icon-smiley-happy class="mr-4 h-16 w-16"></it-icon-smiley-happy> <it-icon-smiley-happy class="mr-4 h-16 w-16"></it-icon-smiley-happy>
<span class="text-large font-bold">{{ $t("selfEvaluation.yes") }}</span> <span class="text-large font-bold">{{ $t("selfEvaluation.yes") }}</span>
@ -51,11 +51,11 @@ const singleCriteria = computed(() => {
<button <button
class="inline-flex flex-1 items-center border p-4 text-left" class="inline-flex flex-1 items-center border p-4 text-left"
:class="{ :class="{
'border-orange-500': singleCriteria.completion_status === 'fail', 'border-orange-500': singleCriteria.completion_status === 'FAIL',
'border-2': singleCriteria.completion_status === 'fail', 'border-2': singleCriteria.completion_status === 'FAIL',
}" }"
data-cy="fail" data-cy="fail"
@click="circleStore.markCompletion(singleCriteria, 'fail')" @click="circleStore.markCompletion(singleCriteria, 'FAIL')"
> >
<it-icon-smiley-thinking class="mr-4 h-16 w-16"></it-icon-smiley-thinking> <it-icon-smiley-thinking class="mr-4 h-16 w-16"></it-icon-smiley-thinking>
<span class="text-xl font-bold">{{ $t("selfEvaluation.no") }}</span> <span class="text-xl font-bold">{{ $t("selfEvaluation.no") }}</span>

View File

@ -24,9 +24,9 @@ const props = withDefaults(defineProps<Props>(), {
const circleStore = useCircleStore(); const circleStore = useCircleStore();
function toggleCompleted(learningContent: LearningContentInterface) { function toggleCompleted(learningContent: LearningContentInterface) {
let completionStatus: CourseCompletionStatus = "success"; let completionStatus: CourseCompletionStatus = "SUCCESS";
if (learningContent.completion_status === "success") { if (learningContent.completion_status === "SUCCESS") {
completionStatus = "fail"; completionStatus = "FAIL";
} }
circleStore.markCompletion(learningContent, completionStatus); circleStore.markCompletion(learningContent, completionStatus);
} }
@ -56,7 +56,7 @@ const continueTranslationKeyTuple = computed(() => {
const lastFinished = findLast( const lastFinished = findLast(
circleStore.circle.flatLearningContents, circleStore.circle.flatLearningContents,
(learningContent) => { (learningContent) => {
return learningContent.completion_status === "success"; return learningContent.completion_status === "SUCCESS";
} }
); );
@ -127,7 +127,7 @@ const learningSequenceBorderClass = computed(() => {
> >
<div v-if="props.readonly"> <div v-if="props.readonly">
<it-icon-check <it-icon-check
v-if="learningContent.completion_status === 'success'" v-if="learningContent.completion_status === 'SUCCESS'"
class="block h-8 w-8" class="block h-8 w-8"
></it-icon-check> ></it-icon-check>
<div v-else class="h-8 w-8"></div> <div v-else class="h-8 w-8"></div>
@ -136,8 +136,9 @@ const learningSequenceBorderClass = computed(() => {
v-else v-else
:checkbox-item="{ :checkbox-item="{
value: learningContent.completion_status, value: learningContent.completion_status,
checked: learningContent.completion_status === 'success', checked: learningContent.completion_status === 'SUCCESS',
}" }"
:disabled="!learningContent.can_user_self_toggle_course_completion"
:data-cy="`${learningContent.slug}-checkbox`" :data-cy="`${learningContent.slug}-checkbox`"
@toggle="toggleCompleted(learningContent)" @toggle="toggleCompleted(learningContent)"
/> />
@ -194,14 +195,14 @@ const learningSequenceBorderClass = computed(() => {
@click="!props.readonly && circleStore.openSelfEvaluation(learningUnit)" @click="!props.readonly && circleStore.openSelfEvaluation(learningUnit)"
> >
<div <div
v-if="circleStore.calcSelfEvaluationStatus(learningUnit) === 'success'" v-if="circleStore.calcSelfEvaluationStatus(learningUnit) === 'SUCCESS'"
class="self-evaluation-success flex items-center gap-4 pb-3 lg:pb-6" class="self-evaluation-success flex items-center gap-4 pb-3 lg:pb-6"
> >
<it-icon-smiley-happy class="h-8 w-8 flex-none" data-cy="success" /> <it-icon-smiley-happy class="h-8 w-8 flex-none" data-cy="success" />
<div>{{ $t("selfEvaluation.selfEvaluationYes") }}</div> <div>{{ $t("selfEvaluation.selfEvaluationYes") }}</div>
</div> </div>
<div <div
v-else-if="circleStore.calcSelfEvaluationStatus(learningUnit) === 'fail'" v-else-if="circleStore.calcSelfEvaluationStatus(learningUnit) === 'FAIL'"
class="self-evaluation-fail flex items-center gap-4 pb-3 lg:pb-6" class="self-evaluation-fail flex items-center gap-4 pb-3 lg:pb-6"
> >
<it-icon-smiley-thinking class="h-8 w-8 flex-none" data-cy="fail" /> <it-icon-smiley-thinking class="h-8 w-8 flex-none" data-cy="fail" />

View File

@ -4,9 +4,11 @@ import ItButton from "@/components/ui/ItButton.vue";
import ItCheckbox from "@/components/ui/ItCheckbox.vue"; import ItCheckbox from "@/components/ui/ItCheckbox.vue";
import ItSuccessAlert from "@/components/ui/ItSuccessAlert.vue"; import ItSuccessAlert from "@/components/ui/ItSuccessAlert.vue";
import { useCurrentCourseSession } from "@/composables"; import { useCurrentCourseSession } from "@/composables";
import { bustItGetCache } from "@/fetchHelpers";
import { UPSERT_ASSIGNMENT_COMPLETION_MUTATION } from "@/graphql/mutations"; import { UPSERT_ASSIGNMENT_COMPLETION_MUTATION } from "@/graphql/mutations";
import AssignmentSubmissionResponses from "@/pages/learningPath/learningContentPage/assignment/AssignmentSubmissionResponses.vue"; import AssignmentSubmissionResponses from "@/pages/learningPath/learningContentPage/assignment/AssignmentSubmissionResponses.vue";
import { useCourseSessionsStore } from "@/stores/courseSessions"; import { useCourseSessionsStore } from "@/stores/courseSessions";
import { useUserStore } from "@/stores/user";
import type { Assignment, AssignmentCompletion, AssignmentTask } from "@/types"; import type { Assignment, AssignmentCompletion, AssignmentTask } from "@/types";
import { useMutation } from "@urql/vue"; import { useMutation } from "@urql/vue";
import type { Dayjs } from "dayjs"; import type { Dayjs } from "dayjs";
@ -61,7 +63,7 @@ const onEditTask = (task: AssignmentTask) => {
const onSubmit = async () => { const onSubmit = async () => {
try { try {
// noinspection TypeScriptValidateTypes // noinspection TypeScriptValidateTypes
upsertAssignmentCompletionMutation.executeMutation({ await upsertAssignmentCompletionMutation.executeMutation({
assignmentId: props.assignment.id.toString(), assignmentId: props.assignment.id.toString(),
courseSessionId: courseSession.value.id.toString(), courseSessionId: courseSession.value.id.toString(),
completionDataString: JSON.stringify({}), completionDataString: JSON.stringify({}),
@ -70,6 +72,9 @@ const onSubmit = async () => {
// @ts-ignore // @ts-ignore
id: props.assignmentCompletion?.id, id: props.assignmentCompletion?.id,
}); });
bustItGetCache(
`/api/course/completion/${courseSession.value.id}/${useUserStore().id}/`
);
} catch (error) { } catch (error) {
log.error("Could not submit assignment", error); log.error("Could not submit assignment", error);
} }

View File

@ -93,8 +93,8 @@ onUnmounted(() => {
<button <button
class="inline-flex flex-1 items-center border p-4 text-left" class="inline-flex flex-1 items-center border p-4 text-left"
:class="{ :class="{
'border-green-500': currentQuestion.completion_status === 'success', 'border-green-500': currentQuestion.completion_status === 'SUCCESS',
'border-2': currentQuestion.completion_status === 'success', 'border-2': currentQuestion.completion_status === 'SUCCESS',
}" }"
data-cy="success" data-cy="success"
@click="circleStore.markCompletion(currentQuestion, COMPLETION_SUCCESS)" @click="circleStore.markCompletion(currentQuestion, COMPLETION_SUCCESS)"
@ -112,7 +112,7 @@ onUnmounted(() => {
'border-2': currentQuestion.completion_status === COMPLETION_FAILURE, 'border-2': currentQuestion.completion_status === COMPLETION_FAILURE,
}" }"
data-cy="fail" data-cy="fail"
@click="circleStore.markCompletion(currentQuestion, 'fail')" @click="circleStore.markCompletion(currentQuestion, 'FAIL')"
> >
<it-icon-smiley-thinking <it-icon-smiley-thinking
class="mr-4 h-16 w-16" class="mr-4 h-16 w-16"

View File

@ -70,13 +70,13 @@ export function calcUserAssignmentCompletionStatus(
if (userAssignmentStatus) { if (userAssignmentStatus) {
userStatus = userAssignmentStatus.completion_status; userStatus = userAssignmentStatus.completion_status;
} }
let progressStatus: StatusCountKey = "unknown"; let progressStatus: StatusCountKey = "UNKNOWN";
if ( if (
["SUBMITTED", "EVALUATION_IN_PROGRESS", "EVALUATION_SUBMITTED"].includes( ["SUBMITTED", "EVALUATION_IN_PROGRESS", "EVALUATION_SUBMITTED"].includes(
userStatus userStatus
) )
) { ) {
progressStatus = "success"; progressStatus = "SUCCESS";
} }
return { return {

View File

@ -203,7 +203,7 @@ export class Circle implements WagtailCircle {
return ( return (
this.flatChildren.filter((lc) => { this.flatChildren.filter((lc) => {
return ( return (
lc.completion_status === "success" && lc.completion_status === "SUCCESS" &&
lc.parentLearningSequence?.translation_key === translationKey lc.parentLearningSequence?.translation_key === translationKey
); );
}).length > 0 }).length > 0
@ -229,12 +229,12 @@ export class Circle implements WagtailCircle {
); );
return ( return (
learningContents.every((lc) => lc.completion_status === "success") && learningContents.every((lc) => lc.completion_status === "SUCCESS") &&
(groupedPerformanceCriteria.length === 0 || (groupedPerformanceCriteria.length === 0 ||
groupedPerformanceCriteria.every((group) => groupedPerformanceCriteria.every((group) =>
group.every( group.every(
(pc) => (pc) =>
pc.completion_status === "success" || pc.completion_status === "fail" pc.completion_status === "SUCCESS" || pc.completion_status === "FAIL"
) )
)) ))
); );
@ -252,12 +252,12 @@ export class Circle implements WagtailCircle {
public parseCompletionData(completionData: CourseCompletion[]) { public parseCompletionData(completionData: CourseCompletion[]) {
this.flatChildren.forEach((page) => { this.flatChildren.forEach((page) => {
const pageIndex = completionData.findIndex((e) => { const pageIndex = completionData.findIndex((e) => {
return e.page_key === page.translation_key; return e.page_id === page.id;
}); });
if (pageIndex >= 0) { if (pageIndex >= 0) {
page.completion_status = completionData[pageIndex].completion_status; page.completion_status = completionData[pageIndex].completion_status;
} else { } else {
page.completion_status = "unknown"; page.completion_status = "UNKNOWN";
} }
}); });

View File

@ -25,8 +25,8 @@ function getLastCompleted(courseSlug: string, completionData: CourseCompletion[]
const courseSession = courseSessionsStore.courseSessionForCourse(courseSlug); const courseSession = courseSessionsStore.courseSessionForCourse(courseSlug);
return orderBy(completionData, ["updated_at"], "desc").find((c: CourseCompletion) => { return orderBy(completionData, ["updated_at"], "desc").find((c: CourseCompletion) => {
return ( return (
c.completion_status === "success" && c.completion_status === "SUCCESS" &&
c.course_session === courseSession?.id && c.course_session_id === courseSession?.id &&
c.page_type.startsWith("learnpath.LearningContent") c.page_type.startsWith("learnpath.LearningContent")
); );
}); });
@ -129,13 +129,13 @@ export class LearningPath implements WagtailLearningPath {
const lastCircle = this.circles.find((circle) => { const lastCircle = this.circles.find((circle) => {
return circle.flatLearningContents.find( return circle.flatLearningContents.find(
(learningContent) => (learningContent) =>
learningContent.translation_key === lastCompletedLearningContent.page_key learningContent.id === lastCompletedLearningContent.page_id
); );
}); });
if (lastCircle) { if (lastCircle) {
const lastLearningContent = lastCircle.flatLearningContents.find( const lastLearningContent = lastCircle.flatLearningContents.find(
(learningContent) => (learningContent) =>
learningContent.translation_key === lastCompletedLearningContent.page_key learningContent.id === lastCompletedLearningContent.page_id
); );
if (lastLearningContent && lastLearningContent.nextLearningContent) { if (lastLearningContent && lastLearningContent.nextLearningContent) {
this.nextLearningContent = lastLearningContent.nextLearningContent; this.nextLearningContent = lastLearningContent.nextLearningContent;

View File

@ -105,7 +105,7 @@ export const useCircleStore = defineStore({
| LearningUnitPerformanceCriteria | LearningUnitPerformanceCriteria
| PerformanceCriteria | PerformanceCriteria
| undefined, | undefined,
completion_status: CourseCompletionStatus = "success" completion_status: CourseCompletionStatus = "SUCCESS"
) { ) {
const completionStore = useCompletionStore(); const completionStore = useCompletionStore();
@ -146,22 +146,27 @@ export const useCircleStore = defineStore({
}, },
calcSelfEvaluationStatus(learningUnit: LearningUnit): CourseCompletionStatus { calcSelfEvaluationStatus(learningUnit: LearningUnit): CourseCompletionStatus {
if (learningUnit.children.length > 0) { if (learningUnit.children.length > 0) {
if (learningUnit.children.every((q) => q.completion_status === "success")) { if (learningUnit.children.every((q) => q.completion_status === "SUCCESS")) {
return "success"; return "SUCCESS";
} }
if ( if (
learningUnit.children.every( learningUnit.children.every(
(q) => q.completion_status === "fail" || q.completion_status === "success" (q) => q.completion_status === "FAIL" || q.completion_status === "SUCCESS"
) )
) { ) {
return "fail"; return "FAIL";
} }
} }
return "unknown"; return "UNKNOWN";
}, },
continueFromLearningContent(currentLearningContent: LearningContentInterface) { continueFromLearningContent(currentLearningContent: LearningContentInterface) {
if (currentLearningContent) { if (currentLearningContent) {
this.markCompletion(currentLearningContent, "success"); if (currentLearningContent.can_user_self_toggle_course_completion) {
this.markCompletion(currentLearningContent, "SUCCESS");
} else {
// reload completion data anyway
currentLearningContent.parentCircle?.parentLearningPath?.reloadCompletionData();
}
this.closeLearningContent(currentLearningContent); this.closeLearningContent(currentLearningContent);
} else { } else {
log.error("currentLearningContent is undefined"); log.error("currentLearningContent is undefined");

View File

@ -35,16 +35,16 @@ export const useCompetenceStore = defineStore({
if (criteria) { if (criteria) {
const grouped = groupBy(criteria, "completion_status"); const grouped = groupBy(criteria, "completion_status");
return { return {
fail: grouped?.fail?.length || 0, UNKNOWN: grouped?.UNKNOWN?.length || 0,
success: grouped?.success?.length || 0, SUCCESS: grouped?.SUCCESS?.length || 0,
unknown: grouped?.unknown?.length || 0, FAIL: grouped?.FAIL?.length || 0,
}; };
} }
return { return {
success: 0, UNKNOWN: 0,
fail: 0, SUCCESS: 0,
unknown: 0, FAIL: 0,
}; };
}, },
criteriaByCompetence(competence: CompetencePage) { criteriaByCompetence(competence: CompetencePage) {
@ -177,14 +177,14 @@ export const useCompetenceStore = defineStore({
competenceProfilePage.children.forEach((competence) => { competenceProfilePage.children.forEach((competence) => {
competence.children.forEach((performanceCriteria) => { competence.children.forEach((performanceCriteria) => {
const completion = completionData.find( const completion = completionData.find(
(c) => c.page_key === performanceCriteria.translation_key (c) => c.page_id === performanceCriteria.id
); );
if (completion) { if (completion) {
performanceCriteria.completion_status = completion.completion_status; performanceCriteria.completion_status = completion.completion_status;
performanceCriteria.completion_status_updated_at = performanceCriteria.completion_status_updated_at =
completion.updated_at; completion.updated_at;
} else { } else {
performanceCriteria.completion_status = "unknown"; performanceCriteria.completion_status = "UNKNOWN";
performanceCriteria.completion_status_updated_at = ""; performanceCriteria.completion_status_updated_at = "";
} }
}); });

View File

@ -43,7 +43,7 @@ export const useCompletionStore = defineStore({
if (courseSessionId) { if (courseSessionId) {
const completionData = await itPost("/api/course/completion/mark/", { const completionData = await itPost("/api/course/completion/mark/", {
page_key: page.translation_key, page_id: page.id,
completion_status: page.completion_status, completion_status: page.completion_status,
course_session_id: courseSessionId, course_session_id: courseSessionId,
}); });

View File

@ -5,7 +5,7 @@ import type { Component } from "vue";
export type LoginMethod = "local" | "sso"; export type LoginMethod = "local" | "sso";
export type CourseCompletionStatus = "unknown" | "fail" | "success"; export type CourseCompletionStatus = "UNKNOWN" | "FAIL" | "SUCCESS";
export interface BaseCourseWagtailPage { export interface BaseCourseWagtailPage {
readonly id: number; readonly id: number;
@ -43,6 +43,7 @@ export interface LearningContentInterface extends BaseCourseWagtailPage {
readonly minutes: number; readonly minutes: number;
readonly description: string; readonly description: string;
readonly content_url: string; readonly content_url: string;
readonly can_user_self_toggle_course_completion: boolean;
parentCircle: Circle; parentCircle: Circle;
parentLearningSequence?: LearningSequence; parentLearningSequence?: LearningSequence;
parentLearningUnit?: LearningUnit; parentLearningUnit?: LearningUnit;
@ -169,14 +170,13 @@ export interface Topic extends BaseCourseWagtailPage {
export type LearningPathChild = Topic | WagtailCircle; export type LearningPathChild = Topic | WagtailCircle;
export interface CourseCompletion { export interface CourseCompletion {
id: number; readonly id: number;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
user: number; readonly user: number;
page_key: string; readonly page_id: number;
page_type: string; readonly page_type: string;
page_slug: string; readonly course_session_id: number;
course_session: number;
completion_status: CourseCompletionStatus; completion_status: CourseCompletionStatus;
additional_json_data: unknown; additional_json_data: unknown;
} }

View File

@ -12,7 +12,8 @@ from vbv_lernwelt.assignment.models import (
) )
from vbv_lernwelt.core.models import User from vbv_lernwelt.core.models import User
from vbv_lernwelt.core.utils import find_first from vbv_lernwelt.core.utils import find_first
from vbv_lernwelt.course.models import CourseSession from vbv_lernwelt.course.models import CourseCompletionStatus, CourseSession
from vbv_lernwelt.course.services import mark_course_completion
def update_assignment_completion( def update_assignment_completion(
@ -172,6 +173,16 @@ def update_assignment_completion(
acl.completion_data[key].update(task_data) acl.completion_data[key].update(task_data)
acl.save() acl.save()
if completion_status == AssignmentCompletionStatus.SUBMITTED:
learning_content = assignment.learningcontentassignment_set.first()
if learning_content:
mark_course_completion(
user=assignment_user,
page=learning_content,
course_session=course_session,
completion_status=CourseCompletionStatus.SUCCESS.value,
)
return ac return ac

View File

@ -0,0 +1,22 @@
# Generated by Django 3.2.13 on 2023-06-26 15:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("competence", "0002_performancecriteria_learning_unit"),
]
operations = [
migrations.AddField(
model_name="performancecriteria",
name="can_user_self_toggle_course_completion",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="performancecriteria",
name="has_course_completion_status",
field=models.BooleanField(default=True),
),
]

View File

@ -75,6 +75,8 @@ class PerformanceCriteria(CourseBasePage):
blank=True, blank=True,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
) )
has_course_completion_status = models.BooleanField(default=True)
can_user_self_toggle_course_completion = models.BooleanField(default=True)
content_panels = [ content_panels = [
FieldPanel("title"), FieldPanel("title"),

View File

@ -3,14 +3,18 @@ import graphene
from vbv_lernwelt.assignment.graphql.mutations import AssignmentMutation from vbv_lernwelt.assignment.graphql.mutations import AssignmentMutation
from vbv_lernwelt.assignment.graphql.queries import AssignmentQuery from vbv_lernwelt.assignment.graphql.queries import AssignmentQuery
from vbv_lernwelt.course.schema import CourseQuery from vbv_lernwelt.course.schema import CourseQuery
from vbv_lernwelt.course_session.graphql.mutations import CourseSessionMutation
from vbv_lernwelt.course_session.graphql.queries import CourseSessionQuery
from vbv_lernwelt.feedback.graphql.mutations import FeedbackMutation from vbv_lernwelt.feedback.graphql.mutations import FeedbackMutation
class Query(AssignmentQuery, CourseQuery, graphene.ObjectType): class Query(AssignmentQuery, CourseQuery, CourseSessionQuery, graphene.ObjectType):
pass pass
class Mutation(AssignmentMutation, FeedbackMutation, graphene.ObjectType): class Mutation(
AssignmentMutation, CourseSessionMutation, FeedbackMutation, graphene.ObjectType
):
pass pass

View File

@ -1,8 +1,8 @@
import json import json
import random from datetime import datetime
from datetime import datetime, timedelta
import wagtail_factories import wagtail_factories
from dateutil.relativedelta import relativedelta, TH, TU
from django.conf import settings from django.conf import settings
from django.utils import timezone from django.utils import timezone
from slugify import slugify from slugify import slugify
@ -98,6 +98,45 @@ def create_test_course(include_uk=True, include_vv=True, with_sessions=False):
id=TEST_COURSE_SESSION_BERN_ID, id=TEST_COURSE_SESSION_BERN_ID,
start_date=now, start_date=now,
) )
csac = CourseSessionAttendanceCourse.objects.create(
course_session=cs_bern,
learning_content=LearningContentAttendanceCourse.objects.get(
slug="test-lehrgang-lp-circle-fahrzeug-lc-präsenzkurs-fahrzeug"
),
location="Handelsschule KV Bern, Zimmer 123, Eigerstrasse 16, 3012 Bern",
trainer="Roland Grossenbacher, roland.grossenbacher@helvetia.ch",
)
tuesday_in_two_weeks = (
datetime.now() + relativedelta(weekday=TU(2)) + relativedelta(weeks=2)
)
csac.due_date.start = timezone.make_aware(
tuesday_in_two_weeks.replace(hour=8, minute=30, second=0, microsecond=0)
)
csac.due_date.end = timezone.make_aware(
tuesday_in_two_weeks.replace(hour=17, minute=0, second=0, microsecond=0)
)
csac.due_date.save()
csa = CourseSessionAssignment.objects.create(
course_session=cs_bern,
learning_content=LearningContentAssignment.objects.get(
slug=f"{course.slug}-lp-circle-fahrzeug-lc-überprüfen-einer-motorfahrzeug-versicherungspolice"
),
)
next_thursday = datetime.now() + relativedelta(weekday=TH(2))
csa.submission_deadline.start = timezone.make_aware(
(next_thursday + relativedelta(weeks=3)).replace(
hour=23, minute=59, second=59, microsecond=0
)
)
csa.submission_deadline.save()
csa.evaluation_deadline.start = timezone.make_aware(
(next_thursday + relativedelta(weeks=5)).replace(
hour=23, minute=59, second=59, microsecond=0
)
)
csa.evaluation_deadline.save()
cs_zurich = CourseSession.objects.create( cs_zurich = CourseSession.objects.create(
course_id=COURSE_TEST_ID, course_id=COURSE_TEST_ID,
title="Test Zürich 2022 a", title="Test Zürich 2022 a",
@ -129,18 +168,6 @@ def create_test_course(include_uk=True, include_vv=True, with_sessions=False):
course_session=cs_zurich, course_session=cs_zurich,
user=student2, user=student2,
) )
course = Course.objects.get(id=COURSE_TEST_ID)
for cs in CourseSession.objects.filter(course_id=COURSE_TEST_ID):
for assignment in LearningContentAssignment.objects.descendant_of(
course.coursepage
):
create_course_session_assignment(cs, assignment)
for (
attendance_course
) in LearningContentAttendanceCourse.objects.descendant_of(
course.coursepage
):
create_course_session_attendance_course(cs, attendance_course)
return course return course
@ -169,45 +196,6 @@ def create_test_assignment_submitted_data(assignment, course_session, user):
) )
def create_course_session_assignment(course_session, assignment):
csa, created = CourseSessionAssignment.objects.get_or_create(
course_session=course_session,
learning_content=assignment,
)
if course_session.start_date is None:
course_session.start_date = datetime.now() + timedelta(days=12)
course_session.save()
submission_deadline = csa.submission_deadline
if submission_deadline:
submission_deadline.start = course_session.start_date + timedelta(days=14)
submission_deadline.save()
evaluation_deadline = csa.evaluation_deadline
if evaluation_deadline:
evaluation_deadline.start = course_session.start_date + timedelta(days=28)
evaluation_deadline.save()
return csa
def create_course_session_attendance_course(course_session, course):
casc = CourseSessionAttendanceCourse.objects.create(
course_session=course_session,
learning_content=course,
location="Handelsschule KV Bern, Zimmer 123, Eigerstrasse 16, 3012 Bern",
trainer="Roland Grossenbacher, roland.grossenbacher@helvetia.ch",
)
random_week = random.randint(1, 26)
casc.due_date.start = timezone.make_aware(
datetime(2023, 6, 14, 8, 30) + timedelta(weeks=random_week)
)
casc.due_date.end = timezone.make_aware(
datetime(2023, 6, 14, 17, 0) + timedelta(weeks=random_week)
)
casc.due_date.save()
return casc
def create_test_course_with_categories(apps=None, schema_editor=None): def create_test_course_with_categories(apps=None, schema_editor=None):
if apps is not None: if apps is not None:
Course = apps.get_model("course", "Course") Course = apps.get_model("course", "Course")

View File

@ -3,6 +3,7 @@ import random
from datetime import datetime, timedelta from datetime import datetime, timedelta
import djclick as click import djclick as click
from dateutil.relativedelta import relativedelta, TH, TU
from django.utils import timezone from django.utils import timezone
from vbv_lernwelt.assignment.creators.create_assignments import ( from vbv_lernwelt.assignment.creators.create_assignments import (
@ -245,13 +246,6 @@ def create_course_uk_de():
title="Bern 2023 a", title="Bern 2023 a",
) )
for i, cs in enumerate(CourseSession.objects.filter(course_id=COURSE_UK_TRAINING)):
create_course_session_assignments(
cs,
f"{course.slug}-lp-circle-fahrzeug-lc-überprüfen-einer-motorfahrzeug-versicherungspolice",
i=i,
)
csac = CourseSessionAttendanceCourse.objects.create( csac = CourseSessionAttendanceCourse.objects.create(
course_session=cs, course_session=cs,
learning_content=LearningContentAttendanceCourse.objects.get( learning_content=LearningContentAttendanceCourse.objects.get(
@ -260,17 +254,37 @@ def create_course_uk_de():
location="Handelsschule KV Bern, Zimmer 123, Eigerstrasse 16, 3012 Bern", location="Handelsschule KV Bern, Zimmer 123, Eigerstrasse 16, 3012 Bern",
trainer="Roland Grossenbacher, roland.grossenbacher@helvetia.ch", trainer="Roland Grossenbacher, roland.grossenbacher@helvetia.ch",
) )
tuesday_in_two_weeks = (
# TODO: create dates schlauer datetime.now() + relativedelta(weekday=TU(2)) + relativedelta(weeks=2)
random_week = random.randint(1, 26) )
csac.due_date.start = timezone.make_aware( csac.due_date.start = timezone.make_aware(
datetime(2023, 6, 14, 8, 30) + timedelta(weeks=random_week) tuesday_in_two_weeks.replace(hour=8, minute=30, second=0, microsecond=0)
) )
csac.due_date.end = timezone.make_aware( csac.due_date.end = timezone.make_aware(
datetime(2023, 6, 14, 17, 0) + timedelta(weeks=random_week) tuesday_in_two_weeks.replace(hour=17, minute=0, second=0, microsecond=0)
) )
csac.due_date.save() csac.due_date.save()
csa = CourseSessionAssignment.objects.create(
course_session=cs,
learning_content=LearningContentAssignment.objects.get(
slug=f"{course.slug}-lp-circle-fahrzeug-lc-überprüfen-einer-motorfahrzeug-versicherungspolice"
),
)
next_thursday = datetime.now() + relativedelta(weekday=TH(2))
csa.submission_deadline.start = timezone.make_aware(
(next_thursday + relativedelta(weeks=3)).replace(
hour=23, minute=59, second=59, microsecond=0
)
)
csa.submission_deadline.save()
csa.evaluation_deadline.start = timezone.make_aware(
(next_thursday + relativedelta(weeks=5)).replace(
hour=23, minute=59, second=59, microsecond=0
)
)
csa.evaluation_deadline.save()
# figma demo users and data # figma demo users and data
csu = CourseSessionUser.objects.create( csu = CourseSessionUser.objects.create(
course_session=cs, course_session=cs,
@ -464,19 +478,19 @@ def create_course_uk_de_completion_data(course_session):
for circle in circles: for circle in circles:
for index, lc in enumerate(circle.get_descendants().type(LearningContent)): for index, lc in enumerate(circle.get_descendants().type(LearningContent)):
mark_course_completion( mark_course_completion(
str(lc.translation_key), page=lc,
User.objects.get(email="lina.egger@example.com"), user=User.objects.get(email="lina.egger@example.com"),
course_session=course_session, course_session=course_session,
completion_status="success", completion_status="SUCCESS",
) )
random_number = random.randint(1, 3) random_number = random.randint(1, 3)
if index % random_number == 0: if index % random_number == 0:
mark_course_completion( mark_course_completion(
str(lc.translation_key), page=lc,
User.objects.get(email="michael.meier@example.com"), user=User.objects.get(email="michael.meier@example.com"),
course_session=course_session, course_session=course_session,
completion_status="success", completion_status="SUCCESS",
) )
performance_criteria = ( performance_criteria = (
@ -486,26 +500,26 @@ def create_course_uk_de_completion_data(course_session):
) )
for index, pc in enumerate(performance_criteria): for index, pc in enumerate(performance_criteria):
mark_course_completion( mark_course_completion(
str(pc.translation_key), page=pc,
User.objects.get(email="lina.egger@example.com"), user=User.objects.get(email="lina.egger@example.com"),
course_session=course_session, course_session=course_session,
completion_status="success", completion_status="SUCCESS",
) )
random_number = random.randint(1, 4) random_number = random.randint(1, 4)
if index % random_number == 0: if index % random_number == 0:
mark_course_completion( mark_course_completion(
str(pc.translation_key), page=pc,
User.objects.get(email="michael.meier@example.com"), user=User.objects.get(email="michael.meier@example.com"),
course_session=course_session, course_session=course_session,
completion_status="success", completion_status="SUCCESS",
) )
if index % random_number == 1: if index % random_number == 1:
mark_course_completion( mark_course_completion(
str(pc.translation_key), page=pc,
User.objects.get(email="michael.meier@example.com"), user=User.objects.get(email="michael.meier@example.com"),
course_session=course_session, course_session=course_session,
completion_status="fail", completion_status="FAIL",
) )
@ -587,6 +601,7 @@ def create_course_session_assignments(course_session, assignment_slug, i=1):
if course_session.start_date is None: if course_session.start_date is None:
course_session.start_date = datetime.now() + timedelta(days=i * 12) course_session.start_date = datetime.now() + timedelta(days=i * 12)
course_session.save() course_session.save()
submission_deadline = csa.submission_deadline submission_deadline = csa.submission_deadline
if submission_deadline: if submission_deadline:
submission_deadline.start = course_session.start_date + timedelta(days=14) submission_deadline.start = course_session.start_date + timedelta(days=14)

View File

@ -0,0 +1,65 @@
# Generated by Django 3.2.13 on 2023-06-26 15:24
import django.db.models.deletion
from django.db import migrations, models
import vbv_lernwelt.course.models
class Migration(migrations.Migration):
dependencies = [
("wagtailcore", "0083_workflowcontenttype"),
("course", "0005_remove_coursesession_attendance_courses"),
]
operations = [
migrations.RemoveConstraint(
model_name="coursecompletion",
name="course_completion_unique_user_page_key",
),
migrations.RemoveField(
model_name="coursecompletion",
name="page_key",
),
migrations.RemoveField(
model_name="coursecompletion",
name="page_slug",
),
migrations.AddField(
model_name="coursecompletion",
name="page",
field=models.ForeignKey(
default=1,
on_delete=django.db.models.deletion.CASCADE,
to="wagtailcore.page",
),
preserve_default=False,
),
migrations.AlterField(
model_name="coursecompletion",
name="completion_status",
field=models.CharField(
choices=[
(
vbv_lernwelt.course.models.CourseCompletionStatus["SUCCESS"],
"SUCCESS",
),
(vbv_lernwelt.course.models.CourseCompletionStatus["FAIL"], "FAIL"),
(
vbv_lernwelt.course.models.CourseCompletionStatus["UNKNOWN"],
"UNKNOWN",
),
],
default="UNKNOWN",
max_length=255,
),
),
migrations.AddConstraint(
model_name="coursecompletion",
constraint=models.UniqueConstraint(
fields=("user", "page", "course_session"),
name="course_completion_unique_user_page_key",
),
),
]

View File

@ -0,0 +1,13 @@
# Generated by Django 3.2.13 on 2023-07-11 09:08
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("course", "0006_auto_20230626_1724"),
("course", "0006_remove_coursesession_assignment_details_list"),
]
operations = []

View File

@ -1,3 +1,5 @@
import enum
from django.db import models from django.db import models
from django.db.models import UniqueConstraint from django.db.models import UniqueConstraint
from django.utils.text import slugify from django.utils.text import slugify
@ -152,6 +154,12 @@ class CoursePage(CourseBasePage):
return f"{self.title}" return f"{self.title}"
class CourseCompletionStatus(enum.Enum):
SUCCESS = "SUCCESS"
FAIL = "FAIL"
UNKNOWN = "UNKNOWN"
class CourseCompletion(models.Model): class CourseCompletion(models.Model):
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
@ -159,27 +167,24 @@ class CourseCompletion(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE) user = models.ForeignKey(User, on_delete=models.CASCADE)
# page can logically be a LearningContent or a PerformanceCriteria for now # page can logically be a LearningContent or a PerformanceCriteria for now
page_key = models.UUIDField() page = models.ForeignKey(Page, on_delete=models.CASCADE)
# store for convenience and performance...
page_type = models.CharField(max_length=255, default="", blank=True) page_type = models.CharField(max_length=255, default="", blank=True)
page_slug = models.CharField(max_length=255, default="", blank=True)
course_session = models.ForeignKey("course.CourseSession", on_delete=models.CASCADE) course_session = models.ForeignKey("course.CourseSession", on_delete=models.CASCADE)
completion_status = models.CharField( completion_status = models.CharField(
max_length=255, max_length=255,
choices=[ choices=[(status, status.value) for status in CourseCompletionStatus],
("unknown", "unknown"), default=CourseCompletionStatus.UNKNOWN.value,
("success", "success"),
("fail", "fail"),
],
default="unknown",
) )
additional_json_data = models.JSONField(default=dict) additional_json_data = models.JSONField(default=dict)
class Meta: class Meta:
constraints = [ constraints = [
UniqueConstraint( UniqueConstraint(
fields=["user", "page_key", "course_session"], fields=["user", "page", "course_session"],
name="course_completion_unique_user_page_key", name="course_completion_unique_user_page_key",
) )
] ]

View File

@ -43,10 +43,9 @@ class CourseCompletionSerializer(serializers.ModelSerializer):
"created_at", "created_at",
"updated_at", "updated_at",
"user", "user",
"page_key", "page_id",
"page_type", "page_type",
"page_slug", "course_session_id",
"course_session",
"completion_status", "completion_status",
"additional_json_data", "additional_json_data",
] ]

View File

@ -1,20 +1,31 @@
from wagtail.models import Page from vbv_lernwelt.course.models import CourseCompletion, CourseCompletionStatus
from vbv_lernwelt.course.models import CourseCompletion
from vbv_lernwelt.learnpath.utils import get_wagtail_type from vbv_lernwelt.learnpath.utils import get_wagtail_type
def mark_course_completion(page_key, user, course_session, completion_status="success"): def mark_course_completion(
page = Page.objects.get(translation_key=page_key, locale__language_code="de-CH") page, user, course_session, completion_status=CourseCompletionStatus.SUCCESS.value
page_type = get_wagtail_type(page.specific) ):
course = page.specific.get_course() if completion_status not in CourseCompletionStatus.__members__:
raise ValueError(
f"Invalid value for CourseCompletionStatus: {completion_status}"
)
if not (
hasattr(page.specific, "has_course_completion_status")
and page.specific.has_course_completion_status
):
return ValueError(
f"Page {page.id} of type {get_wagtail_type(page)}"
f" cannot be marked as completed"
)
cc, created = CourseCompletion.objects.get_or_create( cc, created = CourseCompletion.objects.get_or_create(
user=user, user_id=user.id,
page_key=page_key, page_id=page.id,
course_session_id=course_session.id, course_session_id=course_session.id,
) )
cc.page_slug = page.slug
cc.page_type = page_type
cc.completion_status = completion_status cc.completion_status = completion_status
cc.page_type = get_wagtail_type(page.specific)
cc.save() cc.save()
return cc return cc

View File

@ -18,7 +18,7 @@ class CourseCompletionApiTestCase(APITestCase):
def setUp(self) -> None: def setUp(self) -> None:
create_default_users() create_default_users()
create_test_course(include_uk=False) create_test_course(include_uk=False)
self.user = User.objects.get(username="admin") self.user = User.objects.get(username="test-student1@example.com")
self.cs = CourseSession.objects.create( self.cs = CourseSession.objects.create(
course_id=COURSE_TEST_ID, course_id=COURSE_TEST_ID,
title="Test Lehrgang Session", title="Test Lehrgang Session",
@ -27,20 +27,19 @@ class CourseCompletionApiTestCase(APITestCase):
course_session=self.cs, course_session=self.cs,
user=self.user, user=self.user,
) )
self.client.login(username="admin", password="test") self.client.force_login(self.user)
def test_completeLearningContent_works(self): def test_completeLearningContent_happyCase(self):
learning_content = LearningContentPlaceholder.objects.get( learning_content = LearningContentPlaceholder.objects.get(
title="Fachcheck Reisen" title="Fachcheck Reisen"
) )
learning_content_key = str(learning_content.translation_key)
mark_url = f"/api/course/completion/mark/" mark_url = f"/api/course/completion/mark/"
response = self.client.post( response = self.client.post(
mark_url, mark_url,
{ {
"page_key": learning_content_key, "page_id": learning_content.id,
"course_session_id": self.cs.id, "course_session_id": self.cs.id,
}, },
) )
@ -49,13 +48,16 @@ class CourseCompletionApiTestCase(APITestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(len(response_json), 1) self.assertEqual(len(response_json), 1)
self.assertEqual(response_json[0]["page_key"], learning_content_key) self.assertEqual(response_json[0]["page_id"], learning_content.id)
self.assertEqual(response_json[0]["completion_status"], "success") self.assertEqual(
response_json[0]["page_type"], "learnpath.LearningContentPlaceholder"
)
self.assertEqual(response_json[0]["completion_status"], "SUCCESS")
db_entry = CourseCompletion.objects.get( db_entry = CourseCompletion.objects.get(
user=self.user, course_session_id=self.cs.id, page_key=learning_content_key user=self.user, course_session_id=self.cs.id, page_id=learning_content.id
) )
self.assertEqual(db_entry.completion_status, "success") self.assertEqual(db_entry.completion_status, "SUCCESS")
# test getting the circle data # test getting the circle data
response = self.client.get(f"/api/course/completion/{self.cs.id}/") response = self.client.get(f"/api/course/completion/{self.cs.id}/")
@ -65,15 +67,18 @@ class CourseCompletionApiTestCase(APITestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(len(response_json), 1) self.assertEqual(len(response_json), 1)
self.assertEqual(response_json[0]["page_key"], learning_content_key) self.assertEqual(response_json[0]["page_id"], learning_content.id)
self.assertTrue(response_json[0]["completion_status"], "success") self.assertEqual(
response_json[0]["page_type"], "learnpath.LearningContentPlaceholder"
)
self.assertEqual(response_json[0]["completion_status"], "SUCCESS")
# test with "fail" # test with "fail"
response = self.client.post( response = self.client.post(
mark_url, mark_url,
{ {
"page_key": learning_content_key, "page_id": learning_content.id,
"completion_status": "fail", "completion_status": "FAIL",
"course_session_id": self.cs.id, "course_session_id": self.cs.id,
}, },
) )
@ -81,10 +86,13 @@ class CourseCompletionApiTestCase(APITestCase):
response_json = response.json() response_json = response.json()
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(len(response_json), 1) self.assertEqual(len(response_json), 1)
self.assertEqual(response_json[0]["page_key"], learning_content_key) self.assertEqual(response_json[0]["page_id"], learning_content.id)
self.assertEqual(response_json[0]["completion_status"], "fail") self.assertEqual(
response_json[0]["page_type"], "learnpath.LearningContentPlaceholder"
)
self.assertEqual(response_json[0]["completion_status"], "FAIL")
db_entry = CourseCompletion.objects.get( db_entry = CourseCompletion.objects.get(
user=self.user, course_session_id=self.cs.id, page_key=learning_content_key user=self.user, course_session_id=self.cs.id, page_id=learning_content.id
) )
self.assertEqual(db_entry.completion_status, "fail") self.assertEqual(db_entry.completion_status, "FAIL")

View File

@ -89,20 +89,17 @@ def request_course_completion_for_user(request, course_session_id, user_id):
@api_view(["POST"]) @api_view(["POST"])
def mark_course_completion_view(request): def mark_course_completion_view(request):
try: try:
page_key = request.data.get("page_key") page_id = request.data.get("page_id")
completion_status = request.data.get("completion_status", "success") completion_status = request.data.get("completion_status", "SUCCESS")
course_session_id = request.data.get("course_session_id") course_session_id = request.data.get("course_session_id")
page = Page.objects.get(translation_key=page_key, locale__language_code="de-CH") page = Page.objects.get(id=page_id)
if not has_course_access_by_page_request(request, page): if not has_course_access_by_page_request(request, page):
raise PermissionDenied() raise PermissionDenied()
page_type = get_wagtail_type(page.specific)
course = page.specific.get_course()
mark_course_completion( mark_course_completion(
page_key, page=page,
request.user, user=request.user,
course_session=CourseSession.objects.get(id=course_session_id), course_session=CourseSession.objects.get(id=course_session_id),
completion_status=completion_status, completion_status=completion_status,
) )
@ -117,8 +114,8 @@ def mark_course_completion_view(request):
logger.debug( logger.debug(
"mark_course_completion successful", "mark_course_completion successful",
label="completion_api", label="completion_api",
page_key=page_key, page_id=page_id,
page_type=page_type, page_type=get_wagtail_type(page.specific),
page_slug=page.slug, page_slug=page.slug,
page_title=page.title, page_title=page.title,
user_id=request.user.id, user_id=request.user.id,

View File

@ -9,7 +9,12 @@ from vbv_lernwelt.course_session.models import (
@admin.register(CourseSessionAttendanceCourse) @admin.register(CourseSessionAttendanceCourse)
class CourseSessionAttendanceCourseAdmin(admin.ModelAdmin): class CourseSessionAttendanceCourseAdmin(admin.ModelAdmin):
# Inline fields are not possible for the DueDate model, because it is not a ForeignKey relatoion. # Inline fields are not possible for the DueDate model, because it is not a ForeignKey relatoion.
readonly_fields = ["course_session", "learning_content", "due_date"] readonly_fields = [
"course_session",
"learning_content",
"due_date",
"attendance_user_list",
]
list_display = [ list_display = [
"course_session", "course_session",
"learning_content", "learning_content",

View File

@ -0,0 +1,57 @@
import graphene
import structlog
from rest_framework.exceptions import PermissionDenied
from vbv_lernwelt.course.permissions import has_course_access
from vbv_lernwelt.course_session.graphql.types import CourseSessionAttendanceCourseType
from vbv_lernwelt.course_session.models import CourseSessionAttendanceCourse
from vbv_lernwelt.course_session.services.attendance import (
AttendanceUserStatus,
update_attendance_list,
)
logger = structlog.get_logger(__name__)
class AttendanceUserInputType(graphene.InputObjectType):
user_id = graphene.ID(required=True)
status = graphene.Field(
graphene.Enum.from_enum(AttendanceUserStatus), required=True
)
class AttendanceCourseUserMutation(graphene.Mutation):
course_session_attendance_course = graphene.Field(CourseSessionAttendanceCourseType)
class Input:
id = graphene.ID(required=True)
attendance_user_list = graphene.List(AttendanceUserInputType, required=True)
@classmethod
def mutate(
cls,
root,
info,
id,
attendance_user_list,
):
attendance_course = CourseSessionAttendanceCourse.objects.get(id=id)
if not attendance_course or not has_course_access(
info.context.user,
attendance_course.course_session.course_id,
):
raise PermissionDenied()
attendance_course = update_attendance_list(
attendance_course=attendance_course,
attendance_user_list=attendance_user_list,
)
return AttendanceCourseUserMutation(
course_session_attendance_course=attendance_course
)
class CourseSessionMutation:
update_course_session_attendance_course_users = AttendanceCourseUserMutation.Field()

View File

@ -0,0 +1,41 @@
import graphene
from rest_framework.exceptions import PermissionDenied
from vbv_lernwelt.course.models import CourseSession
from vbv_lernwelt.course.permissions import has_course_access, is_course_session_expert
from vbv_lernwelt.course_session.graphql.types import CourseSessionAttendanceCourseType
from vbv_lernwelt.course_session.models import CourseSessionAttendanceCourse
class CourseSessionQuery(object):
course_session_attendance_course = graphene.Field(
CourseSessionAttendanceCourseType,
id=graphene.ID(required=True),
assignment_user_id=graphene.ID(required=False),
)
def resolve_course_session_attendance_course(
root,
info,
id=None,
user_id=graphene.ID(required=False),
):
if user_id is None:
user_id = info.context.user.id
attendance_course = CourseSessionAttendanceCourse.objects.filter(
id=id,
).first()
if attendance_course is None:
return None
if str(user_id) == str(info.context.user.id) or is_course_session_expert(
info.context.user, attendance_course.course_session_id
):
course_id = CourseSession.objects.get(
id=attendance_course.course_session_id
).course_id
if has_course_access(info.context.user, course_id):
return attendance_course
raise PermissionDenied()

View File

@ -0,0 +1,49 @@
import graphene
from graphene_django import DjangoObjectType
from vbv_lernwelt.course_session.models import CourseSessionAttendanceCourse
from vbv_lernwelt.course_session.services.attendance import AttendanceUserStatus
class AttendanceUserType(graphene.ObjectType):
user_id = graphene.ID(required=True)
status = graphene.Field(
graphene.Enum.from_enum(AttendanceUserStatus), required=True
)
first_name = graphene.String()
last_name = graphene.String()
email = graphene.String()
class CourseSessionAttendanceCourseType(DjangoObjectType):
course_session_id = graphene.ID(source="course_session_id")
learning_content_id = graphene.ID(source="learning_content_id")
due_date_id = graphene.ID(source="due_date_id")
end = graphene.DateTime()
start = graphene.DateTime()
attendance_user_list = graphene.List(
AttendanceUserType, source="attendance_user_list"
)
class Meta:
model = CourseSessionAttendanceCourse
fields = (
"id",
"course_session_id",
"learning_content_id",
"due_date_id",
"location",
"trainer",
"start",
"end",
)
def resolve_start(self, info):
if self.due_date is None:
return None
return self.due_date.start
def resolve_end(self, info):
if self.due_date is None:
return None
return self.due_date.end

View File

@ -0,0 +1,18 @@
# Generated by Django 3.2.13 on 2023-06-23 15:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("course_session", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="coursesessionattendancecourse",
name="attendance_user_list",
field=models.JSONField(default=list),
),
]

View File

@ -0,0 +1,13 @@
# Generated by Django 3.2.13 on 2023-07-11 09:08
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("course_session", "0002_coursesessionattendancecourse_attendance_user_list"),
("course_session", "0003_auto_20230628_1321"),
]
operations = []

View File

@ -1,5 +1,6 @@
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_jsonform.models.fields import JSONField as JSONSchemaField
from vbv_lernwelt.assignment.models import AssignmentType from vbv_lernwelt.assignment.models import AssignmentType
from vbv_lernwelt.duedate.models import DueDate from vbv_lernwelt.duedate.models import DueDate
@ -29,6 +30,28 @@ class CourseSessionAttendanceCourse(models.Model):
location = models.CharField(max_length=255, blank=True, default="") location = models.CharField(max_length=255, blank=True, default="")
trainer = models.CharField(max_length=255, blank=True, default="") trainer = models.CharField(max_length=255, blank=True, default="")
# because the attendance list is more of a snapshot of the current state
# we will store the attendance list as a JSONField
# the important field of the list type is "user_id"
ATTENDANCE_USER_LIST_SCHEMA = {
"type": "array",
"items": {
"type": "object",
"properties": {
"user_id": {
"type": "number",
"required": True,
},
"email": {"type": "string"},
"first_name": {"type": "string"},
"last_name": {"type": "string"},
},
},
}
attendance_user_list = JSONSchemaField(
default=list, schema=ATTENDANCE_USER_LIST_SCHEMA
)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if not self.pk: if not self.pk:
title = "" title = ""

View File

@ -14,7 +14,7 @@ class CourseSessionAttendanceCourseSerializer(serializers.ModelSerializer):
model = CourseSessionAttendanceCourse model = CourseSessionAttendanceCourse
fields = [ fields = [
"id", "id",
"course_session_id", "course_session",
"learning_content_id", "learning_content_id",
"due_date_id", "due_date_id",
"location", "location",

View File

@ -0,0 +1,73 @@
import enum
from typing import List, TypedDict
from vbv_lernwelt.core.models import User
from vbv_lernwelt.course.services import mark_course_completion
from vbv_lernwelt.course_session.models import CourseSessionAttendanceCourse
class AttendanceUserStatus(enum.Enum):
PRESENT = "PRESENT"
ABSENT = "ABSENT"
class AttendanceUser(TypedDict):
user_id: str
status: AttendanceUserStatus
def update_attendance_list(
attendance_course: CourseSessionAttendanceCourse,
attendance_user_list: List[AttendanceUser],
):
user_id_set_before = set(
[u["user_id"] for u in attendance_course.attendance_user_list]
)
result_user_list = []
for attendance_user in attendance_user_list:
u = User.objects.filter(id=attendance_user.get("user_id")).first()
if u is not None:
result_user_list.append(
{
"user_id": u.id,
"status": attendance_user.get(
"status", AttendanceUserStatus.PRESENT
).value,
"email": u.email,
"first_name": u.first_name,
"last_name": u.last_name,
}
)
completion_status = (
"SUCCESS"
if attendance_user.get("status").value == "PRESENT"
else "FAIL"
)
mark_course_completion(
page=attendance_course.learning_content,
user=u,
course_session=attendance_course.course_session,
completion_status=completion_status,
)
attendance_course.attendance_user_list = result_user_list
attendance_course.save()
user_id_set_after = set(
[u["user_id"] for u in attendance_course.attendance_user_list]
)
user_id_set_removed = user_id_set_before - user_id_set_after
for user_id in user_id_set_removed:
u = User.objects.filter(id=user_id).first()
if u is not None:
mark_course_completion(
page=attendance_course.learning_content,
user=u,
course_session=attendance_course.course_session,
completion_status="FAIL",
)
return attendance_course

View File

@ -0,0 +1,89 @@
from django.test import TestCase
from vbv_lernwelt.core.create_default_users import create_default_users
from vbv_lernwelt.core.models import User
from vbv_lernwelt.course.creators.test_course import create_test_course
from vbv_lernwelt.course.models import CourseCompletion, CourseSession
from vbv_lernwelt.course.services import mark_course_completion
from vbv_lernwelt.course_session.models import CourseSessionAttendanceCourse
from vbv_lernwelt.course_session.services.attendance import (
AttendanceUserStatus,
update_attendance_list,
)
class AttendanceServicesTestCase(TestCase):
def setUp(self):
create_default_users()
create_test_course(include_vv=False, with_sessions=True)
self.course_session = CourseSession.objects.get(title="Test Bern 2022 a")
self.attendance_course = (
self.course_session.coursesessionattendancecourse_set.first()
)
self.trainer = User.objects.get(username="test-trainer1@example.com")
self.client.force_login(self.trainer)
def test_updateAttendanceList_withSingleUserId_findsDetailsAndStoresResult(self):
student = User.objects.get(username="test-student1@example.com")
update_attendance_list(
self.attendance_course,
[{"user_id": student.id, "status": AttendanceUserStatus.PRESENT}],
)
attendance_course = CourseSessionAttendanceCourse.objects.get(
id=self.attendance_course.id
)
self.assertEqual(
attendance_course.attendance_user_list[0]["user_id"], student.id
)
self.assertEqual(
attendance_course.attendance_user_list,
[
{
"email": "test-student1@example.com",
"status": "PRESENT",
"user_id": -21,
"last_name": "Student1",
"first_name": "Test",
}
],
)
def test_updateAttendanceList_willUpdateUserCourseCompletion(self):
student = User.objects.get(username="test-student1@example.com")
update_attendance_list(
self.attendance_course,
[{"user_id": student.id, "status": AttendanceUserStatus.PRESENT}],
)
self.assertEqual(CourseCompletion.objects.count(), 1)
cc = CourseCompletion.objects.first()
self.assertEqual(cc.user, student)
self.assertEqual(cc.completion_status, "SUCCESS")
self.assertEqual(cc.page_id, self.attendance_course.learning_content.id)
def test_updateAttendanceList_withRemovedUser_willUpdateUserCourseCompletion(self):
student = User.objects.get(username="test-student1@example.com")
self.attendance_course.attendance_user_list = [
{
"email": "test-student1@example.com",
"status": "PRESENT",
"user_id": "-21",
"last_name": "Student1",
"first_name": "Test",
}
]
self.attendance_course.save()
mark_course_completion(
page=self.attendance_course.learning_content,
user=student,
course_session=self.course_session,
completion_status="SUCCESS",
)
update_attendance_list(self.attendance_course, [])
self.assertEqual(CourseCompletion.objects.count(), 1)
cc = CourseCompletion.objects.first()
self.assertEqual(cc.user, student)
self.assertEqual(cc.completion_status, "FAIL")
self.assertEqual(cc.page_id, self.attendance_course.learning_content.id)

View File

@ -0,0 +1,96 @@
import json
from graphene_django.utils.testing import GraphQLTestCase
from vbv_lernwelt.core.create_default_users import create_default_users
from vbv_lernwelt.core.models import User
from vbv_lernwelt.course.creators.test_course import create_test_course
from vbv_lernwelt.course.models import CourseSession
class AttendanceCourseUserMutationTestCase(GraphQLTestCase):
GRAPHQL_URL = "/server/graphql/"
def setUp(self):
create_default_users()
create_test_course(include_vv=False, with_sessions=True)
self.course_session = CourseSession.objects.get(title="Test Bern 2022 a")
self.attendance_course = (
self.course_session.coursesessionattendancecourse_set.first()
)
self.trainer = User.objects.get(username="test-trainer1@example.com")
self.client.force_login(self.trainer)
def test_simple_query(self):
response = self.query(
f"""
{{
course_session_attendance_course(id:{self.attendance_course.id}) {{
id
trainer
}}
}}
"""
)
content = json.loads(response.content)
self.assertResponseNoErrors(response)
self.assertEqual(
content["data"]["course_session_attendance_course"]["trainer"],
"Roland Grossenbacher, roland.grossenbacher@helvetia.ch",
)
def test_mutation_add_user_when_present(self):
student = User.objects.get(username="test-student1@example.com")
query = f"""
mutation {{
update_course_session_attendance_course_users(
id:{self.attendance_course.id},
attendance_user_list:[
{{user_id: {student.id}, status: PRESENT}},
{{user_id: "123123123", status: PRESENT}},
]
) {{
course_session_attendance_course {{
id
attendance_user_list {{
user_id
first_name
last_name
email
status
}}
}}
}}
}}
"""
print(query)
response = self.query(query)
self.assertResponseNoErrors(response)
data = json.loads(response.content)
self.maxDiff = None
self.assertDictEqual(
{
"data": {
"update_course_session_attendance_course_users": {
"course_session_attendance_course": {
"id": str(self.attendance_course.id),
"attendance_user_list": [
{
"user_id": str(student.id),
"first_name": student.first_name,
"last_name": student.last_name,
"email": student.email,
"status": "PRESENT",
}
],
}
}
}
},
data,
)

View File

@ -0,0 +1,112 @@
# Generated by Django 3.2.13 on 2023-06-26 15:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("learnpath", "0007_learningunit_title_hidden"),
]
operations = [
migrations.AddField(
model_name="learningcontentassignment",
name="can_user_self_toggle_course_completion",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="learningcontentassignment",
name="has_course_completion_status",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="learningcontentattendancecourse",
name="can_user_self_toggle_course_completion",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="learningcontentattendancecourse",
name="has_course_completion_status",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="learningcontentdocumentlist",
name="can_user_self_toggle_course_completion",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="learningcontentdocumentlist",
name="has_course_completion_status",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="learningcontentfeedback",
name="can_user_self_toggle_course_completion",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="learningcontentfeedback",
name="has_course_completion_status",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="learningcontentlearningmodule",
name="can_user_self_toggle_course_completion",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="learningcontentlearningmodule",
name="has_course_completion_status",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="learningcontentmedialibrary",
name="can_user_self_toggle_course_completion",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="learningcontentmedialibrary",
name="has_course_completion_status",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="learningcontentplaceholder",
name="can_user_self_toggle_course_completion",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="learningcontentplaceholder",
name="has_course_completion_status",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="learningcontentrichtext",
name="can_user_self_toggle_course_completion",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="learningcontentrichtext",
name="has_course_completion_status",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="learningcontenttest",
name="can_user_self_toggle_course_completion",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="learningcontenttest",
name="has_course_completion_status",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="learningcontentvideo",
name="can_user_self_toggle_course_completion",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="learningcontentvideo",
name="has_course_completion_status",
field=models.BooleanField(default=True),
),
]

View File

@ -0,0 +1,22 @@
# Generated by Django 3.2.13 on 2023-06-26 16:30
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("assignment", "0004_assignment_assignment_type"),
("learnpath", "0008_auto_20230626_1747"),
]
operations = [
migrations.AlterField(
model_name="learningcontentassignment",
name="content_assignment",
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT, to="assignment.assignment"
),
),
]

View File

@ -0,0 +1,13 @@
# Generated by Django 3.2.13 on 2023-07-11 09:08
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("learnpath", "0008_alter_learningcontentassignment_content_assignment"),
("learnpath", "0009_alter_learningcontentassignment_content_assignment"),
]
operations = []

View File

@ -234,17 +234,21 @@ class LearningContent(CourseBasePage):
"minutes", "minutes",
"description", "description",
"content_url", "content_url",
"can_user_self_toggle_course_completion",
] ]
minutes = models.PositiveIntegerField(default=15) minutes = models.PositiveIntegerField(default=15)
description = RichTextField(blank=True) description = RichTextField(blank=True)
content_url = models.TextField(blank=True) content_url = models.TextField(blank=True)
has_course_completion_status = models.BooleanField(default=True)
can_user_self_toggle_course_completion = models.BooleanField(default=False)
content_panels = [ content_panels = [
FieldPanel("title", classname="full title"), FieldPanel("title", classname="full title"),
FieldPanel("minutes"), FieldPanel("minutes"),
FieldPanel("content_url"), FieldPanel("content_url"),
FieldPanel("description"), FieldPanel("description"),
FieldPanel("can_user_self_toggle_course_completion"),
] ]
def get_admin_display_title(self): def get_admin_display_title(self):
@ -290,26 +294,31 @@ class LearningContentAttendanceCourse(LearningContent):
class LearningContentVideo(LearningContent): class LearningContentVideo(LearningContent):
parent_page_types = ["learnpath.Circle"] parent_page_types = ["learnpath.Circle"]
subpage_types = [] subpage_types = []
can_user_self_toggle_course_completion = models.BooleanField(default=True)
class LearningContentPlaceholder(LearningContent): class LearningContentPlaceholder(LearningContent):
parent_page_types = ["learnpath.Circle"] parent_page_types = ["learnpath.Circle"]
subpage_types = [] subpage_types = []
can_user_self_toggle_course_completion = models.BooleanField(default=True)
class LearningContentFeedback(LearningContent): class LearningContentFeedback(LearningContent):
parent_page_types = ["learnpath.Circle"] parent_page_types = ["learnpath.Circle"]
subpage_types = [] subpage_types = []
can_user_self_toggle_course_completion = models.BooleanField(default=True)
class LearningContentLearningModule(LearningContent): class LearningContentLearningModule(LearningContent):
parent_page_types = ["learnpath.Circle"] parent_page_types = ["learnpath.Circle"]
subpage_types = [] subpage_types = []
can_user_self_toggle_course_completion = models.BooleanField(default=True)
class LearningContentMediaLibrary(LearningContent): class LearningContentMediaLibrary(LearningContent):
parent_page_types = ["learnpath.Circle"] parent_page_types = ["learnpath.Circle"]
subpage_types = [] subpage_types = []
can_user_self_toggle_course_completion = models.BooleanField(default=True)
class LearningContentTest(LearningContent): class LearningContentTest(LearningContent):
@ -328,6 +337,7 @@ class LearningContentTest(LearningContent):
class LearningContentRichText(LearningContent): class LearningContentRichText(LearningContent):
text = RichTextField(blank=True, features=DEFAULT_RICH_TEXT_FEATURES_WITH_HEADER) text = RichTextField(blank=True, features=DEFAULT_RICH_TEXT_FEATURES_WITH_HEADER)
can_user_self_toggle_course_completion = models.BooleanField(default=True)
parent_page_types = ["learnpath.Circle"] parent_page_types = ["learnpath.Circle"]
serialize_field_names = LearningContent.serialize_field_names + [ serialize_field_names = LearningContent.serialize_field_names + [
@ -368,6 +378,8 @@ class LearningContentAssignment(LearningContent):
class LearningContentDocumentList(LearningContent): class LearningContentDocumentList(LearningContent):
can_user_self_toggle_course_completion = models.BooleanField(default=True)
serialize_field_names = LearningContent.serialize_field_names + [ serialize_field_names = LearningContent.serialize_field_names + [
"documents", "documents",
] ]

View File

@ -10,7 +10,12 @@ from vbv_lernwelt.learnpath.models import LearningUnit
class LearningUnitSerializer( class LearningUnitSerializer(
get_course_serializer_class( get_course_serializer_class(
LearningUnit, LearningUnit,
field_names=["evaluate_url", "course_category", "children", "title_hidden"], field_names=[
"evaluate_url",
"course_category",
"children",
"title_hidden",
],
) )
): ):
evaluate_url = SerializerMethodField() evaluate_url = SerializerMethodField()