Merged in feature/notifications (pull request #15)

This commit is contained in:
Elia Bieri 2023-02-08 11:39:27 +00:00
parent aac1c638df
commit b5e4c30d40
57 changed files with 1522 additions and 102 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

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

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

@ -1,3 +1,4 @@
import dayjs from "dayjs";
import { nextTick } from "vue";
import { createI18n } from "vue-i18n";
@ -8,6 +9,7 @@ let i18n: any = null;
export function setupI18n(options = { locale: "de", legacy: false }) {
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

@ -137,6 +137,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 +412,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 +476,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"

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

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

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

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

@ -18,6 +18,7 @@ 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)
objects = UserManager()

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,6 +40,19 @@ class FeedbackResponse(models.Model):
EIGHTY = 80, "80%"
HUNDRED = 100, "100%"
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)

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

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,125 @@
# 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)

View File

@ -0,0 +1,92 @@
import json
from typing import List
from django.test import TestCase
from vbv_lernwelt.core.models import User
from vbv_lernwelt.core.tests.factories import UserFactory
from vbv_lernwelt.notify.models import Notification, NotificationType
from vbv_lernwelt.notify.service import NotificationService
class TestNotificationService(TestCase):
def setUp(self) -> None:
self.notification_service = NotificationService
self.notification_service._send_email = lambda a, b, c: True
self.admin = UserFactory(username="admin", email="admin")
self.sender_username = "Bob"
UserFactory(username=self.sender_username, email="bob@gmail.com")
self.sender = User.objects.get(username=self.sender_username)
self.recipient_username = "Alice"
UserFactory(username=self.recipient_username, email="alice@gmail.com")
self.recipient = User.objects.get(username=self.recipient_username)
self.recipient.additional_json_data["email_notification_types"] = json.dumps(
["USER_INTERACTION", "INFORMATION"]
)
self.recipient.save()
self.client.login(username=self.recipient, password="pw")
def test_send_information_notification(self):
verb = "Wartungsarbeiten: 13.12 10:00 - 13:00 Uhr"
target_url = "https://www.vbv.ch"
self.notification_service.send_information_notification(
recipient=self.recipient,
verb=verb,
target_url=target_url,
)
notifications: List[Notification] = Notification.objects.all()
self.assertEqual(1, len(notifications))
notification = notifications[0]
self.assertEqual(self.admin, notification.actor)
self.assertEqual(verb, notification.verb)
self.assertEqual(target_url, notification.target_url)
self.assertEqual(
str(NotificationType.INFORMATION), notification.notification_type
)
self.assertTrue(notification.emailed)
def test_send_progress_notification(self):
verb = "Super Fortschritt! Melde dich jetzt an."
target_url = "https://www.vbv.ch"
course = "Versicherungsvermittler/in"
self.notification_service.send_progress_notification(
recipient=self.recipient, verb=verb, target_url=target_url, course=course
)
notifications: List[Notification] = Notification.objects.all()
self.assertEqual(1, len(notifications))
notification = notifications[0]
self.assertEqual(self.admin, notification.actor)
self.assertEqual(verb, notification.verb)
self.assertEqual(target_url, notification.target_url)
self.assertEqual(course, notification.course)
self.assertEqual(str(NotificationType.PROGRESS), notification.notification_type)
self.assertFalse(notification.emailed)
def test_send_user_interaction_notification(self):
verb = "Anne hat deinen Auftrag bewertet"
target_url = "https://www.vbv.ch"
course = "Versicherungsvermittler/in"
self.notification_service.send_user_interaction_notification(
sender=self.sender,
recipient=self.recipient,
verb=verb,
target_url=target_url,
course=course,
)
notifications: List[Notification] = Notification.objects.all()
self.assertEqual(1, len(notifications))
notification = notifications[0]
self.assertEqual(self.sender, notification.actor)
self.assertEqual(verb, notification.verb)
self.assertEqual(target_url, notification.target_url)
self.assertEqual(course, notification.course)
self.assertEqual(
str(NotificationType.USER_INTERACTION), notification.notification_type
)
self.assertTrue(notification.emailed)

View File

@ -0,0 +1,14 @@
from rest_framework.decorators import api_view
from rest_framework.response import Response
@api_view(["POST", "GET"])
def email_notification_settings(request):
EMAIL_NOTIFICATION_TYPES = "email_notification_types"
if request.method == "POST":
request.user.additional_json_data[EMAIL_NOTIFICATION_TYPES] = request.data
request.user.save()
return Response(
status=200,
data=request.user.additional_json_data.get(EMAIL_NOTIFICATION_TYPES, []),
)

View File

