feat: implement VAPID-based web push notifications with subscription management and auto-cleanup logic
This commit is contained in:
parent
23936a5767
commit
300feca9f1
16 changed files with 1155 additions and 7 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
|
|
|
|||
|
|
@ -377,6 +377,10 @@ if ('serviceWorker' in navigator) {
|
|||
<!-- MapLibre GL 5.7.0 (local) -->
|
||||
<script src="{% static 'common/maplibre-gl.js' %}"></script>
|
||||
|
||||
{% if user.is_authenticated %}
|
||||
<script src="{% static 'notifications/webpush.js' %}"></script>
|
||||
{% endif %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
{% endblock extra_scripts %}
|
||||
{% block extra_js %}
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
|
|
|||
37
loko/notifications/migrations/0003_webpushsubscription.py
Normal file
37
loko/notifications/migrations/0003_webpushsubscription.py
Normal file
|
|
@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
76
loko/notifications/static/notifications/service-worker.js
Normal file
76
loko/notifications/static/notifications/service-worker.js
Normal file
|
|
@ -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);
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
192
loko/notifications/static/notifications/webpush.js
Normal file
192
loko/notifications/static/notifications/webpush.js
Normal file
|
|
@ -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);
|
||||
|
|
@ -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 %}
|
||||
<div class="container py-3" style="max-width: 760px;">
|
||||
|
|
@ -16,12 +16,108 @@
|
|||
<h1 class="h4 mb-0 fw-bold">
|
||||
<i class="bi bi-gear-fill text-primary me-2"></i>{% translate "Paramètres des notifications" %}
|
||||
</h1>
|
||||
<p class="text-muted small mb-0">{% translate "Gérez vos préférences de rappel par e-mail pour les notifications non lues." %}</p>
|
||||
<p class="text-muted small mb-0">{% translate "Gérez vos alertes instantanées sur cet appareil et vos rappels par e-mail." %}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preferences Form Card -->
|
||||
<!-- 1. Web Push / Notifications OS Card -->
|
||||
<div class="card shadow-sm border-0 rounded-4 overflow-hidden mb-4">
|
||||
<div class="card-header bg-white py-3 px-4 border-bottom border-light d-flex align-items-center justify-content-between">
|
||||
<h5 class="card-title fw-bold mb-0 text-dark d-flex align-items-center">
|
||||
<i class="bi bi-app-indicator text-primary me-2"></i>{% translate "Notifications sur cet appareil (Alertes système / Push)" %}
|
||||
</h5>
|
||||
<span id="push-status-badge" class="badge rounded-pill bg-secondary-subtle text-secondary px-3 py-2">
|
||||
<i class="bi bi-hourglass me-1"></i>{% translate "Vérification..." %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body p-3 p-md-4">
|
||||
<p class="text-muted small mb-3">
|
||||
{% 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." %}
|
||||
</p>
|
||||
|
||||
<!-- Unsupported warning -->
|
||||
<div id="push-unsupported-alert" class="alert alert-warning border-0 rounded-3 small d-none mb-3">
|
||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
||||
{% 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')." %}
|
||||
</div>
|
||||
|
||||
<!-- Denied warning -->
|
||||
<div id="push-denied-alert" class="alert alert-danger border-0 rounded-3 small d-none mb-3">
|
||||
<i class="bi bi-x-circle-fill me-2"></i>
|
||||
{% 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." %}
|
||||
</div>
|
||||
|
||||
<!-- Action Controls -->
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-between gap-3 p-3 bg-light rounded-3 border mb-4">
|
||||
<div>
|
||||
<div class="fw-bold text-dark mb-1" id="push-toggle-title">
|
||||
{% translate "Activer les notifications sur cet appareil" %}
|
||||
</div>
|
||||
<div class="small text-muted" id="push-toggle-desc">
|
||||
{% translate "Cliquez pour autoriser et enregistrer cet appareil." %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<button id="btn-toggle-push" type="button" class="btn btn-primary rounded-pill px-4 btn-sm d-flex align-items-center gap-2">
|
||||
<i class="bi bi-bell-fill"></i>
|
||||
<span id="btn-toggle-push-text">{% translate "Activer sur cet appareil" %}</span>
|
||||
</button>
|
||||
<button id="btn-test-push" type="button" class="btn btn-outline-secondary rounded-pill px-3 btn-sm d-none d-flex align-items-center gap-1.5" title="{% translate 'Envoyer une notification test' %}">
|
||||
<i class="bi bi-send-fill"></i>
|
||||
<span>{% translate "Tester" %}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Registered Devices List -->
|
||||
<div>
|
||||
<h6 class="fw-bold text-dark mb-3 d-flex align-items-center">
|
||||
<i class="bi bi-devices text-secondary me-2"></i>{% translate "Appareils enregistrés pour votre compte" %}
|
||||
<span class="badge bg-secondary-subtle text-secondary ms-2 rounded-pill">{{ webpush_subscriptions.count }}</span>
|
||||
</h6>
|
||||
{% if webpush_subscriptions %}
|
||||
<div class="list-group list-group-flush rounded-3 border">
|
||||
{% for sub in webpush_subscriptions %}
|
||||
<div class="list-group-item d-flex align-items-center justify-content-between p-3">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<div class="rounded-circle bg-primary-subtle text-primary p-2 d-flex align-items-center justify-content-center" style="width: 40px; height: 40px;">
|
||||
{% if "Android" in sub.os_name or "iOS" in sub.os_name %}
|
||||
<i class="bi bi-phone fs-5"></i>
|
||||
{% else %}
|
||||
<i class="bi bi-laptop fs-5"></i>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-semibold text-dark mb-0">
|
||||
{{ sub.device_name|default:sub.browser_name|default:_("Appareil") }}
|
||||
</div>
|
||||
<div class="text-muted small">
|
||||
<i class="bi bi-calendar3 me-1"></i>{% translate "Enregistré le" %} {{ sub.created_at|date:"d/m/Y H:i" }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="{% url 'notifications:api_webpush_delete_device' sub.pk %}">
|
||||
{% csrf_token %}
|
||||
{% if is_mobile %}<input type="hidden" name="from" value="mobile">{% endif %}
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger rounded-circle p-2 d-flex align-items-center justify-content-center" style="width:34px; height:34px;" title="{% translate 'Retirer cet appareil' %}" onclick="return confirm('{% translate 'Voulez-vous retirer cet appareil des notifications push ?' %}');">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="p-3 text-center text-muted small bg-light rounded-3 border">
|
||||
<i class="bi bi-info-circle me-1"></i>{% translate "Aucun appareil n'est actuellement enregistré pour recevoir des notifications push." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. Email Reminders Preferences Form Card -->
|
||||
<div class="card shadow-sm border-0 rounded-4 overflow-hidden mb-4">
|
||||
<div class="card-header bg-white py-3 px-4 border-bottom border-light">
|
||||
<h5 class="card-title fw-bold mb-0 text-dark d-flex align-items-center">
|
||||
|
|
@ -112,4 +208,94 @@
|
|||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Web Push Management Script -->
|
||||
<script src="{% static 'notifications/webpush.js' %}"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', async function () {
|
||||
const badge = document.getElementById('push-status-badge');
|
||||
const unsupportedAlert = document.getElementById('push-unsupported-alert');
|
||||
const deniedAlert = document.getElementById('push-denied-alert');
|
||||
const toggleBtn = document.getElementById('btn-toggle-push');
|
||||
const toggleBtnText = document.getElementById('btn-toggle-push-text');
|
||||
const testBtn = document.getElementById('btn-test-push');
|
||||
const toggleTitle = document.getElementById('push-toggle-title');
|
||||
const toggleDesc = document.getElementById('push-toggle-desc');
|
||||
|
||||
if (!window.LokoWebPush || !window.LokoWebPush.isSupported()) {
|
||||
badge.className = 'badge rounded-pill bg-warning-subtle text-warning-emphasis px-3 py-2';
|
||||
badge.innerHTML = '<i class="bi bi-x-circle me-1"></i>{% translate "Non supporté" %}';
|
||||
unsupportedAlert.classList.remove('d-none');
|
||||
toggleBtn.classList.add('disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
async function updateUiState() {
|
||||
const perm = window.LokoWebPush.getPermissionState();
|
||||
if (perm === 'denied') {
|
||||
badge.className = 'badge rounded-pill bg-danger-subtle text-danger px-3 py-2';
|
||||
badge.innerHTML = '<i class="bi bi-shield-lock-fill me-1"></i>{% translate "Bloqué par le navigateur" %}';
|
||||
deniedAlert.classList.remove('d-none');
|
||||
toggleBtn.className = 'btn btn-secondary rounded-pill px-4 btn-sm disabled';
|
||||
toggleBtnText.textContent = '{% translate "Permissions bloquées" %}';
|
||||
testBtn.classList.add('d-none');
|
||||
return;
|
||||
}
|
||||
|
||||
const sub = await window.LokoWebPush.getExistingSubscription();
|
||||
if (sub) {
|
||||
badge.className = 'badge rounded-pill bg-success-subtle text-success px-3 py-2';
|
||||
badge.innerHTML = '<i class="bi bi-check-circle-fill me-1"></i>{% translate "Actif sur cet appareil" %}';
|
||||
toggleBtn.className = 'btn btn-outline-danger rounded-pill px-4 btn-sm d-flex align-items-center gap-2';
|
||||
toggleBtn.innerHTML = '<i class="bi bi-bell-slash"></i><span>{% translate "Désactiver sur cet appareil" %}</span>';
|
||||
toggleTitle.textContent = '{% translate "Notifications actives sur cet appareil" %}';
|
||||
toggleDesc.textContent = '{% translate "Cet appareil reçoit les alertes système instantanées." %}';
|
||||
testBtn.classList.remove('d-none');
|
||||
} else {
|
||||
badge.className = 'badge rounded-pill bg-secondary-subtle text-secondary px-3 py-2';
|
||||
badge.innerHTML = '<i class="bi bi-bell-slash me-1"></i>{% translate "Désactivé sur cet appareil" %}';
|
||||
toggleBtn.className = 'btn btn-primary rounded-pill px-4 btn-sm d-flex align-items-center gap-2';
|
||||
toggleBtn.innerHTML = '<i class="bi bi-bell-fill"></i><span>{% translate "Activer sur cet appareil" %}</span>';
|
||||
toggleTitle.textContent = '{% translate "Activer les notifications sur cet appareil" %}';
|
||||
toggleDesc.textContent = '{% translate "Cliquez pour autoriser et enregistrer cet appareil." %}';
|
||||
testBtn.classList.add('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
await updateUiState();
|
||||
|
||||
toggleBtn.addEventListener('click', async function () {
|
||||
toggleBtn.disabled = true;
|
||||
try {
|
||||
const sub = await window.LokoWebPush.getExistingSubscription();
|
||||
if (sub) {
|
||||
await window.LokoWebPush.unsubscribe();
|
||||
} else {
|
||||
await window.LokoWebPush.subscribe();
|
||||
}
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
alert(err.message || '{% translate "Erreur lors de la modification des notifications." %}');
|
||||
await updateUiState();
|
||||
} finally {
|
||||
toggleBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
testBtn.addEventListener('click', async function () {
|
||||
testBtn.disabled = true;
|
||||
const originalHtml = testBtn.innerHTML;
|
||||
testBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>{% translate "Envoi..." %}';
|
||||
try {
|
||||
const res = await window.LokoWebPush.sendTestNotification();
|
||||
// L'alerte native de l'OS va s'afficher via le service worker
|
||||
} catch (err) {
|
||||
alert(err.message || '{% translate "Erreur lors de l\'envoi du test." %}');
|
||||
} finally {
|
||||
testBtn.disabled = false;
|
||||
testBtn.innerHTML = originalHtml;
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock content %}
|
||||
|
|
|
|||
|
|
@ -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/",
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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/<int:pk>/delete/', views.api_webpush_delete_device, name='api_webpush_delete_device'),
|
||||
path('service-worker.js', views.service_worker_js, name='service_worker_js'),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
226
loko/notifications/webpush.py
Normal file
226
loko/notifications/webpush.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -549,6 +549,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
</script>
|
||||
|
||||
<script src="{% static 'common/help_panel.js' %}"></script>
|
||||
{% if user.is_authenticated %}
|
||||
<script src="{% static 'notifications/webpush.js' %}"></script>
|
||||
{% endif %}
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -44,3 +44,4 @@ boto3>=1.34.0
|
|||
markdown
|
||||
onnxruntime>=1.19.0
|
||||
rapidocr-onnxruntime>=1.2.0
|
||||
pywebpush>=2.0.0
|
||||
|
|
|
|||
Loading…
Reference in a new issue