412 lines
13 KiB
Rust
412 lines
13 KiB
Rust
use std::sync::Arc;
|
|
|
|
use axum::extract::{Query, State};
|
|
use axum::response::{IntoResponse, Json, Response};
|
|
use axum::Extension;
|
|
use rustc_hash::FxHashSet;
|
|
use serde::{Deserialize, Serialize};
|
|
use tracing::info;
|
|
|
|
use crate::api_error::ApiError;
|
|
use crate::auth::OptionalUser;
|
|
use crate::consts::NAN_U16;
|
|
use crate::data::ActualListing;
|
|
use crate::licensing::{check_license_bounds, resolve_share_code};
|
|
use crate::parsing::{
|
|
parse_filters_with_poi, require_bounds, ParsedEnumFilter, ParsedFilter, ParsedPoiFilter,
|
|
};
|
|
use crate::state::{AppState, SharedState};
|
|
|
|
use super::travel_time::{load_travel_data, parse_optional_travel, row_passes_travel_filters};
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct ActualListingsParams {
|
|
bounds: Option<String>,
|
|
/// `;;`-separated filters: `name:min:max;;...`
|
|
filters: Option<String>,
|
|
/// Pipe-separated travel time entries: `mode:slug|mode:slug:min:max`
|
|
travel: Option<String>,
|
|
/// Number of results to skip. Defaults to 0.
|
|
offset: Option<usize>,
|
|
/// Share-link code; grants bbox-scoped access for unlicensed users.
|
|
share: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct ActualListingsResponse {
|
|
pub listings: Vec<ActualListing>,
|
|
pub total: usize,
|
|
pub offset: usize,
|
|
pub truncated: bool,
|
|
}
|
|
|
|
const KEEP_UNKNOWN_LISTING_FILTER_FEATURES: &[&str] = &[
|
|
"Total floor area (sqm)",
|
|
"Leasehold/Freehold",
|
|
"Number of bedrooms & living rooms",
|
|
"Property type",
|
|
];
|
|
const LISTING_BOUNDS_EPSILON_DEGREES: f64 = 0.00001;
|
|
|
|
pub async fn get_actual_listings(
|
|
State(shared): State<Arc<SharedState>>,
|
|
Extension(user): Extension<OptionalUser>,
|
|
Query(params): Query<ActualListingsParams>,
|
|
) -> Result<Json<ActualListingsResponse>, Response> {
|
|
let state = shared.load_state();
|
|
let offset = params.offset.unwrap_or(0);
|
|
|
|
// Gate the entire feature behind the per-user `can_see_listings` flag. The
|
|
// flag is off by default for everyone, so listings are invisible unless a
|
|
// superuser has explicitly granted access to this account.
|
|
if !user.0.as_ref().is_some_and(|u| u.can_see_listings) {
|
|
return Err(
|
|
ApiError::Forbidden("You do not have access to listings".to_string()).into_response(),
|
|
);
|
|
}
|
|
|
|
let actual_listings = state.actual_listings.clone();
|
|
let (south, west, north, east) =
|
|
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
|
|
|
|
let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
|
|
check_license_bounds(&user.0, (south, west, north, east), share_bounds)?;
|
|
|
|
let quant = state.data.quant_ref();
|
|
let poi_quant = state.data.poi_metrics.quant_ref();
|
|
let (parsed_filters, parsed_enum_filters, parsed_poi_filters) = parse_filters_with_poi(
|
|
params.filters.as_deref(),
|
|
&state.feature_name_to_index,
|
|
&state.data.enum_values,
|
|
&quant,
|
|
&state.data.poi_metrics.name_to_index,
|
|
&poi_quant,
|
|
)
|
|
.map_err(|err| ApiError::BadRequest(err).into_response())?;
|
|
|
|
let travel_entries = parse_optional_travel(params.travel.as_deref())
|
|
.map_err(|err| ApiError::BadRequest(err).into_response())?;
|
|
|
|
let keep_unknown_listing_filter_idxs = keep_unknown_listing_filter_feature_idxs(&state);
|
|
let listing_filters = parsed_filters;
|
|
let listing_enum_filters = parsed_enum_filters;
|
|
|
|
let has_listing_filters = !listing_filters.is_empty() || !listing_enum_filters.is_empty();
|
|
|
|
let state_clone = state.clone();
|
|
let response =
|
|
tokio::task::spawn_blocking(move || -> Result<ActualListingsResponse, String> {
|
|
let t0 = std::time::Instant::now();
|
|
let has_poi_filters = !parsed_poi_filters.is_empty();
|
|
let has_travel_filters = !travel_entries.is_empty();
|
|
let poi_num_features = state_clone.data.poi_metrics.num_features();
|
|
let travel_data = if has_travel_filters {
|
|
load_travel_data(&state_clone.travel_time_store, &travel_entries)?
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
|
|
let row_indices = actual_listings.grid.query(south, west, north, east);
|
|
let total_grid_candidates = row_indices.len();
|
|
let candidate_rows: Vec<usize> = row_indices
|
|
.iter()
|
|
.filter_map(|&row_idx| {
|
|
let row = row_idx as usize;
|
|
row_is_within_bounds(
|
|
actual_listings.lat[row],
|
|
actual_listings.lon[row],
|
|
south,
|
|
west,
|
|
north,
|
|
east,
|
|
)
|
|
.then_some(row)
|
|
})
|
|
.collect();
|
|
let total_in_bounds = candidate_rows.len();
|
|
|
|
// Build (row, sort_key) pairs so we can sort by index without
|
|
// materializing the full ActualListing for every matching row.
|
|
let mut matching_rows: Vec<usize> = candidate_rows
|
|
.into_iter()
|
|
.filter(|&row| {
|
|
if has_listing_filters
|
|
&& !row_passes_listing_filters(
|
|
row,
|
|
&listing_filters,
|
|
&listing_enum_filters,
|
|
&actual_listings.filter_feature_data,
|
|
state_clone.data.num_features,
|
|
&keep_unknown_listing_filter_idxs,
|
|
)
|
|
{
|
|
return false;
|
|
}
|
|
if has_poi_filters
|
|
&& !row_passes_listing_poi_filters(
|
|
row,
|
|
&parsed_poi_filters,
|
|
&actual_listings.poi_filter_feature_data,
|
|
poi_num_features,
|
|
)
|
|
{
|
|
return false;
|
|
}
|
|
if has_travel_filters
|
|
&& !row_passes_travel_filters(
|
|
actual_listings.postcode[row].as_str(),
|
|
&travel_entries,
|
|
&travel_data,
|
|
)
|
|
{
|
|
return false;
|
|
}
|
|
true
|
|
})
|
|
.collect();
|
|
|
|
let total_matching = matching_rows.len();
|
|
|
|
matching_rows.sort_by(|&left, &right| {
|
|
actual_listings.listing_date_iso[right]
|
|
.cmp(&actual_listings.listing_date_iso[left])
|
|
.then_with(|| {
|
|
actual_listings.asking_price[right].cmp(&actual_listings.asking_price[left])
|
|
})
|
|
});
|
|
|
|
let listings: Vec<ActualListing> = matching_rows
|
|
.iter()
|
|
.skip(offset)
|
|
.map(|&row| actual_listings.listing_at(row))
|
|
.collect();
|
|
|
|
let elapsed = t0.elapsed();
|
|
info!(
|
|
results = listings.len(),
|
|
total = total_matching,
|
|
total_in_bounds,
|
|
total_grid_candidates,
|
|
offset,
|
|
listing_filtered = has_listing_filters,
|
|
poi_filtered = has_poi_filters,
|
|
travel_filtered = has_travel_filters,
|
|
ms = format_args!("{:.1}", elapsed.as_secs_f64() * 1000.0),
|
|
"GET /api/actual-listings"
|
|
);
|
|
|
|
Ok(ActualListingsResponse {
|
|
listings,
|
|
total: total_matching,
|
|
offset,
|
|
truncated: false,
|
|
})
|
|
})
|
|
.await
|
|
.map_err(|error| ApiError::Internal(error.to_string()).into_response())?
|
|
.map_err(|err| ApiError::Internal(err).into_response())?;
|
|
|
|
Ok(Json(response))
|
|
}
|
|
|
|
fn keep_unknown_listing_filter_feature_idxs(state: &AppState) -> FxHashSet<usize> {
|
|
feature_idxs(state, KEEP_UNKNOWN_LISTING_FILTER_FEATURES)
|
|
}
|
|
|
|
fn feature_idxs(state: &AppState, names: &[&str]) -> FxHashSet<usize> {
|
|
names
|
|
.iter()
|
|
.filter_map(|name| state.feature_name_to_index.get(*name).copied())
|
|
.collect()
|
|
}
|
|
|
|
fn row_passes_listing_filters(
|
|
row: usize,
|
|
filters: &[ParsedFilter],
|
|
enum_filters: &[ParsedEnumFilter],
|
|
feature_data: &[u16],
|
|
num_features: usize,
|
|
keep_unknown_filter_idxs: &FxHashSet<usize>,
|
|
) -> bool {
|
|
let base = row * num_features;
|
|
|
|
filters.iter().all(|filter| {
|
|
let raw = feature_data[base + filter.feat_idx];
|
|
if raw == NAN_U16 {
|
|
keep_unknown_filter_idxs.contains(&filter.feat_idx)
|
|
} else {
|
|
raw >= filter.min_u16 && raw <= filter.max_u16
|
|
}
|
|
}) && enum_filters.iter().all(|filter| {
|
|
let raw = feature_data[base + filter.feat_idx];
|
|
if raw == NAN_U16 {
|
|
keep_unknown_filter_idxs.contains(&filter.feat_idx)
|
|
} else {
|
|
filter.allowed.contains(&raw)
|
|
}
|
|
})
|
|
}
|
|
|
|
fn row_is_within_bounds(lat: f32, lon: f32, south: f64, west: f64, north: f64, east: f64) -> bool {
|
|
let lat = lat as f64;
|
|
let lon = lon as f64;
|
|
lat >= south - LISTING_BOUNDS_EPSILON_DEGREES
|
|
&& lat <= north + LISTING_BOUNDS_EPSILON_DEGREES
|
|
&& lon >= west - LISTING_BOUNDS_EPSILON_DEGREES
|
|
&& lon <= east + LISTING_BOUNDS_EPSILON_DEGREES
|
|
}
|
|
|
|
fn row_passes_listing_poi_filters(
|
|
row: usize,
|
|
filters: &[ParsedPoiFilter],
|
|
feature_data: &[u16],
|
|
num_features: usize,
|
|
) -> bool {
|
|
if filters.is_empty() {
|
|
return true;
|
|
}
|
|
if num_features == 0 || feature_data.is_empty() {
|
|
return false;
|
|
}
|
|
|
|
let base = row * num_features;
|
|
filters.iter().all(|filter| {
|
|
let raw = feature_data
|
|
.get(base + filter.metric_idx)
|
|
.copied()
|
|
.unwrap_or(NAN_U16);
|
|
raw != NAN_U16 && raw >= filter.min_u16 && raw <= filter.max_u16
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn listing_bounds_check_keeps_only_exact_viewport_rows() {
|
|
assert!(row_is_within_bounds(51.5, -0.1, 51.4, -0.2, 51.6, 0.0));
|
|
|
|
// Bounds are inclusive so edge points are retained.
|
|
assert!(row_is_within_bounds(51.4, -0.2, 51.4, -0.2, 51.6, 0.0));
|
|
assert!(row_is_within_bounds(51.6, 0.0, 51.4, -0.2, 51.6, 0.0));
|
|
|
|
assert!(!row_is_within_bounds(51.399, -0.1, 51.4, -0.2, 51.6, 0.0));
|
|
assert!(!row_is_within_bounds(51.601, -0.1, 51.4, -0.2, 51.6, 0.0));
|
|
assert!(!row_is_within_bounds(51.5, -0.201, 51.4, -0.2, 51.6, 0.0));
|
|
assert!(!row_is_within_bounds(51.5, 0.001, 51.4, -0.2, 51.6, 0.0));
|
|
}
|
|
|
|
#[test]
|
|
fn listing_floor_area_filter_keeps_unknown_values() {
|
|
let floor_area_filter = ParsedFilter {
|
|
feat_idx: 0,
|
|
min_u16: 10,
|
|
max_u16: 20,
|
|
};
|
|
let keep_unknown_filter_idxs: FxHashSet<usize> = [0usize].into_iter().collect();
|
|
|
|
assert!(row_passes_listing_filters(
|
|
0,
|
|
&[floor_area_filter],
|
|
&[],
|
|
&[NAN_U16],
|
|
1,
|
|
&keep_unknown_filter_idxs
|
|
));
|
|
|
|
assert!(!row_passes_listing_filters(
|
|
0,
|
|
&[ParsedFilter {
|
|
feat_idx: 0,
|
|
min_u16: 10,
|
|
max_u16: 20,
|
|
}],
|
|
&[],
|
|
&[9],
|
|
1,
|
|
&keep_unknown_filter_idxs
|
|
));
|
|
|
|
assert!(row_passes_listing_filters(
|
|
0,
|
|
&[ParsedFilter {
|
|
feat_idx: 0,
|
|
min_u16: 10,
|
|
max_u16: 20,
|
|
}],
|
|
&[],
|
|
&[15],
|
|
1,
|
|
&keep_unknown_filter_idxs
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn listing_enum_filter_keeps_allowlisted_unknown_values() {
|
|
let enum_filter = ParsedEnumFilter {
|
|
feat_idx: 0,
|
|
allowed: [1u16].into_iter().collect(),
|
|
};
|
|
let keep_unknown_filter_idxs: FxHashSet<usize> = [0usize].into_iter().collect();
|
|
|
|
assert!(row_passes_listing_filters(
|
|
0,
|
|
&[],
|
|
&[enum_filter],
|
|
&[NAN_U16],
|
|
1,
|
|
&keep_unknown_filter_idxs
|
|
));
|
|
|
|
assert!(!row_passes_listing_filters(
|
|
0,
|
|
&[],
|
|
&[ParsedEnumFilter {
|
|
feat_idx: 0,
|
|
allowed: [1u16].into_iter().collect(),
|
|
}],
|
|
&[2],
|
|
1,
|
|
&keep_unknown_filter_idxs
|
|
));
|
|
|
|
assert!(row_passes_listing_filters(
|
|
0,
|
|
&[],
|
|
&[ParsedEnumFilter {
|
|
feat_idx: 0,
|
|
allowed: [1u16].into_iter().collect(),
|
|
}],
|
|
&[1],
|
|
1,
|
|
&keep_unknown_filter_idxs
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn listing_poi_filter_uses_listing_metric_matrix() {
|
|
let filter = ParsedPoiFilter {
|
|
metric_idx: 1,
|
|
min_u16: 10,
|
|
max_u16: 20,
|
|
};
|
|
|
|
assert!(row_passes_listing_poi_filters(
|
|
0,
|
|
&[filter],
|
|
&[NAN_U16, 15],
|
|
2
|
|
));
|
|
assert!(!row_passes_listing_poi_filters(
|
|
0,
|
|
&[ParsedPoiFilter {
|
|
metric_idx: 1,
|
|
min_u16: 10,
|
|
max_u16: 20,
|
|
}],
|
|
&[NAN_U16, NAN_U16],
|
|
2
|
|
));
|
|
}
|
|
}
|