1.0.3
This commit is contained in:
parent
a0758d4015
commit
cb35610046
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
core/__pycache__/tests.cpython-311.pyc
Normal file
BIN
core/__pycache__/tests.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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]
|
||||
|
||||
|
||||
@ -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"]
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 browser’s 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 %}
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"),
|
||||
]
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user