Merge branch 'develop' into feature/VBV-488-import-edoniq-data

This commit is contained in:
Daniel Egger 2023-10-05 09:16:49 +02:00
commit d92b514759
30 changed files with 880 additions and 305 deletions

View File

@ -20,14 +20,19 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
defineProps<{ withDefaults(
doc: { defineProps<{
url: string; doc: {
name: string; url: string;
}; name: string;
canDelete: boolean; };
subtitle: string; canDelete?: boolean;
}>(); subtitle: string;
}>(),
{
canDelete: false,
}
);
const emit = defineEmits(["delete"]); const emit = defineEmits(["delete"]);
</script> </script>

View File

@ -1,26 +1,41 @@
<script lang="ts" setup> <script lang="ts" setup>
import { formatDueDate } from "@/components/dueDates/dueDatesUtils";
import type { CourseSession, DueDate } from "@/types"; import type { CourseSession, DueDate } from "@/types";
import { useCourseSessionsStore } from "@/stores/courseSessions"; import { useCourseSessionsStore } from "@/stores/courseSessions";
import { useTranslation } from "i18next-vue";
import dayjs from "dayjs";
import { computed } from "vue";
const props = defineProps<{ const props = defineProps<{
dueDate: DueDate; dueDate: DueDate;
singleLine?: boolean; singleLine?: boolean;
showCourseSession?: boolean;
}>(); }>();
/* FIXME @livioso 19.09.23: This is a temporary workaround to have a ship-able / deployable const { t } = useTranslation();
version of the preview feature (VBV-516). The plan is to tackle the role-based const dateType = t(props.dueDate.date_type_translation_key);
due dates calendar next (VBV-524) which will touch all usage of this component. const assignmentType = t(props.dueDate.assignment_type_translation_key);
For now, just disable links for trainer / expert -> to reduce level of confusion ;)
*/
const courseSessionsStore = useCourseSessionsStore(); const courseSessionsStore = useCourseSessionsStore();
const courseSession = courseSessionsStore.allCourseSessions.find( const courseSession = courseSessionsStore.allCourseSessions.find(
(cs: CourseSession) => cs.id === props.dueDate.course_session (cs: CourseSession) => cs.id === props.dueDate.course_session
); );
const disableLink = courseSession if (!courseSession) {
? !courseSessionsStore.hasCockpit(courseSession) throw new Error("Course session not found");
: false; }
const isExpert = courseSessionsStore.hasCockpit(courseSession);
const url = isExpert ? props.dueDate.url_expert : props.dueDate.url;
const courseSessionTitle = computed(() => {
if (props.dueDate.course_session) {
return (
courseSessionsStore.getCourseSessionById(props.dueDate.course_session)?.title ??
""
);
}
return "";
});
</script> </script>
<template> <template>
@ -29,26 +44,35 @@ const disableLink = courseSession
:class="{ 'flex-col': props.singleLine, 'items-center': !props.singleLine }" :class="{ 'flex-col': props.singleLine, 'items-center': !props.singleLine }"
> >
<div class="space-y-1"> <div class="space-y-1">
<div class="text-bold"> <div>
<a v-if="disableLink" class="underline" :href="props.dueDate.url"> <a class="underline" :href="url">
{{ props.dueDate.title }} <span class="text-bold">
{{ dayjs(props.dueDate.start).format("D. MMMM YYYY") }}:
<template v-if="dateType">
{{ dateType }}
</template>
<template v-else>
{{ assignmentType }}
</template>
{{ " " }}
</span>
<template v-if="assignmentType && dateType">
{{ assignmentType }}:
{{ props.dueDate.title }}
</template>
<template v-else>
{{ props.dueDate.title }}
</template>
</a> </a>
<template v-else>
{{ props.dueDate.title }}
</template>
</div> </div>
<div class="text-small text-gray-900"> <div class="text-small text-gray-900">
<div v-if="props.dueDate.date_type_translation_key"> <div>
{{ $t(props.dueDate.assignment_type_translation_key) }}: <span v-if="props.showCourseSession ?? courseSessionTitle">
{{ $t(props.dueDate.date_type_translation_key) }} {{ courseSessionTitle }}:
</div> </span>
<div v-else> {{ $t("a.Circle") }} «{{ props.dueDate.circle?.title }}»
{{ $t(props.dueDate.assignment_type_translation_key) }}
</div> </div>
</div> </div>
</div> </div>
<div>
{{ formatDueDate(props.dueDate.start, props.dueDate.end) }}
</div>
</div> </div>
</template> </template>

View File

