On suggère des événements sur la page d'un événement

Fix #284
This commit is contained in:
Jean-Marie Favreau 2025-03-30 14:40:10 +02:00
parent 74fe6cb86f
commit 7a9840d5cb
4 changed files with 157 additions and 2 deletions

View File

@ -24,7 +24,7 @@ from django.core.files import File
from django.core.files.storage import default_storage from django.core.files.storage import default_storage
from django.core.mail import send_mail from django.core.mail import send_mail
from django.db import connection, models 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.db.models.functions import Lower
from django.template.defaultfilters import date as _date from django.template.defaultfilters import date as _date
from django.template.defaultfilters import slugify from django.template.defaultfilters import slugify
@ -40,8 +40,13 @@ from django_resized import ResizedImageField
from icalendar import Calendar as icalCal from icalendar import Calendar as icalCal
from icalendar import Event as icalEvent from icalendar import Event as icalEvent
from location_field.models.spatial import LocationField from location_field.models.spatial import LocationField
from django.contrib.gis.db.models.functions import Distance
from django.dispatch import receiver from django.dispatch import receiver
from django.db.models.signals import post_save 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 .calendar import CalendarDay
from .import_tasks.extractor import Extractor from .import_tasks.extractor import Extractor
@ -914,6 +919,7 @@ class Event(models.Model):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.processing_user = None self.processing_user = None
self._proposed_events = None
self._messages = [] self._messages = []
def get_import_messages(self): 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): def chronology_dates(self):
return self.chronology(True) return self.chronology(True)

View File

@ -1238,7 +1238,6 @@ article form div .recurrence-widget {
article>article { article>article {
margin: 3em 0.5em; margin: 3em 0.5em;
padding: 0.6em 0.2em;
} }
@ -2156,3 +2155,34 @@ dialog {
margin-bottom: 0.5em; 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;
}

View File

@ -128,6 +128,17 @@
{% include "agenda_culturel/event-info-inc.html" with allbutdates=1 %} {% include "agenda_culturel/event-info-inc.html" with allbutdates=1 %}
</article> </article>
{% endif %} {% endif %}
{% if event.proposed_events %}
<article>
<header>
<h2>Événements similaires</h2>
<p>Cet événement vous intéresse&nbsp;? Vous pourriez aussi aimer les événements ci-dessous.</p>
</header>
{% for pe in event.proposed_events %}
{% include "agenda_culturel/single-event/event-slim-inc.html" with event=pe %}
{% endfor %}
</article>
{% endif %}
</div> </div>
{% with cache_timeout=user.is_authenticated|yesno:"30,600" %} {% with cache_timeout=user.is_authenticated|yesno:"30,600" %}
{% cache cache_timeout event_aside user.is_authenticated event.pk %} {% cache cache_timeout event_aside user.is_authenticated event.pk %}

View File

@ -0,0 +1,36 @@
{% load tag_extra %}
{% load utils_extra %}
<div class="slim-description">
<div class="date-heure">{% include "agenda_culturel/ephemeris-inc.html" with event=event filter=filter %}</div>
<div class="texte-principal">
<h3>
<a href="{{ pe.get_absolute_url }}">{{ pe.title }}</a>
</h3>
<p>
{% if pe.exact_location %}
{% picto_from_name "map-pin" %}
{{ pe.exact_location.name }}, {{ pe.exact_location.city }}
</p>
{% else %}
{% if pe.location %}
{% picto_from_name "map-pin" %} {{ pe.location }}
{% endif %}
{% endif %}
<div class="description">
{{ event.description |truncatewords:15 }}
<p class="tag-list">
{% for tag in event.sorted_tags %}
<a href="{% url 'view_tag' tag|prepare_tag %}"
role="button"
class="small-cat">{{ tag|tw_highlight }}</a>
{% endfor %}
</p>
</div>
</div>
{% if event.image or event.local_image %}
<div class="illustration">
<img src="{% if pe.local_image %}{{ event.local_image.url }}{% else %}{{ event.image }}{% endif %}"
alt="{{ event.image_alt }}" />
</div>
{% endif %}
</div>