This commit is contained in:
Flatlogic Bot 2026-06-04 18:40:22 +00:00
parent a0758d4015
commit cb35610046
16 changed files with 197 additions and 15 deletions

Binary file not shown.

View File

@ -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]

View File

@ -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, well 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"]

View File

@ -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")

View File

@ -16,7 +16,7 @@
<div class="mini-tags">
{% if entry.phone %}<span>Phone</span>{% endif %}
{% if entry.email %}<span>Email</span>{% endif %}
{% if entry.idealista_url %}<span>Idealista link</span>{% endif %}
{% if entry.idealista_url %}<span>{% if entry.idealista_is_search %}Idealista search{% else %}Idealista listing{% endif %}</span>{% endif %}
{% if not entry.phone and not entry.email %}<span>Needs details</span>{% endif %}
</div>
<a class="stretched-link" href="{{ entry.get_absolute_url }}" aria-label="Open property {{ entry.pk }}"></a>

View File

@ -23,7 +23,7 @@
<div class="analysis-box"><h2>Text spotted in photo</h2><p>{{ entry.extracted_text }}</p></div>
{% endif %}
{% if entry.idealista_url %}
<a class="btn btn-ghost" href="{{ entry.idealista_url }}" target="_blank" rel="noopener">Open best-effort Idealista search</a>
<a class="btn btn-ghost" href="{{ entry.idealista_url }}" target="_blank" rel="noopener">{{ entry.idealista_link_label }}</a>
{% endif %}
</div>
</article>
@ -40,6 +40,19 @@
<button class="btn btn-nest w-100" type="submit">Save suggestion</button>
</form>
</section>
<section class="form-card compact-card">
<h2>{% if entry.idealista_url and not entry.idealista_is_search %}Update Idealista link{% else %}Add exact Idealista link{% endif %}</h2>
<p class="text-muted">{% 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 %}</p>
<form method="post" action="{% url 'update_idealista_link' entry.pk %}" novalidate>
{% csrf_token %}
<div class="mb-3">
<label class="form-label" for="{{ idealista_form.idealista_url.id_for_label }}">{{ idealista_form.idealista_url.label }}</label>
{{ idealista_form.idealista_url }}
<div class="form-text">Only Idealista links are accepted. You can paste without https://.</div>
</div>
<button class="btn btn-nest w-100" type="submit">Save Idealista link</button>
</form>
</section>
<section class="form-card compact-card danger-soft">
<h2>Flag for removal</h2>
<p class="text-muted">Repeated flags hide an entry until a future admin review flow is added.</p>

View File

@ -6,7 +6,7 @@
<div class="form-card">
<p class="eyebrow">Create from location or address</p>
<h1>Add a property pin</h1>
<p class="text-muted">Use your current GPS position when available, or type/paste the address manually if browser location is blocked, denied, or unsupported.</p>
<p class="text-muted">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 browsers current location. If you already have the exact Idealista listing, paste it before publishing.</p>
<div class="location-choice-panel mb-3" aria-label="Choose how to add location information">
<button class="btn btn-ghost" type="button" data-action="fill-current-location">Use my current location</button>
<button class="btn btn-outline-dark rounded-pill" type="button" data-action="manual-address">Type or paste address</button>
@ -26,7 +26,8 @@
<label class="form-label" for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
{% if field.name == "address" %}<div class="form-text">Required if you do not grant current-location access.</div>{% endif %}
{% if field.help_text %}<div class="form-text">{{ field.help_text }}</div>{% endif %}
{% if field.name == "idealista_url" %}<div class="form-text">Optional: manual links are saved first. If empty, NearbyNest creates a best-effort Idealista search link.</div>{% endif %}
{% if field.help_text and field.name != "idealista_url" %}<div class="form-text">{{ field.help_text }}</div>{% endif %}
{% for error in field.errors %}<div class="invalid-copy">{{ error }}</div>{% endfor %}
</div>
{% endfor %}

View File

@ -6,7 +6,7 @@
<div class="form-card">
<p class="eyebrow">Create from photo</p>
<h1>Upload a property photo</h1>
<p class="text-muted">The MVP stores only a resized JPEG under 256KB, checks EXIF GPS, and attempts local OCR if a system OCR binary is installed.</p>
<p class="text-muted">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.</p>
<form method="post" enctype="multipart/form-data" novalidate>
{% csrf_token %}
{% if form.non_field_errors %}<div class="alert alert-danger">{{ form.non_field_errors }}</div>{% endif %}
@ -15,6 +15,7 @@
<label class="form-label" for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
{% if field.name == 'photo' %}<div class="form-text">Originals are discarded after compression; max upload 12MB.</div>{% endif %}
{% if field.name == 'idealista_url' %}<div class="form-text">Optional: paste the exact Idealista listing URL. If empty, NearbyNest creates a best-effort search link.</div>{% endif %}
{% for error in field.errors %}<div class="invalid-copy">{{ error }}</div>{% endfor %}
</div>
{% endfor %}

View File

@ -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)

View File

@ -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/<int:pk>/", property_detail, name="property_detail"),
path("properties/<int:pk>/suggest/", suggest_property_update, name="suggest_property_update"),
path("properties/<int:pk>/idealista/", update_idealista_link, name="update_idealista_link"),
path("properties/<int:pk>/flag/", flag_property, name="flag_property"),
]

View File

@ -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)