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." %}
+