Skip to content

Commit

Permalink
feat(communications): render news modal on first device visit
Browse files Browse the repository at this point in the history
feat(communications/admin): AnnouncementCampaign admin and validation

fix(AnnouncementCampaignAdminForm): modifying date range

feat(communications): render news modal on first device visit

feat(communications): implemented a cache for AnnouncementCampaign

greatly restricts database usage

AnnouncementItemFactory: French locale and better string content

refactor(CommunicationsConfig): django signals safe to import at module level

refactor(communications): model changes requested in feedback

refactor(communications): implemented requested cache changes

fix: make quality

fix(communications): AnnouncementCampaign ordering

fix(static): delete unused image file

feat(communications): AnnouncementItem.unique_together on campaign and priority

refactor(communications): cache changes, added live field

refactor(communications): UniqueConstraint on start_date

refactor(communications/cache): rename variable

make quality
  • Loading branch information
calummackervoy committed Aug 8, 2024
1 parent 602fde2 commit 01450dc
Show file tree
Hide file tree
Showing 15 changed files with 606 additions and 2 deletions.
1 change: 1 addition & 0 deletions config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
},
}
Expand Down
17 changes: 17 additions & 0 deletions itou/communications/admin.py
Original file line number Diff line number Diff line change
@@ -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,)
29 changes: 29 additions & 0 deletions itou/communications/cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from datetime import date, datetime

from django.core.cache import cache

from itou.communications.models import AnnouncementCampaign


CACHE_ACTIVE_ANNOUNCEMENTS_KEY = "active-announcement-campaign"


def update_active_announcement_cache():
campaign_qs = AnnouncementCampaign.objects.filter(
start_date=date.today().replace(day=1), live=True
).prefetch_related("items")

def get_cache_expiration():
if not len(campaign_qs):
return None
return (datetime.combine(campaign_qs[0].end_date, datetime.min.time()) - datetime.now()).total_seconds()

cache.set(CACHE_ACTIVE_ANNOUNCEMENTS_KEY, campaign_qs, get_cache_expiration())
return campaign_qs


def get_cached_active_announcement():
campaign_qs = cache.get(CACHE_ACTIVE_ANNOUNCEMENTS_KEY)
if campaign_qs is None:
campaign_qs = update_active_announcement_cache()
return campaign_qs.first()
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# 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é",
),
),
(
"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.UniqueConstraint(
django.db.models.functions.datetime.ExtractYear("start_date"),
django.db.models.functions.datetime.ExtractMonth("start_date"),
name="unique_announcement_campaign_month_year",
),
),
migrations.AddConstraint(
model_name="announcementcampaign",
constraint=models.CheckConstraint(
check=models.Q(("max_items__gte", 1), ("max_items__lte", 10)), name="max_items_range"
),
),
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")},
),
]
117 changes: 117 additions & 0 deletions itou/communications/models.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -107,3 +112,115 @@ 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,
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.UniqueConstraint(
models.functions.ExtractYear("start_date"),
models.functions.ExtractMonth("start_date"),
name="unique_announcement_campaign_month_year",
),
models.CheckConstraint(name="max_items_range", check=Q(max_items__gte=1, max_items__lte=10)),
]

@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()
44 changes: 44 additions & 0 deletions itou/templates/layout/_news_modal.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{% load static %}
{% load theme_inclusion %}
{% load django_bootstrap5 %}

<input id="news-modal-start-date" type="hidden" value="{{ active_campaign_announce.start_date|date:'Y-m-d' }}" />

<div class="modal" id="news-modal" tabindex="-1" role="dialog" aria-labelledby="news-modal-label" aria-modal="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title" id="news-modal-label">Il y a du nouveau !</h3>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Fermer"></button>
</div>
<div class="modal-body">
<div class="text-center mb-5" aria-hidden="true">
<img class="img-fluid color-filter-primary" src="{% static_theme_images 'ico-bicro-important.svg' %}" alt="Dessin d'étoiles" />
</div>
{% for item in active_campaign_announce.items_for_template %}
<h6>{{ forloop.counter }}. {{ item.title }}</h6>
<p>{{ item.description }}</p>
{% endfor %}
</div>
<div class="modal-footer">
<a href="https://aide.emplois.inclusion.beta.gouv.fr/hc/fr/categories/25225629682321--Nouveaut%C3%A9s" class="btn btn-sm btn-primary">Voir toutes les nouveautés</a>
</div>
</div>
</div>
</div>

<script nonce="{{ CSP_NONCE }}">
$(document).ready(function() {
// news modal is rendered if there are recent updates which haven't been viewed on this device
if (supports_local_storage()) {
let lastNewsModalViewed = localStorage.getItem("lastNewsModalViewed");
let newsModalUpdated = new Date($("#news-modal-start-date").val());

if (!lastNewsModalViewed || new Date(lastNewsModalViewed) < newsModalUpdated) {
localStorage.setItem("lastNewsModalViewed", new Date().toISOString());
const newsModal = new bootstrap.Modal("#news-modal");
newsModal.show();
}
}
});
</script>
4 changes: 4 additions & 0 deletions itou/templates/layout/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -249,5 +249,9 @@
{% endif %}

{% block script %}{% endblock %}

{% if active_campaign_announce %}
{% include "layout/_news_modal.html" %}
{% endif %}
</body>
</html>
9 changes: 9 additions & 0 deletions itou/utils/context_processors.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import itou.institutions.enums as institutions_enums
import itou.job_applications.enums as job_applications_enums
import itou.prescribers.enums as prescribers_enums
from itou.communications.cache import get_cached_active_announcement


def expose_enums(*args):
Expand Down Expand Up @@ -38,3 +39,11 @@ def matomo(request):
context["matomo_custom_url"] = url
context["matomo_user_id"] = getattr(request.user, "pk", None)
return context


def active_announcement_campaign(request):
campaign = get_cached_active_announcement()

return {
"active_campaign_announce": (campaign if campaign is not None and campaign.items.count() else None),
}
33 changes: 33 additions & 0 deletions tests/communications/__snapshots__/test_campaigns.ambr
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# serializer version: 1
# name: TestRenderAnnouncementCampaign.test_campaign_rendered_dashboard
'''
<div aria-labelledby="news-modal-label" aria-modal="true" class="modal" id="news-modal" role="dialog" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title" id="news-modal-label">Il y a du nouveau !</h3>
<button aria-label="Fermer" class="btn-close" data-bs-dismiss="modal" type="button"></button>
</div>
<div class="modal-body">
<div aria-hidden="true" class="text-center mb-5">
<img alt="Dessin d'étoiles" class="img-fluid color-filter-primary" src="/static/vendor/theme-inclusion/images/ico-bicro-important.svg"/>
</div>

<h6>1. Item A</h6>
<p>Item A</p>

<h6>2. Item B</h6>
<p>Item B</p>

<h6>3. Item C</h6>
<p>Item C</p>

</div>
<div class="modal-footer">
<a class="btn btn-sm btn-primary" href="https://aide.emplois.inclusion.beta.gouv.fr/hc/fr/categories/25225629682321--Nouveaut%C3%A9s">Voir toutes les nouveautés</a>
</div>
</div>
</div>
</div>
'''
# ---
Loading

0 comments on commit 01450dc

Please sign in to comment.