feat: implement VAPID-based web push notifications with subscription management and auto-cleanup logic

This commit is contained in:
kdeterme 2026-09-04 11:44:40 +02:00
parent 23936a5767
commit 300feca9f1
16 changed files with 1155 additions and 7 deletions

View file

@ -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_BUCKET = getenv("PANORAMAX_AWS_S3_BUCKET")
PANORAMAX_AWS_S3_PREFIX = getenv("PANORAMAX_AWS_S3_PREFIX", "photos") 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 # Optimization for tests: use a fast password hasher and preserve test database by default
import sys import sys
import os import os

View file

@ -26,8 +26,10 @@ from django.contrib.auth.decorators import login_not_required
from common import views as common_views from common import views as common_views
from common.private_files import views as private_files_views from common.private_files import views as private_files_views
from notifications import views as notifications_views
urlpatterns = [ urlpatterns = [
path("service-worker.js", notifications_views.service_worker_js, name="root_service_worker"),
path("", include("home.urls")), path("", include("home.urls")),
path("reporting/", include("reporting.urls_frontend")), path("reporting/", include("reporting.urls_frontend")),
path("common/", include("common.urls")), path("common/", include("common.urls")),

View file

@ -377,6 +377,10 @@ if ('serviceWorker' in navigator) {
<!-- MapLibre GL 5.7.0 (local) --> <!-- MapLibre GL 5.7.0 (local) -->
<script src="{% static 'common/maplibre-gl.js' %}"></script> <script src="{% static 'common/maplibre-gl.js' %}"></script>
{% if user.is_authenticated %}
<script src="{% static 'notifications/webpush.js' %}"></script>
{% endif %}
{% block extra_scripts %} {% block extra_scripts %}
{% endblock extra_scripts %} {% endblock extra_scripts %}
{% block extra_js %} {% block extra_js %}

View file

@ -3,7 +3,7 @@ from .models import (
Team, Notification, NotificationAttachment, NotificationRecipient, Team, Notification, NotificationAttachment, NotificationRecipient,
TeamNotificationTarget, TeamNotificationRead, TeamNotificationTarget, TeamNotificationRead,
Discussion, Message, MessageAttachment, Discussion, Message, MessageAttachment,
NotificationRule, NotificationRule, WebPushSubscription,
) )
@ -162,3 +162,11 @@ class MessageAdmin(admin.ModelAdmin):
search_fields = ['author__username', 'content', 'discussion__title'] search_fields = ['author__username', 'content', 'discussion__title']
readonly_fields = ['created_at'] readonly_fields = ['created_at']
inlines = [MessageAttachmentInline] 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']

View 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'],
},
),
]

View file

@ -618,3 +618,64 @@ class MessageAttachment(models.Model):
verbose_name = _("Pièce jointe de message") verbose_name = _("Pièce jointe de message")
verbose_name_plural = _("Pièces jointes de messages") verbose_name_plural = _("Pièces jointes de messages")
ordering = ['uploaded_at'] 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']

View file

@ -96,6 +96,13 @@ def notify_user(
notification=notification, notification=notification,
user=recipient, 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 return recipient_obj
@ -132,6 +139,13 @@ def notify_users(
NotificationRecipient(notification=notification, user=user) NotificationRecipient(notification=notification, user=user)
for user in recipients for user in recipients
], ignore_conflicts=True) ], 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 return notification
@ -167,6 +181,13 @@ def notify_team(
notification=notification, notification=notification,
team=team, 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 return target
@ -345,6 +366,13 @@ def _notify_mentions(discussion, author, content):
], ignore_conflicts=True) ], ignore_conflicts=True)
# Ajouter les utilisateurs mentionnés comme participants # Ajouter les utilisateurs mentionnés comme participants
discussion.participants.add(*users_to_notify) 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 # Mentions d'équipes
if mentioned_team_ids: if mentioned_team_ids:
@ -363,6 +391,13 @@ def _notify_mentions(discussion, author, content):
TeamNotificationTarget.objects.create(notification=notif, team=team) TeamNotificationTarget.objects.create(notification=notif, team=team)
# Ajouter les membres de l'équipe comme participants # Ajouter les membres de l'équipe comme participants
discussion.participants.add(*team.members.all()) 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): 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], for uid in recipient_user_ids],
ignore_conflicts=True, 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): def can_post_observation_message(user, observation):

