210 lines
8.1 KiB
Python
210 lines
8.1 KiB
Python
import math
|
|
from urllib.parse import quote_plus
|
|
|
|
from django.contrib import messages
|
|
from django.db import transaction
|
|
from django.shortcuts import get_object_or_404, redirect, render
|
|
from django.urls import reverse
|
|
|
|
from .forms import (
|
|
PropertyFlagForm,
|
|
PropertyIdealistaLinkForm,
|
|
PropertyLocationForm,
|
|
PropertyPhotoForm,
|
|
PropertySuggestionForm,
|
|
)
|
|
from .image_tools import compress_image, ocr_text_best_effort
|
|
from .models import PropertyEntry
|
|
|
|
|
|
def build_idealista_url(entry):
|
|
query = entry.address or " ".join(filter(None, [entry.phone, entry.email]))
|
|
if not query:
|
|
return ""
|
|
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
|
|
radius = 6371
|
|
phi1, phi2 = math.radians(float(lat1)), math.radians(float(lat2))
|
|
d_phi = math.radians(float(lat2) - float(lat1))
|
|
d_lambda = math.radians(float(lng2) - float(lng1))
|
|
a = math.sin(d_phi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(d_lambda / 2) ** 2
|
|
return radius * (2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)))
|
|
|
|
|
|
def _decorate_distances(entries, user_lat, user_lng):
|
|
decorated = []
|
|
for entry in entries:
|
|
distance = _distance_km(user_lat, user_lng, entry.latitude, entry.longitude) if user_lat and user_lng else None
|
|
entry.distance_km = distance
|
|
decorated.append(entry)
|
|
return decorated
|
|
|
|
|
|
def home(request):
|
|
entries = list(PropertyEntry.objects.filter(is_flagged=False).order_by("-created_at")[:6])
|
|
context = {
|
|
"page_title": "NearbyNest — mobile property pinboard",
|
|
"meta_description": "A mobile-first PWA for posting nearby property sightings by current location or photo.",
|
|
"recent_entries": entries,
|
|
"total_entries": PropertyEntry.objects.count(),
|
|
"photo_entries": PropertyEntry.objects.exclude(photo="").count(),
|
|
}
|
|
return render(request, "core/index.html", context)
|
|
|
|
|
|
def onboarding(request):
|
|
context = {
|
|
"page_title": "Onboarding permissions — NearbyNest",
|
|
"meta_description": "Grant location, notification, and photo permissions for the mobile property pinboard.",
|
|
}
|
|
return render(request, "core/onboarding.html", context)
|
|
|
|
|
|
def property_list(request):
|
|
sort = request.GET.get("sort", "recent")
|
|
user_lat = request.GET.get("lat")
|
|
user_lng = request.GET.get("lng")
|
|
entries = list(PropertyEntry.objects.filter(is_flagged=False).order_by("-created_at"))
|
|
entries = _decorate_distances(entries, user_lat, user_lng)
|
|
if sort == "distance" and user_lat and user_lng:
|
|
entries.sort(key=lambda entry: entry.distance_km if entry.distance_km is not None else float("inf"))
|
|
context = {
|
|
"page_title": "Public property list — NearbyNest",
|
|
"meta_description": "Browse public property posts by recency or distance from your current location.",
|
|
"entries": entries,
|
|
"sort": sort,
|
|
"user_lat": user_lat or "",
|
|
"user_lng": user_lng or "",
|
|
}
|
|
return render(request, "core/property_list.html", context)
|
|
|
|
|
|
def property_detail(request, pk):
|
|
entry = get_object_or_404(PropertyEntry.objects.prefetch_related("suggestions", "flags"), pk=pk)
|
|
context = {
|
|
"page_title": f"Property #{entry.pk} — NearbyNest",
|
|
"meta_description": "Review a public property pin, extracted details, community suggestions, and flagging options.",
|
|
"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)
|
|
|
|
|
|
@transaction.atomic
|
|
def add_location_property(request):
|
|
if request.method == "POST":
|
|
form = PropertyLocationForm(request.POST)
|
|
if form.is_valid():
|
|
entry = form.save(commit=False)
|
|
entry.source = PropertyEntry.Source.CURRENT_LOCATION
|
|
apply_idealista_fallback(entry)
|
|
entry.save()
|
|
messages.success(request, "Property pinned to the public list.")
|
|
return redirect(entry.get_absolute_url())
|
|
else:
|
|
form = PropertyLocationForm()
|
|
context = {
|
|
"page_title": "Add property by location or address — NearbyNest",
|
|
"meta_description": "Add a property sighting from current GPS location or a typed/pasted address, with optional contact details.",
|
|
"form": form,
|
|
}
|
|
return render(request, "core/property_form_location.html", context)
|
|
|
|
|
|
@transaction.atomic
|
|
def add_photo_property(request):
|
|
if request.method == "POST":
|
|
form = PropertyPhotoForm(request.POST, request.FILES)
|
|
if form.is_valid():
|
|
uploaded = form.cleaned_data["photo"]
|
|
processed, error = compress_image(uploaded, uploaded.name)
|
|
if error:
|
|
form.add_error("photo", error)
|
|
else:
|
|
uploaded.seek(0)
|
|
entry = form.save(commit=False)
|
|
entry.source = PropertyEntry.Source.PHOTO
|
|
entry.photo = processed["file"]
|
|
if processed["latitude"] is not None and not entry.latitude:
|
|
entry.latitude = processed["latitude"]
|
|
entry.longitude = processed["longitude"]
|
|
entry.has_gps_data = True
|
|
entry.extracted_text = ocr_text_best_effort(uploaded)
|
|
apply_idealista_fallback(entry)
|
|
entry.save()
|
|
messages.success(request, "Photo compressed under 256KB and added to the pinboard.")
|
|
return redirect(entry.get_absolute_url())
|
|
else:
|
|
form = PropertyPhotoForm()
|
|
context = {
|
|
"page_title": "Add property photo — NearbyNest",
|
|
"meta_description": "Upload a property photo; the MVP compresses it, checks EXIF GPS, and attempts local OCR.",
|
|
"form": form,
|
|
}
|
|
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)
|
|
if request.method != "POST":
|
|
return redirect(entry.get_absolute_url())
|
|
form = PropertySuggestionForm(request.POST)
|
|
if form.is_valid():
|
|
suggestion = form.save(commit=False)
|
|
suggestion.property_entry = entry
|
|
suggestion.save()
|
|
messages.success(request, "Thanks — your suggested details were saved for review.")
|
|
else:
|
|
messages.error(request, "Please add at least one valid detail before submitting a suggestion.")
|
|
return redirect(entry.get_absolute_url())
|
|
|
|
|
|
@transaction.atomic
|
|
def flag_property(request, pk):
|
|
entry = get_object_or_404(PropertyEntry, pk=pk)
|
|
if request.method != "POST":
|
|
return redirect(entry.get_absolute_url())
|
|
form = PropertyFlagForm(request.POST)
|
|
if form.is_valid():
|
|
flag = form.save(commit=False)
|
|
flag.property_entry = entry
|
|
flag.save()
|
|
entry.flag_count = entry.flags.count()
|
|
entry.is_flagged = entry.flag_count >= 3
|
|
entry.save(update_fields=["flag_count", "is_flagged", "updated_at"])
|
|
messages.success(request, "Flag saved. Entries with repeated flags are hidden from the public list.")
|
|
else:
|
|
messages.error(request, "Add a short reason so reviewers know what to check.")
|
|
return redirect(entry.get_absolute_url())
|