Merged in feature/VBV-524 (pull request #213)
Feature/VBV-524: Alle Termine Approved-by: Daniel Egger
This commit is contained in:
commit
65ad6fbfdd
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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: "/",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -188,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"),
|
||||||
|
|
@ -206,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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -91,13 +91,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);
|
||||||
|
|
@ -268,7 +286,8 @@ export const useCourseSessionsStore = defineStore("courseSessions", () => {
|
||||||
uniqueCourseSessionsByCourse,
|
uniqueCourseSessionsByCourse,
|
||||||
allCurrentCourseSessions,
|
allCurrentCourseSessions,
|
||||||
courseSessionForCourse,
|
courseSessionForCourse,
|
||||||
switchCourseSession,
|
getCourseSessionById,
|
||||||
|
switchCourseSessionById,
|
||||||
hasCockpit,
|
hasCockpit,
|
||||||
hasCourseSessionPreview,
|
hasCourseSessionPreview,
|
||||||
currentCourseSessionHasCockpit,
|
currentCourseSessionHasCockpit,
|
||||||
|
|
|
||||||
|
|
@ -630,6 +630,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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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")}),
|
||||||
(
|
(
|
||||||
|
|
|
||||||
|
|
@ -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 = (
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
]
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue