From cb35610046b5e896740ba63024a56d643f2c7630 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Thu, 4 Jun 2026 18:40:22 +0000 Subject: [PATCH] 1.0.3 --- core/__pycache__/admin.cpython-311.pyc | Bin 2291 -> 2306 bytes core/__pycache__/forms.cpython-311.pyc | Bin 7515 -> 11280 bytes core/__pycache__/models.cpython-311.pyc | Bin 6187 -> 6695 bytes core/__pycache__/tests.cpython-311.pyc | Bin 0 -> 2771 bytes core/__pycache__/urls.cpython-311.pyc | Bin 1142 -> 1270 bytes core/__pycache__/views.cpython-311.pyc | Bin 11844 -> 13357 bytes core/admin.py | 2 +- core/forms.py | 83 ++++++++++++++++-- core/models.py | 8 ++ .../core/partials/property_card.html | 2 +- core/templates/core/property_detail.html | 15 +++- .../core/property_form_location.html | 5 +- core/templates/core/property_form_photo.html | 3 +- core/tests.py | 55 +++++++++++- core/urls.py | 2 + core/views.py | 37 +++++++- 16 files changed, 197 insertions(+), 15 deletions(-) create mode 100644 core/__pycache__/tests.cpython-311.pyc diff --git a/core/__pycache__/admin.cpython-311.pyc b/core/__pycache__/admin.cpython-311.pyc index 095ec4f7e9b78887d0c6ab24737eb1be1665a781..d688b31ba0cd181a8d30c922e9e9205ffef779ae 100644 GIT binary patch delta 190 zcmew?*d)ZeoR^o20SMfVDrOzo$eYHZ#9AZ;yxP45h5T$3rtKFWYc2Qnry=sV4@8axW!hK znpm8lSHunEXfhTV04cvBU64{V?fM`w{mBP_+Vv;DW=m%So2NfHf?bx;Y;zfVEh7N+ C_bdni delta 175 zcmZn?`Yg!1oR^o20SFpbC}wTm$eYF@%u*x;^nsLL nkq$^Hnsz;qnBL^`K<#>yf3c;rfz8vKoX#%GXu7$Ty_OLG*!w0G diff --git a/core/__pycache__/forms.cpython-311.pyc b/core/__pycache__/forms.cpython-311.pyc index 5ac251f2397de19670ce6f3cb1c77e38a3b68886..8a4c55e41eb860cf89562c2d44b67de39cacd387 100644 GIT binary patch literal 11280 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 CSvE8P delta 2286 zcma)7O>7fK6rQoY>)81*cH+drBw3RXupuN+B23C(2nkSx0kuHoHf*=qP3m-QhgrM) zNF}sFYA*fL2*IJJ7PX|MQlngY;83Yj&sIy-XxpktJygA!7PVC>s=hZHh%xkMy#99P zdoyp|y!YnqTTg#G5d5IN-cO*-ei)ma3lQ=np9_VL+ydq@(TPq?lBH=%iSE+frYq}C zyR)9OCo82TN?c?g(LEQ5F1_y}wv9aX2Z35!;>T)_!+l}Q(GF(v#iCxqRIKIb;n%P=64&0rfqpVW0<;S%P(nW z(SY~qj6Pu$Y@e!*nVMy(s$HY1IW22IPtpnnX4!6>AXx>LnTmUDk7bzSwnt+Vu=>T* z;w=fStO>TZy<9c`CLGu-uW*B0ea$Bm_XtZYlfHY zSJiA@FPgY{ovNNHYNlff3!=Dw$;dIoG*xw$I$KZ>?PeW-%y$oDzApHLU%59Ndpj6T z+|zy%%fJ zz8P|cru}>@wAOFeFymA)!wj9j8ET_V{NvD1ROXi&2l&m#B!9;pZ9mtjYXz+*tL5O} zJ({jF!?NI*Enp$*=bM`n(;Kn731KtBV*shyTH!qwLuNIC1Yk?xO2cF!B-}{&o-)o9 z4&n8tST^Wj$SB6Kg&%RE>g<+oy8{QK~$E-S*%HYaPv8G3PQAHUJ8Kf40D zwlroMT8>4K7S;xiYi0)gk;&(V8OyV{%+LzRFPv!TtU?Vg66q#G&&f6?AqM$WONZPA zto1U#)N(KsXf6}#+wZ!vzqD$@C%#hOAg_&dO)D@X{*W7m9d&z+u}oGoNATjXTA0Wj z+p)I`VGyAcL2Oi!kKITjV0K$`0Dn~YFA$q4-VDU9^?%C0h)iwvN-bsb|G2E=~f<&Zs|&bH7T|YknKcJ5X3DK zhuROrUY?D%diRxr`0&@G6BPmGjjOKH1N^g9YSOM-3WUTb`ch#jV*?2HuM~S6huaYv z5a1g@*ban8x%4OqhWMY#yGw4Ie=oL$ZorI>yKUFTKGudShX5?hJVm)6;<4!4K4IjH zGgE9Q|7-OodW83NCaJl#2VRwaU2cQGWbK$Bn{@ z<2>EHcR<0lXjUrjsn#p3t@8UbI3M8{U+5m5R_+ZUytb@QYPpHLQ*C5XEGmcIOkelRXcMu^3qE0n2Vmo9F_GlpcI;@uP_* zt|nxuM25;T{~^(LGEAWoJPf61&$Z(9>Dwr}vn=lm(r}qP45dio+VJ(mw^4LwS-y^2 P>qi@iO#Nj9aqs^Ie>nOJ diff --git a/core/__pycache__/models.cpython-311.pyc b/core/__pycache__/models.cpython-311.pyc index ca61d903e5409929fb39446a527e4716a8483cba..c7b22a7cd6a6ea1a4b95ea498e9f56a28c3ebc74 100644 GIT binary patch delta 888 zcmZ{f&u1II+^+8&3kX&e80QnI}@%Qr_(`T z&3=xp6^GusL`r|w&d{&I1!Y(cZ;@9}+r*ebNqnEygPf({=Y$-9E*zlE-hl z3u6nMA074m+L<6z4fJ=^`%@)Fi(c=Fjvh)9|D>y?nJBWL-H%gt1I8Zl zD&-Wla{7P9dOa6mnjQdvvC*;pQE-&4yGL-(W$X4V8$fRh@pJt6;CCyVhYSBTXxK~@ zDNa3Jw;gf}VS}R=YuiKTg{wAVb2SBOO*RRjWr6F~olc@#k|@?B+E?tR%tGypYf-SO zF!TzKc;3+&zURqZx{1GCMl2z2A#Nkk1dAb-5qANNW7@i*FbVHD-tvdtD;Oe@aP&40 zi(&r$!~tF5-&`U7+WU#Fa?O{aMQ|2$RG`T;s_~{TPfL8sKS|U4segL-Ar3N~GmLbE zDQy#@-j9wl-Vt*=D88g+{!v`EnKH`!WFSnR@MIu0k)UY(-`t*kHs3O9?fwp#e+ZO* E1Is_)(*OVf delta 426 zcmZ2(vf6-eIWI340}wQ>P|V8b-pD7$$areAD&t8ewiMZ522Hum3M`WB%zm0`lRpTH zPJY32ivJdGW=d*ePG)gQVti>)&gAX9C6i70WEo912lMGLi7_y;dN6(flSQIHgNrzT zMDj#I(anzqjF?!{fjrB}Qo@@UZ6==;PO|g{DX{_(ejvghLR&HW-wjI1Cg$gat&MfWi#PtFpHXUv#v$R|8mK$w-Q z6e#Hm(vUg%jaV{c_T&I@Ge(ce?cz=LyK}jL>`C$yR002KczG$)vl;!vWcX>i8>=FXt0wL+@|<^1j~Iv LRuM9JyJP_X7x`sS diff --git a/core/__pycache__/tests.cpython-311.pyc b/core/__pycache__/tests.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0b4677a21eece27ddae5386a340fbf5404b44680 GIT binary patch literal 2771 zcmc&$&rcgi6rQzr?J?jOrBua$i%F=c(_$cm0)a-V+NNq7P?c~(s#Y7%1Ptq4GP@3i zQYnWVs$5d32S|hz4iyEFQ;+=vdbZI@u|}#Csi)i=krSuB*|pag+=Tv+Iy?LM?Ywz! z-t2zwjem|tn-H{--+C6#h7tNhJXD)f5e_bcu#N~Kl8$oHtRx{4AVEEl3(f{56hNOL zBHu$qq0)8`p*^_0HY=0R4U`H$7mq24;~8q%A8QtUE_nlOUomruGJEBknbGX5X{ z>&QN$Ba+x0k>LWTB(xcR65a;=oish8X=5Y7RNeJ z!p;-QBf4qKd7`p3ZyJ;0mo2K_;_@wEt|dNXGI`iPFmLAQK>lXlu=4}-8=A@6CL35b*`l?iW#~Z0WOTq5 ztV}Ph@IE1_m2Io$9f$#TVyQQoxePI?dL=nlKl)2@r%&kuQXE;o0Nh2t$Id_N94vM9 zl@jlk;+>)=Q>C8s_X*dYz8opC2VQW(8f#8VEQDpdOcM2#9JaVsQa!z7jYNa9d^s{HRs9Iho%ePpLh z;#U*_Hw7cptQp=IiwF?)j$091I+ zl49DJOTFV5{E3&r?G+OrPE`P7RwfN>;DGhR*-MrwH8o5un2F#DCB4u6^9K zG2!s_D9_rWCK_Rto1;O^2!`c*b#s zVus7Olicd9FKe|@DCouL?R-}7=*nw&bbrsOc_P}8Mn0T7qom8| zBzOx5-_!87s+lZk#+>OMGfdEgEAD~k6%IFoG>q1wI>eLSPOh*aP>6p^*4F^#pd?8p elq}TuXQ;1G-^-zk5(IV%)sy%#DM``DC4uvM0InPT<$3DiE;bAxl=^euq^|s zT@Az#z{HTsm?E0Wnj(fSlg=5%lOi6>pegYZq*Rmf7E3{5NybY?5QlTJA7h)CXlX%8 zVo7RzW=d*ePG)gQVth_!UiRe6jB6R?CO0yP@W3VXi+Cs3FsU-~PF}?1$OPm(U<%+8 z1DXUftvGbD6|+n|7bDXL26_=v46K|DE*(N0Av4%6u*hFzk)M2;xmQ5*g0{m2_qYoz c@fTU*udu{V4rCGI7v|z;YTyRJA`zf-0nXxA%m4rY delta 305 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 Sc3_D8v@5|#_aVIqOZhLOCj2G< delta 2204 zcmah}eQZ-z6z|>IuHSuIyRBVY`mwQ5HVZOf;cM9x<_8EvG%?Gxy!E|x9k1=U?`=XD zFcTz-5t+w73;{%p5;Z0y{RjUcniyjYe{9K2uuAZ6;|IiO4E~|#-0m|3^d^0 zdCD}Vnnr$t|Ilcl)6)<5AgqEaUvLquHgF?`TORt z48&Gk@Go9i;Kx_+>;J`1DC~eK*#M_m)@v?H{k%A)a^q;9CTB~HRX;NeS?2+}SuSn0 z^j8NWcr3fpN=`TALPkv=%JWVn)t9c=Bxa4`Hd76<g$A?J856e2;} zf}C!}V@xa_f$tqbOE(eVzC&YC7?gsgDW|=P`QeP)@Ya%OO>t;c%@{flfZLO5-6Ck2 zV!=?47>iuXXkrzL znhTcW)rJ?&H?_c}hPT&XVwW5e$@S{v*{rG?xndzbT;%z(eHg0r5(_lN=eLQG@|bt?}LM>C={C7 zS@ga0O>Ch6FNZo-OJ!?bHAadGccBa)MJU^HdU`_Ea!T2(%M&WEg?ph^A9Y~aol{ig zVArL`xdx%|Lyw9wD3I`jVpA_GN?I#sWRa7FK=C6eGl-vl3tkI9?M-aDrp_j|&n33M zvTM#2g&)JmDm}wuFck^27cdadvlRRlIcE7A9kY{TrL(J&Oq#T$8&Hv0la|X4;qpwh z&b@AdYBG%m;MZuxOH*Qg;mxLH!HI$vbFnTs8f%GD82C;CD$G892(fG)&S@&&MAXf2 zBX)!3;ds2cBg~6LaubN~S^A#Xv+DpKBNtZ^wR zbc+fW7fy%iM8I(xDP)a)3`&VVy{%iWubyq~nQQGiXPJ|l;d=8C96GgY?lD(}l#_QD z3-GKx+al~2jxRkf+D3$qeCLPZv$er4Vc^Sn#!eNbq7YA5MMYC~EVZj{LBfK4QhUU4 z2G4Ker+*FIZQU^5ehw?*;g(=2+M!gfwCRmhBkP5$sjEZ(Wtr&ZWtO2%{tCem!BYgU z6Z8@+#q|MZ!?kt(pqTM)8ZGI?ik?RCE2Z1(YMG6uTC$lZ;E(mMo-8|ac{!`5jbc&L zxtAW15U}-%ii#9rvPdkIXJyjvryUJ{F)GD4DO+Wun9pTI=Wt3MrwDO+_Cph=C&e(j zZ`+QF$2GN^UqBk8p{IY&z_($d?FW-G^MAivIGN`*Y=c#ud+Y41m)$ij!vLJ<)WE(m IW~TT40q4N`+yDRo 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)