Add listing decisions (like/dislike) backend with detail endpoint
- ListingDecision model with unique constraint on (user_id, listing_id, listing_type)
- Alembic migration for listingdecision table
- DecisionRepository with dialect-aware upsert (MySQL/SQLite)
- DecisionService with input validation
- Decision API routes: PUT/GET/DELETE on /api/decisions
- GET /api/listing/{id}/detail endpoint extracting full property info from additional_info
- Add listing ID to GeoJSON feature properties
- Decision filtering on GeoJSON stream endpoint (decision_filter param)
This commit is contained in:
parent
a2e7d59af2
commit
9e1beb7495
7 changed files with 447 additions and 138 deletions
49
alembic/versions/d7e8f9a0b1c2_add_listing_decision.py
Normal file
49
alembic/versions/d7e8f9a0b1c2_add_listing_decision.py
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
"""add listing decision
|
||||||
|
|
||||||
|
Revision ID: d7e8f9a0b1c2
|
||||||
|
Revises: c5d6e7f8a9b0
|
||||||
|
Create Date: 2026-02-21 12:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'd7e8f9a0b1c2'
|
||||||
|
down_revision: Union[str, None] = 'c5d6e7f8a9b0'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Create listingdecision table."""
|
||||||
|
op.create_table('listingdecision',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('listing_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('listing_type', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
sa.Column('decision', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=True,
|
||||||
|
server_default=sa.func.now()),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=True,
|
||||||
|
server_default=sa.func.now()),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['user.id']),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('user_id', 'listing_id', 'listing_type',
|
||||||
|
name='uq_decision_user_listing_type'),
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_listingdecision_user_id'),
|
||||||
|
'listingdecision', ['user_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_listingdecision_listing_id'),
|
||||||
|
'listingdecision', ['listing_id'], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Drop listingdecision table."""
|
||||||
|
op.drop_index(op.f('ix_listingdecision_listing_id'), table_name='listingdecision')
|
||||||
|
op.drop_index(op.f('ix_listingdecision_user_id'), table_name='listingdecision')
|
||||||
|
op.drop_table('listingdecision')
|
||||||
300
api/app.py
300
api/app.py
|
|
@ -6,8 +6,8 @@ import logging.config
|
||||||
from typing import Annotated, AsyncGenerator, Optional
|
from typing import Annotated, AsyncGenerator, Optional
|
||||||
from api.auth import get_current_user
|
from api.auth import get_current_user
|
||||||
from api.config import DEV_TIER_ORIGINS, PROD_TIER_ORIGINS, APP_ENV
|
from api.config import DEV_TIER_ORIGINS, PROD_TIER_ORIGINS, APP_ENV
|
||||||
from api.passkey_routes import passkey_router
|
|
||||||
from api.decision_routes import decision_router
|
from api.decision_routes import decision_router
|
||||||
|
from api.passkey_routes import passkey_router
|
||||||
from api.poi_routes import poi_router
|
from api.poi_routes import poi_router
|
||||||
from api.ws_routes import ws_router
|
from api.ws_routes import ws_router
|
||||||
from api.rate_limit_config import RateLimitConfig
|
from api.rate_limit_config import RateLimitConfig
|
||||||
|
|
@ -19,6 +19,7 @@ from api.origin_validator import OriginValidatorMiddleware
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from fastapi import Depends, FastAPI, HTTPException, Query
|
from fastapi import Depends, FastAPI, HTTPException, Query
|
||||||
from fastapi.responses import JSONResponse, StreamingResponse
|
from fastapi.responses import JSONResponse, StreamingResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from api.auth import User
|
from api.auth import User
|
||||||
from models.listing import QueryParameters, ListingType, FurnishType
|
from models.listing import QueryParameters, ListingType, FurnishType
|
||||||
|
|
@ -28,7 +29,8 @@ from database import engine
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from ui_exporter import convert_to_geojson_feature, convert_row_to_geojson
|
from ui_exporter import convert_to_geojson_feature, convert_row_to_geojson
|
||||||
|
|
||||||
from services import listing_service, export_service, district_service, task_service, decision_service
|
from services import listing_service, export_service, district_service, task_service
|
||||||
|
from services import decision_service
|
||||||
from services.listing_cache import (
|
from services.listing_cache import (
|
||||||
get_cached_count,
|
get_cached_count,
|
||||||
get_cached_features,
|
get_cached_features,
|
||||||
|
|
@ -37,8 +39,8 @@ from services.listing_cache import (
|
||||||
finalize_cache_population,
|
finalize_cache_population,
|
||||||
delete_staging_key,
|
delete_staging_key,
|
||||||
)
|
)
|
||||||
from repositories.decision_repository import DecisionRepository
|
|
||||||
from repositories.poi_repository import POIRepository
|
from repositories.poi_repository import POIRepository
|
||||||
|
from repositories.decision_repository import DecisionRepository
|
||||||
from repositories.user_repository import UserRepository
|
from repositories.user_repository import UserRepository
|
||||||
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
|
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
|
||||||
from api.metrics import init_metrics, get_metrics_asgi_app
|
from api.metrics import init_metrics, get_metrics_asgi_app
|
||||||
|
|
@ -169,13 +171,8 @@ async def get_listing_geojson(
|
||||||
user: Annotated[User, Depends(get_current_user)],
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
query_parameters: Annotated[QueryParameters, Depends(get_query_parameters)],
|
query_parameters: Annotated[QueryParameters, Depends(get_query_parameters)],
|
||||||
limit: int | None = None,
|
limit: int | None = None,
|
||||||
decision_filter: str = "all",
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Get listings as GeoJSON for map display.
|
"""Get listings as GeoJSON for map display."""
|
||||||
|
|
||||||
decision_filter: 'all' (hide disliked, default), 'liked', 'disliked',
|
|
||||||
'undecided', 'everything' (no filtering).
|
|
||||||
"""
|
|
||||||
if limit is not None:
|
if limit is not None:
|
||||||
limit = min(limit, _rate_limit_config.geojson_limit_cap)
|
limit = min(limit, _rate_limit_config.geojson_limit_cap)
|
||||||
else:
|
else:
|
||||||
|
|
@ -186,36 +183,10 @@ async def get_listing_geojson(
|
||||||
query_parameters=query_parameters,
|
query_parameters=query_parameters,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Filter features based on decision_filter
|
|
||||||
if decision_filter != "everything":
|
|
||||||
disliked_ids = _get_disliked_ids(
|
|
||||||
user.email, query_parameters.listing_type.value
|
|
||||||
)
|
|
||||||
if disliked_ids:
|
|
||||||
str_disliked = {str(lid) for lid in disliked_ids}
|
|
||||||
result.data["features"] = [
|
|
||||||
f for f in result.data["features"]
|
|
||||||
if f.get("properties", {}).get("url", "").split("/")[-1]
|
|
||||||
not in str_disliked
|
|
||||||
]
|
|
||||||
|
|
||||||
return result.data
|
return result.data
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _get_disliked_ids(user_email: str, listing_type: str) -> set[int]:
|
|
||||||
"""Get the set of disliked listing IDs for a user."""
|
|
||||||
user_repo = UserRepository(engine)
|
|
||||||
db_user = user_repo.get_user_by_email(user_email)
|
|
||||||
if not db_user or db_user.id is None:
|
|
||||||
return set()
|
|
||||||
decision_repo = DecisionRepository(engine)
|
|
||||||
return decision_service.get_disliked_listing_ids(
|
|
||||||
decision_repo, user_id=db_user.id, listing_type=listing_type
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_poi_distances_lookup(
|
def _build_poi_distances_lookup(
|
||||||
user_email: str,
|
user_email: str,
|
||||||
listing_type: ListingType,
|
listing_type: ListingType,
|
||||||
|
|
@ -250,10 +221,41 @@ def _build_poi_distances_lookup(
|
||||||
return lookup
|
return lookup
|
||||||
|
|
||||||
|
|
||||||
|
def _get_user_id_safe(user_email: str) -> int | None:
|
||||||
|
"""Get database user ID by email, or None if user doesn't exist."""
|
||||||
|
try:
|
||||||
|
user_repo = UserRepository(engine)
|
||||||
|
db_user = user_repo.get_user_by_email(user_email)
|
||||||
|
if db_user is None or db_user.id is None:
|
||||||
|
return None
|
||||||
|
return db_user.id
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Could not look up user ID for %s", user_email)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _should_include(
|
||||||
|
feature_id: int,
|
||||||
|
decision_filter: str,
|
||||||
|
disliked_ids: set[int] | None,
|
||||||
|
liked_ids: set[int] | None,
|
||||||
|
) -> bool:
|
||||||
|
"""Determine if a listing should be included based on decision filter."""
|
||||||
|
if decision_filter == "everything":
|
||||||
|
return True
|
||||||
|
if decision_filter == "liked":
|
||||||
|
return liked_ids is not None and feature_id in liked_ids
|
||||||
|
# default "all": hide disliked
|
||||||
|
return disliked_ids is None or feature_id not in disliked_ids
|
||||||
|
|
||||||
|
|
||||||
async def _stream_from_cache(
|
async def _stream_from_cache(
|
||||||
query_parameters: QueryParameters,
|
query_parameters: QueryParameters,
|
||||||
batch_size: int,
|
batch_size: int,
|
||||||
limit: int | None,
|
limit: int | None,
|
||||||
|
disliked_ids: set[int] | None = None,
|
||||||
|
liked_ids: set[int] | None = None,
|
||||||
|
decision_filter: str = "all",
|
||||||
) -> AsyncGenerator[str, None]:
|
) -> AsyncGenerator[str, None]:
|
||||||
"""Stream GeoJSON features from the Redis cache (cache-hit path)."""
|
"""Stream GeoJSON features from the Redis cache (cache-hit path)."""
|
||||||
cached_count = get_cached_count(query_parameters)
|
cached_count = get_cached_count(query_parameters)
|
||||||
|
|
@ -268,10 +270,22 @@ async def _stream_from_cache(
|
||||||
|
|
||||||
count = 0
|
count = 0
|
||||||
for feature_batch in get_cached_features(query_parameters, batch_size=batch_size):
|
for feature_batch in get_cached_features(query_parameters, batch_size=batch_size):
|
||||||
|
# Apply decision filtering
|
||||||
|
if decision_filter != "everything":
|
||||||
|
feature_batch = [
|
||||||
|
f for f in feature_batch
|
||||||
|
if _should_include(
|
||||||
|
f.get("properties", {}).get("id", 0),
|
||||||
|
decision_filter,
|
||||||
|
disliked_ids,
|
||||||
|
liked_ids,
|
||||||
|
)
|
||||||
|
]
|
||||||
if limit and count + len(feature_batch) > limit:
|
if limit and count + len(feature_batch) > limit:
|
||||||
feature_batch = feature_batch[:limit - count]
|
feature_batch = feature_batch[:limit - count]
|
||||||
count += len(feature_batch)
|
count += len(feature_batch)
|
||||||
yield json.dumps({"type": "batch", "features": feature_batch}) + "\n"
|
if feature_batch:
|
||||||
|
yield json.dumps({"type": "batch", "features": feature_batch}) + "\n"
|
||||||
if limit and count >= limit:
|
if limit and count >= limit:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|
@ -285,6 +299,8 @@ async def _stream_from_db(
|
||||||
poi_distances_lookup: dict[int, list[dict[str, str | int]]] | None = None,
|
poi_distances_lookup: dict[int, list[dict[str, str | int]]] | None = None,
|
||||||
skip_cache: bool = False,
|
skip_cache: bool = False,
|
||||||
disliked_ids: set[int] | None = None,
|
disliked_ids: set[int] | None = None,
|
||||||
|
liked_ids: set[int] | None = None,
|
||||||
|
decision_filter: str = "all",
|
||||||
) -> AsyncGenerator[str, None]:
|
) -> AsyncGenerator[str, None]:
|
||||||
"""Stream GeoJSON features from the database, populating the cache as we go."""
|
"""Stream GeoJSON features from the database, populating the cache as we go."""
|
||||||
repository = ListingRepository(engine)
|
repository = ListingRepository(engine)
|
||||||
|
|
@ -309,13 +325,18 @@ async def _stream_from_db(
|
||||||
for row in repository.stream_listings_optimized(
|
for row in repository.stream_listings_optimized(
|
||||||
query_parameters, limit=limit, page_size=batch_size
|
query_parameters, limit=limit, page_size=batch_size
|
||||||
):
|
):
|
||||||
# Skip disliked listings
|
|
||||||
if disliked_ids and row['id'] in disliked_ids:
|
|
||||||
continue
|
|
||||||
feature = convert_row_to_geojson(row, query_parameters.listing_type.value)
|
feature = convert_row_to_geojson(row, query_parameters.listing_type.value)
|
||||||
# Inject POI distances if available
|
# Inject POI distances if available
|
||||||
if poi_distances_lookup and row['id'] in poi_distances_lookup:
|
if poi_distances_lookup and row['id'] in poi_distances_lookup:
|
||||||
feature['properties']['poi_distances'] = poi_distances_lookup[row['id']]
|
feature['properties']['poi_distances'] = poi_distances_lookup[row['id']]
|
||||||
|
|
||||||
|
# Apply decision filtering
|
||||||
|
if not _should_include(row['id'], decision_filter, disliked_ids, liked_ids):
|
||||||
|
# Still cache the feature (it's valid data), just don't stream it
|
||||||
|
if staging_key:
|
||||||
|
cache_features_batch_staged(staging_key, [feature])
|
||||||
|
continue
|
||||||
|
|
||||||
batch.append(feature)
|
batch.append(feature)
|
||||||
count += 1
|
count += 1
|
||||||
|
|
||||||
|
|
@ -349,6 +370,7 @@ async def stream_listing_geojson(
|
||||||
batch_size: int = DEFAULT_BATCH_SIZE,
|
batch_size: int = DEFAULT_BATCH_SIZE,
|
||||||
limit: int | None = None,
|
limit: int | None = None,
|
||||||
include_poi_distances: bool = False,
|
include_poi_distances: bool = False,
|
||||||
|
decision_filter: str = "all",
|
||||||
) -> StreamingResponse:
|
) -> StreamingResponse:
|
||||||
"""Stream listings as NDJSON for progressive map loading.
|
"""Stream listings as NDJSON for progressive map loading.
|
||||||
|
|
||||||
|
|
@ -356,6 +378,11 @@ async def stream_listing_geojson(
|
||||||
- metadata: Initial message with batch_size and total_expected count
|
- metadata: Initial message with batch_size and total_expected count
|
||||||
- batch: Array of GeoJSON features
|
- batch: Array of GeoJSON features
|
||||||
- complete: Final message with total count
|
- complete: Final message with total count
|
||||||
|
|
||||||
|
Decision filter options:
|
||||||
|
- "all" (default): Show all listings except disliked ones
|
||||||
|
- "liked": Show only liked listings
|
||||||
|
- "everything": Show all listings including disliked
|
||||||
"""
|
"""
|
||||||
batch_size = min(batch_size, _rate_limit_config.geojson_stream_batch_size_cap)
|
batch_size = min(batch_size, _rate_limit_config.geojson_stream_batch_size_cap)
|
||||||
if limit is not None:
|
if limit is not None:
|
||||||
|
|
@ -366,21 +393,41 @@ async def stream_listing_geojson(
|
||||||
# Build POI distances lookup if requested
|
# Build POI distances lookup if requested
|
||||||
poi_distances_lookup = _build_poi_distances_lookup(user.email, query_parameters.listing_type) if include_poi_distances else None
|
poi_distances_lookup = _build_poi_distances_lookup(user.email, query_parameters.listing_type) if include_poi_distances else None
|
||||||
|
|
||||||
# Get disliked listing IDs to exclude from stream
|
# Build decision filter sets
|
||||||
disliked_ids = _get_disliked_ids(
|
disliked_ids: set[int] | None = None
|
||||||
user.email, query_parameters.listing_type.value
|
liked_ids: set[int] | None = None
|
||||||
)
|
if decision_filter != "everything":
|
||||||
|
user_id = _get_user_id_safe(user.email)
|
||||||
|
if user_id is not None:
|
||||||
|
decision_repo = DecisionRepository(engine)
|
||||||
|
listing_type_str = query_parameters.listing_type.value
|
||||||
|
if decision_filter == "liked":
|
||||||
|
liked_ids = decision_service.get_liked_ids(
|
||||||
|
decision_repo, user_id, listing_type_str
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# default "all": load disliked to exclude
|
||||||
|
disliked_ids = decision_service.get_disliked_ids(
|
||||||
|
decision_repo, user_id, listing_type_str
|
||||||
|
)
|
||||||
|
|
||||||
cached_count = get_cached_count(query_parameters)
|
cached_count = get_cached_count(query_parameters)
|
||||||
if cached_count is not None and cached_count > 0 and not include_poi_distances:
|
if cached_count is not None and cached_count > 0 and not include_poi_distances:
|
||||||
app_metrics.geojson_cache_operations.add(1, {"result": "hit"})
|
app_metrics.geojson_cache_operations.add(1, {"result": "hit"})
|
||||||
generator = _stream_from_cache(query_parameters, batch_size, limit)
|
generator = _stream_from_cache(
|
||||||
|
query_parameters, batch_size, limit,
|
||||||
|
disliked_ids=disliked_ids,
|
||||||
|
liked_ids=liked_ids,
|
||||||
|
decision_filter=decision_filter,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
app_metrics.geojson_cache_operations.add(1, {"result": "miss"})
|
app_metrics.geojson_cache_operations.add(1, {"result": "miss"})
|
||||||
generator = _stream_from_db(
|
generator = _stream_from_db(
|
||||||
query_parameters, batch_size, limit, poi_distances_lookup,
|
query_parameters, batch_size, limit, poi_distances_lookup,
|
||||||
skip_cache=include_poi_distances,
|
skip_cache=include_poi_distances,
|
||||||
disliked_ids=disliked_ids if disliked_ids else None,
|
disliked_ids=disliked_ids,
|
||||||
|
liked_ids=liked_ids,
|
||||||
|
decision_filter=decision_filter,
|
||||||
)
|
)
|
||||||
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
|
|
@ -491,4 +538,167 @@ async def get_districts(
|
||||||
return district_service.get_all_districts()
|
return district_service.get_all_districts()
|
||||||
|
|
||||||
|
|
||||||
|
class ListingDetailResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
price: float
|
||||||
|
number_of_bedrooms: int
|
||||||
|
square_meters: float | None
|
||||||
|
agency: str | None
|
||||||
|
council_tax_band: str | None
|
||||||
|
url: str
|
||||||
|
listing_type: str
|
||||||
|
description: str | None
|
||||||
|
display_address: str | None
|
||||||
|
property_sub_type: str | None
|
||||||
|
key_features: list[str]
|
||||||
|
photos: list[dict]
|
||||||
|
floorplans: list[dict]
|
||||||
|
price_history: list[dict]
|
||||||
|
furnish_type: str | None
|
||||||
|
available_from: str | None
|
||||||
|
service_charge: float | None
|
||||||
|
lease_left: int | None
|
||||||
|
decision: str | None
|
||||||
|
poi_distances: list[dict]
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/listing/{listing_id}/detail", response_model=ListingDetailResponse)
|
||||||
|
async def get_listing_detail(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
listing_id: int,
|
||||||
|
listing_type: str = Query(default="RENT"),
|
||||||
|
) -> ListingDetailResponse:
|
||||||
|
"""Get detailed information for a single listing."""
|
||||||
|
repository = ListingRepository(engine)
|
||||||
|
lt = ListingType(listing_type)
|
||||||
|
listings = await repository.get_listings(
|
||||||
|
only_ids=[listing_id], listing_type=lt
|
||||||
|
)
|
||||||
|
if not listings:
|
||||||
|
raise HTTPException(status_code=404, detail="Listing not found")
|
||||||
|
|
||||||
|
listing = listings[0]
|
||||||
|
additional_info = listing.additional_info or {}
|
||||||
|
property_info = additional_info.get("property", {})
|
||||||
|
|
||||||
|
# Extract description
|
||||||
|
text_info = property_info.get("text", {})
|
||||||
|
description = text_info.get("description") if isinstance(text_info, dict) else None
|
||||||
|
|
||||||
|
# Extract photos
|
||||||
|
photos_raw = property_info.get("images", [])
|
||||||
|
photos: list[dict] = []
|
||||||
|
if isinstance(photos_raw, list):
|
||||||
|
for img in photos_raw:
|
||||||
|
if isinstance(img, dict):
|
||||||
|
photos.append({
|
||||||
|
"url": img.get("url", ""),
|
||||||
|
"caption": img.get("caption", ""),
|
||||||
|
"type": img.get("type", ""),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Extract floorplans
|
||||||
|
floorplans_raw = property_info.get("floorplans", [])
|
||||||
|
floorplans: list[dict] = []
|
||||||
|
if isinstance(floorplans_raw, list):
|
||||||
|
for fp in floorplans_raw:
|
||||||
|
if isinstance(fp, dict):
|
||||||
|
floorplans.append({
|
||||||
|
"url": fp.get("url", ""),
|
||||||
|
"caption": fp.get("caption", ""),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Extract other fields
|
||||||
|
key_features = property_info.get("keyFeatures", [])
|
||||||
|
if not isinstance(key_features, list):
|
||||||
|
key_features = []
|
||||||
|
display_address_info = property_info.get("address", {})
|
||||||
|
display_address = (
|
||||||
|
display_address_info.get("displayAddress")
|
||||||
|
if isinstance(display_address_info, dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
property_sub_type = property_info.get("propertySubType")
|
||||||
|
council_tax_band = property_info.get("councilTaxBand") or listing.council_tax_band
|
||||||
|
furnish_type_val = property_info.get("letFurnishType")
|
||||||
|
available_from_val = property_info.get("letDateAvailable")
|
||||||
|
|
||||||
|
# Price history
|
||||||
|
price_history = [item.to_dict() for item in listing.price_history]
|
||||||
|
|
||||||
|
# Service charge and lease (for BuyListing)
|
||||||
|
service_charge: float | None = None
|
||||||
|
lease_left: int | None = None
|
||||||
|
if hasattr(listing, "service_charge"):
|
||||||
|
service_charge = listing.service_charge # type: ignore[union-attr]
|
||||||
|
if hasattr(listing, "lease_left"):
|
||||||
|
lease_left = listing.lease_left # type: ignore[union-attr]
|
||||||
|
|
||||||
|
# Available from (for RentListing)
|
||||||
|
if available_from_val is None and hasattr(listing, "available_from"):
|
||||||
|
af = listing.available_from # type: ignore[union-attr]
|
||||||
|
if af is not None:
|
||||||
|
available_from_val = af.isoformat() if hasattr(af, "isoformat") else str(af)
|
||||||
|
|
||||||
|
# Furnish type (for RentListing)
|
||||||
|
if furnish_type_val is None and hasattr(listing, "furnish_type"):
|
||||||
|
ft = listing.furnish_type # type: ignore[union-attr]
|
||||||
|
if ft is not None:
|
||||||
|
furnish_type_val = str(ft)
|
||||||
|
|
||||||
|
# Load user's decision for this listing
|
||||||
|
decision_val: str | None = None
|
||||||
|
user_id = _get_user_id_safe(user.email)
|
||||||
|
if user_id is not None:
|
||||||
|
decision_repo = DecisionRepository(engine)
|
||||||
|
decisions = decision_repo.get_decisions_for_user(user_id)
|
||||||
|
for d in decisions:
|
||||||
|
if d.listing_id == listing_id and d.listing_type == listing_type:
|
||||||
|
decision_val = d.decision
|
||||||
|
break
|
||||||
|
|
||||||
|
# Load POI distances
|
||||||
|
poi_distances_list: list[dict] = []
|
||||||
|
if user_id is not None:
|
||||||
|
poi_repo = POIRepository(engine)
|
||||||
|
pois = {p.id: p for p in poi_repo.get_pois_for_user(user_id)}
|
||||||
|
if pois:
|
||||||
|
distances = poi_repo.get_distances_for_listings(
|
||||||
|
[listing_id], lt, user_id
|
||||||
|
)
|
||||||
|
for d in distances:
|
||||||
|
poi_name = pois[d.poi_id].name if d.poi_id in pois else "Unknown"
|
||||||
|
poi_distances_list.append({
|
||||||
|
"poi_id": d.poi_id,
|
||||||
|
"poi_name": poi_name,
|
||||||
|
"travel_mode": d.travel_mode,
|
||||||
|
"duration_seconds": d.duration_seconds,
|
||||||
|
"distance_meters": d.distance_meters,
|
||||||
|
})
|
||||||
|
|
||||||
|
return ListingDetailResponse(
|
||||||
|
id=listing.id,
|
||||||
|
price=listing.price,
|
||||||
|
number_of_bedrooms=listing.number_of_bedrooms,
|
||||||
|
square_meters=listing.square_meters,
|
||||||
|
agency=listing.agency,
|
||||||
|
council_tax_band=council_tax_band,
|
||||||
|
url=listing.url,
|
||||||
|
listing_type=listing_type,
|
||||||
|
description=description,
|
||||||
|
display_address=display_address,
|
||||||
|
property_sub_type=property_sub_type,
|
||||||
|
key_features=key_features,
|
||||||
|
photos=photos,
|
||||||
|
floorplans=floorplans,
|
||||||
|
price_history=price_history,
|
||||||
|
furnish_type=furnish_type_val,
|
||||||
|
available_from=available_from_val,
|
||||||
|
service_charge=service_charge,
|
||||||
|
lease_left=lease_left,
|
||||||
|
decision=decision_val,
|
||||||
|
poi_distances=poi_distances_list,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
FastAPIInstrumentor.instrument_app(app)
|
FastAPIInstrumentor.instrument_app(app)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
"""API routes for listing decisions (like/dislike)."""
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from api.auth import User, get_current_user
|
from api.auth import User, get_current_user
|
||||||
|
|
@ -18,11 +17,10 @@ decision_router = APIRouter(prefix="/api/decisions", tags=["decisions"])
|
||||||
|
|
||||||
class SetDecisionRequest(BaseModel):
|
class SetDecisionRequest(BaseModel):
|
||||||
decision: str = Field(description="'liked' or 'disliked'")
|
decision: str = Field(description="'liked' or 'disliked'")
|
||||||
listing_type: str = Field(default="RENT", description="'RENT' or 'BUY'")
|
listing_type: str = Field(description="'RENT' or 'BUY'")
|
||||||
|
|
||||||
|
|
||||||
class DecisionResponse(BaseModel):
|
class DecisionResponse(BaseModel):
|
||||||
id: int
|
|
||||||
listing_id: int
|
listing_id: int
|
||||||
listing_type: str
|
listing_type: str
|
||||||
decision: str
|
decision: str
|
||||||
|
|
@ -35,19 +33,30 @@ def _get_user_id(user: User) -> int:
|
||||||
user_repo = UserRepository(engine)
|
user_repo = UserRepository(engine)
|
||||||
db_user = user_repo.get_user_by_email(user.email)
|
db_user = user_repo.get_user_by_email(user.email)
|
||||||
if db_user is None:
|
if db_user is None:
|
||||||
|
# Auto-create user on first decision interaction
|
||||||
db_user = user_repo.create_user(user.email)
|
db_user = user_repo.create_user(user.email)
|
||||||
if db_user.id is None:
|
if db_user.id is None:
|
||||||
raise HTTPException(status_code=500, detail="Failed to create user")
|
raise HTTPException(status_code=500, detail="Failed to create user")
|
||||||
return db_user.id
|
return db_user.id
|
||||||
|
|
||||||
|
|
||||||
|
def _to_response(d: decision_service.ListingDecision) -> DecisionResponse:
|
||||||
|
return DecisionResponse(
|
||||||
|
listing_id=d.listing_id,
|
||||||
|
listing_type=d.listing_type,
|
||||||
|
decision=d.decision,
|
||||||
|
created_at=d.created_at.isoformat(),
|
||||||
|
updated_at=d.updated_at.isoformat(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@decision_router.put("/{listing_id}", response_model=DecisionResponse)
|
@decision_router.put("/{listing_id}", response_model=DecisionResponse)
|
||||||
async def set_decision(
|
async def set_decision(
|
||||||
user: Annotated[User, Depends(get_current_user)],
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
listing_id: int,
|
listing_id: int,
|
||||||
body: SetDecisionRequest,
|
body: SetDecisionRequest,
|
||||||
) -> DecisionResponse:
|
) -> DecisionResponse:
|
||||||
"""Set a decision (like/dislike) on a listing."""
|
"""Set or update a like/dislike decision for a listing."""
|
||||||
user_id = _get_user_id(user)
|
user_id = _get_user_id(user)
|
||||||
repo = DecisionRepository(engine)
|
repo = DecisionRepository(engine)
|
||||||
try:
|
try:
|
||||||
|
|
@ -60,14 +69,7 @@ async def set_decision(
|
||||||
)
|
)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
return DecisionResponse(
|
return _to_response(result)
|
||||||
id=result.id, # type: ignore[arg-type]
|
|
||||||
listing_id=result.listing_id,
|
|
||||||
listing_type=result.listing_type,
|
|
||||||
decision=result.decision,
|
|
||||||
created_at=result.created_at.isoformat(),
|
|
||||||
updated_at=result.updated_at.isoformat(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@decision_router.get("", response_model=list[DecisionResponse])
|
@decision_router.get("", response_model=list[DecisionResponse])
|
||||||
|
|
@ -77,35 +79,25 @@ async def get_decisions(
|
||||||
"""Get all decisions for the current user."""
|
"""Get all decisions for the current user."""
|
||||||
user_id = _get_user_id(user)
|
user_id = _get_user_id(user)
|
||||||
repo = DecisionRepository(engine)
|
repo = DecisionRepository(engine)
|
||||||
decisions = decision_service.get_decisions(repo, user_id=user_id)
|
decisions = decision_service.get_user_decisions(repo, user_id)
|
||||||
return [
|
return [_to_response(d) for d in decisions]
|
||||||
DecisionResponse(
|
|
||||||
id=d.id, # type: ignore[arg-type]
|
|
||||||
listing_id=d.listing_id,
|
|
||||||
listing_type=d.listing_type,
|
|
||||||
decision=d.decision,
|
|
||||||
created_at=d.created_at.isoformat(),
|
|
||||||
updated_at=d.updated_at.isoformat(),
|
|
||||||
)
|
|
||||||
for d in decisions
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@decision_router.delete("/{listing_id}")
|
@decision_router.delete("/{listing_id}")
|
||||||
async def delete_decision(
|
async def delete_decision(
|
||||||
user: Annotated[User, Depends(get_current_user)],
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
listing_id: int,
|
listing_id: int,
|
||||||
listing_type: str = "RENT",
|
listing_type: str = Query(..., description="RENT or BUY"),
|
||||||
) -> dict[str, bool]:
|
) -> dict[str, bool]:
|
||||||
"""Remove a decision (back to neutral)."""
|
"""Remove a decision (un-like/un-dislike)."""
|
||||||
user_id = _get_user_id(user)
|
user_id = _get_user_id(user)
|
||||||
repo = DecisionRepository(engine)
|
repo = DecisionRepository(engine)
|
||||||
deleted = decision_service.clear_decision(
|
try:
|
||||||
repo,
|
deleted = decision_service.remove_decision(
|
||||||
user_id=user_id,
|
repo, user_id, listing_id, listing_type
|
||||||
listing_id=listing_id,
|
)
|
||||||
listing_type=listing_type,
|
except ValueError as e:
|
||||||
)
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
if not deleted:
|
if not deleted:
|
||||||
raise HTTPException(status_code=404, detail="Decision not found")
|
raise HTTPException(status_code=404, detail="Decision not found")
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,20 @@
|
||||||
from datetime import datetime, UTC
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlmodel import SQLModel, Field
|
from sqlmodel import SQLModel, Field, UniqueConstraint
|
||||||
|
|
||||||
|
|
||||||
class ListingDecision(SQLModel, table=True):
|
class ListingDecision(SQLModel, table=True):
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
'user_id', 'listing_id', 'listing_type',
|
||||||
|
name='uq_decision_user_listing_type',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
id: int | None = Field(default=None, primary_key=True)
|
id: int | None = Field(default=None, primary_key=True)
|
||||||
user_id: int = Field(nullable=False, foreign_key="user.id", index=True)
|
user_id: int = Field(nullable=False, foreign_key="user.id", index=True)
|
||||||
listing_id: int = Field(nullable=False, index=True)
|
listing_id: int = Field(nullable=False, index=True)
|
||||||
listing_type: str = Field(nullable=False) # "RENT" or "BUY"
|
listing_type: str = Field(nullable=False) # "RENT" or "BUY"
|
||||||
decision: str = Field(nullable=False) # "liked" or "disliked"
|
decision: str = Field(nullable=False) # "liked" or "disliked"
|
||||||
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
"""Repository for listing decisions (like/dislike)."""
|
from datetime import datetime
|
||||||
from datetime import datetime, UTC
|
|
||||||
|
|
||||||
from models.decision import ListingDecision
|
from models.decision import ListingDecision
|
||||||
from sqlalchemy import Engine
|
from sqlalchemy import Engine
|
||||||
|
|
@ -13,32 +12,53 @@ class DecisionRepository:
|
||||||
self.engine = engine
|
self.engine = engine
|
||||||
|
|
||||||
def upsert_decision(
|
def upsert_decision(
|
||||||
self, user_id: int, listing_id: int, listing_type: str, decision: str
|
self,
|
||||||
|
user_id: int,
|
||||||
|
listing_id: int,
|
||||||
|
listing_type: str,
|
||||||
|
decision: str,
|
||||||
) -> ListingDecision:
|
) -> ListingDecision:
|
||||||
|
"""Create or update a decision. Uses dialect-specific upsert."""
|
||||||
with Session(self.engine) as session:
|
with Session(self.engine) as session:
|
||||||
statement = select(ListingDecision).where(
|
now = datetime.utcnow()
|
||||||
ListingDecision.user_id == user_id,
|
values = {
|
||||||
ListingDecision.listing_id == listing_id,
|
"user_id": user_id,
|
||||||
ListingDecision.listing_type == listing_type,
|
"listing_id": listing_id,
|
||||||
)
|
"listing_type": listing_type,
|
||||||
existing = session.exec(statement).first()
|
"decision": decision,
|
||||||
if existing:
|
"created_at": now,
|
||||||
existing.decision = decision
|
"updated_at": now,
|
||||||
existing.updated_at = datetime.now(UTC)
|
}
|
||||||
session.add(existing)
|
dialect = self.engine.dialect.name
|
||||||
session.commit()
|
if dialect == "mysql":
|
||||||
session.refresh(existing)
|
from sqlalchemy.dialects.mysql import insert as mysql_insert
|
||||||
return existing
|
stmt = mysql_insert(ListingDecision).values(**values)
|
||||||
new_decision = ListingDecision(
|
stmt = stmt.on_duplicate_key_update(
|
||||||
user_id=user_id,
|
decision=stmt.inserted.decision,
|
||||||
listing_id=listing_id,
|
updated_at=stmt.inserted.updated_at,
|
||||||
listing_type=listing_type,
|
)
|
||||||
decision=decision,
|
else:
|
||||||
)
|
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||||||
session.add(new_decision)
|
stmt = sqlite_insert(ListingDecision).values(**values)
|
||||||
|
stmt = stmt.on_conflict_do_update(
|
||||||
|
index_elements=["user_id", "listing_id", "listing_type"],
|
||||||
|
set_={
|
||||||
|
"decision": stmt.excluded.decision,
|
||||||
|
"updated_at": stmt.excluded.updated_at,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
session.execute(stmt)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(new_decision)
|
# Fetch the result
|
||||||
return new_decision
|
result = session.exec(
|
||||||
|
select(ListingDecision).where(
|
||||||
|
ListingDecision.user_id == user_id,
|
||||||
|
ListingDecision.listing_id == listing_id,
|
||||||
|
ListingDecision.listing_type == listing_type,
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
assert result is not None
|
||||||
|
return result
|
||||||
|
|
||||||
def get_decisions_for_user(self, user_id: int) -> list[ListingDecision]:
|
def get_decisions_for_user(self, user_id: int) -> list[ListingDecision]:
|
||||||
with Session(self.engine) as session:
|
with Session(self.engine) as session:
|
||||||
|
|
@ -48,23 +68,29 @@ class DecisionRepository:
|
||||||
return list(session.exec(statement).all())
|
return list(session.exec(statement).all())
|
||||||
|
|
||||||
def delete_decision(
|
def delete_decision(
|
||||||
self, user_id: int, listing_id: int, listing_type: str
|
self,
|
||||||
|
user_id: int,
|
||||||
|
listing_id: int,
|
||||||
|
listing_type: str,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
with Session(self.engine) as session:
|
with Session(self.engine) as session:
|
||||||
statement = select(ListingDecision).where(
|
result = session.exec(
|
||||||
ListingDecision.user_id == user_id,
|
select(ListingDecision).where(
|
||||||
ListingDecision.listing_id == listing_id,
|
ListingDecision.user_id == user_id,
|
||||||
ListingDecision.listing_type == listing_type,
|
ListingDecision.listing_id == listing_id,
|
||||||
)
|
ListingDecision.listing_type == listing_type,
|
||||||
existing = session.exec(statement).first()
|
)
|
||||||
if existing is None:
|
).first()
|
||||||
|
if result is None:
|
||||||
return False
|
return False
|
||||||
session.delete(existing)
|
session.delete(result)
|
||||||
session.commit()
|
session.commit()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def get_disliked_listing_ids(
|
def get_disliked_listing_ids(
|
||||||
self, user_id: int, listing_type: str
|
self,
|
||||||
|
user_id: int,
|
||||||
|
listing_type: str,
|
||||||
) -> set[int]:
|
) -> set[int]:
|
||||||
with Session(self.engine) as session:
|
with Session(self.engine) as session:
|
||||||
statement = select(ListingDecision.listing_id).where(
|
statement = select(ListingDecision.listing_id).where(
|
||||||
|
|
@ -73,3 +99,16 @@ class DecisionRepository:
|
||||||
ListingDecision.decision == "disliked",
|
ListingDecision.decision == "disliked",
|
||||||
)
|
)
|
||||||
return {row for row in session.exec(statement).all()}
|
return {row for row in session.exec(statement).all()}
|
||||||
|
|
||||||
|
def get_liked_listing_ids(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
listing_type: str,
|
||||||
|
) -> set[int]:
|
||||||
|
with Session(self.engine) as session:
|
||||||
|
statement = select(ListingDecision.listing_id).where(
|
||||||
|
ListingDecision.user_id == user_id,
|
||||||
|
ListingDecision.listing_type == listing_type,
|
||||||
|
ListingDecision.decision == "liked",
|
||||||
|
)
|
||||||
|
return {row for row in session.exec(statement).all()}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
"""Unified decision service -- shared between CLI and HTTP API."""
|
"""Unified decision service - shared between CLI and HTTP API.
|
||||||
|
|
||||||
|
This module provides the core business logic for listing decision operations
|
||||||
|
(like/dislike). Both the CLI and HTTP API should use these functions.
|
||||||
|
"""
|
||||||
from models.decision import ListingDecision
|
from models.decision import ListingDecision
|
||||||
from repositories.decision_repository import DecisionRepository
|
from repositories.decision_repository import DecisionRepository
|
||||||
|
|
||||||
|
|
@ -13,47 +17,53 @@ def set_decision(
|
||||||
listing_type: str,
|
listing_type: str,
|
||||||
decision: str,
|
decision: str,
|
||||||
) -> ListingDecision:
|
) -> ListingDecision:
|
||||||
|
"""Set or update a like/dislike decision for a listing."""
|
||||||
if decision not in VALID_DECISIONS:
|
if decision not in VALID_DECISIONS:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Invalid decision '{decision}'. Must be one of: {VALID_DECISIONS}"
|
f"Invalid decision: {decision}. Must be one of {VALID_DECISIONS}"
|
||||||
)
|
)
|
||||||
if listing_type not in VALID_LISTING_TYPES:
|
if listing_type not in VALID_LISTING_TYPES:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Invalid listing_type '{listing_type}'. Must be one of: {VALID_LISTING_TYPES}"
|
f"Invalid listing_type: {listing_type}. Must be one of {VALID_LISTING_TYPES}"
|
||||||
)
|
)
|
||||||
return repository.upsert_decision(
|
return repository.upsert_decision(user_id, listing_id, listing_type, decision)
|
||||||
user_id=user_id,
|
|
||||||
listing_id=listing_id,
|
|
||||||
listing_type=listing_type,
|
|
||||||
decision=decision,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_decisions(
|
def get_user_decisions(
|
||||||
repository: DecisionRepository,
|
repository: DecisionRepository,
|
||||||
user_id: int,
|
user_id: int,
|
||||||
) -> list[ListingDecision]:
|
) -> list[ListingDecision]:
|
||||||
|
"""Get all decisions for a user."""
|
||||||
return repository.get_decisions_for_user(user_id)
|
return repository.get_decisions_for_user(user_id)
|
||||||
|
|
||||||
|
|
||||||
def clear_decision(
|
def remove_decision(
|
||||||
repository: DecisionRepository,
|
repository: DecisionRepository,
|
||||||
user_id: int,
|
user_id: int,
|
||||||
listing_id: int,
|
listing_id: int,
|
||||||
listing_type: str,
|
listing_type: str,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
return repository.delete_decision(
|
"""Remove a decision (un-like/un-dislike). Returns False if not found."""
|
||||||
user_id=user_id,
|
if listing_type not in VALID_LISTING_TYPES:
|
||||||
listing_id=listing_id,
|
raise ValueError(
|
||||||
listing_type=listing_type,
|
f"Invalid listing_type: {listing_type}. Must be one of {VALID_LISTING_TYPES}"
|
||||||
)
|
)
|
||||||
|
return repository.delete_decision(user_id, listing_id, listing_type)
|
||||||
|
|
||||||
|
|
||||||
def get_disliked_listing_ids(
|
def get_disliked_ids(
|
||||||
repository: DecisionRepository,
|
repository: DecisionRepository,
|
||||||
user_id: int,
|
user_id: int,
|
||||||
listing_type: str,
|
listing_type: str,
|
||||||
) -> set[int]:
|
) -> set[int]:
|
||||||
return repository.get_disliked_listing_ids(
|
"""Get all disliked listing IDs for a user and listing type."""
|
||||||
user_id=user_id, listing_type=listing_type
|
return repository.get_disliked_listing_ids(user_id, listing_type)
|
||||||
)
|
|
||||||
|
|
||||||
|
def get_liked_ids(
|
||||||
|
repository: DecisionRepository,
|
||||||
|
user_id: int,
|
||||||
|
listing_type: str,
|
||||||
|
) -> set[int]:
|
||||||
|
"""Get all liked listing IDs for a user and listing type."""
|
||||||
|
return repository.get_liked_listing_ids(user_id, listing_type)
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ def convert_row_to_geojson(row: dict[str, Any], listing_type: str = "RENT") -> d
|
||||||
last_seen_str = str(last_seen_val)
|
last_seen_str = str(last_seen_val)
|
||||||
|
|
||||||
properties: dict[str, Any] = {
|
properties: dict[str, Any] = {
|
||||||
|
"id": row['id'],
|
||||||
"listing_type": listing_type,
|
"listing_type": listing_type,
|
||||||
"city": "London",
|
"city": "London",
|
||||||
"country": "United Kingdom",
|
"country": "United Kingdom",
|
||||||
|
|
@ -98,6 +99,7 @@ def convert_to_geojson_feature(listing: RentListing | BuyListing) -> dict[str, A
|
||||||
listing_type = "RENT" if isinstance(listing, RentListing) else "BUY"
|
listing_type = "RENT" if isinstance(listing, RentListing) else "BUY"
|
||||||
|
|
||||||
properties: dict[str, Any] = {
|
properties: dict[str, Any] = {
|
||||||
|
"id": listing.id,
|
||||||
"listing_type": listing_type,
|
"listing_type": listing_type,
|
||||||
"city": "London",
|
"city": "London",
|
||||||
"country": "United Kingdom",
|
"country": "United Kingdom",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue