feat: add import templates
This commit is contained in:
@@ -3,11 +3,12 @@ use axum::Json;
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
use crate::error::{ApiError, ApiResult};
|
use crate::error::{ApiError, ApiResult};
|
||||||
|
use crate::import_templates::validate_import_payload;
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
AreaScore, AreaScoreSummary, CreateDataSource, CreateIngestionRun, CreateWatchlistItem,
|
AreaScore, AreaScoreSummary, CreateDataSource, CreateIngestionRun, CreateWatchlistItem,
|
||||||
DataSource, FinishIngestionRun, IngestionRun, IngestionRunQuery, MarketOverview, MonthQuery,
|
DataSource, FinishIngestionRun, ImportValidationRequest, ImportValidationResponse,
|
||||||
Neighborhood, NeighborhoodQuery, NeighborhoodScore, NeighborhoodScoreQuery,
|
IngestionRun, IngestionRunQuery, MarketOverview, MonthQuery, Neighborhood, NeighborhoodQuery,
|
||||||
UpdateWatchlistItem, WatchlistItem,
|
NeighborhoodScore, NeighborhoodScoreQuery, UpdateWatchlistItem, WatchlistItem,
|
||||||
};
|
};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
@@ -32,6 +33,13 @@ pub async fn ready(State(state): State<AppState>) -> ApiResult<Json<HealthRespon
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn validate_import(
|
||||||
|
Json(payload): Json<ImportValidationRequest>,
|
||||||
|
) -> ApiResult<Json<ImportValidationResponse>> {
|
||||||
|
let result = validate_import_payload(&payload).map_err(ApiError::BadRequest)?;
|
||||||
|
Ok(Json(result))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn list_area_scores(
|
pub async fn list_area_scores(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(query): Query<MonthQuery>,
|
Query(query): Query<MonthQuery>,
|
||||||
|
|||||||
389
apps/api/src/import_templates.rs
Normal file
389
apps/api/src/import_templates.rs
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::models::{ImportValidationError, ImportValidationRequest, ImportValidationResponse};
|
||||||
|
|
||||||
|
const IMPORT_TEMPLATE_SPEC: &str = include_str!("../../../config/import_templates.json");
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct TemplateSpec {
|
||||||
|
columns: Vec<ColumnSpec>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ColumnSpec {
|
||||||
|
name: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
column_type: String,
|
||||||
|
required: bool,
|
||||||
|
min: Option<f64>,
|
||||||
|
max: Option<f64>,
|
||||||
|
allowed_values: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_import_payload(
|
||||||
|
payload: &ImportValidationRequest,
|
||||||
|
) -> Result<ImportValidationResponse, String> {
|
||||||
|
let specs = load_specs()?;
|
||||||
|
let spec = specs.get(&payload.template_name).ok_or_else(|| {
|
||||||
|
let mut valid_names = specs.keys().cloned().collect::<Vec<_>>();
|
||||||
|
valid_names.sort();
|
||||||
|
format!(
|
||||||
|
"unknown template '{}', expected one of: {}",
|
||||||
|
payload.template_name,
|
||||||
|
valid_names.join(", ")
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
let expected_columns = spec
|
||||||
|
.columns
|
||||||
|
.iter()
|
||||||
|
.map(|column| column.name.as_str())
|
||||||
|
.collect::<HashSet<_>>();
|
||||||
|
let seen_columns = payload
|
||||||
|
.rows
|
||||||
|
.iter()
|
||||||
|
.flat_map(|row| row.keys().map(String::as_str))
|
||||||
|
.collect::<HashSet<_>>();
|
||||||
|
|
||||||
|
for column in &spec.columns {
|
||||||
|
if !seen_columns.contains(column.name.as_str()) {
|
||||||
|
errors.push(ImportValidationError::new(
|
||||||
|
0,
|
||||||
|
&column.name,
|
||||||
|
"missing required header",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut unexpected_columns = seen_columns
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|column| !expected_columns.contains(column))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
unexpected_columns.sort_unstable();
|
||||||
|
for column in unexpected_columns {
|
||||||
|
errors.push(ImportValidationError::new(0, column, "unexpected header"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.rows.is_empty() {
|
||||||
|
errors.push(ImportValidationError::new(0, "*", "file has no data rows"));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (index, row) in payload.rows.iter().enumerate() {
|
||||||
|
let row_number = index + 2;
|
||||||
|
for column in &spec.columns {
|
||||||
|
let value = normalize_value(row.get(&column.name));
|
||||||
|
if column.required && value.is_empty() {
|
||||||
|
errors.push(ImportValidationError::new(
|
||||||
|
row_number,
|
||||||
|
&column.name,
|
||||||
|
"value is required",
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if value.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
errors.extend(validate_value(row_number, &column.name, &value, column));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ImportValidationResponse {
|
||||||
|
template_name: payload.template_name.clone(),
|
||||||
|
row_count: payload.rows.len(),
|
||||||
|
valid: errors.is_empty(),
|
||||||
|
errors,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_specs() -> Result<HashMap<String, TemplateSpec>, String> {
|
||||||
|
serde_json::from_str(IMPORT_TEMPLATE_SPEC)
|
||||||
|
.map_err(|error| format!("failed to parse import template specs: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_value(value: Option<&Value>) -> String {
|
||||||
|
match value {
|
||||||
|
None | Some(Value::Null) => String::new(),
|
||||||
|
Some(Value::String(value)) => value.trim().to_string(),
|
||||||
|
Some(Value::Number(value)) => value.to_string(),
|
||||||
|
Some(Value::Bool(value)) => value.to_string(),
|
||||||
|
Some(value) => value.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_value(
|
||||||
|
row_number: usize,
|
||||||
|
column_name: &str,
|
||||||
|
value: &str,
|
||||||
|
column: &ColumnSpec,
|
||||||
|
) -> Vec<ImportValidationError> {
|
||||||
|
if has_sensitive_text(value) {
|
||||||
|
return vec![ImportValidationError::new(
|
||||||
|
row_number,
|
||||||
|
column_name,
|
||||||
|
"value appears to contain credentials or a sensitive local path",
|
||||||
|
)];
|
||||||
|
}
|
||||||
|
|
||||||
|
match column.column_type.as_str() {
|
||||||
|
"text" => Vec::new(),
|
||||||
|
"month" => {
|
||||||
|
if is_valid_month(value) {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
vec![ImportValidationError::new(
|
||||||
|
row_number,
|
||||||
|
column_name,
|
||||||
|
"must be YYYY-MM",
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"date" => {
|
||||||
|
if is_valid_date(value) {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
vec![ImportValidationError::new(
|
||||||
|
row_number,
|
||||||
|
column_name,
|
||||||
|
"must be YYYY-MM-DD",
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"url" => {
|
||||||
|
if is_valid_url(value) {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
vec![ImportValidationError::new(
|
||||||
|
row_number,
|
||||||
|
column_name,
|
||||||
|
"must be http(s) URL",
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"enum" => {
|
||||||
|
let allowed_values = column.allowed_values.as_deref().unwrap_or_default();
|
||||||
|
if allowed_values.iter().any(|allowed| allowed == value) {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
vec![ImportValidationError::new(
|
||||||
|
row_number,
|
||||||
|
column_name,
|
||||||
|
format!("must be one of: {}", allowed_values.join(", ")),
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"integer" => match parse_integer(value) {
|
||||||
|
Some(parsed) => validate_numeric_range(row_number, column_name, parsed as f64, column),
|
||||||
|
_ => vec![ImportValidationError::new(
|
||||||
|
row_number,
|
||||||
|
column_name,
|
||||||
|
"must be an integer",
|
||||||
|
)],
|
||||||
|
},
|
||||||
|
"decimal" => match value.parse::<f64>() {
|
||||||
|
Ok(parsed) if parsed.is_finite() => {
|
||||||
|
validate_numeric_range(row_number, column_name, parsed, column)
|
||||||
|
}
|
||||||
|
_ => vec![ImportValidationError::new(
|
||||||
|
row_number,
|
||||||
|
column_name,
|
||||||
|
"must be a number",
|
||||||
|
)],
|
||||||
|
},
|
||||||
|
unknown => vec![ImportValidationError::new(
|
||||||
|
row_number,
|
||||||
|
column_name,
|
||||||
|
format!("unknown type '{unknown}'"),
|
||||||
|
)],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_numeric_range(
|
||||||
|
row_number: usize,
|
||||||
|
column_name: &str,
|
||||||
|
value: f64,
|
||||||
|
column: &ColumnSpec,
|
||||||
|
) -> Vec<ImportValidationError> {
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
if let Some(minimum) = column.min {
|
||||||
|
if value < minimum {
|
||||||
|
errors.push(ImportValidationError::new(
|
||||||
|
row_number,
|
||||||
|
column_name,
|
||||||
|
format!("must be >= {}", format_number(minimum)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(maximum) = column.max {
|
||||||
|
if value > maximum {
|
||||||
|
errors.push(ImportValidationError::new(
|
||||||
|
row_number,
|
||||||
|
column_name,
|
||||||
|
format!("must be <= {}", format_number(maximum)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
errors
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_number(value: f64) -> String {
|
||||||
|
if value.fract() == 0.0 {
|
||||||
|
format!("{}", value as i64)
|
||||||
|
} else {
|
||||||
|
value.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_integer(value: &str) -> Option<i64> {
|
||||||
|
let digits = value.strip_prefix('-').unwrap_or(value);
|
||||||
|
if digits.is_empty() || !digits.chars().all(|item| item.is_ascii_digit()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
value.parse::<i64>().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_valid_month(value: &str) -> bool {
|
||||||
|
if value.len() != 7 || value.as_bytes()[4] != b'-' {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
value[..4].chars().all(|item| item.is_ascii_digit())
|
||||||
|
&& value[5..].chars().all(|item| item.is_ascii_digit())
|
||||||
|
&& matches!(value[5..].parse::<u8>(), Ok(month) if (1..=12).contains(&month))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_valid_date(value: &str) -> bool {
|
||||||
|
if value.len() != 10 || value.as_bytes()[4] != b'-' || value.as_bytes()[7] != b'-' {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if !value
|
||||||
|
.chars()
|
||||||
|
.enumerate()
|
||||||
|
.all(|(index, item)| index == 4 || index == 7 || item.is_ascii_digit())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let year = value[..4].parse::<i32>().unwrap_or_default();
|
||||||
|
let month = value[5..7].parse::<u8>().unwrap_or_default();
|
||||||
|
let day = value[8..].parse::<u8>().unwrap_or_default();
|
||||||
|
if !(1..=12).contains(&month) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let max_day = match month {
|
||||||
|
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
|
||||||
|
4 | 6 | 9 | 11 => 30,
|
||||||
|
2 if is_leap_year(year) => 29,
|
||||||
|
2 => 28,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
(1..=max_day).contains(&day)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_leap_year(year: i32) -> bool {
|
||||||
|
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_valid_url(value: &str) -> bool {
|
||||||
|
(value.starts_with("http://") || value.starts_with("https://"))
|
||||||
|
&& value
|
||||||
|
.split_once("://")
|
||||||
|
.is_some_and(|(_, rest)| rest.contains('.') && !rest.starts_with('.'))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_sensitive_text(value: &str) -> bool {
|
||||||
|
let lower = value.to_ascii_lowercase();
|
||||||
|
lower.contains("password")
|
||||||
|
|| lower.contains("secret")
|
||||||
|
|| lower.contains("postgres://")
|
||||||
|
|| lower.contains("database_url")
|
||||||
|
|| lower.contains("root_")
|
||||||
|
|| value.starts_with("/Users/")
|
||||||
|
|| value.starts_with("/private/")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::models::ImportValidationRequest;
|
||||||
|
|
||||||
|
use super::{is_valid_date, validate_import_payload};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validates_good_import_rows() {
|
||||||
|
let request = ImportValidationRequest {
|
||||||
|
template_name: "area_monthly_metrics".to_string(),
|
||||||
|
rows: vec![HashMap::from([
|
||||||
|
("area_id".to_string(), json!("qiantan")),
|
||||||
|
("month".to_string(), json!("2026-05")),
|
||||||
|
("transaction_count".to_string(), json!("93")),
|
||||||
|
("transaction_price_psm".to_string(), json!("122500")),
|
||||||
|
("listing_count".to_string(), json!("380")),
|
||||||
|
("listing_price_psm".to_string(), json!("126500")),
|
||||||
|
("median_days_on_market".to_string(), json!("53")),
|
||||||
|
("rent_price_psm".to_string(), json!("192")),
|
||||||
|
("new_supply_units".to_string(), json!("60")),
|
||||||
|
("land_residential_gfa_sqm".to_string(), json!("0")),
|
||||||
|
("mortgage_rate_pct".to_string(), json!("3.35")),
|
||||||
|
("policy_signal".to_string(), json!("1")),
|
||||||
|
("source".to_string(), json!("manual_research")),
|
||||||
|
])],
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = validate_import_payload(&request).expect("valid spec");
|
||||||
|
|
||||||
|
assert!(result.valid);
|
||||||
|
assert_eq!(result.row_count, 1);
|
||||||
|
assert!(result.errors.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reports_bad_import_rows() {
|
||||||
|
let request = ImportValidationRequest {
|
||||||
|
template_name: "area_monthly_metrics".to_string(),
|
||||||
|
rows: vec![HashMap::from([
|
||||||
|
("area_id".to_string(), json!("")),
|
||||||
|
("month".to_string(), json!("2026-13")),
|
||||||
|
("transaction_count".to_string(), json!("-1")),
|
||||||
|
("transaction_price_psm".to_string(), json!("abc")),
|
||||||
|
("listing_count".to_string(), json!("10")),
|
||||||
|
("listing_price_psm".to_string(), json!("100")),
|
||||||
|
("median_days_on_market".to_string(), json!("30")),
|
||||||
|
("rent_price_psm".to_string(), json!("3")),
|
||||||
|
("new_supply_units".to_string(), json!("0")),
|
||||||
|
("land_residential_gfa_sqm".to_string(), json!("0")),
|
||||||
|
("mortgage_rate_pct".to_string(), json!("3.35")),
|
||||||
|
("policy_signal".to_string(), json!("3")),
|
||||||
|
("source".to_string(), json!("manual")),
|
||||||
|
])],
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = validate_import_payload(&request).expect("valid spec");
|
||||||
|
let messages = result
|
||||||
|
.errors
|
||||||
|
.iter()
|
||||||
|
.map(|error| (error.column.as_str(), error.message.as_str()))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
assert!(!result.valid);
|
||||||
|
assert!(messages.contains(&("area_id", "value is required")));
|
||||||
|
assert!(messages.contains(&("month", "must be YYYY-MM")));
|
||||||
|
assert!(messages.contains(&("transaction_count", "must be >= 0")));
|
||||||
|
assert!(messages.contains(&("transaction_price_psm", "must be a number")));
|
||||||
|
assert!(messages.contains(&("policy_signal", "must be <= 2")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validates_real_calendar_dates() {
|
||||||
|
assert!(is_valid_date("2026-02-28"));
|
||||||
|
assert!(!is_valid_date("2026-02-29"));
|
||||||
|
assert!(is_valid_date("2024-02-29"));
|
||||||
|
assert!(!is_valid_date("2026-13-01"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ mod config;
|
|||||||
mod db;
|
mod db;
|
||||||
mod error;
|
mod error;
|
||||||
mod handlers;
|
mod handlers;
|
||||||
|
mod import_templates;
|
||||||
mod models;
|
mod models;
|
||||||
mod routes;
|
mod routes;
|
||||||
mod state;
|
mod state;
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
use sqlx::FromRow;
|
use sqlx::FromRow;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -22,6 +25,37 @@ pub struct IngestionRunQuery {
|
|||||||
pub source_id: Option<i64>,
|
pub source_id: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ImportValidationRequest {
|
||||||
|
pub template_name: String,
|
||||||
|
pub rows: Vec<HashMap<String, Value>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||||
|
pub struct ImportValidationError {
|
||||||
|
pub row: usize,
|
||||||
|
pub column: String,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ImportValidationError {
|
||||||
|
pub fn new(row: usize, column: impl Into<String>, message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
row,
|
||||||
|
column: column.into(),
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ImportValidationResponse {
|
||||||
|
pub template_name: String,
|
||||||
|
pub row_count: usize,
|
||||||
|
pub valid: bool,
|
||||||
|
pub errors: Vec<ImportValidationError>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||||
pub struct AreaScore {
|
pub struct AreaScore {
|
||||||
pub area_id: String,
|
pub area_id: String,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use crate::handlers::{
|
|||||||
archive_watchlist_item, create_data_source, create_ingestion_run, create_watchlist_item,
|
archive_watchlist_item, create_data_source, create_ingestion_run, create_watchlist_item,
|
||||||
finish_ingestion_run, get_neighborhood, health, list_area_scores, list_data_sources,
|
finish_ingestion_run, get_neighborhood, health, list_area_scores, list_data_sources,
|
||||||
list_ingestion_runs, list_neighborhood_scores, list_neighborhoods, list_watchlist_items,
|
list_ingestion_runs, list_neighborhood_scores, list_neighborhoods, list_watchlist_items,
|
||||||
market_overview, ready, update_watchlist_item,
|
market_overview, ready, update_watchlist_item, validate_import,
|
||||||
};
|
};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
@@ -31,6 +31,7 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
"/ingestion-runs/{run_id}/finish",
|
"/ingestion-runs/{run_id}/finish",
|
||||||
patch(finish_ingestion_run),
|
patch(finish_ingestion_run),
|
||||||
)
|
)
|
||||||
|
.route("/imports/validate", axum::routing::post(validate_import))
|
||||||
.route("/market/overview", get(market_overview))
|
.route("/market/overview", get(market_overview))
|
||||||
.route("/neighborhoods", get(list_neighborhoods))
|
.route("/neighborhoods", get(list_neighborhoods))
|
||||||
.route("/neighborhoods/scores", get(list_neighborhood_scores))
|
.route("/neighborhoods/scores", get(list_neighborhood_scores))
|
||||||
|
|||||||
295
config/import_templates.json
Normal file
295
config/import_templates.json
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
{
|
||||||
|
"area_monthly_metrics": {
|
||||||
|
"description": "Monthly area-level housing market metrics.",
|
||||||
|
"target_table": "silver.area_monthly_metrics",
|
||||||
|
"primary_key": ["area_id", "month"],
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "area_id",
|
||||||
|
"type": "text",
|
||||||
|
"required": true,
|
||||||
|
"description": "Stable area identifier from silver.areas."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "month",
|
||||||
|
"type": "month",
|
||||||
|
"required": true,
|
||||||
|
"description": "Metric month in YYYY-MM format."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "transaction_count",
|
||||||
|
"type": "integer",
|
||||||
|
"required": true,
|
||||||
|
"min": 0,
|
||||||
|
"description": "Observed second-hand transaction count."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "transaction_price_psm",
|
||||||
|
"type": "decimal",
|
||||||
|
"required": true,
|
||||||
|
"min": 0,
|
||||||
|
"description": "Average transaction price, RMB per square meter."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "listing_count",
|
||||||
|
"type": "integer",
|
||||||
|
"required": true,
|
||||||
|
"min": 0,
|
||||||
|
"description": "Active listing count."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "listing_price_psm",
|
||||||
|
"type": "decimal",
|
||||||
|
"required": true,
|
||||||
|
"min": 0,
|
||||||
|
"description": "Average listing price, RMB per square meter."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "median_days_on_market",
|
||||||
|
"type": "decimal",
|
||||||
|
"required": true,
|
||||||
|
"min": 0,
|
||||||
|
"description": "Median days on market."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "rent_price_psm",
|
||||||
|
"type": "decimal",
|
||||||
|
"required": true,
|
||||||
|
"min": 0,
|
||||||
|
"description": "Monthly rent, RMB per square meter."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "new_supply_units",
|
||||||
|
"type": "integer",
|
||||||
|
"required": true,
|
||||||
|
"min": 0,
|
||||||
|
"description": "New residential supply units."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "land_residential_gfa_sqm",
|
||||||
|
"type": "decimal",
|
||||||
|
"required": true,
|
||||||
|
"min": 0,
|
||||||
|
"description": "Residential land transaction GFA, square meters."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mortgage_rate_pct",
|
||||||
|
"type": "decimal",
|
||||||
|
"required": true,
|
||||||
|
"min": 0,
|
||||||
|
"max": 20,
|
||||||
|
"description": "Mainstream mortgage rate percentage."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "policy_signal",
|
||||||
|
"type": "integer",
|
||||||
|
"required": true,
|
||||||
|
"min": -2,
|
||||||
|
"max": 2,
|
||||||
|
"description": "Policy direction signal from -2 to 2."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "source",
|
||||||
|
"type": "text",
|
||||||
|
"required": true,
|
||||||
|
"description": "Human-readable source name or source code."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"neighborhood_monthly_metrics": {
|
||||||
|
"description": "Monthly neighborhood-level housing market metrics.",
|
||||||
|
"target_table": "silver.neighborhood_monthly_metrics",
|
||||||
|
"primary_key": ["neighborhood_id", "month"],
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "neighborhood_id",
|
||||||
|
"type": "text",
|
||||||
|
"required": true,
|
||||||
|
"description": "Stable neighborhood identifier from silver.neighborhoods."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "month",
|
||||||
|
"type": "month",
|
||||||
|
"required": true,
|
||||||
|
"description": "Metric month in YYYY-MM format."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "transaction_count",
|
||||||
|
"type": "integer",
|
||||||
|
"required": true,
|
||||||
|
"min": 0,
|
||||||
|
"description": "Observed transaction count."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "transaction_price_psm",
|
||||||
|
"type": "decimal",
|
||||||
|
"required": true,
|
||||||
|
"min": 0,
|
||||||
|
"description": "Average transaction price, RMB per square meter."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "listing_count",
|
||||||
|
"type": "integer",
|
||||||
|
"required": true,
|
||||||
|
"min": 0,
|
||||||
|
"description": "Active listing count."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "listing_price_psm",
|
||||||
|
"type": "decimal",
|
||||||
|
"required": true,
|
||||||
|
"min": 0,
|
||||||
|
"description": "Average listing price, RMB per square meter."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "rent_price_psm",
|
||||||
|
"type": "decimal",
|
||||||
|
"required": true,
|
||||||
|
"min": 0,
|
||||||
|
"description": "Monthly rent, RMB per square meter."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "median_days_on_market",
|
||||||
|
"type": "decimal",
|
||||||
|
"required": true,
|
||||||
|
"min": 0,
|
||||||
|
"description": "Median days on market."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "available_units",
|
||||||
|
"type": "integer",
|
||||||
|
"required": true,
|
||||||
|
"min": 0,
|
||||||
|
"description": "Currently available units in the neighborhood sample."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "source",
|
||||||
|
"type": "text",
|
||||||
|
"required": true,
|
||||||
|
"description": "Human-readable source name or source code."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"policy_events": {
|
||||||
|
"description": "Policy events that can affect market liquidity, credit or demand.",
|
||||||
|
"target_table": "raw.policy_events",
|
||||||
|
"primary_key": ["event_date", "title"],
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "event_date",
|
||||||
|
"type": "date",
|
||||||
|
"required": true,
|
||||||
|
"description": "Policy event date in YYYY-MM-DD format."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "level",
|
||||||
|
"type": "enum",
|
||||||
|
"required": true,
|
||||||
|
"allowed_values": ["national", "shanghai", "district"],
|
||||||
|
"description": "Policy jurisdiction level."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"required": true,
|
||||||
|
"description": "Short policy title."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "impact_direction",
|
||||||
|
"type": "integer",
|
||||||
|
"required": true,
|
||||||
|
"min": -2,
|
||||||
|
"max": 2,
|
||||||
|
"description": "Expected impact from -2 tightening to 2 easing."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "notes",
|
||||||
|
"type": "text",
|
||||||
|
"required": false,
|
||||||
|
"description": "Analyst notes and interpretation."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "source_url",
|
||||||
|
"type": "url",
|
||||||
|
"required": false,
|
||||||
|
"description": "Official notice or source URL."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "source",
|
||||||
|
"type": "text",
|
||||||
|
"required": true,
|
||||||
|
"description": "Human-readable source name or source code."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"land_sales": {
|
||||||
|
"description": "Residential land transaction records used for supply pressure analysis.",
|
||||||
|
"target_table": "raw.land_sales",
|
||||||
|
"primary_key": ["parcel_id"],
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "parcel_id",
|
||||||
|
"type": "text",
|
||||||
|
"required": true,
|
||||||
|
"description": "Stable land parcel identifier from the source."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "area_id",
|
||||||
|
"type": "text",
|
||||||
|
"required": true,
|
||||||
|
"description": "Mapped area identifier from silver.areas."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "transaction_date",
|
||||||
|
"type": "date",
|
||||||
|
"required": true,
|
||||||
|
"description": "Land transaction date in YYYY-MM-DD format."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "parcel_name",
|
||||||
|
"type": "text",
|
||||||
|
"required": true,
|
||||||
|
"description": "Land parcel name."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "residential_gfa_sqm",
|
||||||
|
"type": "decimal",
|
||||||
|
"required": true,
|
||||||
|
"min": 0,
|
||||||
|
"description": "Residential planned gross floor area, square meters."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "transaction_price_total_cny",
|
||||||
|
"type": "decimal",
|
||||||
|
"required": true,
|
||||||
|
"min": 0,
|
||||||
|
"description": "Total transaction price in RMB."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "floor_price_cny_psm",
|
||||||
|
"type": "decimal",
|
||||||
|
"required": true,
|
||||||
|
"min": 0,
|
||||||
|
"description": "Implied floor price, RMB per square meter."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "buyer",
|
||||||
|
"type": "text",
|
||||||
|
"required": false,
|
||||||
|
"description": "Winning bidder or buyer."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "source_url",
|
||||||
|
"type": "url",
|
||||||
|
"required": false,
|
||||||
|
"description": "Announcement or source URL."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "source",
|
||||||
|
"type": "text",
|
||||||
|
"required": true,
|
||||||
|
"description": "Human-readable source name or source code."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
74
docs/import_templates/README.md
Normal file
74
docs/import_templates/README.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# CSV/Excel 导入模板
|
||||||
|
|
||||||
|
本目录定义真实数据进入系统前的标准格式。模板当前以 CSV 提供,Excel 可按相同列名保存为首行表头后再导出为 CSV。
|
||||||
|
|
||||||
|
## 使用原则
|
||||||
|
|
||||||
|
- 表头必须与模板完全一致。
|
||||||
|
- 文件编码建议使用 UTF-8 或 UTF-8 with BOM。
|
||||||
|
- 所有金额和面积字段只填写数字,不带单位、逗号或中文说明。
|
||||||
|
- `source` 必须填写可识别的数据来源名称,不允许写数据库连接串、密码、本地敏感路径。
|
||||||
|
- 单次导入必须先创建或关联一条 `audit.ingestion_runs` 记录。
|
||||||
|
- 校验通过不代表数据一定正确,只代表结构、类型和值域满足入库前置条件。
|
||||||
|
|
||||||
|
## 模板清单
|
||||||
|
|
||||||
|
| 模板 | 目标表 | 主键 | 用途 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `area_monthly_metrics.csv` | `silver.area_monthly_metrics` | `area_id`, `month` | 板块月度成交、挂牌、租金、供应、信贷和政策信号 |
|
||||||
|
| `neighborhood_monthly_metrics.csv` | `silver.neighborhood_monthly_metrics` | `neighborhood_id`, `month` | 小区月度成交、挂牌、租金和可售样本 |
|
||||||
|
| `policy_events.csv` | `raw.policy_events` | `event_date`, `title` | 政策事件留痕和人工影响评分 |
|
||||||
|
| `land_sales.csv` | `raw.land_sales` | `parcel_id` | 土地成交记录,用于供给压力研究 |
|
||||||
|
|
||||||
|
## 字段规则
|
||||||
|
|
||||||
|
字段规格的机器可读版本位于 `config/import_templates.json`。Python CLI 和 Rust API 均应以该文件为准。
|
||||||
|
|
||||||
|
### 通用类型
|
||||||
|
|
||||||
|
| 类型 | 规则 |
|
||||||
|
| --- | --- |
|
||||||
|
| `text` | 非空文本,若字段非必填则可为空 |
|
||||||
|
| `integer` | 整数,可配置最小值和最大值 |
|
||||||
|
| `decimal` | 数字,可配置最小值和最大值 |
|
||||||
|
| `month` | `YYYY-MM`,月份必须是 01-12 |
|
||||||
|
| `date` | `YYYY-MM-DD`,日期必须真实存在 |
|
||||||
|
| `url` | 空值或 `http://` / `https://` URL |
|
||||||
|
| `enum` | 必须属于规格中的 `allowed_values` |
|
||||||
|
|
||||||
|
## CLI 校验
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONPATH=src python3 -m shanghai_housing validate-import \
|
||||||
|
--template area_monthly_metrics \
|
||||||
|
--file docs/import_templates/area_monthly_metrics.csv
|
||||||
|
```
|
||||||
|
|
||||||
|
通过时输出行数和模板名;失败时逐行返回错误信息。
|
||||||
|
|
||||||
|
## API 校验
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://127.0.0.1:8080/api/v1/imports/validate \
|
||||||
|
-H 'content-type: application/json' \
|
||||||
|
-d '{
|
||||||
|
"template_name": "area_monthly_metrics",
|
||||||
|
"rows": [
|
||||||
|
{
|
||||||
|
"area_id": "qiantan",
|
||||||
|
"month": "2026-05",
|
||||||
|
"transaction_count": "93",
|
||||||
|
"transaction_price_psm": "122500",
|
||||||
|
"listing_count": "380",
|
||||||
|
"listing_price_psm": "126500",
|
||||||
|
"median_days_on_market": "53",
|
||||||
|
"rent_price_psm": "192",
|
||||||
|
"new_supply_units": "60",
|
||||||
|
"land_residential_gfa_sqm": "0",
|
||||||
|
"mortgage_rate_pct": "3.35",
|
||||||
|
"policy_signal": "1",
|
||||||
|
"source": "manual_research"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}'
|
||||||
|
```
|
||||||
2
docs/import_templates/area_monthly_metrics.csv
Normal file
2
docs/import_templates/area_monthly_metrics.csv
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
area_id,month,transaction_count,transaction_price_psm,listing_count,listing_price_psm,median_days_on_market,rent_price_psm,new_supply_units,land_residential_gfa_sqm,mortgage_rate_pct,policy_signal,source
|
||||||
|
qiantan,2026-05,93,122500,380,126500,53,192,60,0,3.35,1,manual_research
|
||||||
|
2
docs/import_templates/land_sales.csv
Normal file
2
docs/import_templates/land_sales.csv
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
parcel_id,area_id,transaction_date,parcel_name,residential_gfa_sqm,transaction_price_total_cny,floor_price_cny_psm,buyer,source_url,source
|
||||||
|
sh-2026-001,zhangjiang,2026-05-28,张江示例住宅地块,36000,1800000000,50000,示例房企,https://example.com/land-sale,official_auction
|
||||||
|
2
docs/import_templates/neighborhood_monthly_metrics.csv
Normal file
2
docs/import_templates/neighborhood_monthly_metrics.csv
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
neighborhood_id,month,transaction_count,transaction_price_psm,listing_count,listing_price_psm,rent_price_psm,median_days_on_market,available_units,source
|
||||||
|
qiantan_a,2026-05,12,124000,38,128000,195,50,38,manual_research
|
||||||
|
2
docs/import_templates/policy_events.csv
Normal file
2
docs/import_templates/policy_events.csv
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
event_date,level,title,impact_direction,notes,source_url,source
|
||||||
|
2026-05-17,shanghai,优化住房信贷政策,1,降低置换成本,https://example.com/policy,official_notice
|
||||||
|
@@ -77,6 +77,8 @@
|
|||||||
|
|
||||||
### M1.3 CSV/Excel 导入模板
|
### M1.3 CSV/Excel 导入模板
|
||||||
|
|
||||||
|
状态:已完成。
|
||||||
|
|
||||||
目标:
|
目标:
|
||||||
|
|
||||||
- 建立真实数据的标准导入模板。
|
- 建立真实数据的标准导入模板。
|
||||||
@@ -87,23 +89,27 @@
|
|||||||
- `docs/import_templates/`
|
- `docs/import_templates/`
|
||||||
- 后端导入校验接口。
|
- 后端导入校验接口。
|
||||||
- Python analytics 导入脚本。
|
- Python analytics 导入脚本。
|
||||||
|
- `config/import_templates.json` 作为模板规格源。
|
||||||
|
|
||||||
验收标准:
|
验收标准:
|
||||||
|
|
||||||
- 模板字段和数据库字段一一对应。
|
- 模板字段和现有研究表字段一一对应。
|
||||||
- 错误数据能返回明确错误信息。
|
- 错误数据能返回明确错误信息。
|
||||||
- 导入结果写入 `raw`/`silver`/`audit`。
|
- Python CLI 可以校验 CSV/Excel,并将已支持模板写入本地研究库。
|
||||||
|
- API 可以校验导入行的表头、类型、值域、日期、枚举和敏感文本。
|
||||||
|
|
||||||
### M1.4 原始数据留存与文件哈希
|
### M1.4 原始数据留存与文件哈希
|
||||||
|
|
||||||
目标:
|
目标:
|
||||||
|
|
||||||
- 每次导入都记录原始文件位置、哈希、大小和导入人。
|
- 每次导入都记录原始文件位置、哈希、大小、导入人和入库批次。
|
||||||
|
- 正式导入结果写入 `raw`/`silver`/`audit`,并与 `audit.ingestion_runs` 关联。
|
||||||
|
|
||||||
交付物:
|
交付物:
|
||||||
|
|
||||||
- `audit.raw_artifacts`
|
- `audit.raw_artifacts`
|
||||||
- 导入批次关联 raw artifact。
|
- 导入批次关联 raw artifact。
|
||||||
|
- PostgreSQL 导入执行接口。
|
||||||
|
|
||||||
验收标准:
|
验收标准:
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ name = "shanghai-housing-research"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "A lightweight Shanghai housing market research and investment analysis system."
|
description = "A lightweight Shanghai housing market research and investment analysis system."
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
dependencies = []
|
dependencies = [
|
||||||
|
"openpyxl>=3.1",
|
||||||
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src"]
|
where = ["src"]
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import argparse
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from .db import DEFAULT_DB_PATH, connect, init_db, load_sample_data
|
from .db import DEFAULT_DB_PATH, connect, import_template_file, init_db, load_sample_data
|
||||||
from .indicators import compute_area_scores
|
from .indicators import compute_area_scores
|
||||||
|
from .import_templates import ImportTemplateError, validate_tabular_file
|
||||||
from .reporting import format_scores_table, write_monthly_report
|
from .reporting import format_scores_table, write_monthly_report
|
||||||
|
|
||||||
|
|
||||||
@@ -36,6 +37,38 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
help="Output Markdown path. Default: reports/monthly-YYYY-MM.md",
|
help="Output Markdown path. Default: reports/monthly-YYYY-MM.md",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
validate_import_parser = subparsers.add_parser(
|
||||||
|
"validate-import",
|
||||||
|
help="Validate a CSV or Excel import file against a named template.",
|
||||||
|
)
|
||||||
|
validate_import_parser.add_argument(
|
||||||
|
"--template",
|
||||||
|
required=True,
|
||||||
|
help="Template name, for example area_monthly_metrics.",
|
||||||
|
)
|
||||||
|
validate_import_parser.add_argument(
|
||||||
|
"--file",
|
||||||
|
type=Path,
|
||||||
|
required=True,
|
||||||
|
help="CSV, XLSX, or XLSM file to validate.",
|
||||||
|
)
|
||||||
|
|
||||||
|
import_parser = subparsers.add_parser(
|
||||||
|
"import-file",
|
||||||
|
help="Validate and import a CSV or Excel file into the local research database.",
|
||||||
|
)
|
||||||
|
import_parser.add_argument(
|
||||||
|
"--template",
|
||||||
|
required=True,
|
||||||
|
help="Template name, for example area_monthly_metrics.",
|
||||||
|
)
|
||||||
|
import_parser.add_argument(
|
||||||
|
"--file",
|
||||||
|
type=Path,
|
||||||
|
required=True,
|
||||||
|
help="CSV, XLSX, or XLSM file to import.",
|
||||||
|
)
|
||||||
|
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
@@ -66,5 +99,38 @@ def main(argv: Optional[list[str]] = None) -> int:
|
|||||||
print(f"Generated report: {path}")
|
print(f"Generated report: {path}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
if args.command == "validate-import":
|
||||||
|
try:
|
||||||
|
result = validate_tabular_file(args.template, args.file)
|
||||||
|
except ImportTemplateError as exc:
|
||||||
|
print(f"Import validation setup error: {exc}")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
if result.valid:
|
||||||
|
print(
|
||||||
|
f"Import file is valid: template={result.template_name}, "
|
||||||
|
f"rows={result.row_count}"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"Import file is invalid: template={result.template_name}, "
|
||||||
|
f"rows={result.row_count}, errors={len(result.errors)}"
|
||||||
|
)
|
||||||
|
for error in result.errors:
|
||||||
|
print(f"row {error.row}, column {error.column}: {error.message}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if args.command == "import-file":
|
||||||
|
try:
|
||||||
|
count = import_template_file(args.db, args.template, args.file)
|
||||||
|
except ImportTemplateError as exc:
|
||||||
|
print(f"Import failed: {exc}")
|
||||||
|
return 1
|
||||||
|
print(
|
||||||
|
f"Imported file: template={args.template}, rows={count}, database={args.db}"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
parser.error(f"Unknown command: {args.command}")
|
parser.error(f"Unknown command: {args.command}")
|
||||||
return 2
|
return 2
|
||||||
|
|||||||
@@ -5,12 +5,20 @@ import sqlite3
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Iterable, Mapping, Union
|
from typing import Iterable, Mapping, Union
|
||||||
|
|
||||||
|
from .import_templates import ImportTemplateError, load_tabular_rows, validate_import_rows
|
||||||
|
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
DEFAULT_DB_PATH = PROJECT_ROOT / "data" / "shanghai_housing.sqlite"
|
DEFAULT_DB_PATH = PROJECT_ROOT / "data" / "shanghai_housing.sqlite"
|
||||||
SCHEMA_PATH = Path(__file__).with_name("schema.sql")
|
SCHEMA_PATH = Path(__file__).with_name("schema.sql")
|
||||||
SAMPLE_DIR = PROJECT_ROOT / "data" / "sample"
|
SAMPLE_DIR = PROJECT_ROOT / "data" / "sample"
|
||||||
|
|
||||||
|
SQLITE_IMPORT_TABLES = {
|
||||||
|
"area_monthly_metrics": "area_monthly_metrics",
|
||||||
|
"neighborhood_monthly_metrics": "neighborhood_monthly_metrics",
|
||||||
|
"policy_events": "policy_events",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def connect(db_path: Union[Path, str] = DEFAULT_DB_PATH) -> sqlite3.Connection:
|
def connect(db_path: Union[Path, str] = DEFAULT_DB_PATH) -> sqlite3.Connection:
|
||||||
conn = sqlite3.connect(str(db_path))
|
conn = sqlite3.connect(str(db_path))
|
||||||
@@ -73,6 +81,44 @@ def load_csv(conn: sqlite3.Connection, table: str, path: Path) -> int:
|
|||||||
return len(rows)
|
return len(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def import_template_file(
|
||||||
|
db_path: Union[Path, str],
|
||||||
|
template_name: str,
|
||||||
|
file_path: Path,
|
||||||
|
) -> int:
|
||||||
|
table = SQLITE_IMPORT_TABLES.get(template_name)
|
||||||
|
if table is None:
|
||||||
|
supported = ", ".join(sorted(SQLITE_IMPORT_TABLES))
|
||||||
|
raise ImportTemplateError(
|
||||||
|
f"template '{template_name}' cannot be imported into SQLite; "
|
||||||
|
f"supported templates: {supported}"
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = load_tabular_rows(file_path)
|
||||||
|
result = validate_import_rows(template_name, rows)
|
||||||
|
if not result.valid:
|
||||||
|
first_error = result.errors[0]
|
||||||
|
raise ImportTemplateError(
|
||||||
|
f"validation failed at row {first_error.row}, "
|
||||||
|
f"column {first_error.column}: {first_error.message}"
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
init_db(db_path)
|
||||||
|
with connect(db_path) as conn:
|
||||||
|
columns = list(rows[0].keys())
|
||||||
|
placeholders = ", ".join(["?"] * len(columns))
|
||||||
|
column_sql = ", ".join(columns)
|
||||||
|
update_sql = ", ".join([f"{column}=excluded.{column}" for column in columns])
|
||||||
|
sql = (
|
||||||
|
f"INSERT INTO {table} ({column_sql}) VALUES ({placeholders}) "
|
||||||
|
f"ON CONFLICT DO UPDATE SET {update_sql}"
|
||||||
|
)
|
||||||
|
conn.executemany(sql, [tuple(row[column] for column in columns) for row in rows])
|
||||||
|
return len(rows)
|
||||||
|
|
||||||
|
|
||||||
def fetch_area_metrics(conn: sqlite3.Connection, month: str) -> list[sqlite3.Row]:
|
def fetch_area_metrics(conn: sqlite3.Connection, month: str) -> list[sqlite3.Row]:
|
||||||
return conn.execute(
|
return conn.execute(
|
||||||
"""
|
"""
|
||||||
|
|||||||
306
src/shanghai_housing/import_templates.py
Normal file
306
src/shanghai_housing/import_templates.py
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Mapping
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
IMPORT_TEMPLATE_SPEC_PATH = PROJECT_ROOT / "config" / "import_templates.json"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ImportValidationError:
|
||||||
|
row: int
|
||||||
|
column: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ImportValidationResult:
|
||||||
|
template_name: str
|
||||||
|
row_count: int
|
||||||
|
valid: bool
|
||||||
|
errors: list[ImportValidationError]
|
||||||
|
|
||||||
|
|
||||||
|
class ImportTemplateError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def load_import_template_specs(
|
||||||
|
path: Path = IMPORT_TEMPLATE_SPEC_PATH,
|
||||||
|
) -> dict[str, dict[str, Any]]:
|
||||||
|
with path.open("r", encoding="utf-8") as handle:
|
||||||
|
return json.load(handle)
|
||||||
|
|
||||||
|
|
||||||
|
def list_template_names(path: Path = IMPORT_TEMPLATE_SPEC_PATH) -> list[str]:
|
||||||
|
return sorted(load_import_template_specs(path).keys())
|
||||||
|
|
||||||
|
|
||||||
|
def get_template_spec(
|
||||||
|
template_name: str,
|
||||||
|
path: Path = IMPORT_TEMPLATE_SPEC_PATH,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
specs = load_import_template_specs(path)
|
||||||
|
try:
|
||||||
|
return specs[template_name]
|
||||||
|
except KeyError as exc:
|
||||||
|
valid_names = ", ".join(sorted(specs.keys()))
|
||||||
|
raise ImportTemplateError(
|
||||||
|
f"unknown template '{template_name}', expected one of: {valid_names}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def validate_csv_file(
|
||||||
|
template_name: str,
|
||||||
|
file_path: Path,
|
||||||
|
spec_path: Path = IMPORT_TEMPLATE_SPEC_PATH,
|
||||||
|
) -> ImportValidationResult:
|
||||||
|
rows = read_csv_rows(file_path)
|
||||||
|
return validate_import_rows(template_name, rows, spec_path=spec_path)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_tabular_file(
|
||||||
|
template_name: str,
|
||||||
|
file_path: Path,
|
||||||
|
spec_path: Path = IMPORT_TEMPLATE_SPEC_PATH,
|
||||||
|
) -> ImportValidationResult:
|
||||||
|
suffix = file_path.suffix.lower()
|
||||||
|
if suffix == ".csv":
|
||||||
|
rows = read_csv_rows(file_path)
|
||||||
|
elif suffix in {".xlsx", ".xlsm"}:
|
||||||
|
rows = read_excel_rows(file_path)
|
||||||
|
else:
|
||||||
|
raise ImportTemplateError("file must be a .csv, .xlsx, or .xlsm document")
|
||||||
|
return validate_import_rows(template_name, rows, spec_path=spec_path)
|
||||||
|
|
||||||
|
|
||||||
|
def load_tabular_rows(file_path: Path) -> list[dict[str, str]]:
|
||||||
|
suffix = file_path.suffix.lower()
|
||||||
|
if suffix == ".csv":
|
||||||
|
return read_csv_rows(file_path)
|
||||||
|
if suffix in {".xlsx", ".xlsm"}:
|
||||||
|
return read_excel_rows(file_path)
|
||||||
|
raise ImportTemplateError("file must be a .csv, .xlsx, or .xlsm document")
|
||||||
|
|
||||||
|
|
||||||
|
def read_csv_rows(file_path: Path) -> list[dict[str, str]]:
|
||||||
|
with file_path.open("r", encoding="utf-8-sig", newline="") as handle:
|
||||||
|
reader = csv.DictReader(handle)
|
||||||
|
return [dict(row) for row in reader]
|
||||||
|
|
||||||
|
|
||||||
|
def read_excel_rows(file_path: Path) -> list[dict[str, str]]:
|
||||||
|
try:
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
except ImportError as exc:
|
||||||
|
raise ImportTemplateError(
|
||||||
|
"Excel validation requires openpyxl; install project dependencies first"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
workbook = load_workbook(file_path, read_only=True, data_only=True)
|
||||||
|
worksheet = workbook.active
|
||||||
|
rows = worksheet.iter_rows(values_only=True)
|
||||||
|
try:
|
||||||
|
headers = [str(value).strip() if value is not None else "" for value in next(rows)]
|
||||||
|
except StopIteration:
|
||||||
|
return []
|
||||||
|
|
||||||
|
records: list[dict[str, str]] = []
|
||||||
|
for values in rows:
|
||||||
|
if values is None or all(value is None or str(value).strip() == "" for value in values):
|
||||||
|
continue
|
||||||
|
record = {
|
||||||
|
header: "" if value is None else str(value).strip()
|
||||||
|
for header, value in zip(headers, values)
|
||||||
|
if header
|
||||||
|
}
|
||||||
|
records.append(record)
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
def validate_import_rows(
|
||||||
|
template_name: str,
|
||||||
|
rows: list[Mapping[str, Any]],
|
||||||
|
spec_path: Path = IMPORT_TEMPLATE_SPEC_PATH,
|
||||||
|
) -> ImportValidationResult:
|
||||||
|
spec = get_template_spec(template_name, spec_path)
|
||||||
|
columns = spec.get("columns", [])
|
||||||
|
expected_columns = [column["name"] for column in columns]
|
||||||
|
required_columns = [
|
||||||
|
column["name"] for column in columns if bool(column.get("required", False))
|
||||||
|
]
|
||||||
|
errors: list[ImportValidationError] = []
|
||||||
|
|
||||||
|
seen_columns: set[str] = set()
|
||||||
|
if rows:
|
||||||
|
seen_columns = set(rows[0].keys())
|
||||||
|
missing_headers = [column for column in expected_columns if column not in seen_columns]
|
||||||
|
unexpected_headers = sorted(seen_columns - set(expected_columns))
|
||||||
|
|
||||||
|
for column in missing_headers:
|
||||||
|
errors.append(ImportValidationError(0, column, "missing required header"))
|
||||||
|
for column in unexpected_headers:
|
||||||
|
errors.append(ImportValidationError(0, column, "unexpected header"))
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
errors.append(ImportValidationError(0, "*", "file has no data rows"))
|
||||||
|
|
||||||
|
for index, row in enumerate(rows, start=2):
|
||||||
|
for column in columns:
|
||||||
|
name = column["name"]
|
||||||
|
value = normalize_value(row.get(name))
|
||||||
|
if name in required_columns and value == "":
|
||||||
|
errors.append(ImportValidationError(index, name, "value is required"))
|
||||||
|
continue
|
||||||
|
if value == "":
|
||||||
|
continue
|
||||||
|
errors.extend(validate_value(index, name, value, column))
|
||||||
|
|
||||||
|
return ImportValidationResult(
|
||||||
|
template_name=template_name,
|
||||||
|
row_count=len(rows),
|
||||||
|
valid=not errors,
|
||||||
|
errors=errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_value(value: Any) -> str:
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
return str(value).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def validate_value(
|
||||||
|
row_index: int,
|
||||||
|
column_name: str,
|
||||||
|
value: str,
|
||||||
|
column: Mapping[str, Any],
|
||||||
|
) -> list[ImportValidationError]:
|
||||||
|
errors: list[ImportValidationError] = []
|
||||||
|
if has_sensitive_text(value):
|
||||||
|
return [
|
||||||
|
ImportValidationError(
|
||||||
|
row_index,
|
||||||
|
column_name,
|
||||||
|
"value appears to contain credentials or a sensitive local path",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
column_type = column["type"]
|
||||||
|
if column_type == "text":
|
||||||
|
return errors
|
||||||
|
if column_type == "month":
|
||||||
|
if not is_valid_month(value):
|
||||||
|
errors.append(ImportValidationError(row_index, column_name, "must be YYYY-MM"))
|
||||||
|
return errors
|
||||||
|
if column_type == "date":
|
||||||
|
if not is_valid_date(value):
|
||||||
|
errors.append(ImportValidationError(row_index, column_name, "must be YYYY-MM-DD"))
|
||||||
|
return errors
|
||||||
|
if column_type == "url":
|
||||||
|
if not is_valid_url(value):
|
||||||
|
errors.append(ImportValidationError(row_index, column_name, "must be http(s) URL"))
|
||||||
|
return errors
|
||||||
|
if column_type == "enum":
|
||||||
|
allowed_values = column.get("allowed_values", [])
|
||||||
|
if value not in allowed_values:
|
||||||
|
allowed = ", ".join(allowed_values)
|
||||||
|
errors.append(
|
||||||
|
ImportValidationError(row_index, column_name, f"must be one of: {allowed}")
|
||||||
|
)
|
||||||
|
return errors
|
||||||
|
if column_type == "integer":
|
||||||
|
parsed = parse_integer(value)
|
||||||
|
if parsed is None:
|
||||||
|
errors.append(ImportValidationError(row_index, column_name, "must be an integer"))
|
||||||
|
return errors
|
||||||
|
errors.extend(validate_numeric_range(row_index, column_name, parsed, column))
|
||||||
|
return errors
|
||||||
|
if column_type == "decimal":
|
||||||
|
parsed = parse_decimal(value)
|
||||||
|
if parsed is None:
|
||||||
|
errors.append(ImportValidationError(row_index, column_name, "must be a number"))
|
||||||
|
return errors
|
||||||
|
errors.extend(validate_numeric_range(row_index, column_name, parsed, column))
|
||||||
|
return errors
|
||||||
|
|
||||||
|
errors.append(
|
||||||
|
ImportValidationError(row_index, column_name, f"unknown type '{column_type}'")
|
||||||
|
)
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def validate_numeric_range(
|
||||||
|
row_index: int,
|
||||||
|
column_name: str,
|
||||||
|
value: float,
|
||||||
|
column: Mapping[str, Any],
|
||||||
|
) -> list[ImportValidationError]:
|
||||||
|
errors: list[ImportValidationError] = []
|
||||||
|
minimum = column.get("min")
|
||||||
|
maximum = column.get("max")
|
||||||
|
if minimum is not None and value < float(minimum):
|
||||||
|
errors.append(
|
||||||
|
ImportValidationError(row_index, column_name, f"must be >= {minimum}")
|
||||||
|
)
|
||||||
|
if maximum is not None and value > float(maximum):
|
||||||
|
errors.append(
|
||||||
|
ImportValidationError(row_index, column_name, f"must be <= {maximum}")
|
||||||
|
)
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def parse_integer(value: str) -> int | None:
|
||||||
|
if not re.fullmatch(r"-?\d+", value):
|
||||||
|
return None
|
||||||
|
return int(value)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_decimal(value: str) -> float | None:
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_month(value: str) -> bool:
|
||||||
|
if not re.fullmatch(r"\d{4}-\d{2}", value):
|
||||||
|
return False
|
||||||
|
month = int(value[5:])
|
||||||
|
return 1 <= month <= 12
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_date(value: str) -> bool:
|
||||||
|
if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", value):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
date.fromisoformat(value)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_url(value: str) -> bool:
|
||||||
|
parsed = urlparse(value)
|
||||||
|
return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
|
||||||
|
|
||||||
|
|
||||||
|
def has_sensitive_text(value: str) -> bool:
|
||||||
|
lower = value.lower()
|
||||||
|
return (
|
||||||
|
"password" in lower
|
||||||
|
or "secret" in lower
|
||||||
|
or "postgres://" in lower
|
||||||
|
or "database_url" in lower
|
||||||
|
or "root_" in lower
|
||||||
|
or value.startswith("/Users/")
|
||||||
|
or value.startswith("/private/")
|
||||||
|
)
|
||||||
@@ -60,7 +60,8 @@ CREATE TABLE IF NOT EXISTS policy_events (
|
|||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
impact_direction INTEGER NOT NULL CHECK (impact_direction BETWEEN -2 AND 2),
|
impact_direction INTEGER NOT NULL CHECK (impact_direction BETWEEN -2 AND 2),
|
||||||
notes TEXT DEFAULT '',
|
notes TEXT DEFAULT '',
|
||||||
source_url TEXT
|
source_url TEXT,
|
||||||
|
source TEXT NOT NULL DEFAULT 'manual'
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_area_metrics_month ON area_monthly_metrics(month);
|
CREATE INDEX IF NOT EXISTS idx_area_metrics_month ON area_monthly_metrics(month);
|
||||||
|
|||||||
84
tests/test_import_templates.py
Normal file
84
tests/test_import_templates.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from shanghai_housing.db import connect, import_template_file
|
||||||
|
from shanghai_housing.import_templates import validate_csv_file, validate_import_rows
|
||||||
|
|
||||||
|
|
||||||
|
class ImportTemplateTests(unittest.TestCase):
|
||||||
|
def test_validates_area_monthly_metrics_template_file(self) -> None:
|
||||||
|
result = validate_csv_file(
|
||||||
|
"area_monthly_metrics",
|
||||||
|
Path("docs/import_templates/area_monthly_metrics.csv"),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(result.valid)
|
||||||
|
self.assertEqual(result.row_count, 1)
|
||||||
|
self.assertEqual(result.errors, [])
|
||||||
|
|
||||||
|
def test_reports_missing_required_value_and_invalid_range(self) -> None:
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"area_id": "",
|
||||||
|
"month": "2026-13",
|
||||||
|
"transaction_count": "-1",
|
||||||
|
"transaction_price_psm": "abc",
|
||||||
|
"listing_count": "10",
|
||||||
|
"listing_price_psm": "100",
|
||||||
|
"median_days_on_market": "30",
|
||||||
|
"rent_price_psm": "3",
|
||||||
|
"new_supply_units": "0",
|
||||||
|
"land_residential_gfa_sqm": "0",
|
||||||
|
"mortgage_rate_pct": "3.35",
|
||||||
|
"policy_signal": "3",
|
||||||
|
"source": "manual",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
result = validate_import_rows("area_monthly_metrics", rows)
|
||||||
|
|
||||||
|
self.assertFalse(result.valid)
|
||||||
|
messages = {(error.column, error.message) for error in result.errors}
|
||||||
|
self.assertIn(("area_id", "value is required"), messages)
|
||||||
|
self.assertIn(("month", "must be YYYY-MM"), messages)
|
||||||
|
self.assertIn(("transaction_count", "must be >= 0"), messages)
|
||||||
|
self.assertIn(("transaction_price_psm", "must be a number"), messages)
|
||||||
|
self.assertIn(("policy_signal", "must be <= 2"), messages)
|
||||||
|
|
||||||
|
def test_reports_unexpected_headers_from_csv(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
path = Path(tmpdir) / "bad.csv"
|
||||||
|
with path.open("w", encoding="utf-8", newline="") as handle:
|
||||||
|
writer = csv.DictWriter(handle, fieldnames=["area_id", "unknown"])
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerow({"area_id": "qiantan", "unknown": "value"})
|
||||||
|
|
||||||
|
result = validate_csv_file("area_monthly_metrics", path)
|
||||||
|
|
||||||
|
self.assertFalse(result.valid)
|
||||||
|
messages = {(error.row, error.column, error.message) for error in result.errors}
|
||||||
|
self.assertIn((0, "unknown", "unexpected header"), messages)
|
||||||
|
self.assertIn((0, "month", "missing required header"), messages)
|
||||||
|
|
||||||
|
def test_imports_valid_template_file_to_sqlite(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
db_path = Path(tmpdir) / "research.sqlite"
|
||||||
|
count = import_template_file(
|
||||||
|
db_path,
|
||||||
|
"policy_events",
|
||||||
|
Path("docs/import_templates/policy_events.csv"),
|
||||||
|
)
|
||||||
|
with connect(db_path) as conn:
|
||||||
|
rows = conn.execute("SELECT * FROM policy_events").fetchall()
|
||||||
|
|
||||||
|
self.assertEqual(count, 1)
|
||||||
|
self.assertEqual(len(rows), 1)
|
||||||
|
self.assertEqual(rows[0]["level"], "shanghai")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user