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 @@
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 @@{{ entry.extracted_text }}
{% 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 %}
+ +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
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.
Create from 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.