diff --git a/config/settings/base.py b/config/settings/base.py index 281867d19c..222ada2d6d 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -168,6 +168,7 @@ "itou.utils.settings_context_processors.expose_settings", "itou.utils.context_processors.expose_enums", "itou.utils.context_processors.matomo", + "itou.utils.context_processors.active_announcement_campaign", ] }, } diff --git a/itou/communications/admin.py b/itou/communications/admin.py new file mode 100644 index 0000000000..a18bce4312 --- /dev/null +++ b/itou/communications/admin.py @@ -0,0 +1,17 @@ +from django.contrib import admin + +from itou.communications import models +from itou.utils.admin import ItouModelAdmin, ItouTabularInline + + +class AnnouncementItemInline(ItouTabularInline): + model = models.AnnouncementItem + fields = ("priority", "title", "description") + extra = 0 + + +@admin.register(models.AnnouncementCampaign) +class AnnouncementCampaignAdmin(ItouModelAdmin): + list_display = ("start_date", "end_date", "live") + fields = ("max_items", "start_date", "live") + inlines = (AnnouncementItemInline,) diff --git a/itou/communications/cache.py b/itou/communications/cache.py new file mode 100644 index 0000000000..cd95ab0ada --- /dev/null +++ b/itou/communications/cache.py @@ -0,0 +1,35 @@ +from datetime import date, datetime + +from django.core.cache import cache + +from itou.communications.models import AnnouncementCampaign + + +CACHE_ACTIVE_ANNOUNCEMENTS_KEY = "active-announcement-campaign" +SENTINEL_ACTIVE_ANNOUNCEMENT = object() + + +def update_active_announcement_cache(): + campaign = ( + AnnouncementCampaign.objects.filter(start_date=date.today().replace(day=1), live=True) + .prefetch_related("items") + .first() + ) + + if campaign is None: + cache.set(CACHE_ACTIVE_ANNOUNCEMENTS_KEY, None, None) + else: + cache_exp = ( + datetime.combine(campaign.end_date, datetime.min.time()) - datetime.now() + ).total_seconds() # seconds until the end_date, 00:00 + + cache.set(CACHE_ACTIVE_ANNOUNCEMENTS_KEY, campaign, cache_exp) + + return campaign + + +def get_cached_active_announcement(): + campaign = cache.get(CACHE_ACTIVE_ANNOUNCEMENTS_KEY, SENTINEL_ACTIVE_ANNOUNCEMENT) + if campaign == SENTINEL_ACTIVE_ANNOUNCEMENT: + return update_active_announcement_cache() + return campaign diff --git a/itou/communications/migrations/0002_announcementcampaign_announcementitem_and_more.py b/itou/communications/migrations/0002_announcementcampaign_announcementitem_and_more.py new file mode 100644 index 0000000000..f53e3ef67b --- /dev/null +++ b/itou/communications/migrations/0002_announcementcampaign_announcementitem_and_more.py @@ -0,0 +1,97 @@ +# Generated by Django 5.0.7 on 2024-07-23 17:27 + +import django.core.validators +import django.db.models.deletion +import django.db.models.functions.datetime +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("communications", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="AnnouncementCampaign", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "max_items", + models.PositiveIntegerField( + default=3, + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(10), + ], + verbose_name="nombre d'articles affiché", + ), + ), + ( + "start_date", + models.DateField( + help_text="le mois des nouveautés. Automatiquement fixé au premier du mois saisi", + verbose_name="mois concerné", + unique=True, + ), + ), + ( + "live", + models.BooleanField( + default=True, help_text="les modifications sont toujours possible", verbose_name="prêt" + ), + ), + ], + options={ + "verbose_name": "campagne d'annonce", + "ordering": ["-start_date"], + }, + ), + migrations.CreateModel( + name="AnnouncementItem", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "priority", + models.PositiveIntegerField( + default=0, + help_text="le plus bas le valeur, le plus haut dans le fil des articles", + verbose_name="priorité", + ), + ), + ("title", models.TextField(help_text="résumé de nouveauté", verbose_name="titre")), + ( + "description", + models.TextField(help_text="détail du nouveauté ; le contenu", verbose_name="description"), + ), + ], + options={ + "verbose_name": "article d'annonce", + "ordering": ["-campaign__start_date", "priority", "pk"], + }, + ), + migrations.AddConstraint( + model_name="announcementcampaign", + constraint=models.CheckConstraint( + check=models.Q(("max_items__gte", 1), ("max_items__lte", 10)), name="max_items_range" + ), + ), + migrations.AddConstraint( + model_name="announcementcampaign", + constraint=models.CheckConstraint(check=models.Q(("start_date__day", 1)), name="start_on_month"), + ), + migrations.AddField( + model_name="announcementitem", + name="campaign", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="items", + to="communications.announcementcampaign", + verbose_name="campagne", + ), + ), + migrations.AlterUniqueTogether( + name="announcementitem", + unique_together={("campaign", "priority")}, + ), + ] diff --git a/itou/communications/models.py b/itou/communications/models.py index e3bfa0c27a..9f53da6c72 100644 --- a/itou/communications/models.py +++ b/itou/communications/models.py @@ -1,7 +1,12 @@ +import calendar +from datetime import date + from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType +from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models +from django.db.models import Q class NotificationRecordQuerySet(models.QuerySet): @@ -107,3 +112,112 @@ def get_or_create(user, structure=None): @property def disabled_notifications_names(self): return self.disabled_notifications.actives().values_list("name", flat=True) + + +class AnnouncementCampaign(models.Model): + """ + It is possible on the website to launch announcement content for a limited time period, + intended for displaying the new features of the site to returning visitors + """ + + max_items = models.PositiveIntegerField( + default=3, + validators=[MinValueValidator(1), MaxValueValidator(10)], + verbose_name="nombre d'articles affiché", + ) + start_date = models.DateField( + null=False, + unique=True, + verbose_name="mois concerné", + help_text="le mois des nouveautés. Automatiquement fixé au premier du mois saisi", + ) + live = models.BooleanField( + default=True, + verbose_name="prêt", + help_text="les modifications sont toujours possible", + ) + + class Meta: + verbose_name = "campagne d'annonce" + ordering = ["-start_date"] + constraints = [ + models.CheckConstraint(name="max_items_range", check=Q(max_items__gte=1, max_items__lte=10)), + models.CheckConstraint(name="start_on_month", check=Q(start_date__day=1)), + ] + + @property + def end_date(self): + """:return: the last day of the month targeted""" + return date( + self.start_date.year, + self.start_date.month, + calendar.monthrange(self.start_date.year, self.start_date.month)[1], + ) + + def __str__(self): + return f"Campagne d'annonce du { self.start_date.strftime('%m/%Y') }" + + def clean(self): + self.start_date = self.start_date.replace(day=1) + return super().clean() + + def _update_cached_active_announcement(self): + from itou.communications.cache import get_cached_active_announcement, update_active_announcement_cache + + campaign = get_cached_active_announcement() + if campaign is None or self.pk == campaign.pk: + update_active_announcement_cache() + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + self._update_cached_active_announcement() + + def delete(self, *args, **kwargs): + super().delete(*args, **kwargs) + self._update_cached_active_announcement() + + def items_for_template(self): + return self.items.all()[: self.max_items] + + +class AnnouncementItemQuerySet(models.QuerySet): + def get_queryset(self): + return super().get_queryset().select_related("campaign") + + +class AnnouncementItem(models.Model): + campaign = models.ForeignKey( + AnnouncementCampaign, on_delete=models.CASCADE, related_name="items", verbose_name="campagne" + ) + priority = models.PositiveIntegerField( + default=0, verbose_name="priorité", help_text="le plus bas le valeur, le plus haut dans le fil des articles" + ) + title = models.TextField(null=False, blank=False, verbose_name="titre", help_text="résumé de nouveauté") + description = models.TextField( + null=False, blank=False, verbose_name="description", help_text="détail du nouveauté ; le contenu" + ) + + objects = AnnouncementItemQuerySet.as_manager() + + class Meta: + verbose_name = "article d'annonce" + ordering = ["-campaign__start_date", "priority", "pk"] + unique_together = [("campaign", "priority")] + + def __str__(self): + return self.title + + def _update_cached_active_announcement(self): + from itou.communications.cache import get_cached_active_announcement, update_active_announcement_cache + + campaign = get_cached_active_announcement() + if campaign is None or self.campaign == campaign: + update_active_announcement_cache() + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + self._update_cached_active_announcement() + + def delete(self, *args, **kwargs): + super().delete(*args, **kwargs) + self._update_cached_active_announcement() diff --git a/itou/templates/layout/_news_modal.html b/itou/templates/layout/_news_modal.html new file mode 100644 index 0000000000..a37f23aa52 --- /dev/null +++ b/itou/templates/layout/_news_modal.html @@ -0,0 +1,44 @@ +{% load static %} +{% load theme_inclusion %} +{% load django_bootstrap5 %} + + + +