diff --git a/core/__pycache__/admin.cpython-311.pyc b/core/__pycache__/admin.cpython-311.pyc index 095ec4f..d688b31 100644 Binary files a/core/__pycache__/admin.cpython-311.pyc and b/core/__pycache__/admin.cpython-311.pyc differ diff --git a/core/__pycache__/forms.cpython-311.pyc b/core/__pycache__/forms.cpython-311.pyc index 5ac251f..8a4c55e 100644 Binary files a/core/__pycache__/forms.cpython-311.pyc and b/core/__pycache__/forms.cpython-311.pyc differ diff --git a/core/__pycache__/models.cpython-311.pyc b/core/__pycache__/models.cpython-311.pyc index ca61d90..c7b22a7 100644 Binary files a/core/__pycache__/models.cpython-311.pyc and b/core/__pycache__/models.cpython-311.pyc differ diff --git a/core/__pycache__/tests.cpython-311.pyc b/core/__pycache__/tests.cpython-311.pyc new file mode 100644 index 0000000..0b4677a Binary files /dev/null and b/core/__pycache__/tests.cpython-311.pyc differ diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc index 1c04759..19f8764 100644 Binary files a/core/__pycache__/urls.cpython-311.pyc and b/core/__pycache__/urls.cpython-311.pyc differ diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc index 38663bd..e3e60a1 100644 Binary files a/core/__pycache__/views.cpython-311.pyc and b/core/__pycache__/views.cpython-311.pyc differ diff --git a/core/admin.py b/core/admin.py index 2676b11..5183178 100644 --- a/core/admin.py +++ b/core/admin.py @@ -19,7 +19,7 @@ class PropertyFlagInline(admin.TabularInline): class PropertyEntryAdmin(admin.ModelAdmin): list_display = ("id", "address", "listing_type", "source", "has_gps_data", "flag_count", "created_at") list_filter = ("listing_type", "source", "has_gps_data", "is_flagged") - search_fields = ("address", "phone", "email", "extracted_text") + search_fields = ("address", "phone", "email", "idealista_url", "extracted_text") readonly_fields = ("created_at", "updated_at") inlines = [PropertySuggestionInline, PropertyFlagInline] diff --git a/core/forms.py b/core/forms.py index ce823f0..db12821 100644 --- a/core/forms.py +++ b/core/forms.py @@ -1,8 +1,65 @@ +from urllib.parse import urlparse + from django import forms +from django.core.exceptions import ValidationError +from django.core.validators import URLValidator from .models import PropertyEntry, PropertyFlag, PropertySuggestion +IDEALISTA_DOMAINS = ("idealista.com", "idealista.pt", "idealista.it") + + +def idealista_url_field(*, required=False, label="Idealista link"): + return forms.CharField( + required=required, + label=label, + help_text=( + "Optional. Paste the exact Idealista listing URL if you already found it. " + "If left empty, we’ll create a best-effort Idealista search link." + ), + widget=forms.URLInput( + attrs={ + "placeholder": "https://www.idealista.com/inmueble/123456/", + "autocomplete": "url", + "inputmode": "url", + } + ), + ) + + +def clean_idealista_url_value(value, *, required=False): + value = (value or "").strip() + if not value: + if required: + raise ValidationError("Paste the exact Idealista listing URL.") + return "" + + if "://" not in value: + value = f"https://{value}" + + validator = URLValidator(schemes=["http", "https"]) + try: + validator(value) + except ValidationError as exc: + raise ValidationError( + "Paste a valid Idealista URL, for example https://www.idealista.com/inmueble/123456/." + ) from exc + + host = (urlparse(value).hostname or "").lower() + is_idealista = any(host == domain or host.endswith(f".{domain}") for domain in IDEALISTA_DOMAINS) + if not is_idealista: + raise ValidationError("Use a link from idealista.com, idealista.pt, or idealista.it.") + + return value + + +class IdealistaUrlCleanMixin: + def clean_idealista_url(self): + field = self.fields["idealista_url"] + return clean_idealista_url_value(self.cleaned_data.get("idealista_url"), required=field.required) + + class BootstrapFormMixin: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -19,13 +76,14 @@ class BootstrapFormMixin: widget.attrs["class"] = f"form-control {current}".strip() -class PropertyLocationForm(BootstrapFormMixin, forms.ModelForm): +class PropertyLocationForm(IdealistaUrlCleanMixin, BootstrapFormMixin, forms.ModelForm): + idealista_url = idealista_url_field() latitude = forms.DecimalField(required=False, max_digits=9, decimal_places=6, widget=forms.HiddenInput()) longitude = forms.DecimalField(required=False, max_digits=9, decimal_places=6, widget=forms.HiddenInput()) class Meta: model = PropertyEntry - fields = ["address", "latitude", "longitude", "phone", "email", "listing_type"] + fields = ["address", "latitude", "longitude", "phone", "email", "listing_type", "idealista_url"] widgets = { "address": forms.TextInput(attrs={"placeholder": "Type or paste the full address", "autocomplete": "street-address", "data-manual-address": "true"}), "phone": forms.TextInput(attrs={"placeholder": "+34 600 000 000"}), @@ -38,15 +96,21 @@ class PropertyLocationForm(BootstrapFormMixin, forms.ModelForm): address = cleaned.get("address") latitude = cleaned.get("latitude") longitude = cleaned.get("longitude") - if not address and (latitude is None or longitude is None): + if address: + cleaned["latitude"] = None + cleaned["longitude"] = None + return cleaned + if latitude is None or longitude is None: raise forms.ValidationError("Type or paste an address, or allow location, so this property can be placed on the pinboard.") return cleaned -class PropertyPhotoForm(BootstrapFormMixin, forms.ModelForm): +class PropertyPhotoForm(IdealistaUrlCleanMixin, BootstrapFormMixin, forms.ModelForm): + idealista_url = idealista_url_field() + class Meta: model = PropertyEntry - fields = ["photo", "address", "phone", "email", "listing_type"] + fields = ["photo", "address", "phone", "email", "listing_type", "idealista_url"] widgets = { "address": forms.TextInput(attrs={"placeholder": "Optional if the photo contains GPS or visible text"}), "phone": forms.TextInput(attrs={"placeholder": "Optional contact phone"}), @@ -89,3 +153,12 @@ class PropertyFlagForm(BootstrapFormMixin, forms.ModelForm): widgets = { "reason": forms.TextInput(attrs={"placeholder": "Duplicate, spam, private info, already removed..."}), } + + +class PropertyIdealistaLinkForm(IdealistaUrlCleanMixin, BootstrapFormMixin, forms.ModelForm): + idealista_url = idealista_url_field(required=True, label="Exact Idealista listing URL") + + class Meta: + model = PropertyEntry + fields = ["idealista_url"] + diff --git a/core/models.py b/core/models.py index 88e8c7f..58b7be9 100644 --- a/core/models.py +++ b/core/models.py @@ -44,6 +44,14 @@ class PropertyEntry(models.Model): def has_location(self): return self.latitude is not None and self.longitude is not None + @property + def idealista_is_search(self): + return "/search/" in (self.idealista_url or "") + + @property + def idealista_link_label(self): + return "Open Idealista search" if self.idealista_is_search else "Open Idealista listing" + class PropertySuggestion(models.Model): property_entry = models.ForeignKey(PropertyEntry, on_delete=models.CASCADE, related_name="suggestions") diff --git a/core/templates/core/partials/property_card.html b/core/templates/core/partials/property_card.html index e9db53d..268a75d 100644 --- a/core/templates/core/partials/property_card.html +++ b/core/templates/core/partials/property_card.html @@ -16,7 +16,7 @@
{% if entry.phone %}Phone{% endif %} {% if entry.email %}Email{% endif %} - {% if entry.idealista_url %}Idealista link{% endif %} + {% if entry.idealista_url %}{% if entry.idealista_is_search %}Idealista search{% else %}Idealista listing{% endif %}{% endif %} {% if not entry.phone and not entry.email %}Needs details{% endif %}
diff --git a/core/templates/core/property_detail.html b/core/templates/core/property_detail.html index 835a4c4..c18238b 100644 --- a/core/templates/core/property_detail.html +++ b/core/templates/core/property_detail.html @@ -23,7 +23,7 @@

Text spotted in photo

{{ entry.extracted_text }}

{% endif %} {% if entry.idealista_url %} - Open best-effort Idealista search + {{ entry.idealista_link_label }} {% endif %} @@ -40,6 +40,19 @@ +
+

{% if entry.idealista_url and not entry.idealista_is_search %}Update Idealista link{% else %}Add exact Idealista link{% endif %}

+

{% if entry.idealista_url and entry.idealista_is_search %}We only have an automatic search link right now. Paste the exact listing URL if you find it.{% elif entry.idealista_url %}This listing already has an Idealista URL. You can replace it if needed.{% else %}No Idealista link was found automatically. Paste the exact listing URL if you know it.{% endif %}

+
+ {% csrf_token %} +
+ + {{ idealista_form.idealista_url }} +
Only Idealista links are accepted. You can paste without https://.
+
+ +
+

Flag for removal

Repeated flags hide an entry until a future admin review flow is added.

diff --git a/core/templates/core/property_form_location.html b/core/templates/core/property_form_location.html index c44f476..155a966 100644 --- a/core/templates/core/property_form_location.html +++ b/core/templates/core/property_form_location.html @@ -6,7 +6,7 @@

Create from location or address

Add a property pin

-

Use your current GPS position when available, or type/paste the address manually if browser location is blocked, denied, or unsupported.

+

Use your current GPS position when available, or type/paste the address manually if browser location is blocked, denied, or unsupported. If you paste an address, NearbyNest uses that address for the listing instead of the browser’s current location. If you already have the exact Idealista listing, paste it before publishing.

@@ -26,7 +26,8 @@ {{ field }} {% if field.name == "address" %}
Required if you do not grant current-location access.
{% endif %} - {% if field.help_text %}
{{ field.help_text }}
{% endif %} + {% if field.name == "idealista_url" %}
Optional: manual links are saved first. If empty, NearbyNest creates a best-effort Idealista search link.
{% endif %} + {% if field.help_text and field.name != "idealista_url" %}
{{ field.help_text }}
{% endif %} {% for error in field.errors %}
{{ error }}
{% endfor %}
{% endfor %} diff --git a/core/templates/core/property_form_photo.html b/core/templates/core/property_form_photo.html index 3a0c29e..423d4d3 100644 --- a/core/templates/core/property_form_photo.html +++ b/core/templates/core/property_form_photo.html @@ -6,7 +6,7 @@

Create from photo

Upload a property photo

-

The MVP stores only a resized JPEG under 256KB, checks EXIF GPS, and attempts local OCR if a system OCR binary is installed.

+

The MVP stores only a resized JPEG under 256KB, checks EXIF GPS, and attempts local OCR. You can also paste the exact Idealista listing link before we create any search fallback.

{% csrf_token %} {% if form.non_field_errors %}
{{ form.non_field_errors }}
{% endif %} @@ -15,6 +15,7 @@ {{ field }} {% if field.name == 'photo' %}
Originals are discarded after compression; max upload 12MB.
{% endif %} + {% if field.name == 'idealista_url' %}
Optional: paste the exact Idealista listing URL. If empty, NearbyNest creates a best-effort search link.
{% endif %} {% for error in field.errors %}
{{ error }}
{% endfor %}
{% endfor %} diff --git a/core/tests.py b/core/tests.py index 7ce503c..a6ed092 100644 --- a/core/tests.py +++ b/core/tests.py @@ -1,3 +1,56 @@ from django.test import TestCase -# Create your tests here. +from .forms import PropertyLocationForm + + +class PropertyLocationFormTests(TestCase): + def test_address_overrides_browser_location(self): + form = PropertyLocationForm( + data={ + "address": "123 Main St, Madrid", + "latitude": "40.416800", + "longitude": "-3.703800", + "phone": "", + "email": "", + "listing_type": "unknown", + "idealista_url": "", + } + ) + + self.assertTrue(form.is_valid(), form.errors) + self.assertIsNone(form.cleaned_data["latitude"]) + self.assertIsNone(form.cleaned_data["longitude"]) + self.assertEqual(form.cleaned_data["address"], "123 Main St, Madrid") + + def test_browser_location_is_kept_when_no_address_is_entered(self): + form = PropertyLocationForm( + data={ + "address": "", + "latitude": "40.416800", + "longitude": "-3.703800", + "phone": "", + "email": "", + "listing_type": "unknown", + "idealista_url": "", + } + ) + + self.assertTrue(form.is_valid(), form.errors) + self.assertIsNotNone(form.cleaned_data["latitude"]) + self.assertIsNotNone(form.cleaned_data["longitude"]) + + def test_address_or_location_is_required(self): + form = PropertyLocationForm( + data={ + "address": "", + "latitude": "", + "longitude": "", + "phone": "", + "email": "", + "listing_type": "unknown", + "idealista_url": "", + } + ) + + self.assertFalse(form.is_valid()) + self.assertIn("__all__", form.errors) diff --git a/core/urls.py b/core/urls.py index c9e0db4..9408901 100644 --- a/core/urls.py +++ b/core/urls.py @@ -9,6 +9,7 @@ from .views import ( property_detail, property_list, suggest_property_update, + update_idealista_link, ) urlpatterns = [ @@ -19,5 +20,6 @@ urlpatterns = [ path("properties/add/photo/", add_photo_property, name="add_photo_property"), path("properties//", property_detail, name="property_detail"), path("properties//suggest/", suggest_property_update, name="suggest_property_update"), + path("properties//idealista/", update_idealista_link, name="update_idealista_link"), path("properties//flag/", flag_property, name="flag_property"), ] diff --git a/core/views.py b/core/views.py index 3df766b..ef1749f 100644 --- a/core/views.py +++ b/core/views.py @@ -6,7 +6,13 @@ from django.db import transaction from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse -from .forms import PropertyFlagForm, PropertyLocationForm, PropertyPhotoForm, PropertySuggestionForm +from .forms import ( + PropertyFlagForm, + PropertyIdealistaLinkForm, + PropertyLocationForm, + PropertyPhotoForm, + PropertySuggestionForm, +) from .image_tools import compress_image, ocr_text_best_effort from .models import PropertyEntry @@ -18,6 +24,12 @@ def build_idealista_url(entry): return f"https://www.idealista.com/en/search/{quote_plus(query)}/" +def apply_idealista_fallback(entry): + if not entry.idealista_url: + entry.idealista_url = build_idealista_url(entry) + return entry + + def _distance_km(lat1, lng1, lat2, lng2): if None in (lat1, lng1, lat2, lng2): return None @@ -85,6 +97,9 @@ def property_detail(request, pk): "entry": entry, "suggestion_form": PropertySuggestionForm(), "flag_form": PropertyFlagForm(), + "idealista_form": PropertyIdealistaLinkForm( + initial={"idealista_url": entry.idealista_url} if entry.idealista_url and not entry.idealista_is_search else None + ), } return render(request, "core/property_detail.html", context) @@ -96,7 +111,7 @@ def add_location_property(request): if form.is_valid(): entry = form.save(commit=False) entry.source = PropertyEntry.Source.CURRENT_LOCATION - entry.idealista_url = build_idealista_url(entry) + apply_idealista_fallback(entry) entry.save() messages.success(request, "Property pinned to the public list.") return redirect(entry.get_absolute_url()) @@ -129,7 +144,7 @@ def add_photo_property(request): entry.longitude = processed["longitude"] entry.has_gps_data = True entry.extracted_text = ocr_text_best_effort(uploaded) - entry.idealista_url = build_idealista_url(entry) + apply_idealista_fallback(entry) entry.save() messages.success(request, "Photo compressed under 256KB and added to the pinboard.") return redirect(entry.get_absolute_url()) @@ -143,6 +158,22 @@ def add_photo_property(request): return render(request, "core/property_form_photo.html", context) +@transaction.atomic +def update_idealista_link(request, pk): + entry = get_object_or_404(PropertyEntry, pk=pk) + if request.method != "POST": + return redirect(entry.get_absolute_url()) + + form = PropertyIdealistaLinkForm(request.POST, instance=entry) + if form.is_valid(): + form.save() + messages.success(request, "Idealista listing link saved.") + else: + message = form.errors.get("idealista_url", ["Paste a valid Idealista listing link before saving."])[0] + messages.error(request, message) + return redirect(entry.get_absolute_url()) + + @transaction.atomic def suggest_property_update(request, pk): entry = get_object_or_404(PropertyEntry, pk=pk)