diff --git a/bitbucket-pipelines.yml b/bitbucket-pipelines.yml
index 6c25695a..716cc9c6 100644
--- a/bitbucket-pipelines.yml
+++ b/bitbucket-pipelines.yml
@@ -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:
diff --git a/client/package-lock.json b/client/package-lock.json
index 070a6e83..e2df688f 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -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",
diff --git a/client/package.json b/client/package.json
index 23e695e8..4d8a7a34 100644
--- a/client/package.json
+++ b/client/package.json
@@ -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",
diff --git a/client/src/components/MainNavigationBar.vue b/client/src/components/MainNavigationBar.vue
index 119ef72a..9f553174 100644
--- a/client/src/components/MainNavigationBar.vue
+++ b/client/src/components/MainNavigationBar.vue
@@ -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[] = [
-
-
-
+
{{ $t("mediaLibrary.title") }}
-
-
-
+
{
{{ userStore.first_name }} {{ userStore.last_name }}
diff --git a/client/src/components/learningPath/LearningSequence.vue b/client/src/components/learningPath/LearningSequence.vue
index c1b59510..6f57cd46 100644
--- a/client/src/components/learningPath/LearningSequence.vue
+++ b/client/src/components/learningPath/LearningSequence.vue
@@ -126,7 +126,10 @@ const learningSequenceBorderClass = computed(() => {
diff --git a/client/src/components/notifications/NotificationList.vue b/client/src/components/notifications/NotificationList.vue
new file mode 100644
index 00000000..286551b8
--- /dev/null
+++ b/client/src/components/notifications/NotificationList.vue
@@ -0,0 +1,98 @@
+
+
+
+
+ {{ $t("notifications.no_notifications") }}
+
+
+
+
![Notification icon]()
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/notifications/NotificationPopover.vue b/client/src/components/notifications/NotificationPopover.vue
new file mode 100644
index 00000000..f1c0505c
--- /dev/null
+++ b/client/src/components/notifications/NotificationPopover.vue
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/notifications/NotificationPopoverContent.vue b/client/src/components/notifications/NotificationPopoverContent.vue
new file mode 100644
index 00000000..5ceca56b
--- /dev/null
+++ b/client/src/components/notifications/NotificationPopoverContent.vue
@@ -0,0 +1,20 @@
+
+
+
+ {{ $t("general.notification") }}
+
+
+
+
+ {{ $t("general.showAll") }}
+
+
+
+
+
diff --git a/client/src/components/ui/ItCheckbox.vue b/client/src/components/ui/ItCheckbox.vue
index bcdccbb4..92d44476 100644
--- a/client/src/components/ui/ItCheckbox.vue
+++ b/client/src/components/ui/ItCheckbox.vue
@@ -1,17 +1,11 @@
+
+
+ {{ label }}
+
+
+
+
diff --git a/client/src/components/ui/checkbox.types.ts b/client/src/components/ui/checkbox.types.ts
new file mode 100644
index 00000000..bf806b60
--- /dev/null
+++ b/client/src/components/ui/checkbox.types.ts
@@ -0,0 +1,6 @@
+export interface CheckboxItem {
+ value: T;
+ label?: string;
+ checked: boolean;
+ subtitle?: string;
+}
diff --git a/client/src/i18n.ts b/client/src/i18n.ts
index 1bd3880f..8a012703 100644
--- a/client/src/i18n.ts
+++ b/client/src/i18n.ts
@@ -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;
}
diff --git a/client/src/locales/de.json b/client/src/locales/de.json
index 6476f25f..61cc2089 100644
--- a/client/src/locales/de.json
+++ b/client/src/locales/de.json
@@ -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",
diff --git a/client/src/main.ts b/client/src/main.ts
index 45bf3427..86ba2c70 100644
--- a/client/src/main.ts
+++ b/client/src/main.ts
@@ -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");
diff --git a/client/src/pages/NotificationsPage.vue b/client/src/pages/NotificationsPage.vue
new file mode 100644
index 00000000..e38b96cb
--- /dev/null
+++ b/client/src/pages/NotificationsPage.vue
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+ {{ $t("general.notification") }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/pages/SettingsPage.vue b/client/src/pages/SettingsPage.vue
new file mode 100644
index 00000000..1e13b808
--- /dev/null
+++ b/client/src/pages/SettingsPage.vue
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+ {{ $t("general.settings") }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/pages/StyleGuidePage.vue b/client/src/pages/StyleGuidePage.vue
index f6021185..00001352 100644
--- a/client/src/pages/StyleGuidePage.vue
+++ b/client/src/pages/StyleGuidePage.vue
@@ -137,6 +137,11 @@ function log(data: any) {
+
+ notification
+
+
+
arrow-up
@@ -407,14 +412,56 @@ function log(data: any) {
Checkbox
Label
- Disabled
+
+
+
+ Disabled
+
+
+ Checkbox Group
+
+
Dropdown
@@ -429,12 +476,7 @@ function log(data: any) {
-
+
-
- {{ $t("competences.showAll") }}
+ {{ $t("general.showAll") }}
@@ -137,7 +137,7 @@ const countStatus = computed(() => {
:to="`${competenceStore.competenceProfilePage()?.frontend_url}/criteria`"
class="btn-text inline-flex items-center py-2 pl-0"
>
- {{ $t("competences.showAll") }}
+ {{ $t("general.showAll") }}
@@ -165,7 +165,7 @@ const countStatus = computed(() => {
:to="`${competenceStore.competenceProfilePage()?.frontend_url}/criteria`"
class="btn-text inline-flex items-center py-2 pl-0"
>
- {{ $t("competences.showAll") }}
+ {{ $t("general.showAll") }}
diff --git a/client/src/router/index.ts b/client/src/router/index.ts
index 9732449b..0fa65d18 100644
--- a/client/src/router/index.ts
+++ b/client/src/router/index.ts
@@ -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"),
diff --git a/client/src/stores/notifications.ts b/client/src/stores/notifications.ts
new file mode 100644
index 00000000..104b3e9f
--- /dev/null
+++ b/client/src/stores/notifications.ts
@@ -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 {
+ 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 };
+});
diff --git a/client/src/types.ts b/client/src/types.ts
index 3292c4b5..1f3cbde6 100644
--- a/client/src/types.ts
+++ b/client/src/types.ts
@@ -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;
+}
diff --git a/cypress/e2e/notifications.cy.js b/cypress/e2e/notifications.cy.js
new file mode 100644
index 00000000..22805a73
--- /dev/null
+++ b/cypress/e2e/notifications.cy.js
@@ -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");
+ });
+});
diff --git a/env_secrets/local_elia.env b/env_secrets/local_elia.env
new file mode 100644
index 00000000..16a4c5a0
Binary files /dev/null and b/env_secrets/local_elia.env differ
diff --git a/git-crypt-encrypted-files.txt b/git-crypt-encrypted-files.txt
index 1720be9a..b6fc905c 100644
--- a/git-crypt-encrypted-files.txt
+++ b/git-crypt-encrypted-files.txt
@@ -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
diff --git a/prepare_server.sh b/prepare_server.sh
index 4316e0df..fa4f8916 100755
--- a/prepare_server.sh
+++ b/prepare_server.sh
@@ -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)
diff --git a/server/config/settings/base.py b/server/config/settings/base.py
index 2dd3b40e..db868792 100644
--- a/server/config/settings/base.py
+++ b/server/config/settings/base.py
@@ -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...
- # ------------------------------------------------------------------------------
diff --git a/server/config/urls.py b/server/config/urls.py
index 7cb1c220..46fc8c4f 100644
--- a/server/config/urls.py
+++ b/server/config/urls.py
@@ -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"),
diff --git a/server/requirements/requirements-dev.txt b/server/requirements/requirements-dev.txt
index 9f3d070a..c3762b5a 100644
--- a/server/requirements/requirements-dev.txt
+++ b/server/requirements/requirements-dev.txt
@@ -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
diff --git a/server/requirements/requirements.in b/server/requirements/requirements.in
index 06638e2b..2dc15a5f 100644
--- a/server/requirements/requirements.in
+++ b/server/requirements/requirements.in
@@ -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
diff --git a/server/requirements/requirements.txt b/server/requirements/requirements.txt
index 4da18a30..5de977b6 100644
--- a/server/requirements/requirements.txt
+++ b/server/requirements/requirements.txt
@@ -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
diff --git a/server/vbv_lernwelt/core/management/commands/cypress_reset.py b/server/vbv_lernwelt/core/management/commands/cypress_reset.py
index 45589426..74d3961d 100644
--- a/server/vbv_lernwelt/core/management/commands/cypress_reset.py
+++ b/server/vbv_lernwelt/core/management/commands/cypress_reset.py
@@ -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()
diff --git a/server/vbv_lernwelt/core/migrations/0005_create_users.py b/server/vbv_lernwelt/core/migrations/0005_create_users.py
index b843aad0..bb1a3278 100644
--- a/server/vbv_lernwelt/core/migrations/0005_create_users.py
+++ b/server/vbv_lernwelt/core/migrations/0005_create_users.py
@@ -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
)
diff --git a/server/vbv_lernwelt/core/migrations/0006_user_additional_json_data.py b/server/vbv_lernwelt/core/migrations/0006_user_additional_json_data.py
new file mode 100644
index 00000000..df92c99a
--- /dev/null
+++ b/server/vbv_lernwelt/core/migrations/0006_user_additional_json_data.py
@@ -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),
+ ),
+ ]
diff --git a/server/vbv_lernwelt/core/models.py b/server/vbv_lernwelt/core/models.py
index 34f35c85..9940cd78 100644
--- a/server/vbv_lernwelt/core/models.py
+++ b/server/vbv_lernwelt/core/models.py
@@ -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()
diff --git a/server/vbv_lernwelt/feedback/models.py b/server/vbv_lernwelt/feedback/models.py
index 87206fc8..6b4f2a10 100644
--- a/server/vbv_lernwelt/feedback/models.py
+++ b/server/vbv_lernwelt/feedback/models.py
@@ -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)
diff --git a/server/vbv_lernwelt/feedback/tests/test_feedback_api.py b/server/vbv_lernwelt/feedback/tests/test_feedback_api.py
index 55d2b70e..6b10de7c 100644
--- a/server/vbv_lernwelt/feedback/tests/test_feedback_api.py
+++ b/server/vbv_lernwelt/feedback/tests/test_feedback_api.py
@@ -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
diff --git a/server/vbv_lernwelt/notify/__init__.py b/server/vbv_lernwelt/notify/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/server/vbv_lernwelt/notify/apps.py b/server/vbv_lernwelt/notify/apps.py
new file mode 100644
index 00000000..59aae618
--- /dev/null
+++ b/server/vbv_lernwelt/notify/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class NotifyConfig(AppConfig):
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "vbv_lernwelt.notify"
diff --git a/server/vbv_lernwelt/notify/create_default_notifications.py b/server/vbv_lernwelt/notify/create_default_notifications.py
new file mode 100644
index 00000000..d4a16b1c
--- /dev/null
+++ b/server/vbv_lernwelt/notify/create_default_notifications.py
@@ -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
diff --git a/server/vbv_lernwelt/media_library/management/__init__.py b/server/vbv_lernwelt/notify/management/__init__.py
similarity index 100%
rename from server/vbv_lernwelt/media_library/management/__init__.py
rename to server/vbv_lernwelt/notify/management/__init__.py
diff --git a/server/vbv_lernwelt/media_library/management/commands/__init__.py b/server/vbv_lernwelt/notify/management/commands/__init__.py
similarity index 100%
rename from server/vbv_lernwelt/media_library/management/commands/__init__.py
rename to server/vbv_lernwelt/notify/management/commands/__init__.py
diff --git a/server/vbv_lernwelt/notify/management/commands/create_default_notifications.py b/server/vbv_lernwelt/notify/management/commands/create_default_notifications.py
new file mode 100644
index 00000000..adfbd125
--- /dev/null
+++ b/server/vbv_lernwelt/notify/management/commands/create_default_notifications.py
@@ -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()
diff --git a/server/vbv_lernwelt/notify/migrations/0001_initial.py b/server/vbv_lernwelt/notify/migrations/0001_initial.py
new file mode 100644
index 00000000..3bf38ead
--- /dev/null
+++ b/server/vbv_lernwelt/notify/migrations/0001_initial.py
@@ -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")},
+ },
+ ),
+ ]
diff --git a/server/vbv_lernwelt/notify/migrations/__init__.py b/server/vbv_lernwelt/notify/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/server/vbv_lernwelt/notify/models.py b/server/vbv_lernwelt/notify/models.py
new file mode 100644
index 00000000..0592214f
--- /dev/null
+++ b/server/vbv_lernwelt/notify/models.py
@@ -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"]
diff --git a/server/vbv_lernwelt/notify/service.py b/server/vbv_lernwelt/notify/service.py
new file mode 100644
index 00000000..e14decd9
--- /dev/null
+++ b/server/vbv_lernwelt/notify/service.py
@@ -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}: Link",
+ )
+ 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,
+ )
diff --git a/server/vbv_lernwelt/notify/tests/__init__.py b/server/vbv_lernwelt/notify/tests/__init__.py
new file mode 100644
index 00000000..c9cc73b4
--- /dev/null
+++ b/server/vbv_lernwelt/notify/tests/__init__.py
@@ -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
diff --git a/server/vbv_lernwelt/notify/tests/factories.py b/server/vbv_lernwelt/notify/tests/factories.py
new file mode 100644
index 00000000..8358a282
--- /dev/null
+++ b/server/vbv_lernwelt/notify/tests/factories.py
@@ -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"
diff --git a/server/vbv_lernwelt/notify/tests/test_create_default_notifications.py b/server/vbv_lernwelt/notify/tests/test_create_default_notifications.py
new file mode 100644
index 00000000..e87779f6
--- /dev/null
+++ b/server/vbv_lernwelt/notify/tests/test_create_default_notifications.py
@@ -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)
diff --git a/server/vbv_lernwelt/notify/tests/test_factories.py b/server/vbv_lernwelt/notify/tests/test_factories.py
new file mode 100644
index 00000000..41e661b2
--- /dev/null
+++ b/server/vbv_lernwelt/notify/tests/test_factories.py
@@ -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)
diff --git a/server/vbv_lernwelt/notify/tests/test_notify_api.py b/server/vbv_lernwelt/notify/tests/test_notify_api.py
new file mode 100644
index 00000000..7f10d785
--- /dev/null
+++ b/server/vbv_lernwelt/notify/tests/test_notify_api.py
@@ -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)
diff --git a/server/vbv_lernwelt/notify/tests/test_service.py b/server/vbv_lernwelt/notify/tests/test_service.py
new file mode 100644
index 00000000..b98f51b0
--- /dev/null
+++ b/server/vbv_lernwelt/notify/tests/test_service.py
@@ -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)
diff --git a/server/vbv_lernwelt/notify/views.py b/server/vbv_lernwelt/notify/views.py
new file mode 100644
index 00000000..b5e2f50b
--- /dev/null
+++ b/server/vbv_lernwelt/notify/views.py
@@ -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, []),
+ )
diff --git a/server/vbv_lernwelt/static/icons/icon-bell.svg b/server/vbv_lernwelt/static/icons/icon-bell.svg
deleted file mode 100644
index ef5f7d35..00000000
--- a/server/vbv_lernwelt/static/icons/icon-bell.svg
+++ /dev/null
@@ -1,3 +0,0 @@
-
diff --git a/server/vbv_lernwelt/static/icons/icon-notification.svg b/server/vbv_lernwelt/static/icons/icon-notification.svg
new file mode 100644
index 00000000..092baa49
--- /dev/null
+++ b/server/vbv_lernwelt/static/icons/icon-notification.svg
@@ -0,0 +1 @@
+
\ No newline at end of file