feat: Add search for rescue orgs

This commit is contained in:
2025-08-12 00:06:42 +02:00
parent c93b2631cb
commit 84ad047c01
7 changed files with 142 additions and 25 deletions

View File

@@ -147,3 +147,11 @@ class AdoptionNoticeSearchForm(forms.Form):
max_distance = forms.ChoiceField(choices=DistanceChoices, initial=DistanceChoices.ONE_HUNDRED,
label=_("Suchradius"))
location_string = forms.CharField(max_length=100, label=_("Stadt"), required=False)
class RescueOrgSearchForm(forms.Form):
template_name = "fellchensammlung/forms/form_snippets.html"
location_string = forms.CharField(max_length=100, label=_("Stadt"), required=False)
max_distance = forms.ChoiceField(choices=DistanceChoices, initial=DistanceChoices.ONE_HUNDRED,
label=_("Suchradius"))

View File

@@ -272,6 +272,14 @@ class RescueOrganization(models.Model):
def child_organizations(self):
return RescueOrganization.objects.filter(parent_org=self)
def in_distance(self, position, max_distance, unknown_true=True):
"""
Returns a boolean indicating if the Location of the adoption notice is within a given distance to the position
If the location is none, we by default return that the location is within the given distance
"""
return geo.object_in_distance(self, position, max_distance, unknown_true)
# Admins can perform all actions and have the highest trust associated with them
# Moderators can make moderation decisions regarding the deletion of content
@@ -500,11 +508,7 @@ class AdoptionNotice(models.Model):
If the location is none, we by default return that the location is within the given distance
"""
if unknown_true and self.position is None:
return True
distance = geo.calculate_distance_between_coordinates(self.position, position)
return distance < max_distance
return geo.object_in_distance(self, position, max_distance, unknown_true)
@property
def is_active(self):

View File

@@ -16,11 +16,26 @@
{% block canonical_url %}{% host %}{% url 'rescue-organizations' %}{% endblock %}
{% block content %}
<div class="block">
<div style="height: 70vh">
{% include "fellchensammlung/partials/partial-map.html" %}
<div class="columns block">
<div class=" column is-two-thirds">
<div style="height: 70vh">
{% include "fellchensammlung/partials/partial-map.html" %}
</div>
</div>
<div class="column is-one-third">
<form method="post" autocomplete="off">
{% csrf_token %}
<input type="hidden" name="longitude" maxlength="200" id="longitude">
<input type="hidden" name="latitude" maxlength="200" id="latitude">
<!--- https://docs.djangoproject.com/en/5.2/topics/forms/#reusable-form-templates -->
{{ search_form }}
<button class="button is-primary is-fullwidth" type="submit" value="search" name="search">
<i class="fas fa-search fa-fw"></i> {% trans 'Suchen' %}
</button>
</form>
</div>
</div>
<div class="block">
{% with rescue_organizations=rescue_organizations_to_list %}
{% include "fellchensammlung/lists/list-animal-shelters.html" %}
@@ -32,7 +47,8 @@
href="?page={{ rescue_organizations_to_list.previous_page_number }}">{% trans 'Vorherige' %}</a>
{% endif %}
{% if rescue_organizations_to_list.has_next %}
<a class="pagination-next" href="?page={{ rescue_organizations_to_list.next_page_number }}">{% trans 'Nächste' %}</a>
<a class="pagination-next"
href="?page={{ rescue_organizations_to_list.next_page_number }}">{% trans 'Nächste' %}</a>
{% endif %}
<ul class="pagination-list">
{% for page in elided_page_range %}

View File

@@ -53,6 +53,19 @@ def calculate_distance_between_coordinates(position1, position2):
return distance_in_km
def object_in_distance(obj, position, max_distance, unknown_true=True):
"""
Returns a boolean indicating if the Location of the object is within a given distance to the position
If the location is none, we by default return that the location is within the given distance
"""
if unknown_true and obj.position is None:
return True
distance = calculate_distance_between_coordinates(obj.position, position)
return distance < max_distance
class ResponseMock:
content = b'[{"place_id":138181499,"licence":"Data \xc2\xa9 OpenStreetMap contributors, ODbL 1.0. http://osm.org/copyright","osm_type":"relation","osm_id":1247237,"lat":"48.4949904","lon":"9.040330235970146","category":"boundary","type":"postal_code","place_rank":21, "importance":0.12006895017929346,"addresstype":"postcode","name":"72072","display_name":"72072, Derendingen, T\xc3\xbcbingen, Landkreis T\xc3\xbcbingen, Baden-W\xc3\xbcrttemberg, Deutschland", "boundingbox":["48.4949404","48.4950404","9.0402802","9.0403802"]}]'
status_code = 200

View File

@@ -2,9 +2,9 @@ import logging
from django.utils.translation import gettext_lazy as _
from .geo import LocationProxy, Position
from ..forms import AdoptionNoticeSearchForm
from ..forms import AdoptionNoticeSearchForm, RescueOrgSearchForm
from ..models import SearchSubscription, AdoptionNotice, SexChoicesWithAll, Location, \
Notification, NotificationTypeChoices
Notification, NotificationTypeChoices, RescueOrganization
def notify_search_subscribers(adoption_notice: AdoptionNotice, only_if_active: bool = True):
@@ -18,7 +18,7 @@ def notify_search_subscribers(adoption_notice: AdoptionNotice, only_if_active: b
return
for search_subscription in SearchSubscription.objects.all():
logging.debug(f"Search subscription {search_subscription} found.")
search = Search(search_subscription=search_subscription)
search = AdoptionNoticeSearch(search_subscription=search_subscription)
if search.adoption_notice_fits_search(adoption_notice):
notification_text = f"{_('Zu deiner Suche')} {search_subscription} wurde eine neue Vermittlung gefunden"
Notification.objects.create(user_to_notify=search_subscription.owner,
@@ -33,7 +33,7 @@ def notify_search_subscribers(adoption_notice: AdoptionNotice, only_if_active: b
logging.info(f"Subscribers for AN {adoption_notice.pk} have been notified\n")
class Search:
class AdoptionNoticeSearch:
def __init__(self, request=None, search_subscription=None):
self.sex = None
self.area_search = None
@@ -45,7 +45,7 @@ class Search:
self.location_string = None
if request:
self.search_from_request(request)
self.adoption_notice_search_from_request(request)
elif search_subscription:
self.search_from_search_subscription(search_subscription)
@@ -103,7 +103,7 @@ class Search:
return adoptions
def search_from_request(self, request):
def adoption_notice_search_from_request(self, request):
if request.method == 'POST':
self.search_form = AdoptionNoticeSearchForm(request.POST)
self.search_form.is_valid()
@@ -157,3 +157,75 @@ class Search:
return False
else:
return True
class RescueOrgSearch:
def __init__(self, request):
self.area_search = None
self.max_distance = None
self.location = None # Can either be Location (DjangoModel) or LocationProxy
self.place_not_found = False # Indicates that a location was given but could not be geocoded
self.search_form = None
# Either place_id or location string must be set for area search
self.location_string = None
self.rescue_org_search_from_request(request)
def __str__(self):
return f"{_('Suche')}: {self.location=}, {self.area_search=}, {self.max_distance=}"
def __eq__(self, other):
"""
Custom equals that also supports SearchSubscriptions
Only allowed to be called for located subscriptions
"""
# If both locations are empty check only the max distance
if self.location is None and other.location is None:
return self.max_distance == other.max_distance
# If one location is empty and the other is not, they are not equal
elif self.location is not None and other.location is None or self.location is None and other.location is not None:
return False
return self.location == other.location and self.max_distance == other.max_distance
def _locate(self):
try:
self.location = LocationProxy(self.location_string)
except ValueError:
self.place_not_found = True
@property
def position(self):
if self.area_search and not self.place_not_found:
return Position(latitude=self.location.latitude, longitude=self.location.longitude)
else:
return None
def rescue_org_fits_search(self, rescue_org: RescueOrganization):
# make sure it's an area search and the place is found to check location
if self.area_search and not self.place_not_found:
# If adoption notice is in not in search distance, return false
if not rescue_org.in_distance(self.location.position, self.max_distance):
logging.debug("Area mismatch")
return False
return True
def get_rescue_orgs(self):
rescue_orgs = RescueOrganization.objects.all()
fitting_rescue_orgs = [rescue_org for rescue_org in rescue_orgs if self.rescue_org_fits_search(rescue_org)]
return fitting_rescue_orgs
def rescue_org_search_from_request(self, request):
if request.method == 'POST':
self.search_form = RescueOrgSearchForm(request.POST)
self.search_form.is_valid()
if self.search_form.cleaned_data["location_string"] != "" and self.search_form.cleaned_data[
"max_distance"] != "":
self.area_search = True
self.location_string = self.search_form.cleaned_data["location_string"]
self.max_distance = int(self.search_form.cleaned_data["max_distance"])
self._locate()
else:
self.search_form = RescueOrgSearchForm()

View File

@@ -36,7 +36,7 @@ from .tools.admin import clean_locations, get_unchecked_adoption_notices, deacti
from .tasks import post_adoption_notice_save
from rest_framework.authtoken.models import Token
from .tools.search import Search
from .tools.search import AdoptionNoticeSearch, RescueOrgSearch
def user_is_trust_level_or_above(user, trust_level=TrustLevel.MODERATOR):
@@ -200,7 +200,7 @@ def adoption_notice_edit(request, adoption_notice_id):
def search_important_locations(request, important_location_slug):
i_location = get_object_or_404(ImportantLocation, slug=important_location_slug)
search = Search()
search = AdoptionNoticeSearch()
search.search_from_predefined_i_location(i_location)
site_title = _("Ratten in %(location_name)s") % {"location_name": i_location.name}
@@ -231,8 +231,8 @@ def search(request, templatename="fellchensammlung/search.html"):
# A user just visiting the search site did not search, only upon completing the search form a user has really
# searched. This will toggle the "subscribe" button
searched = False
search = Search()
search.search_from_request(request)
search = AdoptionNoticeSearch()
search.adoption_notice_search_from_request(request)
if request.method == 'POST':
searched = True
if "subscribe_to_search" in request.POST:
@@ -749,7 +749,10 @@ def external_site_warning(request, template_name='fellchensammlung/external-site
def list_rescue_organizations(request, species=None, template='fellchensammlung/animal-shelters.html'):
if species is None:
rescue_organizations = RescueOrganization.objects.all()
# rescue_organizations = RescueOrganization.objects.all()
org_search = RescueOrgSearch(request)
rescue_organizations = org_search.get_rescue_orgs()
else:
rescue_organizations = RescueOrganization.objects.filter(specializations=species)
@@ -767,7 +770,8 @@ def list_rescue_organizations(request, species=None, template='fellchensammlung/
rescue_organizations_to_list = paginator.get_page(page_number)
context = {"rescue_organizations_to_list": rescue_organizations_to_list,
"show_rescue_orgs": True,
"elided_page_range": paginator.get_elided_page_range(page_number, on_each_side=2, on_ends=1)}
"elided_page_range": paginator.get_elided_page_range(page_number, on_each_side=2, on_ends=1),
"search_form": org_search.search_form, }
return render(request, template, context=context)

View File

@@ -8,7 +8,7 @@ from model_bakery import baker
from fellchensammlung.tools.geo import LocationProxy
from fellchensammlung.tools.model_helpers import NotificationTypeChoices
from fellchensammlung.tools.search import Search, notify_search_subscribers
from fellchensammlung.tools.search import AdoptionNoticeSearch, notify_search_subscribers
class TestSearch(TestCase):
@@ -72,7 +72,7 @@ class TestSearch(TestCase):
sex=SexChoicesWithAll.ALL,
max_distance=100
)
search1 = Search()
search1 = AdoptionNoticeSearch()
search1.search_position = LocationProxy("Stuttgart").position
search1.max_distance = 100
search1.area_search = True
@@ -83,11 +83,11 @@ class TestSearch(TestCase):
self.assertEqual(search_subscription1, search1)
def test_adoption_notice_fits_search(self):
search1 = Search(search_subscription=self.subscription1)
search1 = AdoptionNoticeSearch(search_subscription=self.subscription1)
self.assertTrue(search1.adoption_notice_fits_search(self.adoption1))
self.assertFalse(search1.adoption_notice_fits_search(self.adoption2))
search2 = Search(search_subscription=self.subscription2)
search2 = AdoptionNoticeSearch(search_subscription=self.subscription2)
self.assertFalse(search2.adoption_notice_fits_search(self.adoption1))
self.assertTrue(search2.adoption_notice_fits_search(self.adoption2))