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