diff --git a/alembic/versions/d7e8f9a0b1c2_add_listing_decision.py b/alembic/versions/d7e8f9a0b1c2_add_listing_decision.py new file mode 100644 index 0000000..29dd4dc --- /dev/null +++ b/alembic/versions/d7e8f9a0b1c2_add_listing_decision.py @@ -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') diff --git a/api/app.py b/api/app.py index d36bc1d..87b39b1 100644 --- a/api/app.py +++ b/api/app.py @@ -6,8 +6,8 @@ import logging.config from typing import Annotated, AsyncGenerator, Optional from api.auth import get_current_user 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.passkey_routes import passkey_router from api.poi_routes import poi_router from api.ws_routes import ws_router from api.rate_limit_config import RateLimitConfig @@ -19,6 +19,7 @@ from api.origin_validator import OriginValidatorMiddleware from dotenv import load_dotenv from fastapi import Depends, FastAPI, HTTPException, Query from fastapi.responses import JSONResponse, StreamingResponse +from pydantic import BaseModel from starlette.requests import Request from api.auth import User from models.listing import QueryParameters, ListingType, FurnishType @@ -28,7 +29,8 @@ from database import engine from fastapi.middleware.cors import CORSMiddleware 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 ( get_cached_count, get_cached_features, @@ -37,8 +39,8 @@ from services.listing_cache import ( finalize_cache_population, delete_staging_key, ) -from repositories.decision_repository import DecisionRepository from repositories.poi_repository import POIRepository +from repositories.decision_repository import DecisionRepository from repositories.user_repository import UserRepository from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor 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)], query_parameters: Annotated[QueryParameters, Depends(get_query_parameters)], limit: int | None = None, - decision_filter: str = "all", ) -> dict: - """Get listings as GeoJSON for map display. - - decision_filter: 'all' (hide disliked, default), 'liked', 'disliked', - 'undecided', 'everything' (no filtering). - """ + """Get listings as GeoJSON for map display.""" if limit is not None: limit = min(limit, _rate_limit_config.geojson_limit_cap) else: @@ -186,36 +183,10 @@ async def get_listing_geojson( query_parameters=query_parameters, 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 -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( user_email: str, listing_type: ListingType, @@ -250,10 +221,41 @@ def _build_poi_distances_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( query_parameters: QueryParameters, batch_size: int, limit: int | None, + disliked_ids: set[int] | None = None, + liked_ids: set[int] | None = None, + decision_filter: str = "all", ) -> AsyncGenerator[str, None]: """Stream GeoJSON features from the Redis cache (cache-hit path).""" cached_count = get_cached_count(query_parameters) @@ -268,10 +270,22 @@ async def _stream_from_cache( count = 0 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: feature_batch = feature_batch[:limit - count] 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: break @@ -285,6 +299,8 @@ async def _stream_from_db( poi_distances_lookup: dict[int, list[dict[str, str | int]]] | None = None, skip_cache: bool = False, disliked_ids: set[int] | None = None, + liked_ids: set[int] | None = None, + decision_filter: str = "all", ) -> AsyncGenerator[str, None]: """Stream GeoJSON features from the database, populating the cache as we go.""" repository = ListingRepository(engine) @@ -309,13 +325,18 @@ async def _stream_from_db( for row in repository.stream_listings_optimized( 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) # Inject POI distances if available if poi_distances_lookup and row['id'] in poi_distances_lookup: 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) count += 1 @@ -349,6 +370,7 @@ async def stream_listing_geojson( batch_size: int = DEFAULT_BATCH_SIZE, limit: int | None = None, include_poi_distances: bool = False, + decision_filter: str = "all", ) -> StreamingResponse: """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 - batch: Array of GeoJSON features - 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) if limit is not None: @@ -366,21 +393,41 @@ async def stream_listing_geojson( # 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 - # Get disliked listing IDs to exclude from stream - disliked_ids = _get_disliked_ids( - user.email, query_parameters.listing_type.value - ) + # Build decision filter sets + disliked_ids: set[int] | None = None + 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) if cached_count is not None and cached_count > 0 and not include_poi_distances: 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: app_metrics.geojson_cache_operations.add(1, {"result": "miss"}) generator = _stream_from_db( query_parameters, batch_size, limit, poi_distances_lookup, 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( @@ -491,4 +538,167 @@ async def get_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) diff --git a/api/decision_routes.py b/api/decision_routes.py index 1ccf257..e698625 100644 --- a/api/decision_routes.py +++ b/api/decision_routes.py @@ -1,8 +1,7 @@ -"""API routes for listing decisions (like/dislike).""" import logging from typing import Annotated -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel, Field from api.auth import User, get_current_user @@ -18,11 +17,10 @@ decision_router = APIRouter(prefix="/api/decisions", tags=["decisions"]) class SetDecisionRequest(BaseModel): 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): - id: int listing_id: int listing_type: str decision: str @@ -35,19 +33,30 @@ def _get_user_id(user: User) -> int: user_repo = UserRepository(engine) db_user = user_repo.get_user_by_email(user.email) if db_user is None: + # Auto-create user on first decision interaction db_user = user_repo.create_user(user.email) if db_user.id is None: raise HTTPException(status_code=500, detail="Failed to create user") 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) async def set_decision( user: Annotated[User, Depends(get_current_user)], listing_id: int, body: SetDecisionRequest, ) -> 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) repo = DecisionRepository(engine) try: @@ -60,14 +69,7 @@ async def set_decision( ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) - return DecisionResponse( - 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(), - ) + return _to_response(result) @decision_router.get("", response_model=list[DecisionResponse]) @@ -77,35 +79,25 @@ async def get_decisions( """Get all decisions for the current user.""" user_id = _get_user_id(user) repo = DecisionRepository(engine) - decisions = decision_service.get_decisions(repo, user_id=user_id) - return [ - 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 - ] + decisions = decision_service.get_user_decisions(repo, user_id) + return [_to_response(d) for d in decisions] @decision_router.delete("/{listing_id}") async def delete_decision( user: Annotated[User, Depends(get_current_user)], listing_id: int, - listing_type: str = "RENT", + listing_type: str = Query(..., description="RENT or BUY"), ) -> dict[str, bool]: - """Remove a decision (back to neutral).""" + """Remove a decision (un-like/un-dislike).""" user_id = _get_user_id(user) repo = DecisionRepository(engine) - deleted = decision_service.clear_decision( - repo, - user_id=user_id, - listing_id=listing_id, - listing_type=listing_type, - ) + try: + deleted = decision_service.remove_decision( + repo, user_id, listing_id, listing_type + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) if not deleted: raise HTTPException(status_code=404, detail="Decision not found") return {"success": True} diff --git a/models/decision.py b/models/decision.py index 6c76a11..e4d4eb1 100644 --- a/models/decision.py +++ b/models/decision.py @@ -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): + __table_args__ = ( + UniqueConstraint( + 'user_id', 'listing_id', 'listing_type', + name='uq_decision_user_listing_type', + ), + ) + id: int | None = Field(default=None, primary_key=True) user_id: int = Field(nullable=False, foreign_key="user.id", index=True) listing_id: int = Field(nullable=False, index=True) listing_type: str = Field(nullable=False) # "RENT" or "BUY" - decision: str = Field(nullable=False) # "liked" or "disliked" - created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) - updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) + decision: str = Field(nullable=False) # "liked" or "disliked" + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: datetime = Field(default_factory=datetime.utcnow) diff --git a/repositories/decision_repository.py b/repositories/decision_repository.py index 45fe72a..2c11aa3 100644 --- a/repositories/decision_repository.py +++ b/repositories/decision_repository.py @@ -1,5 +1,4 @@ -"""Repository for listing decisions (like/dislike).""" -from datetime import datetime, UTC +from datetime import datetime from models.decision import ListingDecision from sqlalchemy import Engine @@ -13,32 +12,53 @@ class DecisionRepository: self.engine = engine 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: + """Create or update a decision. Uses dialect-specific upsert.""" with Session(self.engine) as session: - statement = select(ListingDecision).where( - ListingDecision.user_id == user_id, - ListingDecision.listing_id == listing_id, - ListingDecision.listing_type == listing_type, - ) - existing = session.exec(statement).first() - if existing: - existing.decision = decision - existing.updated_at = datetime.now(UTC) - session.add(existing) - session.commit() - session.refresh(existing) - return existing - new_decision = ListingDecision( - user_id=user_id, - listing_id=listing_id, - listing_type=listing_type, - decision=decision, - ) - session.add(new_decision) + now = datetime.utcnow() + values = { + "user_id": user_id, + "listing_id": listing_id, + "listing_type": listing_type, + "decision": decision, + "created_at": now, + "updated_at": now, + } + dialect = self.engine.dialect.name + if dialect == "mysql": + from sqlalchemy.dialects.mysql import insert as mysql_insert + stmt = mysql_insert(ListingDecision).values(**values) + stmt = stmt.on_duplicate_key_update( + decision=stmt.inserted.decision, + updated_at=stmt.inserted.updated_at, + ) + else: + from sqlalchemy.dialects.sqlite import insert as sqlite_insert + 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.refresh(new_decision) - return new_decision + # Fetch the result + 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]: with Session(self.engine) as session: @@ -48,23 +68,29 @@ class DecisionRepository: return list(session.exec(statement).all()) def delete_decision( - self, user_id: int, listing_id: int, listing_type: str + self, + user_id: int, + listing_id: int, + listing_type: str, ) -> bool: with Session(self.engine) as session: - statement = select(ListingDecision).where( - ListingDecision.user_id == user_id, - ListingDecision.listing_id == listing_id, - ListingDecision.listing_type == listing_type, - ) - existing = session.exec(statement).first() - if existing is None: + result = session.exec( + select(ListingDecision).where( + ListingDecision.user_id == user_id, + ListingDecision.listing_id == listing_id, + ListingDecision.listing_type == listing_type, + ) + ).first() + if result is None: return False - session.delete(existing) + session.delete(result) session.commit() return True def get_disliked_listing_ids( - self, user_id: int, listing_type: str + self, + user_id: int, + listing_type: str, ) -> set[int]: with Session(self.engine) as session: statement = select(ListingDecision.listing_id).where( @@ -73,3 +99,16 @@ class DecisionRepository: ListingDecision.decision == "disliked", ) 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()} diff --git a/services/decision_service.py b/services/decision_service.py index ad11556..ab6b888 100644 --- a/services/decision_service.py +++ b/services/decision_service.py @@ -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 repositories.decision_repository import DecisionRepository @@ -13,47 +17,53 @@ def set_decision( listing_type: str, decision: str, ) -> ListingDecision: + """Set or update a like/dislike decision for a listing.""" if decision not in VALID_DECISIONS: 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: 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( - user_id=user_id, - listing_id=listing_id, - listing_type=listing_type, - decision=decision, - ) + return repository.upsert_decision(user_id, listing_id, listing_type, decision) -def get_decisions( +def get_user_decisions( repository: DecisionRepository, user_id: int, ) -> list[ListingDecision]: + """Get all decisions for a user.""" return repository.get_decisions_for_user(user_id) -def clear_decision( +def remove_decision( repository: DecisionRepository, user_id: int, listing_id: int, listing_type: str, ) -> bool: - return repository.delete_decision( - user_id=user_id, - listing_id=listing_id, - listing_type=listing_type, - ) + """Remove a decision (un-like/un-dislike). Returns False if not found.""" + if listing_type not in VALID_LISTING_TYPES: + raise ValueError( + 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, user_id: int, listing_type: str, ) -> set[int]: - return repository.get_disliked_listing_ids( - user_id=user_id, listing_type=listing_type - ) + """Get all disliked listing IDs for a user and 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) diff --git a/ui_exporter.py b/ui_exporter.py index b9491a2..b795e1d 100644 --- a/ui_exporter.py +++ b/ui_exporter.py @@ -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) properties: dict[str, Any] = { + "id": row['id'], "listing_type": listing_type, "city": "London", "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" properties: dict[str, Any] = { + "id": listing.id, "listing_type": listing_type, "city": "London", "country": "United Kingdom",