feat: implement notification system, email reminders, and user language preferences
This commit is contained in:
parent
b1e7f726a2
commit
ef3f1bdaa6
16 changed files with 1402 additions and 932 deletions
|
|
@ -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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -158,6 +158,18 @@ class UserConfig(models.Model):
|
|||
verbose_name="Tri de la 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(
|
||||
default=False,
|
||||
verbose_name=_("Activer les rappels par e-mail"),
|
||||
|
|
|
|||
|
|
@ -120,6 +120,8 @@ class LanguageSwitchingTests(TestCase):
|
|||
# Check that the language cookie is set
|
||||
self.assertIn(settings.LANGUAGE_COOKIE_NAME, self.client.cookies)
|
||||
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):
|
||||
"""Test switching to Dutch language."""
|
||||
|
|
@ -134,6 +136,8 @@ class LanguageSwitchingTests(TestCase):
|
|||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(settings.LANGUAGE_COOKIE_NAME, self.client.cookies)
|
||||
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):
|
||||
"""Test switching to English language."""
|
||||
|
|
@ -148,6 +152,8 @@ class LanguageSwitchingTests(TestCase):
|
|||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(settings.LANGUAGE_COOKIE_NAME, self.client.cookies)
|
||||
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):
|
||||
"""Test that invalid language codes are handled gracefully."""
|
||||
|
|
|
|||
|
|
@ -587,4 +587,25 @@ def get_help_document(request, doc_name):
|
|||
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
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -109,11 +109,15 @@ class NotificationReminderSettingsForm(forms.ModelForm):
|
|||
class Meta:
|
||||
model = UserConfig
|
||||
fields = [
|
||||
'preferred_language',
|
||||
'notification_reminder_enabled',
|
||||
'notification_reminder_emails',
|
||||
'notification_reminder_delay_hours',
|
||||
]
|
||||
widgets = {
|
||||
'preferred_language': forms.Select(
|
||||
attrs={'class': 'form-select rounded-3'}
|
||||
),
|
||||
'notification_reminder_enabled': forms.CheckboxInput(
|
||||
attrs={'class': 'form-check-input', 'role': 'switch'}
|
||||
),
|
||||
|
|
|
|||
Binary file not shown.
File diff suppressed because it is too large
Load diff
Binary file not shown.
File diff suppressed because it is too large
Load diff
Binary file not shown.
File diff suppressed because it is too large
Load diff
|
|
@ -5,11 +5,12 @@ from datetime import timedelta
|
|||
from django.conf import settings
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
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 common.models import UserConfig
|
||||
from notifications.models import NotificationRecipient
|
||||
from notifications.views import _translate_notification_data
|
||||
|
||||
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)
|
||||
continue
|
||||
|
||||
# Construire l'e-mail
|
||||
count = len(valid_recipients)
|
||||
subject = f"[StreetUp] Rappel : {count} notification(s) non lue(s)"
|
||||
user_lang = getattr(config, 'preferred_language', 'fr') or 'fr'
|
||||
with translation.override(user_lang):
|
||||
# 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
|
||||
domain = (getattr(settings, 'ROOT_URL', '') or getattr(settings, 'SITE_URL', '') or 'http://localhost:8000').rstrip('/')
|
||||
# Host URL pour les liens
|
||||
domain = (getattr(settings, 'ROOT_URL', '') or getattr(settings, 'SITE_URL', '') or 'http://localhost:8000').rstrip('/')
|
||||
|
||||
lines_text = [
|
||||
f"Bonjour {user.get_full_name() or user.username},\n",
|
||||
f"Vous avez {count} notification(s) non lue(s) reçue(s) il y a plus de {delay_hours} heure(s) :\n",
|
||||
]
|
||||
user_display_name = user.get_full_name() or user.username
|
||||
greeting = _("Bonjour %(name)s,") % {'name': user_display_name}
|
||||
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 = []
|
||||
for r in valid_recipients:
|
||||
n = r.notification
|
||||
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}"
|
||||
lines_text = [
|
||||
f"{greeting}\n",
|
||||
f"{intro_text}\n",
|
||||
]
|
||||
|
||||
html_items = []
|
||||
for r in valid_recipients:
|
||||
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:
|
||||
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})")
|
||||
lines_text.append(f" Lien: {link}\n")
|
||||
|
||||
html_items.append(
|
||||
f"<li>"
|
||||
f"<strong>{n.title}</strong> ({n.get_notification_type_display()})<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"\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}"))
|
||||
# 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}) en [{user_lang}]."))
|
||||
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)."))
|
||||
|
|
|
|||
|
|
@ -30,6 +30,22 @@
|
|||
<form method="post" action="">
|
||||
{% 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 -->
|
||||
<div class="p-3 bg-light rounded-3 mb-4 border d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@ class NotificationReminderTests(TestCase):
|
|||
|
||||
# POST updated settings
|
||||
response = self.client.post(reverse('notifications:settings'), {
|
||||
'preferred_language': 'nl',
|
||||
'notification_reminder_enabled': 'on',
|
||||
'notification_reminder_emails': 'custom@example.com, other@example.com',
|
||||
'notification_reminder_delay_hours': 12,
|
||||
|
|
@ -130,6 +131,7 @@ class NotificationReminderTests(TestCase):
|
|||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
self.config.refresh_from_db()
|
||||
self.assertEqual(self.config.preferred_language, 'nl')
|
||||
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_delay_hours, 12)
|
||||
|
|
@ -175,3 +177,37 @@ class NotificationReminderTests(TestCase):
|
|||
# Verify reminder_sent_at is set
|
||||
recipient.refresh_from_db()
|
||||
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)
|
||||
|
||||
|
|
|
|||
|
|
@ -597,8 +597,9 @@ def discussion_create(request):
|
|||
files = request.FILES.getlist('attachments')
|
||||
|
||||
if content:
|
||||
user_name = request.user.get_full_name() or request.user.username
|
||||
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,
|
||||
)
|
||||
discussion.participants.add(request.user)
|
||||
|
|
|
|||
|
|
@ -21,8 +21,9 @@ from django.conf import settings
|
|||
from django.conf.urls.static import static
|
||||
from django.conf.urls import handler403
|
||||
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 common import views as common_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('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("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"),
|
||||
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue