Merged in feature/VBV-524 (pull request #213)

Feature/VBV-524: Alle Termine

Approved-by: Daniel Egger
This commit is contained in:
Livio Bieri 2023-10-05 07:15:54 +00:00 committed by Daniel Egger
commit 65ad6fbfdd
21 changed files with 536 additions and 57 deletions

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

@ -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";
@ -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);

View File

@ -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,

View File

@ -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;

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

@ -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: