Ajout d'un filtre par proximité géographique

This commit is contained in:
Jean-Marie Favreau 2024-10-16 23:55:03 +02:00
parent fba52afbb0
commit 107c55863c
12 changed files with 3682 additions and 252 deletions

View File

@ -55,6 +55,10 @@ create-categories:
docker exec -it $(BACKEND_APP_NAME) $(SHELL) "-c" \ docker exec -it $(BACKEND_APP_NAME) $(SHELL) "-c" \
"python3 manage.py runscript create_categories" "python3 manage.py runscript create_categories"
create-reference-locations:
docker exec -it $(BACKEND_APP_NAME) $(SHELL) "-c" \
"python3 manage.py runscript create_reference_locations"
build-dev: build-dev:
DOCKER_BUILDKIT=1 COMPOSE_DOCKER_CLI_BUILD=1 docker-compose -f docker-compose.yml up --build -d DOCKER_BUILDKIT=1 COMPOSE_DOCKER_CLI_BUILD=1 docker-compose -f docker-compose.yml up --build -d

View File

@ -15,6 +15,12 @@ On peut aussi peupler les catégories avec un choix de catégories élémentaire
* ```make create-categories``` * ```make create-categories```
On peut aussi peupler les positions de référence qui serviront aux recherches géographiques avec la commande, après avoir éventuellement modifié le fichier [communes.json](./src/scripts/communes.json) qui contient pour l'exemple toutes les communes récupérées depuis [public.opendatasoft.com](https://public.opendatasoft.com/explore/dataset/georef-france-commune/export/?flg=fr-fr&disjunctive.reg_name&disjunctive.dep_name&disjunctive.arrdep_name&disjunctive.ze2020_name&disjunctive.bv2022_name&disjunctive.epci_name&disjunctive.ept_name&disjunctive.com_name&disjunctive.ze2010_name&disjunctive.com_is_mountain_area&sort=year&refine.dep_name=Puy-de-D%C3%B4me&location=9,45.51597,3.05969&basemap=jawg.light):
* ```make create-reference-locations```
## Notes aux développeurs ## Notes aux développeurs
### Ajout d'une nouvelle source *custom* ### Ajout d'une nouvelle source *custom*

View File

@ -8,7 +8,8 @@ from .models import (
BatchImportation, BatchImportation,
RecurrentImport, RecurrentImport,
Place, Place,
ContactMessage ContactMessage,
ReferenceLocation
) )
from django_better_admin_arrayfield.admin.mixins import DynamicArrayMixin from django_better_admin_arrayfield.admin.mixins import DynamicArrayMixin
from django_better_admin_arrayfield.forms.widgets import DynamicArrayWidget from django_better_admin_arrayfield.forms.widgets import DynamicArrayWidget
@ -22,6 +23,7 @@ admin.site.register(BatchImportation)
admin.site.register(RecurrentImport) admin.site.register(RecurrentImport)
admin.site.register(Place) admin.site.register(Place)
admin.site.register(ContactMessage) admin.site.register(ContactMessage)
admin.site.register(ReferenceLocation)
class URLWidget(DynamicArrayWidget): class URLWidget(DynamicArrayWidget):

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,24 @@
# Generated by Django 4.2.9 on 2024-10-16 12:55
import django.contrib.gis.geos.point
from django.db import migrations, models
import location_field.models.spatial
class Migration(migrations.Migration):
dependencies = [
('agenda_culturel', '0086_alter_recurrentimport_processor'),
]
operations = [
migrations.CreateModel(
name='ReferenceLocation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Name of the location', verbose_name='Name')),
('location', location_field.models.spatial.LocationField(default=django.contrib.gis.geos.point.Point(3.08333, 45.783329), srid=4326)),
('main', models.BooleanField(default=False, help_text='This location is one of the main locations (shown first).', verbose_name='Main')),
],
),
]

View File

@ -0,0 +1,22 @@
# Generated by Django 4.2.9 on 2024-10-16 18:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('agenda_culturel', '0087_referencelocation'),
]
operations = [
migrations.AlterModelOptions(
name='referencelocation',
options={'verbose_name': 'Reference location', 'verbose_name_plural': 'Reference locations'},
),
migrations.AlterField(
model_name='referencelocation',
name='name',
field=models.CharField(help_text='Name of the location', unique=True, verbose_name='Name'),
),
]

View File

@ -236,6 +236,22 @@ class DuplicatedEvents(models.Model):
s.delete() s.delete()
return nb return nb
class ReferenceLocation(models.Model):
name = models.CharField(verbose_name=_("Name"), help_text=_("Name of the location"), unique=True, null=False)
location = LocationField(based_fields=["name"], zoom=12, default=Point(3.08333, 45.783329), srid=4326)
main = models.BooleanField(
verbose_name=_("Main"),
help_text=_("This location is one of the main locations (shown first)."),
default=False,
)
class Meta:
verbose_name = _("Reference location")
verbose_name_plural = _("Reference locations")
def __str__(self):
return self.name
class Place(models.Model): class Place(models.Model):
name = models.CharField(verbose_name=_("Name"), help_text=_("Name of the place")) name = models.CharField(verbose_name=_("Name"), help_text=_("Name of the place"))

View File

@ -137,7 +137,7 @@ details[role="list"] summary + ul li.selected>a:hover {
margin-top: 0; margin-top: 0;
} }
#filters .categories { #filters .categories {
width: 75%; width: 50%;
float: left; float: left;
text-align: left; text-align: left;
line-height: 2em; line-height: 2em;
@ -152,6 +152,9 @@ details[role="list"] summary + ul li.selected>a:hover {
padding: 0.2em 0.4em; padding: 0.2em 0.4em;
} }
} }
#filters details.active {
width: 50%;
}
} }
@ -855,6 +858,10 @@ form .buttons [role="button"] {
.grid.two-columns.grid-reverse { .grid.two-columns.grid-reverse {
grid-template-columns: 25% auto; grid-template-columns: 25% auto;
} }
.grid.two-columns-equal {
grid-column-gap: var(--nav-element-spacing-vertical);
grid-template-columns: 50% 50%;
}
} }

View File

@ -10,10 +10,10 @@
{% show_legend filter=filter %} {% show_legend filter=filter %}
</div> </div>
<details> <details {% if filter.is_active %}class="active"{% endif %}>
<summary role="button" class="outline secondary"> <summary role="button" class="outline secondary">
{% if filter.is_active %} {% if filter.is_active %}
Autres filtres&nbsp;: <strong>Filtres&nbsp;: </strong>
{% for t in filter.get_tags %} {% for t in filter.get_tags %}
{{ t | tag_button }} {{ t | tag_button }}
{% endfor %} {% endfor %}
@ -27,8 +27,9 @@
{{ c }} {{ c }}
{% endfor %} {% endfor %}
{{ filter.get_recurrence_filtering }} {{ filter.get_recurrence_filtering }}
{{ filter.get_position_radius }}
{% else %} {% else %}
Autres filtres <strong>Filtrer</strong>
{% endif %} {% endif %}
{% if filter.is_resetable %} {% if filter.is_resetable %}
@ -37,7 +38,32 @@
</summary> </summary>
<form method="get" class="form django-form main-filter"> <form method="get" class="form django-form main-filter">
{{ filter.form.as_p }} <div class="grid two-columns-equal">
<article>
<header>Localisation</header>
{% for f in filter.form.visible_fields %}
{% if f.id_for_label and f.id_for_label in "id_position,id_radius" %}
<div>
{{ f.errors }}
{{ f.label_tag }} {{ f }}
<span class="helptext">{{ f.help_text }}</span>
</div>
{% endif %}
{% endfor %}
</article>
<article>
<header>Autres filtres</header>
{% for f in filter.form.visible_fields %}
{% if not f.id_for_label or not f.id_for_label in "id_position,id_radius" %}
<p>
{{ f.errors }}
{{ f.label_tag }} {{ f }}
<span class="helptext">{{ f.help_text }}</span>
</p>
{% endif %}
{% endfor %}
</article>
</div>
<button type="submit">Appliquer le filtre</button> <button type="submit">Appliquer le filtre</button>
</form> </form>
</details> </details>

View File

@ -11,6 +11,9 @@ from django import forms
from django.contrib.postgres.search import SearchQuery, SearchHeadline from django.contrib.postgres.search import SearchQuery, SearchHeadline
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.contrib.gis.geos import Point
from django.contrib.gis.measure import D
from django.http import HttpResponseRedirect, HttpResponse from django.http import HttpResponseRedirect, HttpResponse
from django.urls import reverse from django.urls import reverse
from collections import Counter from collections import Counter
@ -48,6 +51,7 @@ from .models import (
ModerationQuestion, ModerationQuestion,
ModerationAnswer, ModerationAnswer,
Place, Place,
ReferenceLocation
) )
from django.utils import timezone from django.utils import timezone
from django.utils.html import escape from django.utils.html import escape
@ -170,6 +174,18 @@ class EventFilter(django_filters.FilterSet):
("only_recurrent", "Montrer uniquement les événements récurrents"), ("only_recurrent", "Montrer uniquement les événements récurrents"),
] ]
position = django_filters.ModelChoiceFilter(
label="À proximité de",
method="no_filter",
queryset=ReferenceLocation.objects.all().order_by("-main", "name__unaccent")
)
radius = django_filters.NumberFilter(
label="Distance maximum (km)",
method="no_filter",
widget=forms.NumberInput(attrs={"min": "1"})
)
exclude_tags = django_filters.MultipleChoiceFilter( exclude_tags = django_filters.MultipleChoiceFilter(
label="Exclure les étiquettes", label="Exclure les étiquettes",
choices=[(t, t) for t in Event.get_all_tags()], choices=[(t, t) for t in Event.get_all_tags()],
@ -180,7 +196,7 @@ class EventFilter(django_filters.FilterSet):
) )
tags = django_filters.MultipleChoiceFilter( tags = django_filters.MultipleChoiceFilter(
label="Filtrer par étiquettes", label="Inclure les étiquettes",
choices=[(t, t) for t in Event.get_all_tags()], choices=[(t, t) for t in Event.get_all_tags()],
lookup_expr="icontains", lookup_expr="icontains",
field_name="tags", field_name="tags",
@ -188,7 +204,7 @@ class EventFilter(django_filters.FilterSet):
) )
recurrences = django_filters.ChoiceFilter( recurrences = django_filters.ChoiceFilter(
label="Filtrer par récurrence", label="Inclure la récurrence",
choices=RECURRENT_CHOICES, choices=RECURRENT_CHOICES,
method="filter_recurrences", method="filter_recurrences",
) )
@ -201,12 +217,6 @@ class EventFilter(django_filters.FilterSet):
widget=MultipleHiddenInput, widget=MultipleHiddenInput,
) )
city = django_filters.MultipleChoiceFilter(
label="Filtrer par ville",
field_name="exact_location__city",
choices=[(c, c) for c in Place.get_all_cities()],
widget=forms.CheckboxSelectMultiple,
)
status = django_filters.MultipleChoiceFilter( status = django_filters.MultipleChoiceFilter(
label="Filtrer par status", label="Filtrer par status",
@ -217,7 +227,7 @@ class EventFilter(django_filters.FilterSet):
class Meta: class Meta:
model = Event model = Event
fields = ["category", "city", "tags", "exclude_tags", "status", "recurrences"] fields = ["category", "tags", "exclude_tags", "status", "recurrences"]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -229,6 +239,18 @@ class EventFilter(django_filters.FilterSet):
lookup = "__".join([name, "isnull"]) lookup = "__".join([name, "isnull"])
return queryset.filter(**{lookup: value == "remove_recurrent"}) return queryset.filter(**{lookup: value == "remove_recurrent"})
def no_filter(self, queryset, name, value):
return queryset
@property
def qs(self):
parent = super().qs
if self.get_cleaned_data("position") is None or self.get_cleaned_data("radius") is None:
return parent
d = self.get_cleaned_data("radius")
p = self.get_cleaned_data("position").location
return parent.exclude(exact_location=False).filter(exact_location__location__distance_lt=(p, D(km=d)))
def get_url(self): def get_url(self):
if isinstance(self.form.data, QueryDict): if isinstance(self.form.data, QueryDict):
return self.form.data.urlencode() return self.form.data.urlencode()
@ -296,12 +318,15 @@ class EventFilter(django_filters.FilterSet):
def get_status(self): def get_status(self):
return self.get_cleaned_data("status") return self.get_cleaned_data("status")
def get_cities(self): def get_position(self):
return self.get_cleaned_data("city") return self.get_cleaned_data("position")
def get_radius(self):
return self.get_cleaned_data("radius")
def to_str(self, prefix=''): def to_str(self, prefix=''):
self.form.full_clean() self.form.full_clean()
result = ' '.join([c.name for c in self.get_categories()] + [t for t in self.get_tags()] + [c for c in self.get_cities()]) result = ' '.join([c.name for c in self.get_categories()] + [t for t in self.get_tags()] + [str(self.get_position()), str(self.get_radius())])
if len(result) > 0: if len(result) > 0:
result = prefix + result result = prefix + result
return result return result
@ -346,22 +371,19 @@ class EventFilter(django_filters.FilterSet):
len(self.get_cleaned_data("tags")) != 0 len(self.get_cleaned_data("tags")) != 0
or len(self.get_cleaned_data("exclude_tags")) != 0 or len(self.get_cleaned_data("exclude_tags")) != 0
or len(self.get_cleaned_data("recurrences")) != 0 or len(self.get_cleaned_data("recurrences")) != 0
or len(self.get_cleaned_data("city")) != 0 or ((not self.get_cleaned_data("position") is None) and (not self.get_cleaned_data("radius") is None))
) )
def is_active(self, only_categories=False): def is_active(self, only_categories=False):
if only_categories: if only_categories:
return len(self.get_cleaned_data("category")) != 0 return len(self.get_cleaned_data("category")) != 0
else: else:
if (
len(self.get_cleaned_data("status")) != 0
):
return True
return ( return (
len(self.get_cleaned_data("tags")) != 0 len(self.get_cleaned_data("status")) != 0
or len(self.get_cleaned_data("tags")) != 0
or len(self.get_cleaned_data("exclude_tags")) != 0 or len(self.get_cleaned_data("exclude_tags")) != 0
or len(self.get_cleaned_data("recurrences")) != 0 or len(self.get_cleaned_data("recurrences")) != 0
or len(self.get_cleaned_data("city")) != 0 or ((not self.get_cleaned_data("position") is None) and (not self.get_cleaned_data("radius") is None))
) )
def is_selected(self, cat): def is_selected(self, cat):
@ -376,6 +398,12 @@ class EventFilter(django_filters.FilterSet):
return request return request
return request return request
def get_position_radius(self):
if self.get_cleaned_data("position") is None or self.get_cleaned_data("radius") is None:
return ""
else:
return str(self.get_cleaned_data("position")) + ' (' + str(self.get_cleaned_data("radius")) + ' km)'
def mentions_legales(request): def mentions_legales(request):

3259
src/scripts/communes.json Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,17 @@
import json, os
from django.contrib.gis.geos import Point
from agenda_culturel.models import ReferenceLocation
def run():
input_file = os.path.dirname(__file__) + os.path.sep + "communes.json"
data = []
with open(input_file, 'r') as file:
data = json.load(file)
objs = [ReferenceLocation(location=Point(c["geo_point_2d"]["lon"], c["geo_point_2d"]["lat"]), name=c["com_name"], main="main" in c) for c in data]
objs = ReferenceLocation.objects.bulk_create(objs, ignore_conflicts=True)