@ -1,3 +0,0 @@
<svg viewBox="0 0 21 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.95081 19.8397H6.92941C7.0088 21.0615 7.70572 22.1554 8.77756 22.7464C9.8494 23.3331 11.1506 23.3331 12.2224 22.7464C13.2943 22.1598 13.9912 21.0615 14.0706 19.8397H18.0492C18.5564 19.8397 19.0416 19.6456 19.4033 19.2927C19.765 18.9399 19.9811 18.4635 19.9988 17.9562C20.0164 17.449 19.84 16.9594 19.5004 16.5845L17.7493 14.6349V11.0665C17.7448 9.62853 17.317 8.22588 16.5142 7.03054C15.7114 5.8396 14.5734 4.90891 13.2458 4.36196V3.63858C13.2546 2.93284 12.9943 2.24916 12.5224 1.72868C12.046 1.20378 11.3932 0.881792 10.6919 0.820039C9.92879 0.767109 9.18336 1.03617 8.62759 1.56106C8.07182 2.08155 7.75865 2.80493 7.75424 3.56801V4.3752C6.42657 4.92214 5.28856 5.84842 4.4902 7.03936C3.68742 8.23029 3.25957 9.63294 3.25516 11.0709V14.6349L1.49963 16.5801C1.16 16.955 0.983562 17.449 1.00121 17.9562C1.01885 18.4635 1.23057 18.9399 1.59667 19.2927C1.95836 19.6412 2.44797 19.8397 2.95081 19.8397ZM10.4978 22.1863C9.85381 22.1863 9.23188 21.9437 8.7555 21.5114C8.27913 21.0791 7.97919 20.4837 7.91744 19.8397H13.0781C13.0164 20.4793 12.7165 21.0747 12.2401 21.5114C11.7637 21.9437 11.1418 22.1819 10.4978 22.1863ZM17.0303 15.3141L18.7593 17.2417C18.927 17.4269 19.0152 17.6695 19.0063 17.921C18.9975 18.1724 18.8917 18.4062 18.7108 18.5782C18.53 18.7502 18.2918 18.8472 18.0404 18.8472H2.95081C2.69939 18.8472 2.4612 18.7502 2.28036 18.5782C2.09951 18.4062 1.99365 18.168 1.98483 17.921C1.97601 17.6695 2.06422 17.4269 2.23184 17.2417L4.11086 15.1509C4.19026 15.0583 4.23878 14.9436 4.23878 14.8201V11.0665C4.24319 9.77409 4.64458 8.51259 5.39001 7.45839C6.13545 6.40419 7.19406 5.60142 8.41146 5.16915C8.60995 5.09858 8.73786 4.91332 8.73786 4.70601V3.55919C8.73786 3.09163 8.92312 2.64614 9.25393 2.31532C9.58474 1.98451 10.0302 1.79925 10.4978 1.79925H10.6169C11.0712 1.84336 11.4902 2.05508 11.7946 2.39472C12.0989 2.73435 12.2621 3.17544 12.2533 3.63417V4.69719C12.2533 4.9045 12.3856 5.08976 12.5797 5.16033C13.7971 5.59259 14.8513 6.39096 15.6012 7.44516C16.3466 8.49935 16.7524 9.76086 16.7568 11.0532V14.3217"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="a" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30"><path d="M24.64,19.76l-1.94-2.2v-3.46c0-3.06-1.83-5.82-4.61-7.04v-.76c0-1.7-1.38-3.08-3.08-3.08s-3.08,1.38-3.08,3.08v.76c-2.78,1.22-4.61,3.98-4.61,7.04v3.46l-1.94,2.2c-.62,.69-.76,1.65-.38,2.5s1.19,1.37,2.12,1.37h3.81c.49,1.81,2.13,3.15,4.09,3.15s3.6-1.34,4.09-3.15h3.81c.93,0,1.74-.53,2.12-1.37s.24-1.8-.38-2.5Zm-9.64,5.52c-1.13,0-2.09-.68-2.52-1.65h5.03c-.42,.97-1.39,1.65-2.52,1.65Zm8.65-3.64c-.05,.11-.26,.49-.75,.49H7.1c-.5,0-.7-.37-.75-.49-.05-.11-.19-.52,.13-.89l2.13-2.41c.12-.14,.19-.31,.19-.5v-3.75c0-2.61,1.65-4.95,4.11-5.83,.3-.11,.5-.39,.5-.71v-1.27c0-.87,.71-1.58,1.58-1.58s1.58,.71,1.58,1.58v1.27c0,.32,.2,.6,.5,.71,2.46,.88,4.11,3.22,4.11,5.83v3.75c0,.18,.07,.36,.19,.5l2.13,2.41c.33,.37,.19,.77,.13,.89Z"/></svg>

After

Width:  |  Height:  |  Size: 834 B