feat: implement notification system, email reminders, and user language preferences

This commit is contained in:
Karl Determe 2026-08-14 22:48:09 +02:00
parent b1e7f726a2
commit ef3f1bdaa6
16 changed files with 1402 additions and 932 deletions

View file

@ -0,0 +1,18 @@
# Generated by Django 6.0.7 on 2026-08-14 20:29
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('common', '0010_userconfig_notification_reminder_delay_hours_and_more'),
]
operations = [
migrations.AddField(
model_name='userconfig',
name='preferred_language',
field=models.CharField(choices=[('fr', 'Français'), ('nl', 'Nederlands'), ('en', 'English')], default='fr', help_text="Langue utilisée pour l'interface, les e-mails et les notifications.", max_length=5, verbose_name='Langue préférée'),
),
]

View file

@ -158,6 +158,18 @@ class UserConfig(models.Model):
verbose_name="Tri de la liste des interventions", verbose_name="Tri de la liste des interventions",
help_text="Préférences de tri pour la vue liste des interventions.", help_text="Préférences de tri pour la vue liste des interventions.",
) )
PREFERRED_LANGUAGE_CHOICES = [
('fr', _('Français')),
('nl', _('Nederlands')),
('en', _('English')),
]
preferred_language = models.CharField(
max_length=5,
choices=PREFERRED_LANGUAGE_CHOICES,
default='fr',
verbose_name=_("Langue préférée"),
help_text=_("Langue utilisée pour l'interface, les e-mails et les notifications."),
)
notification_reminder_enabled = models.BooleanField( notification_reminder_enabled = models.BooleanField(
default=False, default=False,
verbose_name=_("Activer les rappels par e-mail"), verbose_name=_("Activer les rappels par e-mail"),

View file

@ -120,6 +120,8 @@ class LanguageSwitchingTests(TestCase):
# Check that the language cookie is set # Check that the language cookie is set
self.assertIn(settings.LANGUAGE_COOKIE_NAME, self.client.cookies) self.assertIn(settings.LANGUAGE_COOKIE_NAME, self.client.cookies)
self.assertEqual(self.client.cookies[settings.LANGUAGE_COOKIE_NAME].value, "fr") self.assertEqual(self.client.cookies[settings.LANGUAGE_COOKIE_NAME].value, "fr")
self.user.config.refresh_from_db()
self.assertEqual(self.user.config.preferred_language, "fr")
def test_set_language_to_dutch(self): def test_set_language_to_dutch(self):
"""Test switching to Dutch language.""" """Test switching to Dutch language."""
@ -134,6 +136,8 @@ class LanguageSwitchingTests(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertIn(settings.LANGUAGE_COOKIE_NAME, self.client.cookies) self.assertIn(settings.LANGUAGE_COOKIE_NAME, self.client.cookies)
self.assertEqual(self.client.cookies[settings.LANGUAGE_COOKIE_NAME].value, "nl") self.assertEqual(self.client.cookies[settings.LANGUAGE_COOKIE_NAME].value, "nl")
self.user.config.refresh_from_db()
self.assertEqual(self.user.config.preferred_language, "nl")
def test_set_language_to_english(self): def test_set_language_to_english(self):
"""Test switching to English language.""" """Test switching to English language."""
@ -148,6 +152,8 @@ class LanguageSwitchingTests(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertIn(settings.LANGUAGE_COOKIE_NAME, self.client.cookies) self.assertIn(settings.LANGUAGE_COOKIE_NAME, self.client.cookies)
self.assertEqual(self.client.cookies[settings.LANGUAGE_COOKIE_NAME].value, "en") self.assertEqual(self.client.cookies[settings.LANGUAGE_COOKIE_NAME].value, "en")
self.user.config.refresh_from_db()
self.assertEqual(self.user.config.preferred_language, "en")
def test_invalid_language_code(self): def test_invalid_language_code(self):
"""Test that invalid language codes are handled gracefully.""" """Test that invalid language codes are handled gracefully."""

View file

@ -587,4 +587,25 @@ def get_help_document(request, doc_name):
return JsonResponse({'html': html_content}) return JsonResponse({'html': html_content})
from django.views.i18n import set_language as django_set_language
@login_not_required
def set_language(request):
"""
Wraps Django's set_language to persist the chosen language in UserConfig.preferred_language
when the user is authenticated.
"""
response = django_set_language(request)
if request.user.is_authenticated and request.method == 'POST':
lang_code = request.POST.get('language')
valid_langs = [code for code, _ in getattr(settings, 'LANGUAGES', [('fr', 'Français'), ('nl', 'Nederlands'), ('en', 'English')])]
if lang_code and lang_code in valid_langs:
config, _ = UserConfig.objects.get_or_create(user=request.user)
if config.preferred_language != lang_code:
config.preferred_language = lang_code
config.save(update_fields=['preferred_language'])
return response

View file

@ -109,11 +109,15 @@ class NotificationReminderSettingsForm(forms.ModelForm):
class Meta: class Meta:
model = UserConfig model = UserConfig
fields = [ fields = [
'preferred_language',
'notification_reminder_enabled', 'notification_reminder_enabled',
'notification_reminder_emails', 'notification_reminder_emails',
'notification_reminder_delay_hours', 'notification_reminder_delay_hours',
] ]
widgets = { widgets = {
'preferred_language': forms.Select(
attrs={'class': 'form-select rounded-3'}
),
'notification_reminder_enabled': forms.CheckboxInput( 'notification_reminder_enabled': forms.CheckboxInput(
attrs={'class': 'form-check-input', 'role': 'switch'} attrs={'class': 'form-check-input', 'role': 'switch'}
), ),

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -5,11 +5,12 @@ from datetime import timedelta
from django.conf import settings from django.conf import settings
from django.core.mail import EmailMultiAlternatives from django.core.mail import EmailMultiAlternatives
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.utils import timezone from django.utils import timezone, translation
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from common.models import UserConfig from common.models import UserConfig
from notifications.models import NotificationRecipient from notifications.models import NotificationRecipient
from notifications.views import _translate_notification_data
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -87,81 +88,96 @@ class Command(BaseCommand):
logger.warning("Aucune adresse e-mail trouvée pour l'utilisateur %s.", user.username) logger.warning("Aucune adresse e-mail trouvée pour l'utilisateur %s.", user.username)
continue continue
# Construire l'e-mail user_lang = getattr(config, 'preferred_language', 'fr') or 'fr'
count = len(valid_recipients) with translation.override(user_lang):
subject = f"[StreetUp] Rappel : {count} notification(s) non lue(s)" # Construire l'e-mail dans la langue de l'utilisateur
count = len(valid_recipients)
subject = _("[StreetUp] Rappel : %(count)d notification(s) non lue(s)") % {'count': count}
# Host URL pour les liens # Host URL pour les liens
domain = (getattr(settings, 'ROOT_URL', '') or getattr(settings, 'SITE_URL', '') or 'http://localhost:8000').rstrip('/') domain = (getattr(settings, 'ROOT_URL', '') or getattr(settings, 'SITE_URL', '') or 'http://localhost:8000').rstrip('/')
lines_text = [ user_display_name = user.get_full_name() or user.username
f"Bonjour {user.get_full_name() or user.username},\n", greeting = _("Bonjour %(name)s,") % {'name': user_display_name}
f"Vous avez {count} notification(s) non lue(s) reçue(s) il y a plus de {delay_hours} heure(s) :\n", intro_text = _("Vous avez %(count)d notification(s) non lue(s) reçue(s) il y a plus de %(delay)d heure(s) :") % {
] 'count': count,
'delay': delay_hours,
}
html_items = [] lines_text = [
for r in valid_recipients: f"{greeting}\n",
n = r.notification f"{intro_text}\n",
created_str = n.created_at.strftime('%d/%m/%Y à %H:%M') ]
sender_str = n.sender.get_full_name() if n.sender else _("Système")
if n.action_url: html_items = []
action_path = n.action_url if n.action_url.startswith('/') else f"/{n.action_url}" for r in valid_recipients:
link = f"{domain}{action_path}" n = r.notification
_translate_notification_data(n)
created_str = n.created_at.strftime('%d/%m/%Y %H:%M')
sender_str = n.sender.get_full_name() if n.sender else _("Système")
if n.action_url:
action_path = n.action_url if n.action_url.startswith('/') else f"/{n.action_url}"
link = f"{domain}{action_path}"
else:
link = f"{domain}/notifications/"
type_label = n.get_notification_type_display()
lines_text.append(f"- [{type_label}] {n.title} ({_('reçue le')} {created_str} {_('par')} {sender_str})")
lines_text.append(f" {_('Lien')}: {link}\n")
html_items.append(
f"<li>"
f"<strong>{n.title}</strong> ({type_label})<br>"
f"<small style='color:#6c757d;'>{_('Reçue le')} {created_str} • {sender_str}</small><br>"
f"<a href='{link}' style='color:#2f3e8b; font-weight:bold;'>{_('Consulter')}</a>"
f"</li>"
)
lines_text.append(f"\n{_('Accédez à votre boîte de réception :')} {domain}/notifications/")
text_body = "\n".join(lines_text)
html_title = _("Notifications non lues")
html_intro = _("Vous avez <strong>%(count)d</strong> notification(s) non lue(s) qui attendent votre attention :") % {'count': count}
btn_label = _("Voir mes notifications")
html_body = f"""
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; color: #333;">
<h2 style="color: #2f3e8b;">{html_title}</h2>
<p>{greeting}</p>
<p>{html_intro}</p>
<ul style="line-height: 1.6; padding-left: 20px;">
{"".join(html_items)}
</ul>
<p style="margin-top: 20px;">
<a href="{domain}/notifications/" style="background-color: #2f3e8b; color: #fff; padding: 10px 20px; text-decoration: none; border-radius: 5px; display: inline-block;">
{btn_label}
</a>
</p>
</div>
"""
if dry_run:
self.stdout.write(f"[DRY-RUN] E-mail simulé [{user_lang}] pour {user.username} ({recipient_emails}) - {count} notification(s).")
else: else:
link = f"{domain}/notifications/" try:
msg = EmailMultiAlternatives(
subject=subject,
body=text_body,
from_email=getattr(settings, 'DEFAULT_FROM_EMAIL', 'StreetUp no-reply <no-reply@streetup.be>'),
to=recipient_emails,
)
msg.attach_alternative(html_body, "text/html")
msg.send()
lines_text.append(f"- [{n.get_notification_type_display()}] {n.title} (reçue le {created_str} par {sender_str})") # Marquer le rappel envoyé
lines_text.append(f" Lien: {link}\n") NotificationRecipient.objects.filter(pk__in=[r.pk for r in valid_recipients]).update(
reminder_sent_at=now
html_items.append( )
f"<li>" sent_count += 1
f"<strong>{n.title}</strong> ({n.get_notification_type_display()})<br>" user_count += 1
f"<small style='color:#6c757d;'>Reçue le {created_str} • {sender_str}</small><br>" self.stdout.write(self.style.SUCCESS(f"Rappel envoyé avec succès à {user.username} ({recipient_emails}) en [{user_lang}]."))
f"<a href='{link}' style='color:#2f3e8b; font-weight:bold;'>Consulter</a>" except Exception as exc:
f"</li>" logger.exception("Erreur d'envoi du rappel pour %s: %s", user.username, exc)
) self.stderr.write(self.style.ERROR(f"Erreur d'envoi pour {user.username}: {exc}"))
lines_text.append(f"\nAccédez à votre boîte de réception : {domain}/notifications/")
text_body = "\n".join(lines_text)
html_body = f"""
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; color: #333;">
<h2 style="color: #2f3e8b;">Notifications non lues</h2>
<p>Bonjour {user.get_full_name() or user.username},</p>
<p>Vous avez <strong>{count}</strong> notification(s) non lue(s) qui attendent votre attention :</p>
<ul style="line-height: 1.6; padding-left: 20px;">
{"".join(html_items)}
</ul>
<p style="margin-top: 20px;">
<a href="{domain}/notifications/" style="background-color: #2f3e8b; color: #fff; padding: 10px 20px; text-decoration: none; border-radius: 5px; display: inline-block;">
Voir mes notifications
</a>
</p>
</div>
"""
if dry_run:
self.stdout.write(f"[DRY-RUN] E-mail simulé pour {user.username} ({recipient_emails}) - {count} notification(s).")
else:
try:
msg = EmailMultiAlternatives(
subject=subject,
body=text_body,
from_email=getattr(settings, 'DEFAULT_FROM_EMAIL', 'StreetUp no-reply <no-reply@streetup.be>'),
to=recipient_emails,
)
msg.attach_alternative(html_body, "text/html")
msg.send()
# Marquer le rappel envoyé
NotificationRecipient.objects.filter(pk__in=[r.pk for r in valid_recipients]).update(
reminder_sent_at=now
)
sent_count += 1
user_count += 1
self.stdout.write(self.style.SUCCESS(f"Rappel envoyé avec succès à {user.username} ({recipient_emails})."))
except Exception as exc:
logger.exception("Erreur d'envoi du rappel pour %s: %s", user.username, exc)
self.stderr.write(self.style.ERROR(f"Erreur d'envoi pour {user.username}: {exc}"))
self.stdout.write(self.style.SUCCESS(f"Traitement terminé : {sent_count} e-mail(s) envoyé(s) pour {user_count} utilisateur(s).")) self.stdout.write(self.style.SUCCESS(f"Traitement terminé : {sent_count} e-mail(s) envoyé(s) pour {user_count} utilisateur(s)."))

View file

@ -30,6 +30,22 @@
<form method="post" action=""> <form method="post" action="">
{% csrf_token %} {% csrf_token %}
<!-- Preferred Language -->
<div class="mb-4">
<label for="{{ form.preferred_language.id_for_label }}" class="form-label fw-semibold text-dark">
<i class="bi bi-translate me-1 text-primary"></i>{% translate "Langue préférée pour les e-mails et notifications" %}
</label>
<div style="max-width: 280px;">
{{ form.preferred_language }}
</div>
{% if form.preferred_language.errors %}
<div class="text-danger small mt-1">{{ form.preferred_language.errors|join:", " }}</div>
{% endif %}
<div class="form-text small text-muted">
{% translate "Les e-mails de rappel et les notifications automatiques vous seront envoyés dans cette langue." %}
</div>
</div>
<!-- Toggle Switch --> <!-- Toggle Switch -->
<div class="p-3 bg-light rounded-3 mb-4 border d-flex align-items-center justify-content-between"> <div class="p-3 bg-light rounded-3 mb-4 border d-flex align-items-center justify-content-between">
<div> <div>

View file

@ -123,6 +123,7 @@ class NotificationReminderTests(TestCase):
# POST updated settings # POST updated settings
response = self.client.post(reverse('notifications:settings'), { response = self.client.post(reverse('notifications:settings'), {
'preferred_language': 'nl',
'notification_reminder_enabled': 'on', 'notification_reminder_enabled': 'on',
'notification_reminder_emails': 'custom@example.com, other@example.com', 'notification_reminder_emails': 'custom@example.com, other@example.com',
'notification_reminder_delay_hours': 12, 'notification_reminder_delay_hours': 12,
@ -130,6 +131,7 @@ class NotificationReminderTests(TestCase):
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.config.refresh_from_db() self.config.refresh_from_db()
self.assertEqual(self.config.preferred_language, 'nl')
self.assertTrue(self.config.notification_reminder_enabled) self.assertTrue(self.config.notification_reminder_enabled)
self.assertEqual(self.config.notification_reminder_emails, 'custom@example.com, other@example.com') self.assertEqual(self.config.notification_reminder_emails, 'custom@example.com, other@example.com')
self.assertEqual(self.config.notification_reminder_delay_hours, 12) self.assertEqual(self.config.notification_reminder_delay_hours, 12)
@ -175,3 +177,37 @@ class NotificationReminderTests(TestCase):
# Verify reminder_sent_at is set # Verify reminder_sent_at is set
recipient.refresh_from_db() recipient.refresh_from_db()
self.assertIsNotNone(recipient.reminder_sent_at) self.assertIsNotNone(recipient.reminder_sent_at)
@override_settings(EMAIL_OVERRIDE=None)
def test_send_unread_notification_reminders_multilingual(self):
from datetime import timedelta
from django.utils import timezone
from django.core import mail
from django.core.management import call_command
from notifications.models import Notification, NotificationRecipient
self.config.notification_reminder_enabled = True
self.config.notification_reminder_delay_hours = 1
self.config.preferred_language = 'nl'
self.config.save()
past_time = timezone.now() - timedelta(hours=2)
notif = Notification.objects.create(
title="Dringende melding",
content="Details",
notification_type=Notification.TYPE_ALERT,
)
recipient = NotificationRecipient.objects.create(
notification=notif,
user=self.user,
)
Notification.objects.filter(pk=notif.pk).update(created_at=past_time)
mail.outbox.clear()
call_command('send_unread_notification_reminders')
self.assertEqual(len(mail.outbox), 1)
sent_email = mail.outbox[0]
# In Dutch, subject contains 'Herinnering'
self.assertIn("Herinnering", sent_email.subject)

View file

@ -597,8 +597,9 @@ def discussion_create(request):
files = request.FILES.getlist('attachments') files = request.FILES.getlist('attachments')
if content: if content:
user_name = request.user.get_full_name() or request.user.username
discussion = Discussion.objects.create( discussion = Discussion.objects.create(
title=title or f"Discussion de {request.user.get_full_name() or request.user.username}", title=title or (_("Discussion de %(name)s") % {'name': user_name}),
created_by=request.user, created_by=request.user,
) )
discussion.participants.add(request.user) discussion.participants.add(request.user)

View file

@ -21,8 +21,9 @@ from django.conf import settings
from django.conf.urls.static import static from django.conf.urls.static import static
from django.conf.urls import handler403 from django.conf.urls import handler403
from django.shortcuts import render from django.shortcuts import render
from django.views.i18n import JavaScriptCatalog, set_language from django.views.i18n import JavaScriptCatalog
from django.contrib.auth.decorators import login_not_required 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 common.private_files import views as private_files_views
@ -55,7 +56,7 @@ urlpatterns = [
path("password/change/", auth_views.PasswordChangeView.as_view(success_url=reverse_lazy("common:account_info")), name="password_change",), path("password/change/", auth_views.PasswordChangeView.as_view(success_url=reverse_lazy("common:account_info")), name="password_change",),
path('accounts/password_reset/', auth_views.PasswordResetView.as_view(), name='password_reset'), path('accounts/password_reset/', auth_views.PasswordResetView.as_view(), name='password_reset'),
path("private/download/", private_files_views.private_file_download, name="private_file_download"), path("private/download/", private_files_views.private_file_download, name="private_file_download"),
path("i18n/setlang/", login_not_required(set_language), name="set_language"), path("i18n/setlang/", login_not_required(common_views.set_language), name="set_language"),
path("jsi18n/", login_not_required(JavaScriptCatalog.as_view()), name="javascript-catalog"), path("jsi18n/", login_not_required(JavaScriptCatalog.as_view()), name="javascript-catalog"),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)