Merge branch 'develop'

This commit is contained in:
Christian Cueni 2023-02-13 09:35:04 +01:00
commit 442e04dfe3
104 changed files with 2044 additions and 312 deletions

View File

@ -19,6 +19,7 @@ e2e: &e2e
- source ./env/bitbucket/prepare_for_test.sh
- npm run build
- source vbvvenv/bin/activate
- pip install -r server/requirements/requirements-dev.txt
- ./prepare_server_cypress.sh --start-background
- npm run cypress:ci
artifacts:
@ -28,7 +29,7 @@ e2e: &e2e
pipelines:
default:
- step:
name: install cypress dependencies
name: install dependencies
services:
- postgres
caches:
@ -75,7 +76,7 @@ pipelines:
- python -m venv vbvvenv
- source vbvvenv/bin/activate
- pip install -r server/requirements/requirements-dev.txt
- git-crypt status -e | sort > git-crypt-encrypted-files-check.txt && diff git-crypt-encrypted-files.txt git-crypt-encrypted-files-check.txt
- git-crypt status -e | sort > git-crypt-encrypted-files-check.txt && diff -w git-crypt-encrypted-files.txt git-crypt-encrypted-files-check.txt
- trufflehog --exclude_paths trufflehog-exclude-patterns.txt --allow trufflehog-allow.json --entropy=True --max_depth=100 .
- ufmt check server
- step:

View File

@ -14,6 +14,7 @@
"@sentry/vue": "^7.20.0",
"@urql/vue": "^1.0.2",
"d3": "^7.6.1",
"dayjs": "^1.11.7",
"graphql": "^16.6.0",
"lodash": "^4.17.21",
"loglevel": "^1.8.0",
@ -5467,6 +5468,11 @@
"integrity": "sha512-qTcEYLen3r7ojZNgVUaRggOI+KM7jrKxXeSHhogh/TWxYMeONEMqY+hmkobiYQozsGIyg9OYVzO4ZIfoB4I0pQ==",
"dev": true
},
"node_modules/dayjs": {
"version": "1.11.7",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.7.tgz",
"integrity": "sha512-+Yw9U6YO5TQohxLcIkrXBeY73WP3ejHWVvx8XCk3gxvQDCTEmS48ZrSZCKciI7Bhl/uCMyxYtE9UqRILmFphkQ=="
},
"node_modules/de-indent": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
@ -15934,6 +15940,11 @@
"integrity": "sha512-qTcEYLen3r7ojZNgVUaRggOI+KM7jrKxXeSHhogh/TWxYMeONEMqY+hmkobiYQozsGIyg9OYVzO4ZIfoB4I0pQ==",
"dev": true
},
"dayjs": {
"version": "1.11.7",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.7.tgz",
"integrity": "sha512-+Yw9U6YO5TQohxLcIkrXBeY73WP3ejHWVvx8XCk3gxvQDCTEmS48ZrSZCKciI7Bhl/uCMyxYtE9UqRILmFphkQ=="
},
"de-indent": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",

View File

@ -21,6 +21,7 @@
"@sentry/vue": "^7.20.0",
"@urql/vue": "^1.0.2",
"d3": "^7.6.1",
"dayjs": "^1.11.7",
"graphql": "^16.6.0",
"lodash": "^4.17.21",
"loglevel": "^1.8.0",

View File

@ -1,13 +1,15 @@
<script setup lang="ts">
import { loadLocaleMessages, setI18nLanguage } from "@/i18n";
import type { availableLanguages } from "@/stores/user";
import { useUserStore } from "@/stores/user";
import * as log from "loglevel";
log.debug("AppFooter created");
const userStore = useUserStore();
async function changeLocale(event: Event) {
const target = event.target as HTMLSelectElement;
await loadLocaleMessages(target.value);
setI18nLanguage(target.value);
userStore.setUserLanguages(target.value as availableLanguages);
}
</script>
@ -22,9 +24,16 @@ async function changeLocale(event: Event) {
<div class="lg:ml-8">Deutsch</div>
<!--div class="locale-changer">
<select @change="changeLocale($event)">
<option v-for="locale in ['de', 'fr']" :key="`locale-${locale}`" :value="locale">{{ locale }}</option>
<option
v-for="locale in ['de', 'fr']"
:key="`locale-${locale}`"
:value="locale"
:selected="locale === userStore.language"
>
{{ locale }}
</option>
</select>
</div-->
</div -->
<div class="lg:ml-8">{{ $t("footer.contact") }}</div>
</footer>
</template>

View File

@ -157,12 +157,9 @@ const MAX_STEPS = 12;
const sendFeedbackMutation = graphql(`
mutation SendFeedbackMutation($input: SendFeedbackInput!) {
sendFeedback(input: $input) {
id
satisfaction
goalAttainment
proficiency
receivedMaterials
materialsRating
feedbackResponse {
id
}
errors {
field
messages
@ -205,17 +202,19 @@ const sendFeedback = () => {
return;
}
const input: SendFeedbackInput = reactive({
materialsRating,
courseNegativeFeedback,
coursePositiveFeedback,
goalAttainment,
instructorCompetence,
instructorRespect,
instructorOpenFeedback,
satisfaction,
proficiency,
receivedMaterials,
wouldRecommend,
data: {
materials_rating: materialsRating,
course_negative_feedback: courseNegativeFeedback,
course_positive_feedback: coursePositiveFeedback,
goald_attainment: goalAttainment,
instructor_competence: instructorCompetence,
instructor_respect: instructorRespect,
instructor_open_feedback: instructorOpenFeedback,
satisfaction,
proficiency,
received_materials: receivedMaterials,
would_recommend: wouldRecommend,
},
page: props.page.translation_key,
courseSession: courseSession.id,
});

View File

@ -4,9 +4,12 @@ import log from "loglevel";
import IconLogout from "@/components/icons/IconLogout.vue";
import IconSettings from "@/components/icons/IconSettings.vue";
import MobileMenu from "@/components/MobileMenu.vue";
import NotificationPopover from "@/components/notifications/NotificationPopover.vue";
import NotificationPopoverContent from "@/components/notifications/NotificationPopoverContent.vue";
import ItDropdown from "@/components/ui/ItDropdown.vue";
import { useAppStore } from "@/stores/app";
import { useCourseSessionsStore } from "@/stores/courseSessions";
import { useNotificationsStore } from "@/stores/notifications";
import { useUserStore } from "@/stores/user";
import type { DropdownListItem } from "@/types";
import type { Component } from "vue";
@ -14,7 +17,7 @@ import { onMounted, reactive } from "vue";
import { useI18n } from "vue-i18n";
import { useRoute, useRouter } from "vue-router";
type DropdownActions = "logout" | "settings";
type DropdownActions = "logout" | "settings" | "profile";
interface DropdownData {
action: DropdownActions;
@ -27,6 +30,7 @@ const router = useRouter();
const userStore = useUserStore();
const appStore = useAppStore();
const courseSessionsStore = useCourseSessionsStore();
const notificationsStore = useNotificationsStore();
const { t } = useI18n();
const state = reactive({ showMenu: false });
@ -60,9 +64,12 @@ function inMediaLibrary() {
function handleDropdownSelect(data: DropdownData) {
switch (data.action) {
case "settings":
case "profile":
router.push("/profile");
break;
case "settings":
router.push("/settings");
break;
case "logout":
userStore.handleLogout();
break;
@ -85,7 +92,14 @@ onMounted(() => {
const profileDropdownData: DropdownListItem[] = [
{
title: t("mainNavigation.settings"),
title: t("mainNavigation.profile"),
icon: IconSettings as Component,
data: {
action: "profile",
},
},
{
title: t("general.settings"),
icon: IconSettings as Component,
data: {
action: "settings",
@ -127,14 +141,23 @@ const profileDropdownData: DropdownListItem[] = [
</div>
<div class="flex items-center lg:hidden">
<router-link
v-if="userStore.loggedIn"
to="/messages"
class="nav-item flex flex-row items-center"
data-cy="messages-link"
>
<it-icon-bell class="mr-6 h-6 w-6" />
</router-link>
<div v-if="userStore.loggedIn" class="mr-6 flex flex-row items-center">
<NotificationPopover>
<template #toggleButtonContent>
<div class="nav-item flex">
<it-icon-notification class="h-6 w-6" />
<div
v-if="notificationsStore.hasUnread"
aria-label="unread notifications"
class="mt-1 h-1.5 w-1.5 rounded-full bg-sky-500"
/>
</div>
</template>
<template #popoverContent>
<NotificationPopoverContent />
</template>
</NotificationPopover>
</div>
<router-link
v-if="userStore.loggedIn"
to="/messages"
@ -218,13 +241,23 @@ const profileDropdownData: DropdownListItem[] = [
>
{{ $t("mediaLibrary.title") }}
</router-link>
<router-link
to="/messages"
class="nav-item flex flex-row items-center"
data-cy="messages-link"
>
<it-icon-bell class="mr-6 h-6 w-6" />
</router-link>
<div v-if="userStore.loggedIn" class="mr-6 flex items-center">
<NotificationPopover>
<template #toggleButtonContent>
<div class="nav-item flex">
<it-icon-notification class="h-6 w-6" />
<div
v-if="notificationsStore.hasUnread"
aria-label="unread notifications"
class="mt-1 h-1.5 w-1.5 rounded-full bg-sky-500"
/>
</div>
</template>
<template #popoverContent>
<NotificationPopoverContent />
</template>
</NotificationPopover>
</div>
<router-link
to="/messages"
class="nav-item flex flex-row items-center"

View File

@ -43,10 +43,10 @@ const clickLink = (to: string | undefined) => {
<h3>{{ userStore.first_name }} {{ userStore.last_name }}</h3>
<button
class="mt-2 inline-block flex items-center"
@click="clickLink('/profile')"
@click="clickLink('/settings')"
>
<IconSettings class="inline-block" />
<span class="ml-3">{{ $t("mainNavigation.settings") }}</span>
<span class="ml-3">{{ $t("general.settings") }}</span>
</button>
</div>
</div>

View File

@ -0,0 +1,135 @@
<script setup lang="ts">
import * as d3 from "d3";
import * as log from "loglevel";
import { computed, onMounted } from "vue";
// @ts-ignore
import colors from "@/colors.json";
export type CircleSectorProgress = "none" | "in_progress" | "finished";
export interface CircleSectorData {
progress: CircleSectorProgress;
}
const props = defineProps<{
sectors: CircleSectorData[];
}>();
onMounted(async () => {
log.debug("LearningPathCircle mounted");
render();
});
interface CircleSector extends d3.PieArcDatum<number> {
arrowStartAngle: number;
arrowEndAngle: number;
progress: CircleSectorProgress;
}
const pieData = computed(() => {
const pieWeights = new Array(Math.max(props.sectors.length, 1)).fill(1);
const pieGenerator = d3.pie();
const angles = pieGenerator(pieWeights);
return angles
.map((angle) => {
// Rotate the circle by PI (180 degrees) normally 0 = 12'o clock, now start at 6 o clock
angle.startAngle += Math.PI;
angle.endAngle += Math.PI;
return Object.assign(
{
startAngle: angle.startAngle,
endAngle: angle.endAngle,
arrowStartAngle: angle.endAngle + (angle.startAngle - angle.endAngle) / 2,
arrowEndAngle: angle.startAngle + (angle.startAngle - angle.endAngle) / 2,
progress: props.sectors[angle.index].progress,
},
angle
);
})
.reverse() as CircleSector[];
});
const width = 450;
const height = 450;
const radius = Math.min(width, height) / 2.4;
function getColor(sector: CircleSector) {
let color = colors.gray[300];
if ("in_progress" === sector.progress) {
color = colors.sky[500];
}
if ("finished" === sector.progress) {
color = colors.green[500];
}
return color;
}
function render() {
const svg = d3.select(".circle-visualization");
// Clean svg before adding new stuff.
svg.selectAll("*").remove();
if (pieData.value) {
const arrowStrokeWidth = 2;
// Append marker as definition to the svg
svg
.attr("viewBox", `0 0 ${width} ${height}`)
.append("svg:defs")
.append("svg:marker")
.attr("id", "triangle")
.attr("refX", 11)
.attr("refY", 11)
.attr("markerWidth", 20)
.attr("markerHeight", 20)
.attr("markerUnits", "userSpaceOnUse")
.attr("orient", "auto")
.append("path")
.attr("d", "M -1 0 l 10 0 M 0 -1 l 0 10")
.attr("transform", "rotate(-90, 10, 0)")
.attr("stroke-width", arrowStrokeWidth)
.attr("stroke", colors.gray[500]);
const g = svg
.append("g")
.attr("transform", "translate(" + width / 2 + "," + height / 2 + ")");
// Generate the pie diagram wedge
const wedgeGenerator = d3
.arc()
.innerRadius(radius / 2.5)
.padAngle(12 / 360)
.outerRadius(radius);
const learningSequences = g
.selectAll(".learningSegmentArc")
.data(pieData.value)
.enter()
.append("g")
.attr("class", "learningSegmentArc");
learningSequences
.transition()
.duration(1)
.attr("fill", (d) => {
return getColor(d);
});
// @ts-ignore
learningSequences.append("path").attr("d", wedgeGenerator);
}
return svg;
}
</script>
<template>
<div class="svg-container h-full content-center">
<pre hidden>{{ pieData }}</pre>
<pre hidden>{{ render() }}</pre>
<svg class="circle-visualization h-full">
<circle :cx="width / 2" :cy="height / 2" :r="radius" :color="colors.gray[300]" />
<circle :cx="width / 2" :cy="height / 2" :r="radius / 2.5" color="white" />
</svg>
</div>
</template>

View File

@ -126,7 +126,10 @@ const learningSequenceBorderClass = computed(() => {
</div>
<ItCheckbox
v-else
:checked="learningContent.completion_status === 'success'"
:checkbox_item="{
value: learningContent.completion_status,
checked: learningContent.completion_status === 'success',
}"
:data-cy="`${learningContent.slug}-checkbox`"
@toggle="toggleCompleted(learningContent)"
/>

View File

@ -0,0 +1,98 @@
<script setup lang="ts">
import router from "@/router";
import { useNotificationsStore } from "@/stores/notifications";
import type { Notification } from "@/types";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { onMounted, ref, watch } from "vue";
dayjs.extend(relativeTime);
const notificationsStore = useNotificationsStore();
const props = defineProps<{
numNotificationsToShow: number;
}>();
const state = ref({ notifications: [] as Notification[] });
async function loadNotifications() {
state.value.notifications = await notificationsStore.loadNotifications(
props.numNotificationsToShow
);
}
onMounted(async () => {
await loadNotifications();
});
watch(
() => props.numNotificationsToShow,
async () => {
await loadNotifications();
}
);
function onNotificationClick(notification: Notification) {
if (notification.target_url) {
router.push(notification.target_url);
}
}
</script>
<template>
<div
v-if="0 === state.notifications.length"
class="mt-14 mb-14 text-center text-black"
data-cy="no-notifications"
>
{{ $t("notifications.no_notifications") }}
</div>
<li
v-for="(notification, index) in state.notifications"
:key="notification.id"
:data-cy="`notification-idx-${index}`"
class="flex flex-row justify-between border-b border-gray-500 py-4 leading-[45px] last:border-0"
>
<div class="flex flex-row">
<img
v-if="notification.notification_type === 'USER_INTERACTION'"
alt="Notification icon"
class="mr-2 h-[45px] min-w-[45px] rounded-full"
:src="notification.actor_avatar_url ?? undefined"
/>
<it-icon-vbv
v-else
class="it-icon mr-2 h-[45px] min-w-[45px] rounded-full bg-blue-900"
/>
<button
:to="notification.target_url"
class="mr-2 flex flex-col lg:mr-10"
:disabled="null === notification.target_url"
:data-cy="`notification-target-idx-${index}`"
@click="() => onNotificationClick(notification)"
>
<span
class="text-left text-sm leading-6 text-black lg:text-base"
style="hyphens: none"
>
{{ notification.verb }}
</span>
<span class="flex flex-wrap text-sm leading-6 text-gray-500 lg:text-base">
<span v-if="notification.course">{{ notification.course }} -&nbsp;</span>
<span>
{{ dayjs(notification.timestamp).fromNow() }}
</span>
</span>
</button>
</div>
<div v-if="notification.unread" class="leading-[45px]">
<div class="flex h-[45px] flex-row items-center pl-3" data-cy="unread">
<div class="h-[10px] w-[10px] rounded-full bg-blue-500" />
</div>
</div>
</li>
</template>
<style scoped></style>

View File

@ -0,0 +1,22 @@
<script setup lang="ts">
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/vue";
</script>
<template>
<Popover>
<PopoverButton data-cy="notification-bell-button">
<slot name="toggleButtonContent"></slot>
</PopoverButton>
<PopoverPanel>
<div
class="absolute right-0 mt-2 bg-white px-4 py-4 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none lg:right-2"
>
<!-- To close the popover withing your content, use the 'PopoverButton'
https://headlessui.com/vue/popover#closing-popovers-manually
-->
<slot name="popoverContent" />
</div>
</PopoverPanel>
</Popover>
</template>

View File

@ -0,0 +1,20 @@
<script setup lang="ts">
import NotificationList from "@/components/notifications/NotificationList.vue";
import { PopoverButton } from "@headlessui/vue";
</script>
<template>
<div class="pb-2 text-lg text-black">{{ $t("general.notification") }}</div>
<div class="border-t bg-white">
<NotificationList :num-notifications-to-show="4" />
<router-link to="/notifications">
<PopoverButton
class="btn-text inline-flex inline-flex items-center text-blue-900"
data-cy="show-all-notifications"
>
<span>{{ $t("general.showAll") }}</span>
<it-icon-arrow-right />
</PopoverButton>
</router-link>
</div>
</template>

View File

@ -1,17 +1,11 @@
<script setup lang="ts">
import type { CheckboxItem } from "@/components/ui/checkbox.types";
import log from "loglevel";
interface Props {
checked?: boolean;
const props = defineProps<{
checkbox_item: CheckboxItem<any>;
disabled?: boolean;
label?: string;
}
const props = withDefaults(defineProps<Props>(), {
checked: false,
disabled: false,
label: undefined,
});
}>();
const emit = defineEmits(["toggle"]);
const toggle = () => {
@ -42,7 +36,7 @@ const input = (e: Event) => {
<label
class="cy-checkbox cy-checkbox-checked block flex h-8 items-center bg-contain bg-no-repeat pl-8 disabled:opacity-50"
:class="
checked
checkbox_item.checked
? 'bg-[url(/static/icons/icon-checkbox-checked.svg)] hover:bg-[url(/static/icons/icon-checkbox-checked-hover.svg)]'
: 'bg-[url(/static/icons/icon-checkbox-unchecked.svg)] hover:bg-[url(/static/icons/icon-checkbox-unchecked-hover.svg)]'
"
@ -51,18 +45,23 @@ const input = (e: Event) => {
>
<input
ref="checkbox"
:checked="checked"
:value="checked"
:checked="checkbox_item.checked"
:value="checkbox_item.value"
:disabled="disabled"
:data-cy="`it-checkbox-${checkbox_item.value}`"
class="sr-only"
type="checkbox"
@keydown="keydown"
@input="input"
/>
<span v-if="label" class="ml-4">
{{ label }}
</span>
<div class="ml-4 flex-col">
<div v-if="checkbox_item.label">
{{ checkbox_item.label }}
</div>
<div v-if="checkbox_item.subtitle" class="text-gray-900">
{{ checkbox_item.subtitle }}
</div>
</div>
</label>
</div>
</template>

View File

@ -1,29 +1,33 @@
<template>
<div
:model-value="modelValue"
@update:modelValue="$emit('update:modelValue', $event)"
>
<h2 class="mb-12 block text-5xl font-bold leading-normal">{{ label }}</h2>
<div class="align-items-start flex flex-col justify-start justify-items-start">
<ItCheckbox
v-for="item in items"
:key="item.value"
:label="item.name"
class="mb-4"
/>
</div>
</div>
</template>
<script setup lang="ts">
import type { RadioItem } from "@/components/learningPath/feedback.types";
import type { CheckboxItem } from "@/components/ui/checkbox.types";
import ItCheckbox from "@/components/ui/ItCheckbox.vue";
defineProps<{
modelValue: any;
items: RadioItem<any>[];
label: string;
const props = defineProps<{
items: CheckboxItem<any>[];
label: string | undefined;
}>();
defineEmits(["update:modelValue"]);
const emit = defineEmits(["update:items"]);
function updateItems(itemValue: string) {
const items = props.items.map((item) => {
if (item.value === itemValue) {
item.checked = !item.checked;
}
return item;
});
emit("update:items", items);
}
</script>
<template>
<h3 class="mb-6 block font-bold leading-normal">{{ label }}</h3>
<div class="flex flex-col">
<ItCheckbox
v-for="item in items"
:key="item.value"
:checkbox_item="item"
:class="item.subtitle ? 'mb-6' : 'mb-4'"
@toggle="updateItems(item.value)"
/>
</div>
</template>

View File

@ -0,0 +1,6 @@
export interface CheckboxItem<T> {
value: T;
label?: string;
checked: boolean;
subtitle?: string;
}

View File

@ -3,13 +3,13 @@ import type { TypedDocumentNode as DocumentNode } from "@graphql-typed-document-
import * as types from "./graphql";
const documents = {
"\n mutation SendFeedbackMutation($input: SendFeedbackInput!) {\n sendFeedback(input: $input) {\n id\n satisfaction\n goalAttainment\n proficiency\n receivedMaterials\n materialsRating\n errors {\n field\n messages\n }\n }\n }\n":
"\n mutation SendFeedbackMutation($input: SendFeedbackInput!) {\n sendFeedback(input: $input) {\n feedbackResponse {\n id\n }\n errors {\n field\n messages\n }\n }\n }\n":
types.SendFeedbackMutationDocument,
};
export function graphql(
source: "\n mutation SendFeedbackMutation($input: SendFeedbackInput!) {\n sendFeedback(input: $input) {\n id\n satisfaction\n goalAttainment\n proficiency\n receivedMaterials\n materialsRating\n errors {\n field\n messages\n }\n }\n }\n"
): typeof documents["\n mutation SendFeedbackMutation($input: SendFeedbackInput!) {\n sendFeedback(input: $input) {\n id\n satisfaction\n goalAttainment\n proficiency\n receivedMaterials\n materialsRating\n errors {\n field\n messages\n }\n }\n }\n"];
source: "\n mutation SendFeedbackMutation($input: SendFeedbackInput!) {\n sendFeedback(input: $input) {\n feedbackResponse {\n id\n }\n errors {\n field\n messages\n }\n }\n }\n"
): typeof documents["\n mutation SendFeedbackMutation($input: SendFeedbackInput!) {\n sendFeedback(input: $input) {\n feedbackResponse {\n id\n }\n errors {\n field\n messages\n }\n }\n }\n"];
export function graphql(source: string): unknown;
export function graphql(source: string) {

View File

@ -17,6 +17,7 @@ export type Scalars = {
Int: number;
Float: number;
DateTime: any;
GenericScalar: any;
JSONString: any;
PositiveInt: any;
UUID: any;
@ -152,6 +153,11 @@ export type CircleSiblingsArgs = {
searchQuery?: InputMaybe<Scalars["String"]>;
};
export type CircleDocument = {
__typename?: "CircleDocument";
id?: Maybe<Scalars["ID"]>;
};
export type CollectionObjectType = {
__typename?: "CollectionObjectType";
ancestors: Array<Maybe<CollectionObjectType>>;
@ -520,6 +526,14 @@ export type ErrorType = {
messages: Array<Scalars["String"]>;
};
export type FeedbackResponse = Node & {
__typename?: "FeedbackResponse";
circle: Circle;
courseSession: CourseSession;
data?: Maybe<Scalars["GenericScalar"]>;
id: Scalars["ID"];
};
export type FloatBlock = StreamFieldInterface & {
__typename?: "FloatBlock";
blockType: Scalars["String"];
@ -1170,6 +1184,10 @@ export type MutationSendFeedbackArgs = {
input: SendFeedbackInput;
};
export type Node = {
id: Scalars["ID"];
};
export type Page = PageInterface & {
__typename?: "Page";
aliasOf?: Maybe<Page>;
@ -1581,6 +1599,7 @@ export type RichTextBlock = StreamFieldInterface & {
export type Search =
| Circle
| CircleDocument
| CompetencePage
| CompetenceProfilePage
| Course
@ -1609,37 +1628,16 @@ export type SecurityRequestResponseLog = {
export type SendFeedbackInput = {
clientMutationId?: InputMaybe<Scalars["String"]>;
courseNegativeFeedback?: InputMaybe<Scalars["String"]>;
coursePositiveFeedback?: InputMaybe<Scalars["String"]>;
goalAttainment?: InputMaybe<Scalars["Int"]>;
id?: InputMaybe<Scalars["Int"]>;
instructorCompetence?: InputMaybe<Scalars["Int"]>;
instructorOpenFeedback?: InputMaybe<Scalars["String"]>;
instructorRespect?: InputMaybe<Scalars["Int"]>;
materialsRating?: InputMaybe<Scalars["Int"]>;
courseSession: Scalars["Int"];
data?: InputMaybe<Scalars["GenericScalar"]>;
page: Scalars["String"];
proficiency?: InputMaybe<Scalars["Int"]>;
receivedMaterials?: InputMaybe<Scalars["Boolean"]>;
satisfaction?: InputMaybe<Scalars["Int"]>;
wouldRecommend?: InputMaybe<Scalars["Boolean"]>;
};
export type SendFeedbackPayload = {
__typename?: "SendFeedbackPayload";
clientMutationId?: Maybe<Scalars["String"]>;
courseNegativeFeedback?: Maybe<Scalars["String"]>;
coursePositiveFeedback?: Maybe<Scalars["String"]>;
errors?: Maybe<Array<Maybe<ErrorType>>>;
goalAttainment?: Maybe<Scalars["Int"]>;
id?: Maybe<Scalars["Int"]>;
instructorCompetence?: Maybe<Scalars["Int"]>;
instructorOpenFeedback?: Maybe<Scalars["String"]>;
instructorRespect?: Maybe<Scalars["Int"]>;
materialsRating?: Maybe<Scalars["Int"]>;
proficiency?: Maybe<Scalars["Int"]>;
receivedMaterials?: Maybe<Scalars["Boolean"]>;
satisfaction?: Maybe<Scalars["Int"]>;
wouldRecommend?: Maybe<Scalars["Boolean"]>;
feedbackResponse?: Maybe<FeedbackResponse>;
};
export type SiteObjectType = {
@ -1851,12 +1849,7 @@ export type SendFeedbackMutationMutation = {
__typename?: "Mutation";
sendFeedback?: {
__typename?: "SendFeedbackPayload";
id?: number | null;
satisfaction?: number | null;
goalAttainment?: number | null;
proficiency?: number | null;
receivedMaterials?: boolean | null;
materialsRating?: number | null;
feedbackResponse?: { __typename?: "FeedbackResponse"; id: string } | null;
errors?: Array<{
__typename?: "ErrorType";
field: string;
@ -1901,12 +1894,16 @@ export const SendFeedbackMutationDocument = {
selectionSet: {
kind: "SelectionSet",
selections: [
{ kind: "Field", name: { kind: "Name", value: "id" } },
{ kind: "Field", name: { kind: "Name", value: "satisfaction" } },
{ kind: "Field", name: { kind: "Name", value: "goalAttainment" } },
{ kind: "Field", name: { kind: "Name", value: "proficiency" } },
{ kind: "Field", name: { kind: "Name", value: "receivedMaterials" } },
{ kind: "Field", name: { kind: "Name", value: "materialsRating" } },
{
kind: "Field",
name: { kind: "Name", value: "feedbackResponse" },
selectionSet: {
kind: "SelectionSet",
selections: [
{ kind: "Field", name: { kind: "Name", value: "id" } },
],
},
},
{
kind: "Field",
name: { kind: "Name", value: "errors" },

View File

@ -1,3 +1,4 @@
import dayjs from "dayjs";
import { nextTick } from "vue";
import { createI18n } from "vue-i18n";
@ -5,9 +6,12 @@ import { createI18n } from "vue-i18n";
export const SUPPORT_LOCALES = ["de", "fr", "it"];
let i18n: any = null;
export function setupI18n(options = { locale: "de", legacy: false }) {
export function setupI18n(
options = { locale: "de", legacy: false, fallbackLocale: "de" }
) {
i18n = createI18n(options);
setI18nLanguage(options.locale);
dayjs.locale(options.locale);
return i18n;
}

View File

@ -8,6 +8,7 @@
"back": "zurück",
"backCapitalized": "@.capitalize:general.back",
"save": "Speichern",
"send": "Senden",
"learningUnit": "Lerneinheit",
"learningPath": "Lernpfad",
"learningSequence": "Lernsequenz",
@ -22,11 +23,13 @@
"profileLink": "Profil anzeigen",
"shop": "Shop",
"yes": "Ja",
"no": "Nein"
"no": "Nein",
"showAll": "Alle anschauen",
"settings": "Kontoeinstellungen"
},
"mainNavigation": {
"logout": "Abmelden",
"settings": "Kontoeinstellungen"
"profile": "Profil"
},
"dashboard": {
"welcome": "Willkommen, {name}"
@ -83,7 +86,6 @@
"competences": "Kompetenzen",
"title": "KompetenzNavi",
"lastImprovements": "Letzte verbesserte Kompetenzen",
"showAll": "Alle anschauen",
"assessment": "Einschätzungen",
"notAssessed": "Nicht eingeschätzt",
"assessAgain": "Sich nochmals einschätzen"
@ -111,7 +113,9 @@
"feedbacksDone": "Abgeschickte Feedbacks von Teilnehmer.",
"examsDone": "Abgelegte Prüfungen von Teilnehmer.",
"progress": "Fortschritt",
"profileLink": "Profil anzeigen"
"profileLink": "Profil anzeigen",
"notifyTaskDescription": "Teilnehmer benachrichtigen",
"notifyTask": "Benachrichtigen"
},
"messages": {
"sendMessage": "Nachricht schreiben"
@ -148,6 +152,13 @@
"answers": "Antworten",
"noFeedbacks": "Es wurden noch keine Feedbacks abgegeben"
},
"notifications": {
"load_more": "Mehr laden",
"no_notifications": "Du hast derzeit keine Benachrichtigungen"
},
"settings": {
"emailNotifications": "Email Benachrichtigungen"
},
"constants": {
"yes": "Ja",
"no": "Nein",

View File

@ -18,7 +18,10 @@ declare module "pinia" {
}
}
if (window.location.href.indexOf("localhost") >= 0) {
if (
window.location.href.indexOf("localhost") >= 0 ||
window.location.href.indexOf("127.0.0.1") >= 0
) {
log.setLevel("trace");
} else {
log.setLevel("warn");

View File

@ -0,0 +1,38 @@
<script setup lang="ts">
import NotificationList from "@/components/notifications/NotificationList.vue";
import { useNotificationsStore } from "@/stores/notifications";
import { ref } from "vue";
const notificationsStore = useNotificationsStore();
const numNotificationsToShow = ref(7);
async function loadAdditionalNotifications() {
numNotificationsToShow.value *= 2;
}
</script>
<template>
<div class="bg-gray-200">
<div class="container-large px-8 py-8">
<header class="mb-6">
<h1>{{ $t("general.notification") }}</h1>
</header>
<main>
<div class="bg-white px-4 py-4">
<NotificationList :num-notifications-to-show="numNotificationsToShow" />
<button
v-if="notificationsStore.allCount > numNotificationsToShow"
class="mt-4 underline"
data-cy="load-more-notifications"
@click="loadAdditionalNotifications()"
>
{{ $t("notifications.load_more") }}
</button>
</div>
</main>
</div>
</div>
</template>
<style scoped></style>

View File

@ -0,0 +1,69 @@
<script setup lang="ts">
import ItCheckboxGroup from "@/components/ui/ItCheckboxGroup.vue";
import { itGet, itPost } from "@/fetchHelpers";
import { onMounted, ref, watch } from "vue";
// TODO: make translation
const items = ref([
{
label: "Aktivität",
subtitle: "z.B. “Deiner Trainer hat dir Feedback zu einem Auftrag gegeben”",
value: "USER_INTERACTION",
checked: false,
},
{
label: "Fortschritt",
subtitle: "z.B. “Du kannst dich jetzt für die Prüfung anmelden.”",
value: "PROGRESS",
checked: false,
},
{
label: "Information",
subtitle:
"z.B. “Am 20.12. zwischen 08:00 und 12:00 Uhr werden wir Wartungsarbeiten durchführen.”",
value: "INFORMATION",
checked: false,
},
]);
watch(items, async (items) => {
try {
await itPost(
"/api/notify/email_notification_settings/",
items.filter((item) => item.checked).map((item) => item.value)
);
console.debug("Updated email notification settings");
} catch (e) {
console.error(`Could not update email notification settings: ${e}`);
}
});
onMounted(async () => {
const response = await itGet("/api/notify/email_notification_settings/");
items.value = items.value.map((item) => {
item.checked = response.includes(item.value);
return item;
});
});
</script>
<template>
<div class="bg-gray-200">
<div class="container-large">
<header class="mt-12 mb-8">
<h1>{{ $t("general.settings") }}</h1>
</header>
<main>
<div class="bg-white p-6">
<ItCheckboxGroup
:items="items"
:label="$t('settings.emailNotifications')"
@update:items="items = $event"
></ItCheckboxGroup>
</div>
</main>
</div>
</div>
</template>
<style scoped></style>

View File

@ -1,6 +1,7 @@
<script setup lang="ts">
import IconLogout from "@/components/icons/IconLogout.vue";
import IconSettings from "@/components/icons/IconSettings.vue";
import LearningPathCircle from "@/components/learningPath/LearningPathCircle.vue";
import HorizontalBarChart from "@/components/ui/HorizontalBarChart.vue";
import ItCheckbox from "@/components/ui/ItCheckbox.vue";
import ItCheckboxGroup from "@/components/ui/ItCheckboxGroup.vue";
@ -137,6 +138,11 @@ function log(data: any) {
<it-icon-message class="it-icon" />
</div>
<div class="inline-flex flex-col">
notification
<it-icon-notification class="it-icon" />
</div>
<div class="inline-flex flex-col">
arrow-up
<it-icon-arrow-up />
@ -407,14 +413,56 @@ function log(data: any) {
<h2 class="mt-8 mb-8">Checkbox</h2>
<ItCheckbox
:checked="state.checkboxValue"
:disabled="false"
:checkbox_item="{
subtitle: 'Subtitle',
label: 'Label',
value: 'value',
checked: false,
}"
@toggle="state.checkboxValue = !state.checkboxValue"
>
Label
</ItCheckbox>
<ItCheckbox disabled class="mt-4">Disabled</ItCheckbox>
<br />
<ItCheckbox
:disabled="true"
:checkbox_item="{
subtitle: 'checked disabled',
label: 'Label',
value: 'value',
checked: true,
}"
class="mt-6"
>
Disabled
</ItCheckbox>
<h2 class="mt-8 mb-8">Checkbox Group</h2>
<ItCheckboxGroup
label="Label"
:items="[
{
value: 'checkbox1',
label: 'Label 1',
subtitle: 'Subtitle 1',
checked: false,
},
{
value: 'checkbox2',
label: 'Label 2',
subtitle: 'Subtitle 2',
checked: false,
},
{
value: 'checkbox3',
label: 'Label 3',
checked: false,
},
]"
/>
<h2 class="mt-8 mb-8">Dropdown</h2>
@ -429,12 +477,7 @@ function log(data: any) {
</ItDropdown>
</div>
<ItTextarea v-model="textValue" label="Hallo Velo" class="mb-8" />
<ItCheckboxGroup
v-model="sourceValues"
:label="sourceLabel"
:items="sourceItems"
class="mb-8"
/>
<ItRadioGroup
v-model="satisfaction"
:label="satisfactionText"
@ -454,6 +497,26 @@ function log(data: any) {
/>
<HorizontalBarChart title="Frage X" text="Fragentext" :items="barChartItems" />
</div>
<h2 class="mt-8 mb-8">LearningPathCircle</h2>
<LearningPathCircle
class="h-48 w-48"
:sectors="[
{
progress: 'finished',
},
{
progress: 'finished',
},
{
progress: 'in_progress',
},
{
progress: 'none',
},
]"
></LearningPathCircle>
</main>
</template>

View File

@ -151,9 +151,9 @@ function setActiveClasses(translationKey: string) {
>
<ul class="w-full">
<li
class="flex h-12 items-center justify-between"
v-for="(circle, i) of cockpitStore.selectedCircles"
:key="i"
class="flex h-12 items-center justify-between"
>
<LearningPathDiagram
v-if="

View File

@ -91,7 +91,7 @@ const countStatus = computed(() => {
:to="`${competenceStore.competenceProfilePage()?.frontend_url}/competences`"
class="btn-text inline-flex items-center py-2 pl-0"
>
<span>{{ $t("competences.showAll") }}</span>
<span>{{ $t("general.showAll") }}</span>
<it-icon-arrow-right></it-icon-arrow-right>
</router-link>
</div>
@ -137,7 +137,7 @@ const countStatus = computed(() => {
:to="`${competenceStore.competenceProfilePage()?.frontend_url}/criteria`"
class="btn-text inline-flex items-center py-2 pl-0"
>
<span>{{ $t("competences.showAll") }}</span>
<span>{{ $t("general.showAll") }}</span>
<it-icon-arrow-right></it-icon-arrow-right>
</router-link>
</div>
@ -165,7 +165,7 @@ const countStatus = computed(() => {
:to="`${competenceStore.competenceProfilePage()?.frontend_url}/criteria`"
class="btn-text inline-flex items-center py-2 pl-0"
>
<span>{{ $t("competences.showAll") }}</span>
<span>{{ $t("general.showAll") }}</span>
<it-icon-arrow-right></it-icon-arrow-right>
</router-link>
</div>

View File

@ -138,6 +138,14 @@ const router = createRouter({
path: "/profile",
component: () => import("@/pages/ProfilePage.vue"),
},
{
path: "/settings",
component: () => import("@/pages/SettingsPage.vue"),
},
{
path: "/notifications",
component: () => import("@/pages/NotificationsPage.vue"),
},
{
path: "/styleguide",
component: () => import("../pages/StyleGuidePage.vue"),

View File

@ -0,0 +1,36 @@
import { itGet } from "@/fetchHelpers";
import type { Notification } from "@/types";
import { defineStore } from "pinia";
import { ref } from "vue";
type NotificationListData = {
all_count: number;
all_list: Notification[];
};
export const useNotificationsStore = defineStore("notifications", () => {
const hasUnread = ref(false);
const allCount = ref(0);
async function loadNotifications(
num_notifications: number,
mark_as_read = true
): Promise<Notification[]> {
const data = (await itGet(
`/notifications/api/all_list/?max=${num_notifications}&mark_as_read=${mark_as_read}`
)) as NotificationListData;
allCount.value = data.all_count;
await updateUnreadCount();
return data.all_list;
}
async function updateUnreadCount() {
const data = await itGet("/notifications/api/unread_count/");
hasUnread.value = data.unread_count !== 0;
}
updateUnreadCount();
setInterval(async () => await updateUnreadCount(), 30000);
return { loadNotifications, hasUnread, allCount };
});

View File

@ -1,12 +1,15 @@
import log from "loglevel";
import { bustItGetCache, itGetCached, itPost } from "@/fetchHelpers";
import { loadLocaleMessages, setI18nLanguage } from "@/i18n";
import { useAppStore } from "@/stores/app";
import { defineStore } from "pinia";
const logoutRedirectUrl = import.meta.env.VITE_LOGOUT_REDIRECT;
// typed state https://stackoverflow.com/questions/71012513/when-using-pinia-and-typescript-how-do-you-use-an-action-to-set-the-state
export type availableLanguages = "de" | "fr" | "it";
export type UserState = {
id: number;
first_name: string;
@ -17,6 +20,7 @@ export type UserState = {
is_superuser: boolean;
course_session_experts: number[];
loggedIn: boolean;
language: availableLanguages;
};
const initialUserState: UserState = {
@ -29,8 +33,14 @@ const initialUserState: UserState = {
is_superuser: false,
course_session_experts: [],
loggedIn: false,
language: "de",
};
async function setLocale(language: availableLanguages) {
await loadLocaleMessages(language);
setI18nLanguage(language);
}
export const useUserStore = defineStore({
id: "user",
state: () => initialUserState as UserState,
@ -80,6 +90,12 @@ export const useUserStore = defineStore({
this.$state = data;
this.loggedIn = true;
appStore.userLoaded = true;
await setLocale(data.language);
},
async setUserLanguages(language: availableLanguages) {
await setLocale(language);
this.$state.language = language;
await itPost("/api/core/me/", { language }, { method: "PUT" });
},
},
});

View File

@ -381,3 +381,23 @@ export interface DocumentUploadData {
name: string;
};
}
// notifications
export type NotificationType = "USER_INTERACTION" | "PROGRESS" | "INFORMATION";
export interface Notification {
// given by AbstractNotification model
id: number;
timestamp: string;
unread: boolean;
actor: string | null;
verb: string;
target: string | null;
action_object: string | null;
// given by Notification model
notification_type: NotificationType;
target_url: string | null;
actor_avatar_url: string | null;
course: string | null;
}

View File

@ -0,0 +1,112 @@
import { login } from "./helpers";
describe("notifications page", () => {
beforeEach(() => {
cy.manageCommand("cypress_reset");
cy.manageCommand("create_default_notifications");
});
it("can paginate notifications", () => {
login("admin", "test");
cy.visit("/notifications");
cy.get('[data-cy="no-notifications"]').should("not.exist");
cy.get("[data-cy^=notification-idx-]").should("have.length", 7);
// All notifications shall be marker as unread
cy.get("[data-cy^=notification-idx-]").within(() => {
cy.get('[data-cy="unread"]').should("exist");
});
// We load additional 7 notifications
cy.get('[data-cy="load-more-notifications"]').click();
cy.get("[data-cy^=notification-idx-]").should("have.length", 13);
cy.get('[data-cy="load-more-notifications"]').should("not.exist");
for (let i = 0; i < 7; i++) {
cy.get(`[data-cy=notification-idx-${i}]`).within(() => {
cy.get('[data-cy="unread"]').should("not.exist");
});
}
for (let i = 7; i < 13; i++) {
cy.get(`[data-cy=notification-idx-${i}]`).within(() => {
cy.get('[data-cy="unread"]').should("exist");
});
}
});
it("can click notifications", () => {
login("admin", "test");
cy.visit("/notifications");
cy.get('[data-cy="no-notifications"]').should("not.exist");
cy.get("[data-cy=notification-target-idx-0]").click();
cy.location().should((loc) => {
expect(loc.pathname).to.eq("/");
});
});
});
describe("notification popover", () => {
beforeEach(() => {
cy.manageCommand("cypress_reset");
cy.manageCommand("create_default_notifications");
});
function toggleNotificationPopover() {
cy.get('[data-cy="notification-bell-button"]')
.filter(":visible")
.click({ force: true });
}
it("displays four notifications", () => {
login("admin", "test");
cy.visit("/");
cy.wait(1000);
toggleNotificationPopover();
cy.get("[data-cy^=notification-idx-]").should("have.length", 4);
});
it("can show all notifications", () => {
login("admin", "test");
cy.visit("/");
cy.wait(1000);
toggleNotificationPopover();
cy.get('[data-cy="show-all-notifications"]').click({ force: true });
cy.location().should((loc) => {
expect(loc.pathname).to.eq("/notifications");
});
});
});
describe("email notification settings", () => {
beforeEach(() => {
cy.manageCommand("cypress_reset");
cy.manageCommand("create_default_notifications");
});
it("can update email notification settings", () => {
login("admin", "test");
cy.visit("/settings");
cy.wait(1000);
cy.get('[data-cy="it-checkbox-USER_INTERACTION"]').should("not.be.checked");
cy.get('[data-cy="it-checkbox-INFORMATION"]').should("not.be.checked");
cy.get('[data-cy="it-checkbox-PROGRESS"]').should("not.be.checked");
cy.get('[data-cy="it-checkbox-USER_INTERACTION"]').click({ force: true });
cy.reload();
cy.wait(1000);
cy.get('[data-cy="it-checkbox-USER_INTERACTION"]').should("be.checked");
cy.get('[data-cy="it-checkbox-INFORMATION"]').should("not.be.checked");
cy.get('[data-cy="it-checkbox-PROGRESS"]').should("not.be.checked");
cy.get('[data-cy="it-checkbox-USER_INTERACTION"]').click({ force: true });
cy.reload();
cy.wait(1000);
cy.get('[data-cy="it-checkbox-USER_INTERACTION"]').should("not.be.checked");
cy.get('[data-cy="it-checkbox-INFORMATION"]').should("not.be.checked");
cy.get('[data-cy="it-checkbox-PROGRESS"]').should("not.be.checked");
});
});

BIN
env_secrets/local_elia.env Normal file

Binary file not shown.

View File

@ -1,7 +1,8 @@
encrypted: env_secrets/caprover_dev.env
encrypted: env_secrets/caprover_prod.env
encrypted: env_secrets/caprover_stage.env
encrypted: env_secrets/local_chrigu.env
encrypted: env_secrets/local_daniel.env
encrypted: env_secrets/local_lorenz.env
encrypted: env_secrets/production.env
encrypted: env_secrets/caprover_dev.env
encrypted: env_secrets/caprover_prod.env
encrypted: env_secrets/caprover_stage.env
encrypted: env_secrets/local_chrigu.env
encrypted: env_secrets/local_daniel.env
encrypted: env_secrets/local_elia.env
encrypted: env_secrets/local_lorenz.env
encrypted: env_secrets/production.env

View File

@ -69,6 +69,7 @@ if [ "$SKIP_SETUP" = false ]; then
python server/manage.py migrate
python server/manage.py create_default_users
python server/manage.py create_default_courses
python server/manage.py create_default_notifications
# make django translations
(cd server && python manage.py compilemessages)

View File

@ -2,7 +2,6 @@
Base settings to build other settings files upon.
"""
import logging
import os
from pathlib import Path
import structlog
@ -101,6 +100,7 @@ THIRD_PARTY_APPS = [
"storages",
"grapple",
"graphene_django",
"notifications",
]
LOCAL_APPS = [
@ -112,6 +112,7 @@ LOCAL_APPS = [
"vbv_lernwelt.media_library",
"vbv_lernwelt.feedback",
"vbv_lernwelt.files",
"vbv_lernwelt.notify",
]
# https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
@ -221,6 +222,7 @@ IT_SERVE_VUE_URL = env("IT_SERVE_VUE_URL", "http://localhost:5173")
# ------------------------------------------------------------------------------
WAGTAIL_SITE_NAME = "VBV Lernwelt"
WAGTAIL_I18N_ENABLED = True
WAGTAILADMIN_BASE_URL = "/server/cms/"
LANGUAGES = [
("en-US", "English (American)"),
@ -573,6 +575,13 @@ GRAPPLE = {
"APPS": ["core", "course", "learnpath", "competence", "media_library"],
}
# Notifications
# django-notifications
DJANGO_NOTIFICATIONS_CONFIG = {"SOFT_DELETE": True}
NOTIFICATIONS_NOTIFICATION_MODEL = "notify.Notification"
# sendgrid (email notifications)
SENDGRID_API_KEY = env("IT_SENDGRID_API_KEY", default="")
# S3 BUCKET CONFIGURATION
FILE_UPLOAD_STORAGE = env("FILE_UPLOAD_STORAGE", default="local") # local | s3
@ -716,6 +725,3 @@ if APP_ENVIRONMENT in ["production", "caprover"] or APP_ENVIRONMENT.startswith(
environment=env("SENTRY_ENVIRONMENT", default="production"),
traces_sample_rate=env.float("SENTRY_TRACES_SAMPLE_RATE", default=0.0),
)
# Your stuff...
# ------------------------------------------------------------------------------

View File

@ -1,3 +1,4 @@
import notifications.urls
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
@ -36,6 +37,7 @@ from vbv_lernwelt.feedback.views import (
get_expert_feedbacks_for_course,
get_feedback_for_circle,
)
from vbv_lernwelt.notify.views import email_notification_settings
from wagtail import urls as wagtail_urls
from wagtail.admin import urls as wagtailadmin_urls
from wagtail.documents import urls as wagtaildocs_urls
@ -66,6 +68,13 @@ urlpatterns = [
name='vue_login'),
re_path(r'api/core/logout/$', vue_logout, name='vue_logout'),
# notifications
re_path(r'^notifications/', include(notifications.urls, namespace='notifications')),
# notify
re_path(r"api/notify/email_notification_settings/$", email_notification_settings,
name='email_notification_settings'),
# core
re_path(r"server/core/icons/$", generate_web_component_icons,
name="generate_web_component_icons"),

View File

@ -10,6 +10,8 @@ anyascii==0.3.1
# via wagtail
anyio==3.5.0
# via watchfiles
appnope==0.1.3
# via ipython
argon2-cffi==21.3.0
# via -r requirements.in
argon2-cffi-bindings==21.2.0
@ -103,6 +105,7 @@ django==3.2.13
# django-filter
# django-model-utils
# django-modelcluster
# django-notifications-hq
# django-permissionedforms
# django-redis
# django-storages
@ -114,6 +117,7 @@ django==3.2.13
# djangorestframework
# drf-spectacular
# graphene-django
# jsonfield
# wagtail
# wagtail-grapple
# wagtail-localize
@ -134,9 +138,13 @@ django-filter==21.1
django-ipware==4.0.2
# via -r requirements.in
django-model-utils==4.2.0
# via -r requirements.in
# via
# -r requirements.in
# django-notifications-hq
django-modelcluster==6.0
# via wagtail
django-notifications-hq==1.7.0
# via -r requirements.in
django-permissionedforms==0.1
# via wagtail
django-ratelimit==3.0.1
@ -242,6 +250,8 @@ jmespath==1.0.1
# via
# boto3
# botocore
jsonfield==3.1.0
# via django-notifications-hq
jsonschema==4.4.0
# via drf-spectacular
l18n==2021.3
@ -370,6 +380,8 @@ python-dotenv==0.20.0
# via
# environs
# uvicorn
python-http-client==3.3.7
# via sendgrid
python-json-logger==2.0.2
# via -r requirements.in
python-slugify==6.1.1
@ -379,6 +391,7 @@ pytz==2022.1
# -r requirements.in
# django
# django-modelcluster
# django-notifications-hq
# djangorestframework
# l18n
pyyaml==6.0
@ -400,6 +413,8 @@ rx==1.6.1
# via graphql-core
s3transfer==0.6.0
# via boto3
sendgrid==6.9.7
# via -r requirements.in
sentry-sdk==1.5.8
# via -r requirements.in
singledispatch==3.7.0
@ -430,10 +445,14 @@ sqlparse==0.4.2
# django-debug-toolbar
stack-data==0.2.0
# via ipython
starkbank-ecdsa==2.2.0
# via sendgrid
stdlibs==2022.6.8
# via usort
structlog==21.5.0
# via -r requirements.in
swapper==1.3.0
# via django-notifications-hq
tablib[xls,xlsx]==3.2.1
# via wagtail
telepath==0.2

View File

@ -24,10 +24,12 @@ django-ratelimit
django-ipware
django-csp
django-storages
django-notifications-hq
psycopg2-binary
gunicorn
sentry-sdk
sendgrid
structlog
python-json-logger

View File

@ -63,6 +63,7 @@ django==3.2.13
# django-filter
# django-model-utils
# django-modelcluster
# django-notifications-hq
# django-permissionedforms
# django-redis
# django-storages
@ -71,6 +72,7 @@ django==3.2.13
# djangorestframework
# drf-spectacular
# graphene-django
# jsonfield
# wagtail
# wagtail-grapple
# wagtail-localize
@ -85,9 +87,13 @@ django-filter==21.1
django-ipware==4.0.2
# via -r requirements.in
django-model-utils==4.2.0
# via -r requirements.in
# via
# -r requirements.in
# django-notifications-hq
django-modelcluster==6.0
# via wagtail
django-notifications-hq==1.7.0
# via -r requirements.in
django-permissionedforms==0.1
# via wagtail
django-ratelimit==3.0.1
@ -147,6 +153,8 @@ jmespath==1.0.1
# via
# boto3
# botocore
jsonfield==3.1.0
# via django-notifications-hq
jsonschema==4.4.0
# via drf-spectacular
l18n==2021.3
@ -188,6 +196,8 @@ python-dotenv==0.20.0
# via
# environs
# uvicorn
python-http-client==3.3.7
# via sendgrid
python-json-logger==2.0.2
# via -r requirements.in
python-slugify==6.1.1
@ -197,6 +207,7 @@ pytz==2022.1
# -r requirements.in
# django
# django-modelcluster
# django-notifications-hq
# djangorestframework
# l18n
pyyaml==6.0
@ -213,6 +224,8 @@ rx==1.6.1
# via graphql-core
s3transfer==0.6.0
# via boto3
sendgrid==6.9.7
# via -r requirements.in
sentry-sdk==1.5.8
# via -r requirements.in
singledispatch==3.7.0
@ -234,8 +247,12 @@ soupsieve==2.3.2.post1
# via beautifulsoup4
sqlparse==0.4.2
# via django
starkbank-ecdsa==2.2.0
# via sendgrid
structlog==21.5.0
# via -r requirements.in
swapper==1.3.0
# via django-notifications-hq
tablib[xls,xlsx]==3.2.1
# via wagtail
telepath==0.2

View File

@ -79,6 +79,10 @@ type Circle implements PageInterface {
ancestors(limit: PositiveInt, offset: PositiveInt, order: String, searchQuery: String, id: ID): [PageInterface!]!
}
type CircleDocument {
id: ID
}
type CollectionObjectType {
id: ID!
path: String!
@ -281,6 +285,13 @@ type ErrorType {
messages: [String!]!
}
type FeedbackResponse implements Node {
id: ID!
data: GenericScalar
circle: Circle!
courseSession: CourseSession!
}
type FloatBlock implements StreamFieldInterface {
id: String
blockType: String!
@ -289,6 +300,8 @@ type FloatBlock implements StreamFieldInterface {
value: Float!
}
scalar GenericScalar
type ImageChooserBlock implements StreamFieldInterface {
id: String
blockType: String!
@ -608,6 +621,10 @@ type Mutation {
sendFeedback(input: SendFeedbackInput!): SendFeedbackPayload
}
interface Node {
id: ID!
}
type Page implements PageInterface {
id: ID
path: String!
@ -783,42 +800,21 @@ type RichTextBlock implements StreamFieldInterface {
value: String!
}
union Search = CoursePage | LearningPath | Topic | Circle | LearningSequence | LearningUnit | LearningContent | CompetenceProfilePage | CompetencePage | PerformanceCriteria | MediaLibraryPage | MediaCategoryPage | Page | LibraryDocument | User | SecurityRequestResponseLog | Course | CourseCategory | CourseCompletion | CourseSession | CourseSessionUser
union Search = CoursePage | LearningPath | Topic | Circle | LearningSequence | LearningUnit | LearningContent | CompetenceProfilePage | CompetencePage | PerformanceCriteria | MediaLibraryPage | MediaCategoryPage | Page | LibraryDocument | User | SecurityRequestResponseLog | Course | CourseCategory | CourseCompletion | CourseSession | CourseSessionUser | CircleDocument
type SecurityRequestResponseLog {
id: ID
}
input SendFeedbackInput {
id: Int
page: String!
satisfaction: Int
goalAttainment: Int
proficiency: Int
receivedMaterials: Boolean
materialsRating: Int
instructorCompetence: Int
instructorRespect: Int
instructorOpenFeedback: String
wouldRecommend: Boolean
coursePositiveFeedback: String
courseNegativeFeedback: String
courseSession: Int!
data: GenericScalar
clientMutationId: String
}
type SendFeedbackPayload {
id: Int
satisfaction: Int
goalAttainment: Int
proficiency: Int
receivedMaterials: Boolean
materialsRating: Int
instructorCompetence: Int
instructorRespect: Int
instructorOpenFeedback: String
wouldRecommend: Boolean
coursePositiveFeedback: String
courseNegativeFeedback: String
feedbackResponse: FeedbackResponse
errors: [ErrorType]
clientMutationId: String
}

View File

@ -7,7 +7,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [

View File

@ -5,7 +5,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [

View File

@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = []
operations = [

View File

@ -3,7 +3,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("sites", "0001_initial")]
operations = [

View File

@ -57,7 +57,6 @@ def update_site_backward(apps, schema_editor):
class Migration(migrations.Migration):
dependencies = [("sites", "0002_alter_domain_unique")]
operations = [migrations.RunPython(update_site_forward, update_site_backward)]

View File

@ -4,7 +4,6 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("sites", "0003_set_site_domain_and_name"),
]

View File

@ -1,9 +1,11 @@
import djclick as click
from vbv_lernwelt.course.models import CourseCompletion
from vbv_lernwelt.notify.models import Notification
@click.command()
def command():
print("cypress reset data")
CourseCompletion.objects.all().delete()
Notification.objects.all().delete()

View File

@ -7,7 +7,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [

View File

@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0001_initial"),
]
@ -22,4 +21,13 @@ class Migration(migrations.Migration):
max_length=254, unique=True, verbose_name="email address"
),
),
migrations.AddField(
model_name="user",
name="language",
field=models.CharField(
choices=[("de", "Deutsch"), ("fr", "Français"), ("it", "Italiano")],
default="de",
max_length=2,
),
),
]

View File

@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0003_alter_user_managers"),
]

View File

@ -1,15 +1,15 @@
from django.conf import settings
from django.contrib.auth.models import Group
from django.db import migrations
from vbv_lernwelt.core.create_default_users import create_default_users
from vbv_lernwelt.core.models import User
def create_users(apps, schema_editor):
default_password = "ACEEs0DCmNaPxdoNV8vhccuCTRl9b"
if settings.APP_ENVIRONMENT == "development":
default_password = None
User = apps.get_model("core", "User")
Group = apps.get_model("auth", "Group")
create_default_users(
user_model=User, group_model=Group, default_password=default_password
)

View File

@ -0,0 +1,17 @@
# Generated by Django 3.2.13 on 2023-01-24 09:15
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0005_create_users"),
]
operations = [
migrations.AddField(
model_name="user",
name="additional_json_data",
field=models.JSONField(blank=True, default=dict),
),
]

View File

@ -11,6 +11,12 @@ class User(AbstractUser):
If adding fields that need to be filled at user signup,
"""
LANGUAGE_CHOICES = (
("de", "Deutsch"),
("fr", "Français"),
("it", "Italiano"),
)
# FIXME: look into it...
# objects = UserManager()
avatar_url = models.CharField(max_length=254, blank=True, default="")
@ -18,6 +24,8 @@ class User(AbstractUser):
sso_id = models.UUIDField(
"SSO subscriber ID", unique=True, null=True, blank=True, default=None
)
additional_json_data = JSONField(default=dict, blank=True)
language = models.CharField(max_length=2, choices=LANGUAGE_CHOICES, default="de")
objects = UserManager()

View File

@ -18,6 +18,15 @@ class UserSerializer(serializers.ModelSerializer):
"avatar_url",
"is_superuser",
"course_session_experts",
"language",
]
read_only_fields = [
"id",
"is_superuser",
"first_name",
"last_name",
"email",
"username",
]
def get_course_session_experts(self, obj):

View File

@ -82,11 +82,25 @@ def vue_login(request):
)
@api_view(["GET"])
@api_view(["GET", "PUT"])
def me_user_view(request):
if request.user.is_authenticated:
if not request.user.is_authenticated:
return Response(status=403)
if request.method == "GET":
return Response(UserSerializer(request.user).data)
return Response(status=403)
if request.method == "PUT":
serializer = UserSerializer(
request.user,
data={"language": request.data.get("language", "de")},
partial=True,
)
if serializer.is_valid():
serializer.save()
return Response(UserSerializer(request.user).data)
return Response(status=400)
@api_view(["POST"])

View File

@ -246,7 +246,7 @@ def command():
)
# initial completion data
for (slug, status, email) in [
for slug, status, email in [
(
"überbetriebliche-kurse-competence-crit-a21-allgemein",
"success",

View File

@ -6,7 +6,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [

View File

@ -6,7 +6,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("course", "0001_initial"),

View File

@ -5,7 +5,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("course", "0002_auto_20221014_0933"),
]

View File

@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("learnpath", "0008_alter_learningcontent_contents"),
("course", "0003_alter_coursepage_course"),

View File

@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("learnpath", "0008_alter_learningcontent_contents"),
("course", "0004_coursesessionuser_expert"),

View File

@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("course", "0005_alter_coursesessionuser_expert"),
]

View File

@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("course", "0006_course_slug"),
]

View File

@ -1,3 +1,4 @@
from factory import Dict
from factory.django import DjangoModelFactory
from factory.fuzzy import FuzzyChoice, FuzzyInteger
@ -5,33 +6,37 @@ from vbv_lernwelt.feedback.models import FeedbackResponse
class FeedbackFactory(DjangoModelFactory):
data = Dict(
{
"satisfaction": FuzzyInteger(2, 4),
"goal_attainment": FuzzyInteger(3, 4),
"proficiency": FuzzyChoice([20, 40, 60, 80]),
"received_materials": FuzzyChoice([True, False]),
"materials_rating": FuzzyInteger(2, 4),
"instructor_competence": FuzzyInteger(3, 4),
"instructor_respect": FuzzyInteger(3, 4),
"instructor_open_feedback": FuzzyChoice(
[
"Alles gut, manchmal etwas langfädig",
"Super, bin begeistert",
"Ok, enspricht den Erwartungen",
]
),
"would_recommend": FuzzyChoice([True, False]),
"course_positive_feedback": FuzzyChoice(
[
"Die Präsentation war super",
"Das Beispiel mit der Katze fand ich sehr gut veranschaulicht!",
]
),
"course_negative_feedback": FuzzyChoice(
[
"Es wäre praktisch, Zugang zu einer FAQ zu haben.",
"Es wäre schön, mehr Videos hinzuzufügen.",
]
),
}
)
class Meta:
model = FeedbackResponse
satisfaction = FuzzyInteger(2, 4)
goal_attainment = FuzzyInteger(3, 4)
proficiency = FuzzyChoice([20, 40, 60, 80])
received_materials = FuzzyChoice([True, False])
materials_rating = FuzzyInteger(2, 4)
instructor_competence = FuzzyInteger(3, 4)
instructor_respect = FuzzyInteger(3, 4)
instructor_open_feedback = FuzzyChoice(
[
"Alles gut, manchmal etwas langfädig",
"Super, bin begeistert",
"Ok, enspricht den Erwartungen",
]
)
would_recommend = FuzzyChoice([True, False])
course_positive_feedback = FuzzyChoice(
[
"Die Präsentation war super",
"Das Beispiel mit der Katze fand ich sehr gut veranschaulicht!",
]
)
course_negative_feedback = FuzzyChoice(
[
"Es wäre praktisch, Zugang zu einer FAQ zu haben.",
"Es wäre schön, mehr Videos hinzuzufügen.",
]
)

View File

@ -1,13 +1,56 @@
import graphene
from graphene_django.rest_framework.mutation import SerializerMutation
import structlog
from graphene import ClientIDMutation, Field, Int, List, String
from graphene.types.generic import GenericScalar
from graphene_django.types import ErrorType
from vbv_lernwelt.feedback.serializers import FeedbackResponseSerializer
from vbv_lernwelt.course.models import CourseSession
from vbv_lernwelt.feedback.graphql.types import FeedbackResponse as FeedbackResponseType
from vbv_lernwelt.feedback.models import FeedbackResponse
from vbv_lernwelt.feedback.serializers import CourseFeedbackSerializer
from wagtail.models import Page
logger = structlog.get_logger(__name__)
class SendFeedback(SerializerMutation):
class Meta:
serializer_class = FeedbackResponseSerializer
model_operations = ["create"]
# https://medium.com/open-graphql/jsonfield-models-in-graphene-django-308ae43d14ee
class SendFeedback(ClientIDMutation):
feedback_response = Field(FeedbackResponseType)
errors = List(
ErrorType, description="May contain more than one error for same field."
)
class Input:
page = String(required=True)
course_session = Int(required=True)
data = GenericScalar()
@classmethod
def mutate_and_get_payload(cls, _, info, **input):
page_key = input["page"]
course_session_id = input["course_session"]
logger.info("creating feedback")
learning_content = Page.objects.get(
translation_key=page_key, locale__language_code="de-CH"
)
circle = learning_content.get_parent().specific
course_session = CourseSession.objects.get(id=course_session_id)
data = input.get("data", {})
serializer = CourseFeedbackSerializer(data=data)
if not serializer.is_valid():
logger.error(serializer.errors)
return SendFeedback(errors=serializer.errors)
feedback_response = FeedbackResponse.objects.create(
circle=circle,
course_session=course_session,
data=serializer.validated_data,
)
logger.info(feedback_response)
return SendFeedback(feedback_response=feedback_response)
class Mutation(object):

View File

@ -1,8 +1,13 @@
from graphene.relay import Node
from graphene.types.generic import GenericScalar
from graphene_django import DjangoObjectType
from vbv_lernwelt.feedback.models import Feedback
from vbv_lernwelt.feedback.models import FeedbackResponse as FeedbackResponseModel
# class FeedbackType(DjangoObjectType):
# class Meta:
# model = Feedback
class FeedbackResponse(DjangoObjectType):
data = GenericScalar()
class Meta:
model = FeedbackResponseModel
interfaces = (Node,)

View File

@ -8,7 +8,6 @@ import vbv_lernwelt.feedback.models
class Migration(migrations.Migration):
initial = True
dependencies = [

View File

@ -7,7 +7,6 @@ import vbv_lernwelt.feedback.models
class Migration(migrations.Migration):
dependencies = [
("feedback", "0001_initial"),
]

View File

@ -0,0 +1,66 @@
# Generated by Django 3.2.13 on 2023-02-06 10:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("feedback", "0002_auto_20230111_1044"),
]
operations = [
migrations.RemoveField(
model_name="feedbackresponse",
name="course_negative_feedback",
),
migrations.RemoveField(
model_name="feedbackresponse",
name="course_positive_feedback",
),
migrations.RemoveField(
model_name="feedbackresponse",
name="goal_attainment",
),
migrations.RemoveField(
model_name="feedbackresponse",
name="instructor_competence",
),
migrations.RemoveField(
model_name="feedbackresponse",
name="instructor_open_feedback",
),
migrations.RemoveField(
model_name="feedbackresponse",
name="instructor_respect",
),
migrations.RemoveField(
model_name="feedbackresponse",
name="materials_rating",
),
migrations.RemoveField(
model_name="feedbackresponse",
name="proficiency",
),
migrations.RemoveField(
model_name="feedbackresponse",
name="received_materials",
),
migrations.RemoveField(
model_name="feedbackresponse",
name="satisfaction",
),
migrations.RemoveField(
model_name="feedbackresponse",
name="would_recommend",
),
migrations.AddField(
model_name="feedbackresponse",
name="data",
field=models.JSONField(default=dict),
),
migrations.AddField(
model_name="feedbackresponse",
name="created_at",
field=models.DateTimeField(auto_now_add=True),
),
]

View File

@ -1,8 +1,11 @@
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.utils.translation import gettext_lazy as _
from vbv_lernwelt.core.models import User
from vbv_lernwelt.course.models import CourseSessionUser
from vbv_lernwelt.notify.service import NotificationService
class FeedbackIntegerField(models.IntegerField):
def __init__(self, **kwargs):
@ -11,7 +14,7 @@ class FeedbackIntegerField(models.IntegerField):
super().__init__(
validators=validators + [MinValueValidator(1), MaxValueValidator(4)],
null=nullable,
**kwargs
**kwargs,
)
@ -37,17 +40,33 @@ class FeedbackResponse(models.Model):
EIGHTY = 80, "80%"
HUNDRED = 100, "100%"
satisfaction = FeedbackIntegerField()
goal_attainment = FeedbackIntegerField()
proficiency = models.IntegerField(null=True)
received_materials = models.BooleanField(null=True)
materials_rating = FeedbackIntegerField()
instructor_competence = FeedbackIntegerField()
instructor_respect = FeedbackIntegerField()
instructor_open_feedback = models.TextField(blank=True)
would_recommend = models.BooleanField(null=True)
course_positive_feedback = models.TextField(blank=True)
course_negative_feedback = models.TextField(blank=True)
def save(self, *args, **kwargs):
if not self.id:
course_session_users = CourseSessionUser.objects.filter(
role="EXPERT", course_session=self.course_session, expert=self.circle
)
for csu in course_session_users:
NotificationService.send_information_notification(
recipient=csu.user,
verb=f"{_('New feedback for circle')} {self.circle.title}",
target_url=f"/course/{self.course_session.course.slug}/cockpit/feedback/{self.circle_id}/",
)
super(FeedbackResponse, self).save(*args, **kwargs)
# satisfaction = FeedbackIntegerField()
# goal_attainment = FeedbackIntegerField()
# proficiency = models.IntegerField(null=True)
# received_materials = models.BooleanField(null=True)
# materials_rating = FeedbackIntegerField()
# instructor_competence = FeedbackIntegerField()
# instructor_respect = FeedbackIntegerField()
# instructor_open_feedback = models.TextField(blank=True)
# would_recommend = models.BooleanField(null=True)
# course_positive_feedback = models.TextField(blank=True)
# course_negative_feedback = models.TextField(blank=True)
data = models.JSONField(default=dict)
created_at = models.DateTimeField(auto_now_add=True)
circle = models.ForeignKey("learnpath.Circle", models.PROTECT)
course_session = models.ForeignKey("course.CourseSession", models.PROTECT)

View File

@ -1,32 +1,31 @@
import structlog
from rest_framework import serializers
from wagtail.models import Page
from vbv_lernwelt.course.models import CourseSession
from vbv_lernwelt.feedback.models import FeedbackResponse
logger = structlog.get_logger(__name__)
class FeedbackResponseSerializer(serializers.ModelSerializer):
page = serializers.CharField(write_only=True)
course_session = serializers.CharField(write_only=True)
class Meta:
model = FeedbackResponse
exclude = ["circle"]
# extra_kwargs = {"course", {"read_only": True}}
def create(self, validated_data):
logger.info("creating feedback")
page_key = validated_data.pop("page")
course_session_id = validated_data.pop("course_session")
learning_content = Page.objects.get(
translation_key=page_key, locale__language_code="de-CH"
)
circle = learning_content.get_parent().specific
course_session = CourseSession.objects.get(id=course_session_id)
return FeedbackResponse.objects.create(
**validated_data, circle=circle, course_session=course_session
class FeedbackIntegerField(serializers.IntegerField):
def __init__(self, **kwargs):
super().__init__(
required=False, allow_null=True, min_value=1, max_value=5, **kwargs
)
class CourseFeedbackSerializer(serializers.Serializer):
satisfaction = FeedbackIntegerField()
goal_attainment = FeedbackIntegerField()
proficiency = serializers.IntegerField(required=False, allow_null=True)
received_materials = serializers.BooleanField(required=False, allow_null=True)
materials_rating = FeedbackIntegerField()
instructor_competence = FeedbackIntegerField()
instructor_respect = FeedbackIntegerField()
instructor_open_feedback = serializers.CharField(
required=False, allow_null=True, allow_blank=True
)
would_recommend = serializers.BooleanField(required=False, allow_null=True)
course_positive_feedback = serializers.CharField(
required=False, allow_null=True, allow_blank=True
)
course_negative_feedback = serializers.CharField(
required=False, allow_null=True, allow_blank=True
)

View File

@ -7,6 +7,7 @@ from vbv_lernwelt.course.creators.test_course import create_test_course
from vbv_lernwelt.course.models import CourseSession, CourseSessionUser
from vbv_lernwelt.feedback.factories import FeedbackFactory
from vbv_lernwelt.learnpath.models import Circle
from vbv_lernwelt.notify.models import Notification
class FeedbackApiBaseTestCase(APITestCase):
@ -50,6 +51,54 @@ class FeedbackApiBaseTestCase(APITestCase):
class FeedbackSummaryApiTestCase(FeedbackApiBaseTestCase):
def test_triggers_notification(self):
expert = User.objects.get(username="patrizia.huggel@eiger-versicherungen.ch")
csu = CourseSessionUser.objects.get(
course_session=self.course_session,
user=expert,
role=CourseSessionUser.Role.EXPERT,
)
basis_circle = Circle.objects.get(slug="test-lehrgang-lp-circle-basis")
csu.expert.add(basis_circle)
FeedbackFactory(circle=basis_circle, course_session=csu.course_session).save()
notifications = Notification.objects.all()
self.assertEqual(len(notifications), 1)
self.assertEqual(notifications[0].recipient, expert)
self.assertEqual(
notifications[0].verb, f"New feedback for circle {basis_circle.title}"
)
self.assertEqual(
notifications[0].target_url,
f"/course/{self.course_session.course.slug}/cockpit/feedback/{basis_circle.id}/",
)
def test_triggers_notification_only_on_create(self):
expert = User.objects.get(username="patrizia.huggel@eiger-versicherungen.ch")
csu = CourseSessionUser.objects.get(
course_session=self.course_session,
user=expert,
role=CourseSessionUser.Role.EXPERT,
)
basis_circle = Circle.objects.get(slug="test-lehrgang-lp-circle-basis")
csu.expert.add(basis_circle)
feedback = FeedbackFactory(
circle=basis_circle, course_session=csu.course_session
)
feedback.save()
# Check that the initial notification was created and then deleted
self.assertEqual(len(Notification.objects.all()), 1)
Notification.objects.all().delete()
self.assertEqual(len(Notification.objects.all()), 0)
# Check that an update of the feedback does not trigger a notification
feedback.name = "Test2"
feedback.save()
self.assertEqual(len(Notification.objects.all()), 0)
def test_can_get_feedback_summary_for_circles(self):
number_basis_feedback = 5
number_analyse_feedback = 10
@ -159,17 +208,25 @@ class FeedbackDetailApiTestCase(FeedbackApiBaseTestCase):
FeedbackFactory(
circle=circle,
course_session=csu.course_session,
satisfaction=feedback_data["satisfaction"][i],
goal_attainment=feedback_data["goal_attainment"][i],
proficiency=feedback_data["proficiency"][i],
received_materials=feedback_data["received_materials"][i],
materials_rating=feedback_data["materials_rating"][i],
instructor_competence=feedback_data["instructor_competence"][i],
instructor_open_feedback=feedback_data["instructor_open_feedback"][i],
instructor_respect=feedback_data["instructor_respect"][i],
would_recommend=feedback_data["would_recommend"][i],
course_positive_feedback=feedback_data["course_positive_feedback"][i],
course_negative_feedback=feedback_data["course_negative_feedback"][i],
data={
"satisfaction": feedback_data["satisfaction"][i],
"goal_attainment": feedback_data["goal_attainment"][i],
"proficiency": feedback_data["proficiency"][i],
"received_materials": feedback_data["received_materials"][i],
"materials_rating": feedback_data["materials_rating"][i],
"instructor_competence": feedback_data["instructor_competence"][i],
"instructor_open_feedback": feedback_data[
"instructor_open_feedback"
][i],
"instructor_respect": feedback_data["instructor_respect"][i],
"would_recommend": feedback_data["would_recommend"][i],
"course_positive_feedback": feedback_data[
"course_positive_feedback"
][i],
"course_negative_feedback": feedback_data[
"course_negative_feedback"
][i],
},
).save()
response = self.client.get(

View File

@ -49,7 +49,7 @@ def get_feedback_for_circle(request, course_id, circle_id):
course_session__course_id=course_id,
circle__expert__user=request.user,
circle_id=circle_id,
)
).order_by("created_at")
# I guess this is ok for the üK case
feedback_data = {"amount": len(feedbacks), "questions": {}}
@ -62,6 +62,8 @@ def get_feedback_for_circle(request, course_id, circle_id):
for feedback in feedbacks:
for field in FEEDBACK_FIELDS:
feedback_data["questions"][field].append(getattr(feedback, field))
data = feedback.data.get(field, None)
if data is not None:
feedback_data["questions"][field].append(data)
return Response(status=200, data=feedback_data)

View File

@ -8,7 +8,6 @@ import vbv_lernwelt.files.utils
class Migration(migrations.Migration):
initial = True
dependencies = [

View File

@ -8,7 +8,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [

View File

@ -6,7 +6,6 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("learnpath", "0001_initial"),
]

View File

@ -6,7 +6,6 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("learnpath", "0002_alter_learningcontent_contents"),
]

View File

@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("learnpath", "0003_alter_learningcontent_contents"),
]

View File

@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("learnpath", "0004_circle_goal_description"),
]

View File

@ -6,7 +6,6 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("learnpath", "0005_circle_job_situation_description"),
]

View File

@ -6,7 +6,6 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("learnpath", "0006_alter_learningcontent_contents"),
]

View File

@ -6,7 +6,6 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("learnpath", "0007_alter_learningcontent_contents"),
]

View File

@ -6,7 +6,6 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("learnpath", "0008_alter_learningcontent_contents"),
]

View File

@ -4,7 +4,6 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("learnpath", "0009_alter_learningcontent_contents"),
]

View File

@ -11,7 +11,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [

View File

@ -6,7 +6,6 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("media_library", "0001_initial"),
]

View File

View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class NotifyConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "vbv_lernwelt.notify"

View File

@ -0,0 +1,150 @@
from datetime import timedelta
from django.utils import timezone
from vbv_lernwelt.core.admin import User
from vbv_lernwelt.notify.models import NotificationType
from vbv_lernwelt.notify.tests.factories import NotificationFactory
def create_default_notifications() -> int:
"""
Creates default notifications for all users.
@return: The number of created notifications per user.
"""
avatar_urls = [
"/static/avatars/avatar_alexandra.png",
"/static/avatars/avatar_bianca.png",
"/static/avatars/avatar_chantal.png",
]
timestamps = [timezone.now() - timedelta(hours=n) for n in range(13)]
notifications_per_user: int
for user in User.objects.all():
notifications_per_user = len(
(
NotificationFactory(
recipient=user,
actor=user,
verb="Alexandra hat einen neuen Beitrag erfasst",
actor_avatar_url=avatar_urls[0],
target_url="/",
notification_type=NotificationType.USER_INTERACTION,
course="Versicherungsvermittler/-in",
timestamp=timestamps[0],
),
NotificationFactory(
recipient=user,
actor=user,
verb="Alexandra hat einen neuen Beitrag erfasst",
actor_avatar_url=avatar_urls[0],
target_url="/",
notification_type=NotificationType.USER_INTERACTION,
course="Versicherungsvermittler/-in",
timestamp=timestamps[1],
),
NotificationFactory(
recipient=user,
actor=user,
verb="Alexandra hat einen neuen Beitrag erfasst",
actor_avatar_url=avatar_urls[0],
target_url="/",
notification_type=NotificationType.USER_INTERACTION,
course="Versicherungsvermittler/-in",
timestamp=timestamps[2],
),
NotificationFactory(
recipient=user,
actor=user,
verb="Alexandra hat einen neuen Beitrag erfasst",
actor_avatar_url=avatar_urls[0],
target_url="/",
notification_type=NotificationType.USER_INTERACTION,
course="Versicherungsvermittler/-in",
timestamp=timestamps[3],
),
NotificationFactory(
recipient=user,
actor=user,
verb="Bianca hat für den Auftrag Autoversicherung 3 eine Lösung abgegeben",
actor_avatar_url=avatar_urls[1],
target_url="/",
notification_type=NotificationType.USER_INTERACTION,
course="Versicherungsvermittler/-in",
timestamp=timestamps[4],
),
NotificationFactory(
recipient=user,
actor=user,
verb="Bianca hat für den Auftrag Autoversicherung 1 eine Lösung abgegeben",
actor_avatar_url=avatar_urls[1],
target_url="/",
notification_type=NotificationType.USER_INTERACTION,
course="Versicherungsvermittler/-in",
timestamp=timestamps[5],
),
NotificationFactory(
recipient=user,
actor=user,
verb="Bianca hat für den Auftrag Autoversicherung 2 eine Lösung abgegeben",
actor_avatar_url=avatar_urls[1],
target_url="/",
notification_type=NotificationType.USER_INTERACTION,
course="Versicherungsvermittler/-in",
timestamp=timestamps[6],
),
NotificationFactory(
recipient=user,
actor=user,
verb="Bianca hat für den Auftrag Autoversicherung 4 eine Lösung abgegeben",
actor_avatar_url=avatar_urls[1],
target_url="/",
notification_type=NotificationType.USER_INTERACTION,
course="Versicherungsvermittler/-in",
timestamp=timestamps[7],
),
NotificationFactory(
recipient=user,
actor=user,
verb="Chantal hat eine Bewertung für den Transferauftrag 3 eingegeben",
target_url="/",
actor_avatar_url=avatar_urls[2],
notification_type=NotificationType.USER_INTERACTION,
course="Versicherungsvermittler/-in",
timestamp=timestamps[8],
),
NotificationFactory(
recipient=user,
actor=user,
verb="Chantal hat eine Bewertung für den Transferauftrag 4 eingegeben",
target_url="/",
actor_avatar_url=avatar_urls[2],
notification_type=NotificationType.USER_INTERACTION,
course="Versicherungsvermittler/-in",
timestamp=timestamps[9],
),
NotificationFactory(
recipient=user,
actor=user,
verb="Super, du kommst in deinem Lernpfad gut voran. Schaue dir jetzt die verfügbaren Prüfungstermine an.",
target_url="/",
notification_type=NotificationType.PROGRESS,
course="Versicherungsvermittler/-in",
timestamp=timestamps[10],
),
NotificationFactory(
recipient=user,
actor=user,
verb="Wartungsarbeiten: 20.12.2022 08:00 - 12:00",
notification_type=NotificationType.INFORMATION,
timestamp=timestamps[11],
),
NotificationFactory(
recipient=user,
actor=user,
verb="Wartungsarbeiten: 31.01.2023 08:00 - 12:00",
notification_type=NotificationType.INFORMATION,
timestamp=timestamps[12],
),
)
)
return notifications_per_user

View File

@ -0,0 +1,11 @@
import djclick as click
from vbv_lernwelt.notify.create_default_notifications import (
create_default_notifications,
)
@click.command()
def command():
print("Creating default notifications")
create_default_notifications()

View File

@ -0,0 +1,124 @@
# Generated by Django 3.2.13 on 2023-02-08 08:43
import django.db.models.deletion
import django.utils.timezone
import jsonfield.fields
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("contenttypes", "0002_remove_content_type_name"),
]
operations = [
migrations.CreateModel(
name="Notification",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"level",
models.CharField(
choices=[
("success", "success"),
("info", "info"),
("warning", "warning"),
("error", "error"),
],
default="info",
max_length=20,
),
),
("unread", models.BooleanField(db_index=True, default=True)),
("actor_object_id", models.CharField(max_length=255)),
("verb", models.CharField(max_length=255)),
("description", models.TextField(blank=True, null=True)),
(
"target_object_id",
models.CharField(blank=True, max_length=255, null=True),
),
(
"action_object_object_id",
models.CharField(blank=True, max_length=255, null=True),
),
(
"timestamp",
models.DateTimeField(
db_index=True, default=django.utils.timezone.now
),
),
("public", models.BooleanField(db_index=True, default=True)),
("deleted", models.BooleanField(db_index=True, default=False)),
("emailed", models.BooleanField(db_index=True, default=False)),
("data", jsonfield.fields.JSONField(blank=True, null=True)),
(
"notification_type",
models.CharField(
choices=[
("USER_INTERACTION", "User Interaction"),
("PROGRESS", "Progress"),
("INFORMATION", "Information"),
],
default="INFORMATION",
max_length=32,
),
),
("target_url", models.URLField(blank=True, null=True)),
("actor_avatar_url", models.URLField(blank=True, null=True)),
("course", models.CharField(blank=True, max_length=32, null=True)),
(
"action_object_content_type",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="notify_action_object",
to="contenttypes.contenttype",
),
),
(
"actor_content_type",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="notify_actor",
to="contenttypes.contenttype",
),
),
(
"recipient",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="notifications",
to=settings.AUTH_USER_MODEL,
),
),
(
"target_content_type",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="notify_target",
to="contenttypes.contenttype",
),
),
],
options={
"ordering": ["-timestamp"],
"abstract": False,
"index_together": {("recipient", "unread")},
},
),
]

View File

@ -0,0 +1,24 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
from notifications.base.models import AbstractNotification
class NotificationType(models.TextChoices):
USER_INTERACTION = "USER_INTERACTION", _("User Interaction")
PROGRESS = "PROGRESS", _("Progress")
INFORMATION = "INFORMATION", _("Information")
class Notification(AbstractNotification):
notification_type = models.CharField(
max_length=32,
choices=NotificationType.choices,
default=NotificationType.INFORMATION,
)
target_url = models.URLField(blank=True, null=True)
actor_avatar_url = models.URLField(blank=True, null=True)
course = models.CharField(max_length=32, blank=True, null=True)
class Meta(AbstractNotification.Meta):
abstract = False
ordering = ["-timestamp"]

View File

@ -0,0 +1,120 @@
import logging
from typing import Optional
from notifications.signals import notify
from sendgrid import Mail, SendGridAPIClient
from storages.utils import setting
from vbv_lernwelt.core.models import User
from vbv_lernwelt.notify.models import Notification, NotificationType
logger = logging.getLogger(__name__)
class EmailService:
_sendgrid_client = SendGridAPIClient(setting("SENDGRID_API_KEY"))
@classmethod
def send_email(cls, recipient: User, verb: str, target_url) -> bool:
message = Mail(
from_email="info@iterativ.ch",
to_emails=recipient.email,
subject=f"myVBV - {verb}",
## TODO: Add HTML content.
html_content=f"{verb}: <a href='{target_url}'>Link</a>",
)
try:
cls._sendgrid_client.send(message)
logger.info(f"Successfully sent email to {recipient}")
return True
except Exception as e:
logger.error(f"Failed to send email to {recipient}: {e}")
return False
class NotificationService:
@classmethod
def send_user_interaction_notification(
cls, recipient: User, verb: str, sender: User, course: str, target_url: str
) -> None:
cls._send_notification(
recipient=recipient,
verb=verb,
sender=sender,
course=course,
target_url=target_url,
notification_type=NotificationType.USER_INTERACTION,
)
@classmethod
def send_progress_notification(
cls, recipient: User, verb: str, course: str, target_url: str
) -> None:
cls._send_notification(
recipient=recipient,
verb=verb,
course=course,
target_url=target_url,
notification_type=NotificationType.PROGRESS,
)
@classmethod
def send_information_notification(
cls, recipient: User, verb: str, target_url: str
) -> None:
cls._send_notification(
recipient=recipient,
verb=verb,
target_url=target_url,
notification_type=NotificationType.INFORMATION,
)
@classmethod
def _send_notification(
cls,
recipient: User,
verb: str,
notification_type: NotificationType,
sender: Optional[User] = None,
course: Optional[str] = None,
target_url: Optional[str] = None,
) -> None:
actor_avatar_url: Optional[str] = None
if not sender:
sender = User.objects.get(email="admin")
else:
actor_avatar_url = sender.avatar_url
emailed = False
if cls._should_send_email(notification_type, recipient):
emailed = cls._send_email(recipient, verb, target_url)
response = notify.send(
sender=sender,
recipient=recipient,
verb=verb,
)
# Custom Notification model fields cannot be set using the notify.send() method.
# https://github.com/django-notifications/django-notifications/issues/301
sent_notification: Notification = response[0][1][0]
sent_notification.target_url = target_url
sent_notification.notification_type = notification_type
sent_notification.course = course
sent_notification.target_url = target_url
sent_notification.actor_avatar_url = actor_avatar_url
sent_notification.emailed = emailed
sent_notification.save()
@staticmethod
def _should_send_email(
notification_type: NotificationType, recipient: User
) -> bool:
return str(notification_type) in recipient.additional_json_data.get(
"email_notification_types", []
)
@staticmethod
def _send_email(recipient: User, verb: str, target_url: Optional[str]) -> bool:
return EmailService.send_email(
recipient=recipient,
verb=verb,
target_url=target_url,
)

View File

@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
#
# Iterativ GmbH
# http://www.iterativ.ch/
#
# Copyright (c) 2015 Iterativ GmbH. All rights reserved.
#
# Created on 2022-12-15
# @author: lorenz.padberg@iterativ.ch

View File

@ -0,0 +1,18 @@
import factory
from django.utils import timezone
from vbv_lernwelt.core.tests.factories import UserFactory
from vbv_lernwelt.notify.models import Notification
class NotificationFactory(factory.django.DjangoModelFactory):
class Meta:
model = Notification
recipient = factory.SubFactory(UserFactory)
timestamp = factory.Faker(
"date_time_this_month",
tzinfo=timezone.get_current_timezone(),
)
actor = factory.SubFactory(UserFactory)
verb = "Elia hat einen neuen Beitrag erfasst"

View File

@ -0,0 +1,19 @@
from django.test import TestCase
from vbv_lernwelt.core.admin import User
from vbv_lernwelt.core.tests.factories import UserFactory
from vbv_lernwelt.notify.create_default_notifications import (
create_default_notifications,
)
from vbv_lernwelt.notify.models import Notification
class TestCreateDefaultNotifications(TestCase):
def test_create_default_notifications(self):
UserFactory(username="John Doe", email="john.doe@gmail.com")
UserFactory(username="Ruedi Hürzeler", email="ruediboy69@gmail.com")
notifications_per_user = create_default_notifications()
notifications = Notification.objects.all()
expected_count = User.objects.all().count() * notifications_per_user
self.assertEqual(len(notifications), expected_count)

View File

@ -0,0 +1,15 @@
from django.test import TestCase
from vbv_lernwelt.core.tests.factories import UserFactory
from vbv_lernwelt.notify.tests.factories import NotificationFactory
class TestFactories(TestCase):
def test_create_notification(self):
notification = NotificationFactory()
self.assertIsNotNone(notification)
def test_create_notification_with_recipient(self):
recipient = UserFactory(username="John Doe", email="john.doe@gmail.com")
notification = NotificationFactory(recipient=recipient)
self.assertEqual(notification.recipient, recipient)

View File

@ -0,0 +1,139 @@
import json
from rest_framework.test import APITestCase
from vbv_lernwelt.core.admin import User
from vbv_lernwelt.core.tests.factories import UserFactory
from vbv_lernwelt.notify.models import Notification, NotificationType
from vbv_lernwelt.notify.tests.factories import NotificationFactory
class TestNotificationApi(APITestCase):
def setUp(self) -> None:
alice = UserFactory(username="Alice", email="alice@gmail.com")
john = UserFactory(username="John Doe", email="john.doe@gmail.com")
self.user = User.objects.get(username="Alice")
self.client.login(username="Alice", password="pw")
self.alice = alice
self.john = john
def create_default_notifications(self):
NotificationFactory(
recipient=self.john, verb="{} hat einen neuen Beitrag erfasst"
)
NotificationFactory(
recipient=self.john,
actor=self.alice,
verb="hat einen Tranverauftrag erstellt",
)
NotificationFactory(
recipient=self.alice,
actor=self.john,
verb="{} hat deinen Beitrag kommentiert",
unread=False,
)
NotificationFactory(
recipient=self.alice,
actor=self.john,
verb="{} ist ganz klein geworden",
unread=True,
)
def test_get_all_only_returns_logged_in_user_notification(self):
self.create_default_notifications()
response = self.client.get("/notifications/api/all_list/")
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertTrue(data["all_count"] < Notification.objects.count())
self.assertEqual(2, data["all_count"])
self.assertTrue(
all(
[
self.alice.id == notification["recipient"]
for notification in data["all_list"]
]
)
)
self.assertEqual("John Doe", data["all_list"][0]["actor"])
def test_get_all_pagination(self):
num_notifications = 322
for i in range(num_notifications):
NotificationFactory(
recipient=self.alice,
actor=self.john,
verb="{} ist ganz klein geworden",
unread=True,
)
response = self.client.get("/notifications/api/all_list/?max=10")
data = response.json()
self.assertEqual(num_notifications, data["all_count"])
self.assertEqual(len(data["all_list"]), 10)
def test_get_unread_pagination(self):
unread_notifications = 120
for i in range(unread_notifications):
NotificationFactory(
recipient=self.alice,
actor=self.john,
verb="{} ist ganz klein geworden",
unread=True,
)
while unread_notifications > 0:
to_read_at_once = 12
# Read to_read_at_once unread notifications at a time
data = self.client.get(
f"/notifications/api/unread_list/?max={to_read_at_once}&mark_as_read=true"
).json()
self.assertEqual(len(data["unread_list"]), to_read_at_once)
unread_notifications -= to_read_at_once
response = self.client.get("/notifications/api/unread_count/")
unread_count = response.json()["unread_count"]
self.assertEqual(unread_count, unread_notifications)
def test_unread_count(self):
self.create_default_notifications()
response = self.client.get("/notifications/api/unread_count/")
unread_count = response.json()["unread_count"]
self.assertEqual(unread_count, 1)
class TestNotificationSettingsApi(APITestCase):
def setUp(self) -> None:
username = "Alice"
UserFactory(username=username, email="alice@gmail.com")
self.user = User.objects.get(username=username)
self.client.login(username=username, password="pw")
def test_store_retrieve_settings(self):
notification_settings = json.dumps(
[NotificationType.INFORMATION, NotificationType.PROGRESS]
)
api_path = "/api/notify/email_notification_settings/"
response = self.client.post(
api_path,
notification_settings,
format="json",
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), notification_settings)
self.user.refresh_from_db()
self.assertEqual(
self.user.additional_json_data["email_notification_types"],
notification_settings,
)
response = self.client.get(
api_path,
format="json",
)
self.assertEqual(response.json(), notification_settings)

Some files were not shown because too many files have changed in this diff Show More