@ -7,6 +7,9 @@ const props = defineProps<{
maxCount: number; maxCount: number;
dueDates: DueDate[]; dueDates: DueDate[];
showTopBorder: boolean; showTopBorder: boolean;
showBottomBorder: boolean;
showAllDueDatesLink: boolean;
showCourseSession: boolean;
}>(); }>();
const allDueDates = computed(() => { const allDueDates = computed(() => {
@ -20,20 +23,32 @@ const dueDatesDisplayed = computed(() => {
<template> <template>
<div> <div>
<ul> <ul :class="showBottomBorder ? '' : 'no-border-last'">
<li <li
v-for="dueDate in dueDatesDisplayed" v-for="dueDate in dueDatesDisplayed"
:key="dueDate.id" :key="dueDate.id"
class="cy-single-due-date"
:class="{ 'first:border-t': props.showTopBorder, 'border-b': true }" :class="{ 'first:border-t': props.showTopBorder, 'border-b': true }"
> >
<DueDateSingle :due-date="dueDate"></DueDateSingle> <DueDateSingle
:due-date="dueDate"
:show-course-session="props.showCourseSession"
></DueDateSingle>
</li> </li>
</ul> </ul>
<div v-if="allDueDates.length > props.maxCount" class="flex items-center pt-6"> <div v-if="allDueDates.length === 0">{{ $t("dueDates.noDueDatesAvailable") }}</div>
<!--a href="">{{ $t("dueDates.showAllDueDates") }}</a--> <div
v-if="showAllDueDatesLink && allDueDates.length > 0"
class="flex items-center pt-6"
>
<a href="/appointments">{{ $t("dueDates.showAllDueDates") }}</a>
<it-icon-arrow-right /> <it-icon-arrow-right />
</div> </div>
<div v-if="allDueDates.length === 0">{{ $t("dueDates.noDueDatesAvailable") }}</div>
</div> </div>
</template> </template>
<style lang="postcss" scoped>
.no-border-last li:last-child {
border-bottom: none !important;
}
</style>

View File

@ -4,6 +4,9 @@
:due-dates="allDueDates" :due-dates="allDueDates"
:max-count="props.maxCount" :max-count="props.maxCount"
:show-top-border="props.showTopBorder" :show-top-border="props.showTopBorder"
show-all-due-dates-link
show-bottom-border
:show-course-session="false"
></DueDatesList> ></DueDatesList>
</div> </div>
</template> </template>

View File

@ -18,7 +18,7 @@ const logout = () => {
userStore.handleLogout(); userStore.handleLogout();
}; };
const selectCourseSession = (courseSession: CourseSession) => { const selectCourseSession = (courseSession: CourseSession) => {
courseSessionsStore.switchCourseSession(courseSession); courseSessionsStore.switchCourseSessionById(courseSession.id);
}; };
const courseSessionsStore = useCourseSessionsStore(); const courseSessionsStore = useCourseSessionsStore();

View File

@ -23,8 +23,14 @@ const breakpoints = useBreakpoints(breakpointsTailwind);
const userStore = useUserStore(); const userStore = useUserStore();
const courseSessionsStore = useCourseSessionsStore(); const courseSessionsStore = useCourseSessionsStore();
const notificationsStore = useNotificationsStore(); const notificationsStore = useNotificationsStore();
const { inCockpit, inCompetenceProfile, inCourse, inLearningPath, inMediaLibrary } = const {
useRouteLookups(); inCockpit,
inCompetenceProfile,
inCourse,
inLearningPath,
inMediaLibrary,
inAppointments,
} = useRouteLookups();
const { t } = useTranslation(); const { t } = useTranslation();
const state = reactive({ const state = reactive({
@ -43,6 +49,15 @@ const selectedCourseSessionTitle = computed(() => {
return courseSessionsStore.currentCourseSession?.title; return courseSessionsStore.currentCourseSession?.title;
}); });
const appointmentsUrl = computed(() => {
const currentCourseSession = courseSessionsStore.currentCourseSession;
if (currentCourseSession) {
return `/course/${currentCourseSession.course.slug}/appointments`;
} else {
return `/appointments`;
}
});
onMounted(() => { onMounted(() => {
log.debug("MainNavigationBar mounted"); log.debug("MainNavigationBar mounted");
}); });
@ -169,6 +184,16 @@ onMounted(() => {
<it-icon-media-library class="h-8 w-8" /> <it-icon-media-library class="h-8 w-8" />
</router-link> </router-link>
<router-link
v-if="userStore.loggedIn"
:to="appointmentsUrl"
data-cy="all-duedates-link"
class="nav-item"
:class="{ 'nav-item--active': inAppointments() }"
>
<it-icon-calendar-light class="h-8 w-8" />
</router-link>
<!-- Notification Bell & Menu --> <!-- Notification Bell & Menu -->
<div v-if="userStore.loggedIn" class="nav-item leading-none"> <div v-if="userStore.loggedIn" class="nav-item leading-none">
<NotificationPopover> <NotificationPopover>

View File

@ -10,6 +10,7 @@ interface Props {
name: string; name: string;
}; };
items?: DropdownSelectable[]; items?: DropdownSelectable[];
borderless?: boolean;
} }
const emit = defineEmits<{ const emit = defineEmits<{
@ -36,7 +37,11 @@ const dropdownSelected = computed<DropdownSelectable>({
<Listbox v-model="dropdownSelected" as="div"> <Listbox v-model="dropdownSelected" as="div">
<div class="relative mt-1 w-full"> <div class="relative mt-1 w-full">
<ListboxButton <ListboxButton
class="relative flex w-full cursor-default flex-row items-center border bg-white py-3 pl-5 pr-10 text-left font-bold" class="relative flex w-full cursor-default flex-row items-center bg-white py-3 pl-5 pr-10 text-left"
:class="{
border: !props.borderless,
'font-bold': !props.borderless,
}"
> >
<span v-if="dropdownSelected.iconName" class="mr-4"> <span v-if="dropdownSelected.iconName" class="mr-4">
<component :is="dropdownSelected.iconName"></component> <component :is="dropdownSelected.iconName"></component>

View File

@ -0,0 +1,181 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { useCourseSessionsStore } from "@/stores/courseSessions";
import { useLearningPathStore } from "@/stores/learningPath";
import { useTranslation } from "i18next-vue";
import ItDropdownSelect from "@/components/ui/ItDropdownSelect.vue";
import type { DueDate } from "@/types";
import DueDatesList from "@/components/dueDates/DueDatesList.vue";
const { t } = useTranslation();
const UNFILTERED = Number.MAX_SAFE_INTEGER;
const courseSessionsStore = useCourseSessionsStore();
const learningPathStore = useLearningPathStore();
type Item = {
id: number;
name: string;
};
type CourseItem = Item & {
slug: string;
};
const courses: CourseItem[] = courseSessionsStore.uniqueCourseSessionsByCourse.map(
(cs) => ({
id: cs.course.id,
name: cs.course.title,
slug: cs.course.slug,
})
);
const selectedCourse = ref<CourseItem>(courses[0]);
const courseSessions = computed(() => {
return [
{
id: UNFILTERED,
name: t("a.AlleDurchführungen"),
},
...courseSessionsStore.allCourseSessions
.filter((cs) => cs.course.id === selectedCourse.value.id)
.map((cs) => ({ id: cs.id, name: cs.title })),
];
});
const selectedSession = ref<Item>(courseSessions.value[0]);
// pre-select course and session if we are in a course session
if (courseSessionsStore.currentCourseSession) {
const session = courseSessionsStore.currentCourseSession;
const { id: courseId, title: courseName, slug: courseSlug } = session.course;
selectedCourse.value = { id: courseId, name: courseName, slug: courseSlug };
const { id: sessionId, title: sessionName } = session;
selectedSession.value = { id: sessionId, name: sessionName };
}
const initialItemCircle: Item = {
id: UNFILTERED,
name: t("a.AlleCircle"),
};
const circles = ref<Item[]>([initialItemCircle]);
const selectedCircle = ref<Item>(circles.value[0]);
async function loadCircleValues() {
const data = await learningPathStore.loadLearningPath(
`${selectedCourse.value.slug}-lp`,
undefined,
false,
false
);
if (data) {
circles.value = [
initialItemCircle,
...data.circles.map((circle) => ({ id: circle.id, name: circle.title })),
];
} else {
circles.value = [initialItemCircle];
}
selectedCircle.value = circles.value[0];
}
watch(selectedCourse, async () => {
selectedSession.value = courseSessions.value[0];
await loadCircleValues();
});
onMounted(async () => {
await loadCircleValues();
});
const appointments = computed(() => {
return courseSessionsStore
.allDueDates()
.filter(
(dueDate) =>
isMatchingCourse(dueDate) &&
isMatchingSession(dueDate) &&
isMatchingCircle(dueDate)
);
});
const isMatchingSession = (dueDate: DueDate) =>
selectedSession.value.id === UNFILTERED ||
dueDate.course_session === selectedSession.value.id;
const isMatchingCircle = (dueDate: DueDate) =>
selectedCircle.value.id === UNFILTERED ||
dueDate.circle?.id === selectedCircle.value.id;
const isMatchingCourse = (dueDate: DueDate) =>
courseSessions.value.map((cs) => cs.id).includes(dueDate.course_session as number);
const numAppointmentsToShow = ref(7);
const canLoadMore = computed(() => {
return numAppointmentsToShow.value < appointments.value.length;
});
async function loadAdditionalAppointments() {
numAppointmentsToShow.value *= 2;
}
</script>
<template>
<div class="bg-gray-200">
<div class="container-large px-8 py-8">
<header class="mb-6 flex flex-col lg:flex-row lg:items-center lg:justify-between">
<h1>{{ $t("a.AlleTermine") }}</h1>
<div>
<ItDropdownSelect
v-model="selectedCourse"
data-cy="appointments-course-select"
:items="courses"
></ItDropdownSelect>
</div>
</header>
<main>
<div class="flex flex-col space-y-2">
<div class="flex flex-col space-x-0 bg-white lg:flex-row lg:space-x-3">
<ItDropdownSelect
v-model="selectedSession"
data-cy="appointments-session-select"
:items="courseSessions"
borderless
></ItDropdownSelect>
<ItDropdownSelect
v-model="selectedCircle"
data-cy="appointments-circle-select"
:items="circles"
borderless
></ItDropdownSelect>
</div>
<div class="bg-white px-5">
<DueDatesList
:show-top-border="false"
:show-bottom-border="canLoadMore"
:due-dates="appointments"
:show-all-due-dates-link="false"
:max-count="numAppointmentsToShow"
data-cy="appointments-list"
:show-course-session="true"
/>
<button
v-if="canLoadMore"
class="py-4 underline"
data-cy="load-more-notifications"
@click="loadAdditionalAppointments()"
>
{{ $t("notifications.load_more") }}
</button>
</div>
</div>
</main>
</div>
</div>
</template>
<style lang="postcss" scoped>
.no-border-last li:last-child {
border-bottom: none !important;
}
</style>

View File

@ -71,6 +71,9 @@ const getNextStepLink = (courseSession: CourseSession) => {
:due-dates="allDueDates" :due-dates="allDueDates"
:max-count="13" :max-count="13"
:show-top-border="false" :show-top-border="false"
:show-all-due-dates-link="true"
:show-bottom-border="true"
:show-course-session="true"
></DueDatesList> ></DueDatesList>
</div> </div>
</div> </div>

View File

@ -9,9 +9,9 @@ const courseSession = useCurrentCourseSession();
const circleDates = computed(() => { const circleDates = computed(() => {
const dueDates = courseSession.value.due_dates.filter((dueDate) => { const dueDates = courseSession.value.due_dates.filter((dueDate) => {
if (!cockpitStore.selectedCircle) return false; if (!cockpitStore.currentCircle) return false;
return ( return (
cockpitStore.selectedCircle.translation_key == dueDate?.circle?.translation_key cockpitStore.currentCircle.translation_key == dueDate?.circle?.translation_key
); );
}); });
return dueDates.slice(0, 4); return dueDates.slice(0, 4);

View File

@ -10,6 +10,7 @@ import { useCompetenceStore } from "@/stores/competence";
import { useLearningPathStore } from "@/stores/learningPath"; import { useLearningPathStore } from "@/stores/learningPath";
import log from "loglevel"; import log from "loglevel";
import CockpitDates from "@/pages/cockpit/cockpitPage/CockpitDates.vue"; import CockpitDates from "@/pages/cockpit/cockpitPage/CockpitDates.vue";
import { useCourseSessionsStore } from "@/stores/courseSessions";
import ItDropdownSelect from "@/components/ui/ItDropdownSelect.vue"; import ItDropdownSelect from "@/components/ui/ItDropdownSelect.vue";
const props = defineProps<{ const props = defineProps<{
@ -22,12 +23,13 @@ const cockpitStore = useCockpitStore();
const competenceStore = useCompetenceStore(); const competenceStore = useCompetenceStore();
const learningPathStore = useLearningPathStore(); const learningPathStore = useLearningPathStore();
const courseSession = useCurrentCourseSession(); const courseSession = useCurrentCourseSession();
const courseSessionsStore = useCourseSessionsStore();
function userCountStatusForCircle(userId: string) { function userCountStatusForCircle(userId: string) {
if (!cockpitStore.selectedCircle) return { FAIL: 0, SUCCESS: 0, UNKNOWN: 0 }; if (!cockpitStore.currentCircle) return { FAIL: 0, SUCCESS: 0, UNKNOWN: 0 };
const criteria = competenceStore.flatPerformanceCriteria( const criteria = competenceStore.flatPerformanceCriteria(
userId, userId,
cockpitStore.selectedCircle.id cockpitStore.currentCircle.id
); );
return competenceStore.calcStatusCount(criteria); return competenceStore.calcStatusCount(criteria);
} }
@ -35,146 +37,173 @@ function userCountStatusForCircle(userId: string) {
<template> <template>
<div class="bg-gray-200"> <div class="bg-gray-200">
<div class="container-large"> <div v-if="cockpitStore.currentCircle" class="container-large">
<div class="mb-9 flex flex-col lg:flex-row lg:items-center lg:justify-between"> <div class="mb-9 flex flex-col lg:flex-row lg:items-center lg:justify-between">
<h1>Cockpit</h1> <h1>Cockpit</h1>
<ItDropdownSelect <ItDropdownSelect
v-model="cockpitStore.selectedCircle" :model-value="cockpitStore.selectedCircle"
class="mt-4 w-full lg:mt-0 lg:w-96" class="mt-4 w-full lg:mt-0 lg:w-96"
:items="cockpitStore.circles" :items="cockpitStore.circles"
@update:model-value="cockpitStore.setCurrentCourseCircleFromEvent"
></ItDropdownSelect> ></ItDropdownSelect>
</div> </div>
<template v-if="cockpitStore.selectedCircle"> <!-- Status -->
<!-- Status --> <div class="mb-4 gap-4 lg:grid lg:grid-cols-3 lg:grid-rows-none">
<div class="mb-4 gap-4 lg:grid lg:grid-cols-3 lg:grid-rows-none"> <div class="my-4 flex flex-col justify-between bg-white p-6 lg:my-0">
<div class="my-4 flex flex-col justify-between bg-white p-6 lg:my-0"> <div>
<div> <h3 class="heading-3 mb-4 flex items-center gap-2">
<h3 class="heading-3 mb-4 flex items-center gap-2"> {{ $t("Trainerunterlagen") }}
{{ $t("Trainerunterlagen") }} </h3>
</h3> <div class="mb-4">
<div class="mb-4"> {{ $t("cockpit.trainerFilesText") }}
{{ $t("cockpit.trainerFilesText") }}
</div>
</div>
<div>
<a
href="https://vbvbern.sharepoint.com/sites/myVBV-AFA_K-CI"
class="btn-secondary min-w-min"
target="_blank"
>
{{ $t("MS Teams öffnen") }}
</a>
</div> </div>
</div> </div>
<div class="my-4 flex flex-col justify-between bg-white p-6 lg:my-0"> <div>
<div> <a
<h3 class="heading-3 mb-4 flex items-center gap-2"> href="https://vbvbern.sharepoint.com/sites/myVBV-AFA_K-CI"
{{ $t("Anwesenheitskontrolle Präsenzkurse") }} class="btn-secondary min-w-min"
</h3> target="_blank"
<div class="mb-4"> >
{{ {{ $t("MS Teams öffnen") }}
$t( </a>
"Hier überprüfst und bestätigst du die Anwesenheit deiner Teilnehmenden."
)
}}
</div>
</div>
<div>
<router-link
:to="`/course/${props.courseSlug}/cockpit/attendance`"
class="btn-secondary min-w-min"
>
{{ $t("Anwesenheit prüfen") }}
</router-link>
</div>
</div>
<div class="bg-white p-6">
<CockpitDates></CockpitDates>
</div> </div>
</div> </div>
<SubmissionsOverview <div class="my-4 flex flex-col justify-between bg-white p-6 lg:my-0">
:course-session="courseSession" <div>
:selected-circle="cockpitStore.selectedCircle.id" <h3 class="heading-3 mb-4 flex items-center gap-2">
></SubmissionsOverview> {{ $t("a.Unterlagen für Teilnehmenden") }}
<div class="pt-4"> </h3>
<!-- progress --> <div class="mb-4">
<div v-if="cockpitStore.courseSessionMembers" class="bg-white p-6"> {{ $t("a.Stelle deinen Lernenden zusätzliche Inhalte zur Verfügung.") }}
<h1 class="heading-3 mb-5">{{ $t("cockpit.progress") }}</h1> </div>
<ul> <div
<ItPersonRow v-if="courseSessionsStore.circleDocuments.length"
v-for="csu in cockpitStore.courseSessionMembers" class="mb-4 flex items-center gap-x-2"
:key="csu.user_id + csu.session_title" >
:name="`${csu.first_name} ${csu.last_name}`" <it-icon-document />
:avatar-url="csu.avatar_url" {{ courseSessionsStore.circleDocuments.length }} {{ $t("a.Unterlagen") }}
> </div>
<template #center> </div>
<div <div>
class="mt-2 flex w-full flex-col items-center justify-between lg:mt-0 lg:flex-row" <router-link
> :to="`/course/${props.courseSlug}/cockpit/documents`"
<LearningPathDiagram class="btn-secondary min-w-min"
v-if=" >
learningPathStore.learningPathForUser( {{ $t("a.Zum Unterlagen-Upload") }}
props.courseSlug, </router-link>
csu.user_id </div>
) </div>
" <div class="my-4 flex flex-col justify-between bg-white p-6 lg:my-0">
:learning-path=" <div>
<h3 class="heading-3 mb-4 flex items-center gap-2">
{{ $t("Anwesenheitskontrolle Präsenzkurse") }}
</h3>
<div class="mb-4">
{{
$t(
"Hier überprüfst und bestätigst du die Anwesenheit deiner Teilnehmenden."
)
}}
</div>
</div>
<div>
<router-link
:to="`/course/${props.courseSlug}/cockpit/attendance`"
class="btn-secondary min-w-min"
>
{{ $t("Anwesenheit prüfen") }}
</router-link>
</div>
</div>
</div>
<div class="mb-4 bg-white p-6">
<CockpitDates></CockpitDates>
</div>
<SubmissionsOverview
:course-session="courseSession"
:selected-circle="cockpitStore.currentCircle.id"
></SubmissionsOverview>
<div class="pt-4">
<!-- progress -->
<div v-if="cockpitStore.courseSessionMembers" class="bg-white p-6">
<h1 class="heading-3 mb-5">{{ $t("cockpit.progress") }}</h1>
<ul>
<ItPersonRow
v-for="csu in cockpitStore.courseSessionMembers"
:key="csu.user_id + csu.session_title"
:name="`${csu.first_name} ${csu.last_name}`"
:avatar-url="csu.avatar_url"
>
<template #center>
<div
class="mt-2 flex w-full flex-col items-center justify-between lg:mt-0 lg:flex-row"
>
<LearningPathDiagram
v-if="
learningPathStore.learningPathForUser(
props.courseSlug,
csu.user_id
)
"
:learning-path="
learningPathStore.learningPathForUser( learningPathStore.learningPathForUser(
props.courseSlug, props.courseSlug,
csu.user_id csu.user_id
) as LearningPath ) as LearningPath
" "
:show-circle-translation-keys="[ :show-circle-translation-keys="[
cockpitStore.selectedCircle.translation_key, cockpitStore.currentCircle.translation_key,
]" ]"
diagram-type="singleSmall" diagram-type="singleSmall"
class="mr-4" class="mr-4"
></LearningPathDiagram> ></LearningPathDiagram>
<p class="lg:min-w-[150px]"> <p class="lg:min-w-[150px]">
{{ cockpitStore.selectedCircle.name }} {{ cockpitStore.currentCircle.title }}
</p> </p>
<div class="ml-4 flex flex-row items-center"> <div class="ml-4 flex flex-row items-center">
<div class="mr-6 flex flex-row items-center"> <div class="mr-6 flex flex-row items-center">
<it-icon-smiley-thinking <it-icon-smiley-thinking
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 w-6"> <p class="text-bold inline-block w-6">
{{ userCountStatusForCircle(csu.user_id).FAIL }} {{ userCountStatusForCircle(csu.user_id).FAIL }}
</p> </p>
</div>
<li class="mr-6 flex flex-row items-center">
<it-icon-smiley-happy
class="mr-2 inline-block h-8 w-8"
></it-icon-smiley-happy>
<p class="text-bold inline-block w-6">
{{ userCountStatusForCircle(csu.user_id).SUCCESS }}
</p>
</li>
<li class="flex flex-row items-center">
<it-icon-smiley-neutral
class="mr-2 inline-block h-8 w-8"
></it-icon-smiley-neutral>
<p class="text-bold inline-block w-6">
{{ userCountStatusForCircle(csu.user_id).UNKNOWN }}
</p>
</li>
</div> </div>
<li class="mr-6 flex flex-row items-center">
<it-icon-smiley-happy
class="mr-2 inline-block h-8 w-8"
></it-icon-smiley-happy>
<p class="text-bold inline-block w-6">
{{ userCountStatusForCircle(csu.user_id).SUCCESS }}
</p>
</li>
<li class="flex flex-row items-center">
<it-icon-smiley-neutral
class="mr-2 inline-block h-8 w-8"
></it-icon-smiley-neutral>
<p class="text-bold inline-block w-6">
{{ userCountStatusForCircle(csu.user_id).UNKNOWN }}
</p>
</li>
</div> </div>
</template> </div>
<template #link> </template>
<router-link <template #link>
:to="`/course/${props.courseSlug}/cockpit/profile/${csu.user_id}`" <router-link
class="link w-full lg:text-right" :to="`/course/${props.courseSlug}/cockpit/profile/${csu.user_id}`"
> class="link w-full lg:text-right"
{{ $t("general.profileLink") }} >
</router-link> {{ $t("general.profileLink") }}
</template> </router-link>
</ItPersonRow> </template>
</ul> </ItPersonRow>
</div> </ul>
</div> </div>
</template> </div>
<span v-else class="text-lg text-orange-600"> </div>
<div v-else class="container-large mt-4">
<span class="text-lg text-orange-600">
{{ $t("a.Kein Circle verfügbar oder ausgewählt.") }} {{ $t("a.Kein Circle verfügbar oder ausgewählt.") }}
</span> </span>
</div> </div>

View File

@ -0,0 +1,132 @@
<script setup lang="ts">
import { useCurrentCourseSession } from "@/composables";
import ItDropdownSelect from "@/components/ui/ItDropdownSelect.vue";
import { useCockpitStore } from "@/stores/cockpit";
import ItModal from "@/components/ui/ItModal.vue";
import DocumentUploadForm from "@/pages/cockpit/documentPage/DocumentUploadForm.vue";
import { computed, ref, watch } from "vue";
import { useCircleStore } from "@/stores/circle";
import { useTranslation } from "i18next-vue";
import type { CircleDocument, DocumentUploadData } from "@/types";
import dialog from "@/utils/confirm-dialog";
import log from "loglevel";
import { uploadCircleDocument } from "@/services/files";
import { useCourseSessionsStore } from "@/stores/courseSessions";
import DocumentListItem from "@/components/circle/DocumentListItem.vue";
const cockpitStore = useCockpitStore();
const courseSession = useCurrentCourseSession();
const courseSessionsStore = useCourseSessionsStore();
const circleStore = useCircleStore();
const { t } = useTranslation();
const showUploadModal = ref(false);
const showUploadErrorMessage = ref(false);
const isUploading = ref(false);
const dropdownLearningSequences = computed(() =>
circleStore.circle?.learningSequences.map((sequence) => ({
id: sequence.id,
name: sequence.title,
}))
);
const deleteDocument = async (doc: CircleDocument) => {
const options = {
title: t("circlePage.documents.deleteModalTitle"),
content: t("circlePage.documents.deleteModalWarning", { title: doc.name }),
};
try {
await dialog.confirm(options);
courseSessionsStore.removeDocument(doc.id);
} catch (e) {
log.debug("rejected");
}
};
watch(showUploadModal, () => (showUploadErrorMessage.value = false));
async function uploadDocument(data: DocumentUploadData) {
isUploading.value = true;
showUploadErrorMessage.value = false;
try {
if (!courseSessionsStore.currentCourseSession) {
throw new Error("No course session found");
}
const newDocument = await uploadCircleDocument(
data,
courseSessionsStore.currentCourseSession.id
);
courseSessionsStore.addDocument(newDocument);
showUploadModal.value = false;
isUploading.value = false;
} catch (error) {
log.error(error);
showUploadErrorMessage.value = true;
isUploading.value = false;
}
}
</script>
<template>
<div class="bg-gray-200">
<div v-if="courseSession" class="container-large">
<nav class="py-4 pb-4">
<router-link
class="btn-text inline-flex items-center pl-0"
:to="`/course/${courseSession.course.slug}/cockpit`"
>
<it-icon-arrow-left />
<span>{{ t("general.back") }}</span>
</router-link>
</nav>
<main>
<div class="mb-9 flex flex-col lg:flex-row lg:items-center lg:justify-between">
<h2>{{ t("a.Unterlagen für Teilnehmenden") }}</h2>
<ItDropdownSelect
:model-value="cockpitStore.selectedCircle"
class="mt-4 w-full lg:mt-0 lg:w-96"
:items="cockpitStore.circles"
@update:model-value="cockpitStore.setCurrentCourseCircleFromEvent"
></ItDropdownSelect>
</div>
<div class="bg-white p-6">
<button class="btn-primary text-xl" @click="showUploadModal = true">
{{ t("circlePage.documents.action") }}
</button>
<ul
v-if="courseSessionsStore.circleDocuments.length"
class="mt-8 border-t border-t-gray-500"
>
<template
v-for="learningSequence of courseSessionsStore.circleDocuments"
:key="learningSequence.id"
>
<DocumentListItem
v-for="doc of learningSequence.documents"
:key="doc.url"
:subtitle="learningSequence.title"
:can-delete="courseSessionsStore.canUploadCircleDocuments"
:doc="doc"
@delete="deleteDocument(doc)"
/>
</template>
</ul>
</div>
<ItModal v-model="showUploadModal">
<template #title>{{ t("circlePage.documents.action") }}</template>
<template #body>
<DocumentUploadForm
:learning-sequences="dropdownLearningSequences"
:show-upload-error-message="showUploadErrorMessage"
:is-uploading="isUploading"
@form-submit="uploadDocument"
/>
</template>
</ItModal>
</main>
</div>
</div>
</template>

View File

@ -5,12 +5,7 @@
</h3> </h3>
<div> <div>
<div class="mt-4 leading-relaxed"> <div class="mt-4 leading-relaxed">
<template v-if="!courseSessionsStore.canUploadCircleDocuments"> {{ $t("circlePage.documents.userDescription") }}
{{ $t("circlePage.documents.userDescription") }}
</template>
<template v-else>
{{ $t("circlePage.documents.expertDescription") }}
</template>
</div> </div>
</div> </div>
<ul <ul
@ -25,103 +20,16 @@
v-for="doc of learningSequence.documents" v-for="doc of learningSequence.documents"
:key="doc.url" :key="doc.url"
:subtitle="learningSequence.title" :subtitle="learningSequence.title"
:can-delete="courseSessionsStore.canUploadCircleDocuments"
:doc="doc" :doc="doc"
@delete="deleteDocument(doc)"
/> />
</template> </template>
</ul> </ul>
<div v-if="courseSessionsStore.canUploadCircleDocuments">
<button class="btn-primary mt-8 text-xl" @click="showUploadModal = true">
{{ $t("circlePage.documents.action") }}
</button>
</div>
</div>
<ItModal v-model="showUploadModal">
<template #title>{{ $t("circlePage.documents.action") }}</template>
<template #body>
<DocumentUploadForm
:learning-sequences="dropdownLearningSequences"
:show-upload-error-message="showUploadErrorMessage"
:is-uploading="isUploading"
@form-submit="uploadDocument"
/>
</template>
</ItModal>
<div
v-if="courseSessionsStore.canUploadCircleDocuments"
class="mt-8 flex flex-col gap-y-4 border p-6"
>
<h3 class="text-blue-dark">
{{ $t("circlePage.documents.trainerTitle") }}
</h3>
<div class="leading-relaxed">
{{ $t("circlePage.documents.trainerDescription") }}
</div>
<a target="_blank" class="link" :href="$t('circlePage.documents.trainerLinkSrc')">
{{ $t("circlePage.documents.trainerLinkText") }}
</a>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import ItModal from "@/components/ui/ItModal.vue";
import { uploadCircleDocument } from "@/services/files";
import { useCircleStore } from "@/stores/circle";
import { useCourseSessionsStore } from "@/stores/courseSessions"; import { useCourseSessionsStore } from "@/stores/courseSessions";
import type { CircleDocument, DocumentUploadData } from "@/types"; import DocumentListItem from "@/components/circle/DocumentListItem.vue";
import dialog from "@/utils/confirm-dialog";
import log from "loglevel";
import { computed, ref, watch } from "vue";
import DocumentListItem from "./DocumentListItem.vue";
import DocumentUploadForm from "./DocumentUploadForm.vue";
import { useTranslation } from "i18next-vue";
const courseSessionsStore = useCourseSessionsStore(); const courseSessionsStore = useCourseSessionsStore();
const circleStore = useCircleStore();
const showUploadModal = ref(false);
const showUploadErrorMessage = ref(false);
const isUploading = ref(false);
const dropdownLearningSequences = computed(() =>
circleStore.circle?.learningSequences.map((sequence) => ({
id: sequence.id,
name: sequence.title,
}))
);
// confirm dialog
const { t } = useTranslation();
const deleteDocument = async (doc: CircleDocument) => {
const options = {
title: t("circlePage.documents.deleteModalTitle"),
content: t("circlePage.documents.deleteModalWarning", { title: doc.name }),
};
try {
await dialog.confirm(options);
courseSessionsStore.removeDocument(doc.id);
} catch (e) {
log.debug("rejected");
}
};
watch(showUploadModal, () => (showUploadErrorMessage.value = false));
async function uploadDocument(data: DocumentUploadData) {
isUploading.value = true;
showUploadErrorMessage.value = false;
try {
if (!courseSessionsStore.currentCourseSession) {
throw new Error("No course session found");
}
const newDocument = await uploadCircleDocument(
data,
courseSessionsStore.currentCourseSession.id
);
courseSessionsStore.addDocument(newDocument);
showUploadModal.value = false;
isUploading.value = false;
} catch (error) {
log.error(error);
showUploadErrorMessage.value = true;
isUploading.value = false;
}
}
</script> </script>

View File

@ -17,7 +17,9 @@ export const redirectToLoginIfRequired: NavigationGuard = (to) => {
if (loginRequired(to) && !userStore.loggedIn) { if (loginRequired(to) && !userStore.loggedIn) {
const appEnv = import.meta.env.VITE_APP_ENVIRONMENT || "local"; const appEnv = import.meta.env.VITE_APP_ENVIRONMENT || "local";
const ssoLogin = appEnv.startsWith("prod") || appEnv.startsWith("stage"); const ssoLogin = appEnv.startsWith("prod") || appEnv.startsWith("stage");
return ssoLogin ? `/login?next=${to.fullPath}` : `/login-local?next=${to.fullPath}`; return ssoLogin
? `/login?next=${encodeURIComponent(to.fullPath)}`
: `/login-local?next=${encodeURIComponent(to.fullPath)}`;
} }
}; };
@ -46,15 +48,52 @@ export const expertRequired: NavigationGuard = (to: RouteLocationNormalized) =>
} }
}; };
export async function handleCourseSessions(to: RouteLocationNormalized) { export async function handleCurrentCourseSession(to: RouteLocationNormalized) {
// register after login hooks // register after login hooks
const courseSessionsStore = useCourseSessionsStore(); const userStore = useUserStore();
if (to.params.courseSlug) { if (userStore.loggedIn) {
courseSessionsStore._currentCourseSlug = to.params.courseSlug as string; const courseSessionsStore = useCourseSessionsStore();
} else { if (to.params.courseSlug) {
courseSessionsStore._currentCourseSlug = ""; courseSessionsStore._currentCourseSlug = to.params.courseSlug as string;
} } else {
if (!courseSessionsStore.loaded) { courseSessionsStore._currentCourseSlug = "";
await courseSessionsStore.loadCourseSessionsData(); }
if (!courseSessionsStore.loaded) {
await courseSessionsStore.loadCourseSessionsData();
}
}
}
export async function handleCourseSessionAsQueryParam(to: RouteLocationNormalized) {
/**
* switch to course session with id from query param `courseSessionId` if it
* is present and valid.
*/
// register after login hooks
const userStore = useUserStore();
if (userStore.loggedIn) {
const courseSessionsStore = useCourseSessionsStore();
if (!courseSessionsStore.loaded) {
await courseSessionsStore.loadCourseSessionsData();
}
if (to.query.courseSessionId) {
const { courseSessionId, ...restOfQuery } = to.query;
const switchSuccessful = courseSessionsStore.switchCourseSessionById(
courseSessionId.toString()
);
if (switchSuccessful) {
return {
path: to.path,
query: restOfQuery,
replace: true,
};
} else {
// courseSessionId is invalid for current user -> redirect to home
return {
path: "/",
};
}
}
} }
} }

View File

@ -1,7 +1,8 @@
import DashboardPage from "@/pages/DashboardPage.vue"; import DashboardPage from "@/pages/DashboardPage.vue";
import LoginPage from "@/pages/LoginPage.vue"; import LoginPage from "@/pages/LoginPage.vue";
import { import {
handleCourseSessions, handleCourseSessionAsQueryParam,
handleCurrentCourseSession,
redirectToLoginIfRequired, redirectToLoginIfRequired,
updateLoggedIn, updateLoggedIn,
} from "@/router/guards"; } from "@/router/guards";
@ -165,6 +166,11 @@ const router = createRouter({
import("@/pages/cockpit/attendanceCheckPage/AttendanceCheckPage.vue"), import("@/pages/cockpit/attendanceCheckPage/AttendanceCheckPage.vue"),
props: true, props: true,
}, },
{
path: "documents",
component: () => import("@/pages/cockpit/documentPage/DocumentPage.vue"),
props: true,
},
], ],
}, },
{ {
@ -183,6 +189,14 @@ const router = createRouter({
path: "/notifications", path: "/notifications",
component: () => import("@/pages/NotificationsPage.vue"), component: () => import("@/pages/NotificationsPage.vue"),
}, },
{
path: "/appointments",
component: () => import("@/pages/AppointmentsPage.vue"),
},
{
path: "/course/:courseSlug/appointments",
component: () => import("@/pages/AppointmentsPage.vue"),
},
{ {
path: "/styleguide", path: "/styleguide",
component: () => import("../pages/StyleGuidePage.vue"), component: () => import("../pages/StyleGuidePage.vue"),
@ -201,7 +215,8 @@ router.beforeEach(updateLoggedIn);
router.beforeEach(redirectToLoginIfRequired); router.beforeEach(redirectToLoginIfRequired);
// register after login hooks // register after login hooks
router.beforeEach(handleCourseSessions); router.beforeEach(handleCurrentCourseSession);
router.beforeEach(handleCourseSessionAsQueryParam);
router.beforeEach(addToHistory); router.beforeEach(addToHistory);

View File

@ -2,26 +2,18 @@ import { itGetCached } from "@/fetchHelpers";
import type { CourseSessionUser, ExpertSessionUser } from "@/types"; import type { CourseSessionUser, ExpertSessionUser } from "@/types";
import log from "loglevel"; import log from "loglevel";
import { useCircleStore } from "@/stores/circle";
import { useLearningPathStore } from "@/stores/learningPath"; import { useLearningPathStore } from "@/stores/learningPath";
import { useUserStore } from "@/stores/user"; import { useUserStore } from "@/stores/user";
import { defineStore } from "pinia"; import { defineStore } from "pinia";
export type CockpitStoreState = { export type CockpitStoreState = {
courseSessionMembers: CourseSessionUser[] | undefined; courseSessionMembers: CourseSessionUser[] | undefined;
selectedCircle:
| {
id: number;
name: string;
slug: string;
translation_key: string;
}
| undefined;
circles: { circles: {
id: number; id: string;
name: string; name: string;
slug: string;
translation_key: string;
}[]; }[];
currentCourseSlug: string | undefined;
}; };
export const useCockpitStore = defineStore({ export const useCockpitStore = defineStore({
@ -29,29 +21,38 @@ export const useCockpitStore = defineStore({
state: () => { state: () => {
return { return {
courseSessionMembers: undefined, courseSessionMembers: undefined,
selectedCircle: undefined,
circles: [], circles: [],
currentCourseSlug: undefined,
} as CockpitStoreState; } as CockpitStoreState;
}, },
actions: { actions: {
async loadCircles(courseSlug: string, courseSessionId: number) { async loadCircles(courseSlug: string, courseSessionId: number) {
log.debug("loadCircles called"); log.debug("loadCircles called", courseSlug, courseSessionId);
this.currentCourseSlug = courseSlug;
const f = await courseCircles(courseSlug, courseSessionId); const f = await courseCircles(this.currentCourseSlug, courseSessionId);
this.circles = f.map((c) => { this.circles = f.map((c) => {
return { return {
id: c.id, id: c.slug,
name: c.title, name: c.title,
slug: c.slug,
translation_key: c.translation_key,
}; };
}); });
if (this.circles.length > 0) { if (this.circles.length > 0) {
this.selectedCircle = this.circles[0]; await this.setCurrentCourseCircle(this.circles[0].id);
} }
}, },
async setCurrentCourseCircle(circleSlug: string) {
if (!this.currentCourseSlug) {
throw new Error("currentCourseSlug is undefined");
}
const circleStore = useCircleStore();
await circleStore.loadCircle(this.currentCourseSlug, circleSlug);
},
async setCurrentCourseCircleFromEvent(event: { id: string }) {
await this.setCurrentCourseCircle(event.id);
},
async loadCourseSessionMembers(courseSessionId: number, reload = false) { async loadCourseSessionMembers(courseSessionId: number, reload = false) {
log.debug("loadCourseSessionMembers called"); log.debug("loadCourseSessionMembers called");
const users = (await itGetCached( const users = (await itGetCached(
@ -65,6 +66,19 @@ export const useCockpitStore = defineStore({
return this.courseSessionMembers; return this.courseSessionMembers;
}, },
}, },
getters: {
currentCircle: () => {
const circleStore = useCircleStore();
return circleStore.circle;
},
selectedCircle: () => {
const circleStore = useCircleStore();
return {
id: circleStore.circle?.id || 0,
name: circleStore.circle?.title || "",
};
},
},
}); });
async function courseCircles(courseSlug: string, courseSessionId: number) { async function courseCircles(courseSlug: string, courseSessionId: number) {

View File

@ -92,13 +92,31 @@ export const useCourseSessionsStore = defineStore("courseSessions", () => {
return undefined; return undefined;
} }
function switchCourseSession(courseSession: CourseSession) { function _switchCourseSession(courseSession: CourseSession) {
log.debug("switchCourseSession", courseSession); log.debug("switchCourseSession", courseSession);
selectedCourseSessionMap.value.set(courseSession.course.slug, courseSession.id); selectedCourseSessionMap.value.set(courseSession.course.slug, courseSession.id);
// Emit event so that the App can re-render with the new courseSession // Emit event so that the App can re-render with the new courseSession
eventBus.emit("switchedCourseSession", courseSession.id); eventBus.emit("switchedCourseSession", courseSession.id);
} }
function getCourseSessionById(courseSessionId: number | string) {
return allCourseSessions.value.find((cs) => {
return courseSessionId.toString() === cs.id.toString();
});
}
function switchCourseSessionById(courseSessionId: number | string) {
const courseSession = allCourseSessions.value.find((cs) => {
return courseSessionId.toString() === cs.id.toString();
});
if (courseSession) {
_switchCourseSession(courseSession);
return true;
} else {
return false;
}
}
function courseSessionForCourse(courseSlug: string) { function courseSessionForCourse(courseSlug: string) {
if (courseSlug) { if (courseSlug) {
const courseSession = selectedCourseSessionForCourse(courseSlug); const courseSession = selectedCourseSessionForCourse(courseSlug);
@ -279,7 +297,8 @@ export const useCourseSessionsStore = defineStore("courseSessions", () => {
uniqueCourseSessionsByCourse, uniqueCourseSessionsByCourse,
allCurrentCourseSessions, allCurrentCourseSessions,
courseSessionForCourse, courseSessionForCourse,
switchCourseSession, getCourseSessionById,
switchCourseSessionById,
hasCockpit, hasCockpit,
hasCourseSessionPreview, hasCourseSessionPreview,
currentCourseSessionHasCockpit, currentCourseSessionHasCockpit,

View File

@ -641,6 +641,7 @@ export type DueDate = {
date_type_translation_key: string; date_type_translation_key: string;
subtitle: string; subtitle: string;
url: string; url: string;
url_expert: string;
course_session: number | null; course_session: number | null;
page: number | null; page: number | null;
circle: CircleLight | null; circle: CircleLight | null;

View File

@ -27,5 +27,17 @@ export function useRouteLookups() {
return regex.test(route.path); return regex.test(route.path);
} }
return { inMediaLibrary, inCockpit, inLearningPath, inCompetenceProfile, inCourse }; function inAppointments() {
const regex = new RegExp("/(?:[^/]+/)?appointments");
return regex.test(route.path);
}
return {
inMediaLibrary,
inCockpit,
inLearningPath,
inCompetenceProfile,
inCourse,
inAppointments: inAppointments,
};
} }

View File

@ -0,0 +1,41 @@
import {login} from "./helpers";
// constants
const COURSE_SELECT = "[data-cy=appointments-course-select]";
const SESSION_SELECT = "[data-cy=appointments-session-select]";
const CIRCLE_SELECT = "[data-cy=appointments-circle-select]";
const APPOINTMENTS = "[data-cy=appointments-list]";
describe("appointments.cy.js", () => {
beforeEach(() => {
cy.manageCommand("cypress_reset");
login("test-student2@example.com", "test");
cy.visit("/course/test-lehrgang/appointments");
});
it("preselects first course (Test Lehrgang)", () => {
cy.visit("/course/test-lehrgang/appointments");
cy.get(COURSE_SELECT).should("contain", "Test Lehrgang");
cy.get(SESSION_SELECT).should("contain", "Bern");
cy.get(CIRCLE_SELECT).should("contain", "Alle");
cy.get(".cy-single-due-date").should("have.length", 4);
});
it("can filter by circle", () => {
cy.get(CIRCLE_SELECT).click();
cy.get(CIRCLE_SELECT).contains("Fahrzeug").click();
// THEN
cy.get(APPOINTMENTS).should("not.contain", "Keine Termine");
});
it("can switch course session", () => {
cy.get(SESSION_SELECT).click();
cy.get(SESSION_SELECT).contains("Zürich").click();
cy.get(SESSION_SELECT).should("contain", "Zürich");
// THEN
cy.get(APPOINTMENTS).should("contain", "Keine Termine");
});
});

View File

@ -37,8 +37,9 @@ class CourseSessionUserAdmin(admin.ModelAdmin):
"user_first_name", "user_first_name",
"course_session", "course_session",
"role", "role",
"created_at", "circles",
"updated_at", # "created_at",
# "updated_at",
] ]
search_fields = [ search_fields = [
"user__first_name", "user__first_name",
@ -66,6 +67,9 @@ class CourseSessionUserAdmin(admin.ModelAdmin):
user_last_name.short_description = "Last Name" user_last_name.short_description = "Last Name"
user_last_name.admin_order_field = "user__last_name" user_last_name.admin_order_field = "user__last_name"
def circles(self, obj):
return ", ".join([c.title for c in obj.expert.all()])
fieldsets = [ fieldsets = [
(None, {"fields": ("user", "course_session", "role")}), (None, {"fields": ("user", "course_session", "role")}),
( (

View File

@ -63,7 +63,10 @@ class CourseSessionAttendanceCourse(models.Model):
) )
if not self.due_date.manual_override_fields: if not self.due_date.manual_override_fields:
self.due_date.url = self.learning_content.get_frontend_url() self.due_date.url = self.learning_content.get_frontend_url(
course_session_id=self.course_session.id
)
self.due_date.url_expert = f"/course/{self.due_date.course_session.course.slug}/cockpit/attendance?id={self.learning_content_id}&courseSessionId={self.course_session.id}"
self.due_date.title = self.learning_content.title self.due_date.title = self.learning_content.title
self.due_date.page = self.learning_content.page_ptr self.due_date.page = self.learning_content.page_ptr
self.due_date.assignment_type_translation_key = ( self.due_date.assignment_type_translation_key = (
@ -122,7 +125,9 @@ class CourseSessionAssignment(models.Model):
if self.learning_content_id: if self.learning_content_id:
title = self.learning_content.title title = self.learning_content.title
page = self.learning_content.page_ptr page = self.learning_content.page_ptr
url = self.learning_content.get_frontend_url() url = self.learning_content.get_frontend_url(
course_session_id=self.course_session.id
)
assignment_type = self.learning_content.assignment_type assignment_type = self.learning_content.assignment_type
assignment_type_translation_keys = { assignment_type_translation_keys = {
AssignmentType.CASEWORK.value: "learningContentTypes.casework", AssignmentType.CASEWORK.value: "learningContentTypes.casework",
@ -130,6 +135,8 @@ class CourseSessionAssignment(models.Model):
AssignmentType.REFLECTION.value: "learningContentTypes.reflection", AssignmentType.REFLECTION.value: "learningContentTypes.reflection",
} }
url_expert = f"/course/{self.course_session.course.slug}/cockpit/assignment/{self.learning_content_id}?courseSessionId={self.course_session.id}"
if assignment_type in ( if assignment_type in (
AssignmentType.CASEWORK.value, AssignmentType.CASEWORK.value,
AssignmentType.PREP_ASSIGNMENT.value, AssignmentType.PREP_ASSIGNMENT.value,
@ -141,6 +148,7 @@ class CourseSessionAssignment(models.Model):
if not self.submission_deadline.manual_override_fields: if not self.submission_deadline.manual_override_fields:
self.submission_deadline.url = url self.submission_deadline.url = url
self.submission_deadline.url_expert = url_expert
self.submission_deadline.title = title self.submission_deadline.title = title
self.submission_deadline.assignment_type_translation_key = ( self.submission_deadline.assignment_type_translation_key = (
assignment_type_translation_keys[assignment_type] assignment_type_translation_keys[assignment_type]
@ -160,6 +168,7 @@ class CourseSessionAssignment(models.Model):
if not self.evaluation_deadline.manual_override_fields: if not self.evaluation_deadline.manual_override_fields:
self.evaluation_deadline.url = url self.evaluation_deadline.url = url
self.evaluation_deadline.url_expert = url_expert
self.evaluation_deadline.title = title self.evaluation_deadline.title = title
self.evaluation_deadline.assignment_type_translation_key = ( self.evaluation_deadline.assignment_type_translation_key = (
assignment_type_translation_keys[assignment_type] assignment_type_translation_keys[assignment_type]
@ -207,7 +216,10 @@ class CourseSessionEdoniqTest(models.Model):
) )
if not self.deadline.manual_override_fields: if not self.deadline.manual_override_fields:
self.deadline.url = self.learning_content.get_frontend_url() self.deadline.url = self.learning_content.get_frontend_url(
course_session_id=self.course_session.id
)
self.deadline.url_expert = f"/course/{self.course_session.course.slug}/cockpit?courseSessionId={self.course_session.id}"
self.deadline.title = self.learning_content.title self.deadline.title = self.learning_content.title
self.deadline.page = self.learning_content.page_ptr self.deadline.page = self.learning_content.page_ptr
self.deadline.assignment_type_translation_key = ( self.deadline.assignment_type_translation_key = (

View File

@ -38,6 +38,7 @@ class DueDateAdmin(admin.ModelAdmin):
"assignment_type_translation_key", "assignment_type_translation_key",
"date_type_translation_key", "date_type_translation_key",
"url", "url",
"url_expert",
] ]
return default_readonly return default_readonly

View File

@ -0,0 +1,71 @@
# Generated by Django 3.2.20 on 2023-09-25 14:48
from django.db import migrations, models
def set_url_expert_course_session_assignments(apps):
# need to load concrete model, so that wagtail page has `specific` instance method...
from vbv_lernwelt.course_session.models import CourseSessionAssignment
for assignment in CourseSessionAssignment.objects.all():
# trigger save to update due_date foreign key fields
assignment.save()
def set_url_expert_course_session_edoniq_test(apps):
# need to load concrete model, so that wagtail page has `specific` instance method...
from vbv_lernwelt.course_session.models import CourseSessionEdoniqTest
for edoniq_test in CourseSessionEdoniqTest.objects.all():
# trigger save to update due_date foreign key fields
edoniq_test.save()
def set_url_expert_course_session_attendances(apps):
# need to load concrete model, so that wagtail page has `specific` instance method...
from vbv_lernwelt.course_session.models import CourseSessionAttendanceCourse
for attendance in CourseSessionAttendanceCourse.objects.all():
# trigger save to update due_date foreign key fields
attendance.save()
def set_url_expert_default(apps, schema_editor):
set_url_expert_course_session_assignments(apps)
set_url_expert_course_session_attendances(apps)
set_url_expert_course_session_edoniq_test(apps)
def reverse_func(apps, schema_editor):
# so we can reverse this migration, but noop
pass
class Migration(migrations.Migration):
dependencies = [
("duedate", "0004_alter_duedate_start"),
]
operations = [
migrations.AddField(
model_name="duedate",
name="url_expert",
field=models.CharField(
blank=True,
default="",
help_text="URL wird aus dem LearningContent generiert (sichtbar für den Experten/Trainer)",
max_length=1024,
),
),
migrations.AlterField(
model_name="duedate",
name="url",
field=models.CharField(
blank=True,
default="",
help_text="URL wird vom LearningContent übernommen (sichtbar für Member/Teilnehmer)",
max_length=1024,
),
),
migrations.RunPython(set_url_expert_default, reverse_func),
]

View File

@ -45,7 +45,13 @@ class DueDate(models.Model):
default="", default="",
blank=True, blank=True,
max_length=1024, max_length=1024,
help_text="URL wird vom LearningContent übernommen", help_text="URL wird vom LearningContent übernommen (sichtbar für Member/Teilnehmer)",
)
url_expert = models.CharField(
default="",
blank=True,
max_length=1024,
help_text="URL wird aus dem LearningContent generiert (sichtbar für den Experten/Trainer)",
) )
course_session = models.ForeignKey( course_session = models.ForeignKey(
"course.CourseSession", "course.CourseSession",

View File

@ -17,6 +17,7 @@ class DueDateSerializer(serializers.ModelSerializer):
"date_type_translation_key", "date_type_translation_key",
"subtitle", "subtitle",
"url", "url",
"url_expert",
"course_session", "course_session",
"page", "page",
"circle", "circle",
@ -24,6 +25,7 @@ class DueDateSerializer(serializers.ModelSerializer):
def get_circle(self, obj): def get_circle(self, obj):
circle = obj.get_circle() circle = obj.get_circle()
if circle: if circle:
return { return {
"id": circle.id, "id": circle.id,

View File

@ -202,6 +202,7 @@ TRANSLATIONS = {
} }
T2L_IGNORE_FIELDS = ["Vorname", "Name", "Email", "Sprache", "Durchführungen"] T2L_IGNORE_FIELDS = ["Vorname", "Name", "Email", "Sprache", "Durchführungen"]
EDONIQ_TEST_PERIOD = 14
class DataImportError(Exception): class DataImportError(Exception):
@ -487,7 +488,9 @@ def create_or_update_course_session_edoniq_test(
# trigger save to update due date # trigger save to update due date
cset.save() cset.save()
cset.deadline.start = timezone.make_aware(start) + timezone.timedelta(days=10) cset.deadline.start = timezone.make_aware(start) + timezone.timedelta(
days=EDONIQ_TEST_PERIOD
)
cset.deadline.end = None cset.deadline.end = None
cset.deadline.save() cset.deadline.save()

View File

@ -309,7 +309,7 @@ class CreateOrUpdateEdoniqTestCase(TestCase):
self._create_or_update_edonqi_test("2023-06-06T11:30:00+00:00") self._create_or_update_edonqi_test("2023-06-06T11:30:00+00:00")
test = CourseSessionEdoniqTest.objects.first() test = CourseSessionEdoniqTest.objects.first()
self.assertEqual(test.deadline.start.isoformat(), "2023-06-16T11:30:00+00:00") self.assertEqual(test.deadline.start.isoformat(), "2023-06-20T11:30:00+00:00")
def test_update_course_session(self): def test_update_course_session(self):
self._create_or_update_edonqi_test("2023-06-06T11:30:00+00:00") self._create_or_update_edonqi_test("2023-06-06T11:30:00+00:00")
@ -318,4 +318,4 @@ class CreateOrUpdateEdoniqTestCase(TestCase):
self.assertEqual(duedate_count, DueDate.objects.count()) self.assertEqual(duedate_count, DueDate.objects.count())
test = CourseSessionEdoniqTest.objects.first() test = CourseSessionEdoniqTest.objects.first()
self.assertEqual(test.deadline.start.isoformat(), "2023-07-16T11:30:00+00:00") self.assertEqual(test.deadline.start.isoformat(), "2023-07-20T11:30:00+00:00")

View File

@ -268,14 +268,19 @@ class LearningContent(CourseBasePage):
<span style="margin-left: 8px;">{self.get_admin_display_title()}</span> <span style="margin-left: 8px;">{self.get_admin_display_title()}</span>
</span>""" </span>"""
def get_frontend_url(self): def get_frontend_url(self, course_session_id=None):
r = re.compile( r = re.compile(
r"^(?P<coursePart>.+?)-lp-circle-(?P<circlePart>.+?)-lc-(?P<lcPart>.+)$" r"^(?P<coursePart>.+?)-lp-circle-(?P<circlePart>.+?)-lc-(?P<lcPart>.+)$"
) )
m = r.match(self.slug) m = r.match(self.slug)
if m is None: if m is None:
return "ERROR: could not parse slug" return "ERROR: could not parse slug"
return f"/course/{m.group('coursePart')}/learn/{m.group('circlePart')}/{m.group('lcPart')}" url = f"/course/{m.group('coursePart')}/learn/{m.group('circlePart')}/{m.group('lcPart')}"
if course_session_id:
url += f"?courseSessionId={course_session_id}"
return url
def get_parent_circle(self): def get_parent_circle(self):
try: try: