diff --git a/loko/loko/settings.py b/loko/loko/settings.py index b61d3ba..047dd02 100644 --- a/loko/loko/settings.py +++ b/loko/loko/settings.py @@ -395,6 +395,17 @@ PANORAMAX_AWS_S3_ENDPOINT_URL = getenv("PANORAMAX_AWS_S3_ENDPOINT_URL", "https:/ PANORAMAX_AWS_S3_BUCKET = getenv("PANORAMAX_AWS_S3_BUCKET") PANORAMAX_AWS_S3_PREFIX = getenv("PANORAMAX_AWS_S3_PREFIX", "photos") +# Web Push Notifications (VAPID Configuration) +WEBPUSH_VAPID_PUBLIC_KEY = getenv( + "WEBPUSH_VAPID_PUBLIC_KEY", + "BIzld-DmEzVBP8TyyubtuFkXMQSyQZ2rfGXUvGezNDW_1FZBYrgSiT_J5uTs1fmmLhXC3kNTbyfrDxSs2P1YzqM" +) +WEBPUSH_VAPID_PRIVATE_KEY = getenv( + "WEBPUSH_VAPID_PRIVATE_KEY", + "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgrORfbYRbN3bcY5xy\n3QT2zoJ2LgaNp327obN6KLXHMF+hRANCAASM5Xfg5hM1QT/E8srm7bhZFzEEskGd\nq3xl1LxnszQ1v9RWQWK4Eok/yebk7NX5pi4Vwt5DU28n6w8UrNj9WM6j\n-----END PRIVATE KEY-----" +) +WEBPUSH_VAPID_ADMIN_EMAIL = getenv("WEBPUSH_VAPID_ADMIN_EMAIL", "mailto:admin@valogeo.be") + # Optimization for tests: use a fast password hasher and preserve test database by default import sys import os diff --git a/loko/loko/urls.py b/loko/loko/urls.py index 2e11961..4019533 100644 --- a/loko/loko/urls.py +++ b/loko/loko/urls.py @@ -26,8 +26,10 @@ from django.contrib.auth.decorators import login_not_required from common import views as common_views from common.private_files import views as private_files_views +from notifications import views as notifications_views urlpatterns = [ + path("service-worker.js", notifications_views.service_worker_js, name="root_service_worker"), path("", include("home.urls")), path("reporting/", include("reporting.urls_frontend")), path("common/", include("common.urls")), diff --git a/loko/mobile/templates/mobile/mobile_base.html b/loko/mobile/templates/mobile/mobile_base.html index 10af0a6..7a0fe12 100644 --- a/loko/mobile/templates/mobile/mobile_base.html +++ b/loko/mobile/templates/mobile/mobile_base.html @@ -377,6 +377,10 @@ if ('serviceWorker' in navigator) { +{% if user.is_authenticated %} + +{% endif %} + {% block extra_scripts %} {% endblock extra_scripts %} {% block extra_js %} diff --git a/loko/notifications/admin.py b/loko/notifications/admin.py index 378f678..11dc90b 100644 --- a/loko/notifications/admin.py +++ b/loko/notifications/admin.py @@ -3,7 +3,7 @@ from .models import ( Team, Notification, NotificationAttachment, NotificationRecipient, TeamNotificationTarget, TeamNotificationRead, Discussion, Message, MessageAttachment, - NotificationRule, + NotificationRule, WebPushSubscription, ) @@ -162,3 +162,11 @@ class MessageAdmin(admin.ModelAdmin): search_fields = ['author__username', 'content', 'discussion__title'] readonly_fields = ['created_at'] inlines = [MessageAttachmentInline] + + +@admin.register(WebPushSubscription) +class WebPushSubscriptionAdmin(admin.ModelAdmin): + list_display = ['user', 'device_name', 'browser_name', 'os_name', 'created_at', 'updated_at'] + list_filter = ['browser_name', 'os_name', 'created_at'] + search_fields = ['user__username', 'device_name', 'user_agent', 'endpoint'] + readonly_fields = ['created_at', 'updated_at'] diff --git a/loko/notifications/migrations/0003_webpushsubscription.py b/loko/notifications/migrations/0003_webpushsubscription.py new file mode 100644 index 0000000..73c00c7 --- /dev/null +++ b/loko/notifications/migrations/0003_webpushsubscription.py @@ -0,0 +1,37 @@ +# Generated by Django 6.0.7 on 2026-09-04 09:35 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0002_notificationrecipient_reminder_sent_at'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='WebPushSubscription', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('endpoint', models.TextField(help_text='URL unique de réception push fournie par le navigateur.', unique=True, verbose_name='Endpoint Push')), + ('p256dh', models.CharField(help_text='Clé publique du client.', max_length=255, verbose_name='Clé p256dh')), + ('auth', models.CharField(help_text="Secret d'authentification pour le chiffrement.", max_length=100, verbose_name='Secret auth')), + ('user_agent', models.CharField(blank=True, max_length=500, verbose_name='User Agent')), + ('browser_name', models.CharField(blank=True, max_length=100, verbose_name='Navigateur')), + ('os_name', models.CharField(blank=True, max_length=100, verbose_name="Système d'exploitation")), + ('device_name', models.CharField(blank=True, max_length=150, verbose_name="Nom de l'appareil")), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Enregistré le')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Mis à jour le')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='webpush_subscriptions', to=settings.AUTH_USER_MODEL, verbose_name='Utilisateur')), + ], + options={ + 'verbose_name': 'Abonnement Web Push', + 'verbose_name_plural': 'Abonnements Web Push', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/loko/notifications/models.py b/loko/notifications/models.py index d7d43c3..b6093ba 100644 --- a/loko/notifications/models.py +++ b/loko/notifications/models.py @@ -618,3 +618,64 @@ class MessageAttachment(models.Model): verbose_name = _("Pièce jointe de message") verbose_name_plural = _("Pièces jointes de messages") ordering = ['uploaded_at'] + + +# ───────────────────────────────────────────────────────────────────────────── +# Notifications Push (Web Push API / VAPID) +# ───────────────────────────────────────────────────────────────────────────── + +class WebPushSubscription(models.Model): + """Abonnement d'un appareil aux notifications push pour un utilisateur.""" + + user = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name='webpush_subscriptions', + verbose_name=_("Utilisateur"), + ) + endpoint = models.TextField( + unique=True, + verbose_name=_("Endpoint Push"), + help_text=_("URL unique de réception push fournie par le navigateur."), + ) + p256dh = models.CharField( + max_length=255, + verbose_name=_("Clé p256dh"), + help_text=_("Clé publique du client."), + ) + auth = models.CharField( + max_length=100, + verbose_name=_("Secret auth"), + help_text=_("Secret d'authentification pour le chiffrement."), + ) + user_agent = models.CharField( + max_length=500, + blank=True, + verbose_name=_("User Agent"), + ) + browser_name = models.CharField( + max_length=100, + blank=True, + verbose_name=_("Navigateur"), + ) + os_name = models.CharField( + max_length=100, + blank=True, + verbose_name=_("Système d'exploitation"), + ) + device_name = models.CharField( + max_length=150, + blank=True, + verbose_name=_("Nom de l'appareil"), + ) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Enregistré le")) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Mis à jour le")) + + def __str__(self): + device = self.device_name or self.browser_name or _("Appareil inconnu") + return f"{self.user.username} ({device})" + + class Meta: + verbose_name = _("Abonnement Web Push") + verbose_name_plural = _("Abonnements Web Push") + ordering = ['-created_at'] diff --git a/loko/notifications/services.py b/loko/notifications/services.py index ebf1fcd..8a78894 100644 --- a/loko/notifications/services.py +++ b/loko/notifications/services.py @@ -96,6 +96,13 @@ def notify_user( notification=notification, user=recipient, ) + from .webpush import send_webpush_to_user + send_webpush_to_user( + user=recipient, + title=title, + body=content, + action_url=action_url, + ) return recipient_obj @@ -132,6 +139,13 @@ def notify_users( NotificationRecipient(notification=notification, user=user) for user in recipients ], ignore_conflicts=True) + from .webpush import send_webpush_to_users + send_webpush_to_users( + users=recipients, + title=title, + body=content, + action_url=action_url, + ) return notification @@ -167,6 +181,13 @@ def notify_team( notification=notification, team=team, ) + from .webpush import send_webpush_to_users + send_webpush_to_users( + users=team.members.all(), + title=title, + body=content, + action_url=action_url, + ) return target @@ -345,6 +366,13 @@ def _notify_mentions(discussion, author, content): ], ignore_conflicts=True) # Ajouter les utilisateurs mentionnés comme participants discussion.participants.add(*users_to_notify) + from .webpush import send_webpush_to_users + send_webpush_to_users( + users=users_to_notify, + title=title, + body=msg_preview, + action_url=action_url, + ) # Mentions d'équipes if mentioned_team_ids: @@ -363,6 +391,13 @@ def _notify_mentions(discussion, author, content): TeamNotificationTarget.objects.create(notification=notif, team=team) # Ajouter les membres de l'équipe comme participants discussion.participants.add(*team.members.all()) + from .webpush import send_webpush_to_users + send_webpush_to_users( + users=team.members.all(), + title=title, + body=msg_preview, + action_url=action_url, + ) def mark_discussion_seen(discussion, user=None): @@ -475,6 +510,13 @@ def process_notification_rules(event, context, source_app=''): for uid in recipient_user_ids], ignore_conflicts=True, ) + from .webpush import send_webpush_to_users + send_webpush_to_users( + users=recipient_user_ids, + title=title, + body=content, + action_url=safe_ctx.get('action_url', ''), + ) def can_post_observation_message(user, observation): diff --git a/loko/notifications/static/notifications/service-worker.js b/loko/notifications/static/notifications/service-worker.js new file mode 100644 index 0000000..e095d21 --- /dev/null +++ b/loko/notifications/static/notifications/service-worker.js @@ -0,0 +1,76 @@ +/** + * Service Worker pour les Notifications Push Loko (Web Push API) + */ + +self.addEventListener('install', function (event) { + self.skipWaiting(); +}); + +self.addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()); +}); + +self.addEventListener('push', function (event) { + if (!event.data) { + return; + } + + let payload = {}; + try { + payload = event.data.json(); + } catch (e) { + payload = { + title: 'Loko', + body: event.data.text() + }; + } + + const title = payload.title || 'Loko'; + const actionUrl = payload.action_url || (payload.data && payload.data.url) || '/notifications/'; + const options = { + body: payload.body || '', + icon: payload.icon || '/static/common/android-192.png', + badge: payload.badge || '/static/common/favicon-48.png', + tag: payload.tag || 'loko-notification', + renotify: true, + data: { + url: actionUrl, + ...(payload.data || {}) + } + }; + + event.waitUntil( + self.registration.showNotification(title, options) + ); +}); + +self.addEventListener('notificationclick', function (event) { + event.notification.close(); + + const targetUrl = (event.notification.data && event.notification.data.url) + ? event.notification.data.url + : '/notifications/'; + + event.waitUntil( + clients.matchAll({ type: 'window', includeUncontrolled: true }).then(function (clientList) { + // Chercher une fenêtre/onglet déjà ouvert sur le domaine + for (let i = 0; i < clientList.length; i++) { + const client = clientList[i]; + if ('focus' in client) { + if (client.url.includes(targetUrl)) { + return client.focus(); + } + } + } + // Si aucune fenêtre correspondante n'est trouvée, ouvrir une nouvelle fenêtre ou focus le premier client + if (clientList.length > 0 && 'focus' in clientList[0]) { + const client = clientList[0]; + client.navigate(targetUrl); + return client.focus(); + } + if (clients.openWindow) { + return clients.openWindow(targetUrl); + } + }) + ); +}); diff --git a/loko/notifications/static/notifications/webpush.js b/loko/notifications/static/notifications/webpush.js new file mode 100644 index 0000000..71965c2 --- /dev/null +++ b/loko/notifications/static/notifications/webpush.js @@ -0,0 +1,192 @@ +/** + * Client Web Push pour Loko (Gestion des abonnements et notifications OS) + */ + +(function (window) { + 'use strict'; + + function urlB64ToUint8Array(base64String) { + const padding = '='.repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding) + .replace(/\-/g, '+') + .replace(/_/g, '/'); + + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; + } + + function getCookie(name) { + let cookieValue = null; + if (document.cookie && document.cookie !== '') { + const cookies = document.cookie.split(';'); + for (let i = 0; i < cookies.length; i++) { + const cookie = cookies[i].trim(); + if (cookie.substring(0, name.length + 1) === (name + '=')) { + cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); + break; + } + } + } + return cookieValue; + } + + const LokoWebPush = { + swRegistration: null, + + isSupported: function () { + return ( + 'serviceWorker' in navigator && + 'PushManager' in window && + 'Notification' in window + ); + }, + + getPermissionState: function () { + if (!this.isSupported()) return 'unsupported'; + return Notification.permission; // 'granted', 'denied', or 'default' + }, + + initServiceWorker: async function () { + if (!this.isSupported()) return null; + if (this.swRegistration) return this.swRegistration; + + try { + const reg = await navigator.serviceWorker.register('/service-worker.js', { scope: '/' }); + this.swRegistration = reg; + return reg; + } catch (err) { + console.warn('[LokoWebPush] Échec enregistrement Service Worker:', err); + return null; + } + }, + + getExistingSubscription: async function () { + const reg = await this.initServiceWorker(); + if (!reg) return null; + return await reg.pushManager.getSubscription(); + }, + + fetchVapidPublicKey: async function () { + const response = await fetch('/notifications/api/webpush/vapid-key/'); + if (!response.ok) { + throw new Error('Impossible de récupérer la clé VAPID publique'); + } + const data = await response.json(); + return data.publicKey; + }, + + subscribe: async function () { + if (!this.isSupported()) { + throw new Error('Les notifications push ne sont pas supportées par votre navigateur.'); + } + + const permission = await Notification.requestPermission(); + if (permission !== 'granted') { + throw new Error(permission === 'denied' + ? 'Vous avez bloqué les notifications dans votre navigateur. Veuillez les réautoriser dans les paramètres du site.' + : 'La permission pour les notifications n\'a pas été accordée.' + ); + } + + const reg = await this.initServiceWorker(); + if (!reg) { + throw new Error('Le Service Worker n\'a pas pu être initialisé.'); + } + + const publicKey = await this.fetchVapidPublicKey(); + if (!publicKey) { + throw new Error('Clé VAPID introuvable.'); + } + + const convertedVapidKey = urlB64ToUint8Array(publicKey); + + let subscription = await reg.pushManager.getSubscription(); + if (!subscription) { + subscription = await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: convertedVapidKey + }); + } + + const subJson = subscription.toJSON(); + const payload = { + endpoint: subscription.endpoint, + p256dh: subJson.keys ? subJson.keys.p256dh : '', + auth: subJson.keys ? subJson.keys.auth : '', + user_agent: navigator.userAgent + }; + + const csrftoken = getCookie('csrftoken') || ''; + const saveResponse = await fetch('/notifications/api/webpush/subscribe/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrftoken + }, + body: JSON.stringify(payload) + }); + + if (!saveResponse.ok) { + const errData = await saveResponse.json().catch(() => ({})); + throw new Error(errData.error || 'Erreur lors de l\'enregistrement de l\'abonnement sur le serveur.'); + } + + return await saveResponse.json(); + }, + + unsubscribe: async function () { + const reg = await this.initServiceWorker(); + if (!reg) return; + + const subscription = await reg.pushManager.getSubscription(); + if (subscription) { + const csrftoken = getCookie('csrftoken') || ''; + try { + await fetch('/notifications/api/webpush/unsubscribe/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrftoken + }, + body: JSON.stringify({ endpoint: subscription.endpoint }) + }); + } catch (e) { + console.warn('[LokoWebPush] Erreur désinscription serveur:', e); + } + await subscription.unsubscribe(); + } + }, + + sendTestNotification: async function () { + const csrftoken = getCookie('csrftoken') || ''; + const response = await fetch('/notifications/api/webpush/test/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrftoken + } + }); + if (!response.ok) { + const errData = await response.json().catch(() => ({})); + throw new Error(errData.error || 'Erreur lors de l\'envoi du test.'); + } + return await response.json(); + } + }; + + // Auto-init SW on page load if user already has permissions + if (typeof window !== 'undefined') { + window.LokoWebPush = LokoWebPush; + window.addEventListener('load', function () { + if (LokoWebPush.isSupported() && Notification.permission === 'granted') { + LokoWebPush.initServiceWorker(); + } + }); + } + +})(window); diff --git a/loko/notifications/templates/notifications/notification_settings.html b/loko/notifications/templates/notifications/notification_settings.html index e105154..8f82ba2 100644 --- a/loko/notifications/templates/notifications/notification_settings.html +++ b/loko/notifications/templates/notifications/notification_settings.html @@ -1,7 +1,7 @@ {% extends base_template|default:"base.html" %} -{% load i18n %} +{% load i18n static %} -{% block title %}{% translate "Paramètres" %}{% endblock title %} +{% block title %}{% translate "Paramètres des notifications" %}{% endblock title %} {% block content %}
@@ -16,12 +16,108 @@

{% translate "Paramètres des notifications" %}

-

{% translate "Gérez vos préférences de rappel par e-mail pour les notifications non lues." %}

+

{% translate "Gérez vos alertes instantanées sur cet appareil et vos rappels par e-mail." %}

- + +
+
+
+ {% translate "Notifications sur cet appareil (Alertes système / Push)" %} +
+ + {% translate "Vérification..." %} + +
+
+

+ {% translate "Recevez des alertes instantanées sur votre écran (ordinateur ou smartphone) dès qu'une tâche, intervention ou mention vous concerne, même si le site est en arrière-plan." %} +

+ + +
+ + {% translate "Votre navigateur ou appareil actuel ne prend pas en charge les notifications Web Push. (Sur iPhone/iPad, installez l'application via le menu Partager > 'Sur l'écran d'accueil')." %} +
+ + +
+ + {% translate "Les notifications sont bloquées dans les paramètres de votre navigateur pour ce site. Veuillez modifier vos permissions de site pour les autoriser." %} +
+ + +
+
+
+ {% translate "Activer les notifications sur cet appareil" %} +
+
+ {% translate "Cliquez pour autoriser et enregistrer cet appareil." %} +
+
+
+ + +
+
+ + +
+
+ {% translate "Appareils enregistrés pour votre compte" %} + {{ webpush_subscriptions.count }} +
+ {% if webpush_subscriptions %} +
+ {% for sub in webpush_subscriptions %} +
+
+
+ {% if "Android" in sub.os_name or "iOS" in sub.os_name %} + + {% else %} + + {% endif %} +
+
+
+ {{ sub.device_name|default:sub.browser_name|default:_("Appareil") }} +
+
+ {% translate "Enregistré le" %} {{ sub.created_at|date:"d/m/Y H:i" }} +
+
+
+
+ {% csrf_token %} + {% if is_mobile %}{% endif %} + +
+
+ {% endfor %} +
+ {% else %} +
+ {% translate "Aucun appareil n'est actuellement enregistré pour recevoir des notifications push." %} +
+ {% endif %} +
+ +
+
+ +
@@ -112,4 +208,94 @@
+ + + + {% endblock content %} diff --git a/loko/notifications/tests.py b/loko/notifications/tests.py index 4db9cc1..fe1ce3a 100644 --- a/loko/notifications/tests.py +++ b/loko/notifications/tests.py @@ -211,3 +211,155 @@ class NotificationReminderTests(TestCase): # In Dutch, subject contains 'Herinnering' self.assertIn("Herinnering", sent_email.subject) + +class WebPushTests(TestCase): + def setUp(self): + self.user = User.objects.create_user(username='pushuser', password='password123') + self.client.force_login(self.user) + + def test_device_info_parsing(self): + from notifications.webpush import parse_device_info + ua_win_chrome = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + res = parse_device_info(ua_win_chrome) + self.assertEqual(res['browser'], 'Google Chrome') + self.assertEqual(res['os'], 'Windows') + self.assertEqual(res['device_name'], 'Google Chrome sur Windows') + + ua_ios_safari = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1" + res_ios = parse_device_info(ua_ios_safari) + self.assertEqual(res_ios['browser'], 'Safari') + self.assertEqual(res_ios['os'], 'iOS') + + def test_service_worker_route_and_headers(self): + response = self.client.get('/service-worker.js') + self.assertEqual(response.status_code, 200) + self.assertIn('application/javascript', response['Content-Type']) + self.assertEqual(response['Service-Worker-Allowed'], '/') + + def test_api_vapid_public_key(self): + response = self.client.get(reverse('notifications:api_webpush_vapid_key')) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertIn('publicKey', data) + self.assertTrue(len(data['publicKey']) > 10) + + def test_api_subscribe_and_unsubscribe(self): + from notifications.models import WebPushSubscription + payload = { + 'endpoint': 'https://fcm.googleapis.com/fcm/send/test-sub-12345', + 'p256dh': 'BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA_0QTpQtUbVlUls0VJXg7A8u-Ts1XbjhazAkj7I99e8QcYP7DkM', + 'auth': 'tBHItJI5svbpez7KI4CCXg', + 'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0' + } + + # 1. Subscribe + response = self.client.post( + reverse('notifications:api_webpush_subscribe'), + data=payload, + content_type='application/json' + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(WebPushSubscription.objects.filter(user=self.user).count(), 1) + sub = WebPushSubscription.objects.get(user=self.user) + self.assertEqual(sub.endpoint, payload['endpoint']) + self.assertEqual(sub.browser_name, 'Google Chrome') + self.assertEqual(sub.os_name, 'Windows') + + # 2. Delete device via delete route + del_resp = self.client.post(reverse('notifications:api_webpush_delete_device', args=[sub.pk])) + self.assertEqual(del_resp.status_code, 302) + self.assertEqual(WebPushSubscription.objects.filter(user=self.user).count(), 0) + + # 3. Subscribe again and unsubscribe via API + self.client.post( + reverse('notifications:api_webpush_subscribe'), + data=payload, + content_type='application/json' + ) + self.assertEqual(WebPushSubscription.objects.filter(user=self.user).count(), 1) + + unsub_resp = self.client.post( + reverse('notifications:api_webpush_unsubscribe'), + data={'endpoint': payload['endpoint']}, + content_type='application/json' + ) + self.assertEqual(unsub_resp.status_code, 200) + self.assertEqual(WebPushSubscription.objects.filter(user=self.user).count(), 0) + + def test_send_webpush_mocked(self): + from unittest.mock import patch + from notifications.models import WebPushSubscription + from notifications.webpush import send_webpush_to_user + + sub = WebPushSubscription.objects.create( + user=self.user, + endpoint='https://fcm.googleapis.com/fcm/send/mock-123', + p256dh='fake-key', + auth='fake-auth', + device_name='Test Device' + ) + + with patch('notifications.webpush.webpush') as mock_push: + send_webpush_to_user( + user=self.user, + title="Alerte Intervention", + body="Nouvelle intervention assignée", + action_url="/interventions/1/", + async_send=False + ) + mock_push.assert_called_once() + called_kwargs = mock_push.call_args[1] + self.assertEqual(called_kwargs['subscription_info']['endpoint'], sub.endpoint) + self.assertIn("Alerte Intervention", called_kwargs['data']) + + def test_send_webpush_410_auto_cleanup(self): + from unittest.mock import patch, MagicMock + from notifications.models import WebPushSubscription + from notifications.webpush import send_webpush_to_user + from pywebpush import WebPushException + + sub = WebPushSubscription.objects.create( + user=self.user, + endpoint='https://fcm.googleapis.com/fcm/send/expired-123', + p256dh='fake-key', + auth='fake-auth', + ) + + mock_resp = MagicMock() + mock_resp.status_code = 410 + + with patch('notifications.webpush.webpush', side_effect=WebPushException("Subscription expired", response=mock_resp)): + send_webpush_to_user( + user=self.user, + title="Test", + async_send=False + ) + # Subscription should be automatically deleted from DB + self.assertFalse(WebPushSubscription.objects.filter(pk=sub.pk).exists()) + + def test_notify_user_triggers_webpush(self): + from unittest.mock import patch + from notifications.models import WebPushSubscription + from notifications.services import notify_user + + WebPushSubscription.objects.create( + user=self.user, + endpoint='https://fcm.googleapis.com/fcm/send/auto-123', + p256dh='fake-key', + auth='fake-auth', + ) + + with patch('notifications.webpush.send_webpush_to_user') as mock_send: + notify_user( + recipient=self.user, + title="Nouvelle tâche", + content="Veuillez vérifier l'intervention.", + action_url="/interventions/99/", + ) + mock_send.assert_called_once_with( + user=self.user, + title="Nouvelle tâche", + body="Veuillez vérifier l'intervention.", + action_url="/interventions/99/", + ) + diff --git a/loko/notifications/urls.py b/loko/notifications/urls.py index dfd387f..a4dc4d0 100644 --- a/loko/notifications/urls.py +++ b/loko/notifications/urls.py @@ -27,4 +27,11 @@ urlpatterns = [ path('api/unread-count/', views.api_unread_count, name='api_unread_count'), path('api/recent/', views.api_recent, name='api_recent'), path('api/mention-search/', views.api_mention_search, name='api_mention_search'), + # Web Push API + path('api/webpush/vapid-key/', views.api_webpush_vapid_key, name='api_webpush_vapid_key'), + path('api/webpush/subscribe/', views.api_webpush_subscribe, name='api_webpush_subscribe'), + path('api/webpush/unsubscribe/', views.api_webpush_unsubscribe, name='api_webpush_unsubscribe'), + path('api/webpush/test/', views.api_webpush_test, name='api_webpush_test'), + path('api/webpush/device//delete/', views.api_webpush_delete_device, name='api_webpush_delete_device'), + path('service-worker.js', views.service_worker_js, name='service_worker_js'), ] diff --git a/loko/notifications/views.py b/loko/notifications/views.py index 3859ccc..c9611c6 100644 --- a/loko/notifications/views.py +++ b/loko/notifications/views.py @@ -1,14 +1,17 @@ from django.shortcuts import render, redirect, get_object_or_404 from django.urls import reverse -from django.contrib.auth.decorators import login_required +from django.contrib.auth.decorators import login_required, login_not_required from django.contrib.auth.models import User from django.db.models import Q -from django.http import JsonResponse +from django.http import JsonResponse, HttpResponse from django.utils import timezone +from django.conf import settings from django.contrib import messages from django.utils.translation import gettext_lazy as _ from django.views.decorators.http import require_POST, require_GET from urllib.parse import urlsplit, urlunsplit +import json +from pathlib import Path from .models import ( Notification, @@ -20,6 +23,7 @@ from .models import ( Discussion, Message, MessageAttachment, + WebPushSubscription, ) from .services import ( get_user_notifications, @@ -647,9 +651,13 @@ def notification_settings(request): base_template = "mobile/mobile_base.html" if is_mobile else "base.html" back_url = f"{reverse('mobile:notifications_mobile')}?tab=notifications" if is_mobile else f"{reverse('notifications:inbox')}?tab=notifications" + webpush_subscriptions = WebPushSubscription.objects.filter(user=request.user) + return render(request, 'notifications/notification_settings.html', { 'form': form, 'config': config, + 'webpush_subscriptions': webpush_subscriptions, + 'vapid_public_key': getattr(settings, 'WEBPUSH_VAPID_PUBLIC_KEY', ''), 'is_mobile': is_mobile, 'base_template': base_template, 'back_url': back_url, @@ -697,3 +705,135 @@ def api_mention_search(request): results.append({'type': 'team', 'id': t.pk, 'display': t.name}) return JsonResponse({'results': results}) + + +# ────────────────────────────────────────────────────────────────────────────── +# Web Push APIs & Service Worker +# ────────────────────────────────────────────────────────────────────────────── + +@login_not_required +def service_worker_js(request): + """ + Sert le Service Worker pour les notifications push avec le header Service-Worker-Allowed. + """ + sw_path = Path(__file__).resolve().parent / 'static' / 'notifications' / 'service-worker.js' + try: + with open(sw_path, 'r', encoding='utf-8') as f: + content = f.read() + except Exception: + content = "// Service Worker unavailable" + + response = HttpResponse(content, content_type='application/javascript; charset=utf-8') + response['Service-Worker-Allowed'] = '/' + response['Cache-Control'] = 'no-cache, no-store, must-revalidate' + return response + + +@login_required +@require_GET +def api_webpush_vapid_key(request): + """Retourne la clé publique VAPID pour l'inscription du navigateur.""" + return JsonResponse({'publicKey': getattr(settings, 'WEBPUSH_VAPID_PUBLIC_KEY', '')}) + + +@login_required +@require_POST +def api_webpush_subscribe(request): + """ + Enregistre ou met à jour la souscription Web Push de l'utilisateur pour cet appareil. + """ + try: + data = json.loads(request.body.decode('utf-8')) + except Exception: + return JsonResponse({'error': _("Requête invalide")}, status=400) + + endpoint = data.get('endpoint') + p256dh = data.get('p256dh') + auth = data.get('auth') + user_agent = data.get('user_agent', request.META.get('HTTP_USER_AGENT', '')) + + if not endpoint or not p256dh or not auth: + return JsonResponse({'error': _("Paramètres d'abonnement push incomplets")}, status=400) + + from .webpush import parse_device_info + device_info = parse_device_info(user_agent) + + sub, created = WebPushSubscription.objects.update_or_create( + endpoint=endpoint, + defaults={ + 'user': request.user, + 'p256dh': p256dh, + 'auth': auth, + 'user_agent': user_agent[:500], + 'browser_name': device_info['browser'], + 'os_name': device_info['os'], + 'device_name': device_info['device_name'], + } + ) + + return JsonResponse({ + 'status': 'success', + 'created': created, + 'device_name': sub.device_name, + }) + + +@login_required +@require_POST +def api_webpush_unsubscribe(request): + """ + Supprime la souscription Web Push de l'utilisateur pour cet appareil. + """ + try: + data = json.loads(request.body.decode('utf-8')) + endpoint = data.get('endpoint') + except Exception: + endpoint = None + + if endpoint: + WebPushSubscription.objects.filter(user=request.user, endpoint=endpoint).delete() + return JsonResponse({'status': 'success'}) + + +@login_required +@require_POST +def api_webpush_test(request): + """ + Envoie immédiatement une notification push de test sur les appareils de l'utilisateur. + """ + subs = WebPushSubscription.objects.filter(user=request.user) + if not subs.exists(): + return JsonResponse({'error': _("Aucun appareil actif n'est enregistré pour votre compte.")}, status=400) + + from .webpush import send_webpush_to_user + send_webpush_to_user( + user=request.user, + title=_("Test de notification Loko"), + body=_("Les notifications instantanées sur votre appareil fonctionnent correctement !"), + action_url='/notifications/settings/', + async_send=False, + ) + return JsonResponse({'status': 'success', 'message': _("Notification de test envoyée !")}) + + +@login_required +@require_POST +def api_webpush_delete_device(request, pk): + """ + Supprime un appareil enregistré depuis la page des paramètres. + """ + sub = get_object_or_404(WebPushSubscription, pk=pk, user=request.user) + device_name = sub.device_name or sub.browser_name or _("Appareil") + sub.delete() + messages.success(request, _("L'appareil « %(name)s » a été retiré.") % {'name': device_name}) + + is_mobile = ( + request.GET.get('from') == 'mobile' or + request.POST.get('from') == 'mobile' or + request.path.startswith('/mobile/') or + (request.META.get('HTTP_REFERER') and '/mobile/' in request.META.get('HTTP_REFERER')) + ) + if is_mobile: + return redirect(f"{reverse('mobile:notifications_mobile')}?tab=notifications") + return redirect('notifications:settings') + diff --git a/loko/notifications/webpush.py b/loko/notifications/webpush.py new file mode 100644 index 0000000..5222a8f --- /dev/null +++ b/loko/notifications/webpush.py @@ -0,0 +1,226 @@ +import json +import logging +import re +from threading import Thread + +from django.conf import settings +from django.utils import timezone +from py_vapid import Vapid +from pywebpush import webpush, WebPushException + +from .models import WebPushSubscription + +logger = logging.getLogger(__name__) + + +def parse_device_info(user_agent: str) -> dict: + """ + Analyse succincte du User-Agent pour extraire le navigateur et le système d'exploitation. + """ + if not user_agent: + return { + 'browser': 'Navigateur inconnu', + 'os': 'Système inconnu', + 'device_name': 'Appareil inconnu', + } + + ua = user_agent + + # 1. OS Detection + if 'Windows' in ua: + os_name = 'Windows' + elif 'Android' in ua: + os_name = 'Android' + elif 'iPhone' in ua or 'iPad' in ua or 'iPod' in ua: + os_name = 'iOS' + elif 'Macintosh' in ua or 'Mac OS' in ua: + os_name = 'macOS' + elif 'Linux' in ua: + os_name = 'Linux' + else: + os_name = 'Autre OS' + + # 2. Browser Detection + if 'Edg/' in ua or 'Edge/' in ua: + browser_name = 'Microsoft Edge' + elif 'OPR/' in ua or 'Opera/' in ua: + browser_name = 'Opera' + elif 'Chrome/' in ua and 'Mobile' not in ua: + browser_name = 'Google Chrome' + elif 'Chrome/' in ua and 'Mobile' in ua: + browser_name = 'Chrome Mobile' + elif 'Safari/' in ua and 'Chrome' not in ua: + browser_name = 'Safari' + elif 'Firefox/' in ua: + browser_name = 'Mozilla Firefox' + else: + browser_name = 'Navigateur Web' + + device_name = f"{browser_name} sur {os_name}" + return { + 'browser': browser_name, + 'os': os_name, + 'device_name': device_name, + } + + +def get_vapid_key_obj(): + """Charge l'objet Vapid pour la signature des requêtes push.""" + priv_key = getattr(settings, 'WEBPUSH_VAPID_PRIVATE_KEY', None) + if not priv_key: + return None + + try: + if isinstance(priv_key, str) and 'BEGIN PRIVATE KEY' in priv_key: + return Vapid.from_pem(priv_key.encode('utf-8')) + elif isinstance(priv_key, str) and (priv_key.endswith('.pem') or priv_key.endswith('.key')): + return Vapid.from_file(priv_key) + else: + return Vapid.from_string(priv_key) + except Exception as exc: + logger.error("Impossible de charger la clé privée VAPID: %s", exc) + return None + + +def get_vapid_claims(): + """Retourne les claims requis par la norme VAPID.""" + admin_email = getattr(settings, 'WEBPUSH_VAPID_ADMIN_EMAIL', 'mailto:admin@valogeo.be') + if not admin_email.startswith('mailto:') and '@' in admin_email: + admin_email = f"mailto:{admin_email}" + return {'sub': admin_email} + + +def _send_single_push(subscription: WebPushSubscription, payload: dict, vapid_obj): + """Envoie un message push chiffré à un abonnement individuel.""" + claims = get_vapid_claims() + subscription_info = { + 'endpoint': subscription.endpoint, + 'keys': { + 'p256dh': subscription.p256dh, + 'auth': subscription.auth, + }, + } + + try: + webpush( + subscription_info=subscription_info, + data=json.dumps(payload), + vapid_private_key=vapid_obj, + vapid_claims=claims, + timeout=8.0, + ttl=86400, + ) + logger.info("Notification push envoyée avec succès à %s (%s)", subscription.user.username, subscription.device_name) + except WebPushException as exc: + if exc.response is not None and exc.response.status_code in (404, 410): + # L'abonnement a expiré ou a été révoqué par le navigateur + logger.info("Suppression de l'abonnement push expiré pour %s (HTTP %s)", subscription.user.username, exc.response.status_code) + try: + subscription.delete() + except Exception: + pass + else: + logger.warning("Erreur WebPushException pour %s: %s", subscription.user.username, exc) + except Exception as exc: + logger.warning("Erreur inattendue envoi WebPush pour %s: %s", subscription.user.username, exc) + + +def send_webpush_to_user( + user, + title: str, + body: str = '', + action_url: str = '', + tag: str = None, + icon: str = None, + badge: str = None, + extra_data: dict = None, + async_send: bool = True, +): + """ + Envoie une notification push à tous les appareils enregistrés pour un utilisateur. + """ + if not user or not user.is_authenticated: + return + + subscriptions = list(WebPushSubscription.objects.filter(user=user)) + if not subscriptions: + return + + vapid_obj = get_vapid_key_obj() + if not vapid_obj: + logger.warning("Envoi push ignoré : clé VAPID non configurée.") + return + + payload = { + 'title': title, + 'body': body or '', + 'icon': icon or '/static/common/android-192.png', + 'badge': badge or '/static/common/favicon-48.png', + 'action_url': action_url or '/notifications/', + 'tag': tag or f"loko-{int(timezone.now().timestamp())}", + 'data': { + 'url': action_url or '/notifications/', + **(extra_data or {}), + }, + } + + def _worker(): + for sub in subscriptions: + _send_single_push(sub, payload, vapid_obj) + + if async_send: + t = Thread(target=_worker, daemon=True) + t.start() + else: + _worker() + + +def send_webpush_to_users( + users, + title: str, + body: str = '', + action_url: str = '', + tag: str = None, + icon: str = None, + badge: str = None, + extra_data: dict = None, + async_send: bool = True, +): + """ + Envoie une notification push à une liste d'utilisateurs. + """ + if not users: + return + + user_ids = [u.pk if hasattr(u, 'pk') else u for u in users if u] + subscriptions = list(WebPushSubscription.objects.filter(user_id__in=user_ids).select_related('user')) + if not subscriptions: + return + + vapid_obj = get_vapid_key_obj() + if not vapid_obj: + logger.warning("Envoi push ignoré : clé VAPID non configurée.") + return + + payload = { + 'title': title, + 'body': body or '', + 'icon': icon or '/static/common/android-192.png', + 'badge': badge or '/static/common/favicon-48.png', + 'action_url': action_url or '/notifications/', + 'tag': tag or f"loko-{int(timezone.now().timestamp())}", + 'data': { + 'url': action_url or '/notifications/', + **(extra_data or {}), + }, + } + + def _worker(): + for sub in subscriptions: + _send_single_push(sub, payload, vapid_obj) + + if async_send: + t = Thread(target=_worker, daemon=True) + t.start() + else: + _worker() diff --git a/loko/templates/base.html b/loko/templates/base.html index 1b81028..04baf23 100644 --- a/loko/templates/base.html +++ b/loko/templates/base.html @@ -549,6 +549,9 @@ document.addEventListener('DOMContentLoaded', () => { +{% if user.is_authenticated %} + +{% endif %} {% block extra_js %}{% endblock %} \ No newline at end of file diff --git a/requirements/base.txt b/requirements/base.txt index a482495..366cfb6 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -44,3 +44,4 @@ boto3>=1.34.0 markdown onnxruntime>=1.19.0 rapidocr-onnxruntime>=1.2.0 +pywebpush>=2.0.0