View 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);
}
})
);
});

View 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);

View file

@ -1,7 +1,7 @@
{% extends base_template|default:"base.html" %} {% 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 %} {% block content %}
<div class="container py-3" style="max-width: 760px;"> <div class="container py-3" style="max-width: 760px;">
@ -16,12 +16,108 @@
<h1 class="h4 mb-0 fw-bold"> <h1 class="h4 mb-0 fw-bold">
<i class="bi bi-gear-fill text-primary me-2"></i>{% translate "Paramètres des notifications" %} <i class="bi bi-gear-fill text-primary me-2"></i>{% translate "Paramètres des notifications" %}
</h1> </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> </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 shadow-sm border-0 rounded-4 overflow-hidden mb-4">
<div class="card-header bg-white py-3 px-4 border-bottom border-light"> <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"> <h5 class="card-title fw-bold mb-0 text-dark d-flex align-items-center">
@ -112,4 +208,94 @@
</div> </div>
</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 %} {% endblock content %}

View file

@ -211,3 +211,155 @@ class NotificationReminderTests(TestCase):
# In Dutch, subject contains 'Herinnering' # In Dutch, subject contains 'Herinnering'
self.assertIn("Herinnering", sent_email.subject) 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/",
)

View file

@ -27,4 +27,11 @@ urlpatterns = [
path('api/unread-count/', views.api_unread_count, name='api_unread_count'), path('api/unread-count/', views.api_unread_count, name='api_unread_count'),
path('api/recent/', views.api_recent, name='api_recent'), path('api/recent/', views.api_recent, name='api_recent'),
path('api/mention-search/', views.api_mention_search, name='api_mention_search'), 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'),
] ]

View file

@ -1,14 +1,17 @@
from django.shortcuts import render, redirect, get_object_or_404 from django.shortcuts import render, redirect, get_object_or_404
from django.urls import reverse 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.contrib.auth.models import User
from django.db.models import Q from django.db.models import Q
from django.http import JsonResponse from django.http import JsonResponse, HttpResponse
from django.utils import timezone from django.utils import timezone
from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_POST, require_GET from django.views.decorators.http import require_POST, require_GET
from urllib.parse import urlsplit, urlunsplit from urllib.parse import urlsplit, urlunsplit
import json
from pathlib import Path
from .models import ( from .models import (
Notification, Notification,
@ -20,6 +23,7 @@ from .models import (
Discussion, Discussion,
Message, Message,
MessageAttachment, MessageAttachment,
WebPushSubscription,
) )
from .services import ( from .services import (
get_user_notifications, get_user_notifications,
@ -647,9 +651,13 @@ def notification_settings(request):
base_template = "mobile/mobile_base.html" if is_mobile else "base.html" 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" 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', { return render(request, 'notifications/notification_settings.html', {
'form': form, 'form': form,
'config': config, 'config': config,
'webpush_subscriptions': webpush_subscriptions,
'vapid_public_key': getattr(settings, 'WEBPUSH_VAPID_PUBLIC_KEY', ''),
'is_mobile': is_mobile, 'is_mobile': is_mobile,
'base_template': base_template, 'base_template': base_template,
'back_url': back_url, 'back_url': back_url,
@ -697,3 +705,135 @@ def api_mention_search(request):
results.append({'type': 'team', 'id': t.pk, 'display': t.name}) results.append({'type': 'team', 'id': t.pk, 'display': t.name})
return JsonResponse({'results': results}) 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')

View 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()

View file

@ -549,6 +549,9 @@ document.addEventListener('DOMContentLoaded', () => {
</script> </script>
<script src="{% static 'common/help_panel.js' %}"></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 %} {% block extra_js %}{% endblock %}
</body> </body>
</html> </html>

View file

@ -44,3 +44,4 @@ boto3>=1.34.0
markdown markdown
onnxruntime>=1.19.0 onnxruntime>=1.19.0
rapidocr-onnxruntime>=1.2.0 rapidocr-onnxruntime>=1.2.0
pywebpush>=2.0.0