diff --git a/src/agenda_culturel/models.py b/src/agenda_culturel/models.py index e571def..18e0523 100644 --- a/src/agenda_culturel/models.py +++ b/src/agenda_culturel/models.py @@ -24,7 +24,7 @@ from django.core.files import File from django.core.files.storage import default_storage from django.core.mail import send_mail from django.db import connection, models -from django.db.models import Count, F, Func, OuterRef, Q, Subquery +from django.db.models import Count, F, Func, OuterRef, Q, Subquery, Value from django.db.models.functions import Lower from django.template.defaultfilters import date as _date from django.template.defaultfilters import slugify @@ -40,8 +40,13 @@ from django_resized import ResizedImageField from icalendar import Calendar as icalCal from icalendar import Event as icalEvent from location_field.models.spatial import LocationField +from django.contrib.gis.db.models.functions import Distance from django.dispatch import receiver from django.db.models.signals import post_save +from django.db.models.functions import Now +from django.db.models.functions import ExtractDay +from django.db.models.expressions import RawSQL + from .calendar import CalendarDay from .import_tasks.extractor import Extractor @@ -914,6 +919,7 @@ class Event(models.Model): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.processing_user = None + self._proposed_events = None self._messages = [] def get_import_messages(self): @@ -1047,6 +1053,78 @@ class Event(models.Model): ), ] + def proposed_events(self): + threshold_distance = 30000 + min_tags = 1 + + if self._proposed_events is None: + qs = ( + Event.objects.filter( + Q(start_day__gte=Now()) & Q(category=self.category) & ~Q(pk=self.pk) + ) + .filter( + Q(other_versions__isnull=True) + | Q(other_versions__representative=F("pk")) + | Q(other_versions__representative__isnull=True) + ) + .filter(status=Event.STATUS.PUBLISHED) + ) + + if len(self.tags) > 0: + qs = qs.annotate( + overlap_tags=RawSQL( + sql="ARRAY(select UNNEST(%s::text[]) INTERSECT select UNNEST(tags))", + params=(self.tags,), + output_field=ArrayField(models.CharField(max_length=50)), + ) + ).annotate( + overlap_tags_count=Func( + F("overlap_tags"), + function="CARDINALITY", + output_field=models.IntegerField(), + ) + ) + else: + qs = qs.annotate(overlap_tags_count=Value(1)) + qs = ( + qs.filter(overlap_tags_count__gte=min_tags) + .annotate( + distance=Distance( + F("exact_location__location"), self.exact_location.location + ) + ) + .filter(distance__lte=threshold_distance) + .annotate( + nbday_distance=Func( + (ExtractDay(F("start_day") - self.start_day)), function="ABS" + ) + ) + .annotate(similarity_title=TrigramSimilarity("title", self.title)) + .annotate( + similarity_description=TrigramSimilarity( + "description", self.description + ) + ) + .annotate( + score=F("overlap_tags_count") * 30 + + F("similarity_title") * 2 + + F("similarity_description") * 10 + + 10 / (F("distance") + 1) + + 30 / (F("nbday_distance") + 1) + ) + .order_by("-score")[:10] + ) + + self._proposed_events = qs.only( + "title", "start_day", "start_time", "exact_location", "location" + ) + self._proposed_events = sorted( + self._proposed_events, + key=lambda e: (e.start_day, e.start_time, e.title), + ) + + return self._proposed_events + def chronology_dates(self): return self.chronology(True) diff --git a/src/agenda_culturel/static/style.scss b/src/agenda_culturel/static/style.scss index 7f56d37..03d70ac 100644 --- a/src/agenda_culturel/static/style.scss +++ b/src/agenda_culturel/static/style.scss @@ -1238,7 +1238,6 @@ article form div .recurrence-widget { article>article { margin: 3em 0.5em; - padding: 0.6em 0.2em; } @@ -2156,3 +2155,34 @@ dialog { margin-bottom: 0.5em; } } + +.slim-description { + font-size: 85%; + clear: both; + .ephemeris { + font-size: 70%; + max-width: 10em; + } + display: grid; + grid-template-columns: auto 1fr; + .tag-list { + margin-bottom: 0em; + font-size: 80%; + } + .illustration { + grid-column: 1/3; + max-height: 5em; + overflow: hidden; + display: flex; + align-items: center; + } + @media (min-width: 800px) { + grid-template-columns: auto 1fr auto; + .illustration { + max-height: 12em; + grid-column: 3/4; + width: 14em; + } + } + margin-bottom: 1em; +} diff --git a/src/agenda_culturel/templates/agenda_culturel/page-event.html b/src/agenda_culturel/templates/agenda_culturel/page-event.html index fa97020..d42a0b0 100644 --- a/src/agenda_culturel/templates/agenda_culturel/page-event.html +++ b/src/agenda_culturel/templates/agenda_culturel/page-event.html @@ -128,6 +128,17 @@ {% include "agenda_culturel/event-info-inc.html" with allbutdates=1 %} {% endif %} + {% if event.proposed_events %} +
+
+

Événements similaires

+

Cet événement vous intéresse ? Vous pourriez aussi aimer les événements ci-dessous.

+
+ {% for pe in event.proposed_events %} + {% include "agenda_culturel/single-event/event-slim-inc.html" with event=pe %} + {% endfor %} +
+ {% endif %} {% with cache_timeout=user.is_authenticated|yesno:"30,600" %} {% cache cache_timeout event_aside user.is_authenticated event.pk %} diff --git a/src/agenda_culturel/templates/agenda_culturel/single-event/event-slim-inc.html b/src/agenda_culturel/templates/agenda_culturel/single-event/event-slim-inc.html new file mode 100644 index 0000000..4cee44a --- /dev/null +++ b/src/agenda_culturel/templates/agenda_culturel/single-event/event-slim-inc.html @@ -0,0 +1,36 @@ +{% load tag_extra %} +{% load utils_extra %} +
+
{% include "agenda_culturel/ephemeris-inc.html" with event=event filter=filter %}
+
+

+ {{ pe.title }} +

+

+ {% if pe.exact_location %} + {% picto_from_name "map-pin" %} + {{ pe.exact_location.name }}, {{ pe.exact_location.city }} +

+ {% else %} + {% if pe.location %} + {% picto_from_name "map-pin" %} {{ pe.location }} + {% endif %} + {% endif %} +
+ {{ event.description |truncatewords:15 }} +

+ {% for tag in event.sorted_tags %} + {{ tag|tw_highlight }} + {% endfor %} +

+
+
+ {% if event.image or event.local_image %} +
+ {{ event.image_alt }} +
+ {% endif %} +