fully fonctionnal
This commit is contained in:
parent
07ac6c422f
commit
94f5d37f7a
@ -10,6 +10,7 @@ from uuid import UUID, uuid4
|
||||
import psycopg
|
||||
from psycopg import sql
|
||||
from psycopg.rows import dict_row
|
||||
from psycopg.types.json import Json
|
||||
from dotenv import load_dotenv
|
||||
from flask import Flask, abort, jsonify, request, g
|
||||
from flask_cors import CORS
|
||||
@ -156,7 +157,11 @@ def _isoformat(dt: datetime | None) -> str | None:
|
||||
|
||||
|
||||
def _ensure_json_compatible(value: Any, field_name: str) -> Any:
|
||||
if isinstance(value, (dict, list, str, int, float, bool)) or value is None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (dict, list)):
|
||||
return Json(value)
|
||||
if isinstance(value, (str, int, float, bool)):
|
||||
return value
|
||||
abort(400, description=f"Field '{field_name}' must be valid JSON data")
|
||||
|
||||
@ -202,6 +207,8 @@ def _serialize_row(
|
||||
for field in datetime_fields or ():
|
||||
result[field] = _isoformat(result.get(field))
|
||||
for field, value in list(result.items()):
|
||||
if isinstance(value, Json):
|
||||
result[field] = value.data
|
||||
if isinstance(value, UUID):
|
||||
result[field] = str(value)
|
||||
return result
|
||||
@ -244,6 +251,12 @@ def _insert_row(
|
||||
) -> Mapping[str, Any]:
|
||||
if not data:
|
||||
raise ValueError("Cannot insert without data")
|
||||
adapted_data: Dict[str, Any] = {}
|
||||
for key, value in data.items():
|
||||
if isinstance(value, (dict, list)):
|
||||
adapted_data[key] = Json(value)
|
||||
else:
|
||||
adapted_data[key] = value
|
||||
query = sql.SQL(
|
||||
"INSERT INTO {table} ({columns}) VALUES ({values}) RETURNING {returning}"
|
||||
).format(
|
||||
@ -252,7 +265,7 @@ def _insert_row(
|
||||
values=_placeholders(data.keys()),
|
||||
returning=_columns_sql(returning),
|
||||
)
|
||||
row = _fetch_one(query, data)
|
||||
row = _fetch_one(query, adapted_data)
|
||||
if row is None:
|
||||
raise RuntimeError("Insert statement did not return a row")
|
||||
return row
|
||||
@ -286,6 +299,9 @@ def _update_row(
|
||||
)
|
||||
params: Dict[str, Any] = dict(data)
|
||||
params["identifier"] = identifier_value
|
||||
for key, value in list(params.items()):
|
||||
if isinstance(value, (dict, list)):
|
||||
params[key] = Json(value)
|
||||
return _fetch_one(query, params)
|
||||
|
||||
|
||||
|
||||
@ -55,7 +55,17 @@ function validateNode(schema: SchemaNode, value: unknown, path = ""): SchemaVali
|
||||
}
|
||||
|
||||
function validatePrimitive(schema: SchemaPrimitiveNode, value: unknown, path: string) {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
if (value === undefined || value === null) {
|
||||
return schema.required === false
|
||||
? []
|
||||
: [createError(path, "Valeur requise.")];
|
||||
}
|
||||
|
||||
if (value === "") {
|
||||
if (schema.required === false && schema.type === "string") {
|
||||
return [];
|
||||
}
|
||||
|
||||
return schema.required === false
|
||||
? []
|
||||
: [createError(path, "Valeur requise.")];
|
||||
@ -84,11 +94,19 @@ function validatePrimitive(schema: SchemaPrimitiveNode, value: unknown, path: st
|
||||
}
|
||||
|
||||
function validateEnum(schema: SchemaEnumNode, value: unknown, path: string) {
|
||||
if (value === undefined || value === null) {
|
||||
return schema.required === false ? [] : [createError(path, "Valeur requise.")];
|
||||
}
|
||||
|
||||
if (schema.multiple) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [createError(path, "Selection multiple attendue.")];
|
||||
}
|
||||
|
||||
if (value.length === 0) {
|
||||
return schema.required === false ? [] : [createError(path, "Valeur requise.")];
|
||||
}
|
||||
|
||||
const invalid = value.filter((item) => !schema.options.includes(item as string));
|
||||
|
||||
if (invalid.length > 0) {
|
||||
@ -110,6 +128,10 @@ function validateEnum(schema: SchemaEnumNode, value: unknown, path: string) {
|
||||
}
|
||||
|
||||
function validateRange(schema: SchemaRangeNode, value: unknown, path: string) {
|
||||
if (value === undefined || value === null) {
|
||||
return schema.required === false ? [] : [createError(path, "Objet {min,max} attendu.")];
|
||||
}
|
||||
|
||||
if (!value || typeof value !== "object") {
|
||||
return [createError(path, "Objet {min,max} attendu.")];
|
||||
}
|
||||
@ -142,14 +164,26 @@ function validateRange(schema: SchemaRangeNode, value: unknown, path: string) {
|
||||
}
|
||||
|
||||
function validateArray(schema: SchemaArrayNode, value: unknown, path: string) {
|
||||
if (value === undefined || value === null) {
|
||||
return schema.required === false ? [] : [createError(path, "Tableau attendu.")];
|
||||
}
|
||||
|
||||
if (!Array.isArray(value)) {
|
||||
return [createError(path, "Tableau attendu.")];
|
||||
}
|
||||
|
||||
if (value.length === 0) {
|
||||
return schema.required === false ? [] : [createError(path, "Valeur requise.")];
|
||||
}
|
||||
|
||||
return value.flatMap((item, index) => validateNode(schema.element, item, makeIndexPath(path, index)));
|
||||
}
|
||||
|
||||
function validateObject(schema: SchemaObjectNode, value: unknown, path: string) {
|
||||
if (value === undefined || value === null) {
|
||||
return schema.required === false ? [] : [createError(path, "Objet attendu.")];
|
||||
}
|
||||
|
||||
if (!value || typeof value !== "object") {
|
||||
return [createError(path, "Objet attendu.")];
|
||||
}
|
||||
|
||||
@ -74,7 +74,7 @@ function getErrorMessage(error: unknown) {
|
||||
function transformPayload(values: ProfileFormValues): CreateProfilePayload {
|
||||
const payload: CreateProfilePayload = {
|
||||
name: values.name.trim(),
|
||||
criteria: values.criteria,
|
||||
criteria: cleanupCriteriaForSubmit(values.criteria),
|
||||
is_active: values.is_active,
|
||||
};
|
||||
|
||||
@ -118,6 +118,85 @@ function createDefaultValues(): ProfileFormValues {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeProfileCriteriaForForm(criteria: JsonObject): JsonObject {
|
||||
const clone = JSON.parse(JSON.stringify(criteria ?? {})) as JsonObject;
|
||||
|
||||
const vision = clone.vision_requirements;
|
||||
|
||||
if (vision && typeof vision === "object") {
|
||||
normalizeVisionSection(vision as Record<string, unknown>, "kitchen");
|
||||
normalizeVisionSection(vision as Record<string, unknown>, "bathroom");
|
||||
}
|
||||
|
||||
return clone;
|
||||
}
|
||||
|
||||
function normalizeVisionSection(parent: Record<string, unknown>, key: string) {
|
||||
const section = parent[key];
|
||||
|
||||
if (!section || typeof section !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
const record = section as Record<string, unknown>;
|
||||
const appliancesValue = record["appliances"];
|
||||
|
||||
if (Array.isArray(appliancesValue)) {
|
||||
record["appliances"] = appliancesValue.join(", ");
|
||||
return;
|
||||
}
|
||||
|
||||
if (appliancesValue === null || appliancesValue === undefined || appliancesValue === "") {
|
||||
delete record["appliances"];
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof appliancesValue !== "string") {
|
||||
record["appliances"] = String(appliancesValue);
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupCriteriaForSubmit(criteria: JsonObject): JsonObject {
|
||||
const cleaned = cleanupValue(criteria);
|
||||
|
||||
if (!cleaned || typeof cleaned !== "object") {
|
||||
return {};
|
||||
}
|
||||
|
||||
return cleaned as JsonObject;
|
||||
}
|
||||
|
||||
function cleanupValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
const cleanedArray = value
|
||||
.map((item) => cleanupValue(item))
|
||||
.filter((item) => item !== undefined);
|
||||
|
||||
return cleanedArray.length > 0 ? cleanedArray : undefined;
|
||||
}
|
||||
|
||||
if (value && typeof value === "object") {
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
Object.entries(value as Record<string, unknown>).forEach(([key, entryValue]) => {
|
||||
const cleanedEntry = cleanupValue(entryValue);
|
||||
|
||||
if (cleanedEntry !== undefined) {
|
||||
result[key] = cleanedEntry;
|
||||
}
|
||||
});
|
||||
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function ProfilesTab() {
|
||||
const [criteriaValid, setCriteriaValid] = useState(true);
|
||||
const [editingProfileId, setEditingProfileId] = useState<string | null>(null);
|
||||
@ -425,7 +504,9 @@ export function ProfilesTab() {
|
||||
profile_id: profile.profile_id,
|
||||
name: profile.name,
|
||||
description: profile.description ?? "",
|
||||
criteria: profile.criteria,
|
||||
criteria: normalizeProfileCriteriaForForm(
|
||||
profile.criteria
|
||||
),
|
||||
created_at: createdAtInput,
|
||||
is_active: profile.is_active,
|
||||
});
|
||||
|
||||
@ -228,6 +228,7 @@ Profile criteria are expressed as an object that follows the schema below. As wi
|
||||
| profile.min_bathrooms | number | | Minimum required bathroom count. |
|
||||
| profile.property_type | array<string> | | List of desired property categories (apartment, house, etc.). |
|
||||
| profile.dpe_classes | enum[] | A, B, C, D, E, F, G, NC | Accepted energy classes. |
|
||||
| profile.ges_classes | enum[] | A, B, C, D, E, F, G, NC | Accepted greenhouse gas emission classes. |
|
||||
| profile.city | string | | Single target city name. |
|
||||
| profile.cities | array<string> | | List of acceptable cities. |
|
||||
| profile.postal_code | string | | Target postal code. |
|
||||
|
||||
@ -36,6 +36,7 @@ const characteristicsSchema: SchemaObjectNode = {
|
||||
fields: [
|
||||
{
|
||||
key: "type",
|
||||
required: false,
|
||||
schema: {
|
||||
kind: "enum",
|
||||
options: ["any", "all", "none"],
|
||||
@ -46,6 +47,7 @@ const characteristicsSchema: SchemaObjectNode = {
|
||||
},
|
||||
{
|
||||
key: "description",
|
||||
required: false,
|
||||
schema: {
|
||||
kind: "primitive",
|
||||
type: "string",
|
||||
@ -55,6 +57,7 @@ const characteristicsSchema: SchemaObjectNode = {
|
||||
},
|
||||
{
|
||||
key: "items",
|
||||
required: false,
|
||||
schema: {
|
||||
kind: "array",
|
||||
required: false,
|
||||
@ -76,6 +79,7 @@ const poiConstraintSchema: SchemaObjectNode = {
|
||||
fields: [
|
||||
{
|
||||
key: "poi_category",
|
||||
required: false,
|
||||
schema: {
|
||||
kind: "primitive",
|
||||
type: "string",
|
||||
@ -85,34 +89,42 @@ const poiConstraintSchema: SchemaObjectNode = {
|
||||
},
|
||||
{
|
||||
key: "max_walk_time_minutes",
|
||||
required: false,
|
||||
schema: { kind: "primitive", type: "number", required: false },
|
||||
},
|
||||
{
|
||||
key: "max_car_time_minutes",
|
||||
required: false,
|
||||
schema: { kind: "primitive", type: "number", required: false },
|
||||
},
|
||||
{
|
||||
key: "max_transport_time_minutes",
|
||||
required: false,
|
||||
schema: { kind: "primitive", type: "number", required: false },
|
||||
},
|
||||
{
|
||||
key: "max_bike_time_minutes",
|
||||
required: false,
|
||||
schema: { kind: "primitive", type: "number", required: false },
|
||||
},
|
||||
{
|
||||
key: "max_distance_meters",
|
||||
required: false,
|
||||
schema: { kind: "primitive", type: "number", required: false },
|
||||
},
|
||||
{
|
||||
key: "lift_type",
|
||||
required: false,
|
||||
schema: { kind: "primitive", type: "string", required: false },
|
||||
},
|
||||
{
|
||||
key: "domain_min_altitude",
|
||||
required: false,
|
||||
schema: { kind: "primitive", type: "number", required: false },
|
||||
},
|
||||
{
|
||||
key: "min_rating",
|
||||
required: false,
|
||||
schema: { kind: "primitive", type: "number", required: false },
|
||||
},
|
||||
],
|
||||
@ -121,6 +133,7 @@ const poiConstraintSchema: SchemaObjectNode = {
|
||||
const visionWeightFields: SchemaObjectNode["fields"] = [
|
||||
{
|
||||
key: "weight",
|
||||
required: false,
|
||||
schema: {
|
||||
kind: "primitive",
|
||||
type: "number",
|
||||
@ -129,6 +142,7 @@ const visionWeightFields: SchemaObjectNode["fields"] = [
|
||||
},
|
||||
{
|
||||
key: "missing_penalty",
|
||||
required: false,
|
||||
schema: {
|
||||
kind: "primitive",
|
||||
type: "number",
|
||||
@ -143,6 +157,7 @@ export const profileSchema: SchemaDefinition = {
|
||||
fields: [
|
||||
{
|
||||
key: "transaction_type",
|
||||
required: false,
|
||||
schema: {
|
||||
kind: "enum",
|
||||
options: transactionOptions,
|
||||
@ -153,34 +168,42 @@ export const profileSchema: SchemaDefinition = {
|
||||
},
|
||||
{
|
||||
key: "min_price",
|
||||
required: false,
|
||||
schema: { kind: "primitive", type: "number", required: false },
|
||||
},
|
||||
{
|
||||
key: "max_price",
|
||||
required: false,
|
||||
schema: { kind: "primitive", type: "number", required: false },
|
||||
},
|
||||
{
|
||||
key: "min_size",
|
||||
required: false,
|
||||
schema: { kind: "primitive", type: "number", required: false },
|
||||
},
|
||||
{
|
||||
key: "max_size",
|
||||
required: false,
|
||||
schema: { kind: "primitive", type: "number", required: false },
|
||||
},
|
||||
{
|
||||
key: "min_bedrooms",
|
||||
required: false,
|
||||
schema: { kind: "primitive", type: "number", required: false },
|
||||
},
|
||||
{
|
||||
key: "max_bedrooms",
|
||||
required: false,
|
||||
schema: { kind: "primitive", type: "number", required: false },
|
||||
},
|
||||
{
|
||||
key: "min_bathrooms",
|
||||
required: false,
|
||||
schema: { kind: "primitive", type: "number", required: false },
|
||||
},
|
||||
{
|
||||
key: "property_type",
|
||||
required: false,
|
||||
schema: {
|
||||
kind: "array",
|
||||
required: false,
|
||||
@ -190,6 +213,7 @@ export const profileSchema: SchemaDefinition = {
|
||||
},
|
||||
{
|
||||
key: "dpe_classes",
|
||||
required: false,
|
||||
schema: {
|
||||
kind: "enum",
|
||||
options: dpeClassOptions,
|
||||
@ -198,12 +222,25 @@ export const profileSchema: SchemaDefinition = {
|
||||
description: "Accepted energy classes.",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "ges_classes",
|
||||
required: false,
|
||||
schema: {
|
||||
kind: "enum",
|
||||
options: dpeClassOptions,
|
||||
multiple: true,
|
||||
required: false,
|
||||
description: "Accepted greenhouse emission classes.",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "city",
|
||||
required: false,
|
||||
schema: { kind: "primitive", type: "string", required: false },
|
||||
},
|
||||
{
|
||||
key: "cities",
|
||||
required: false,
|
||||
schema: {
|
||||
kind: "array",
|
||||
required: false,
|
||||
@ -212,22 +249,27 @@ export const profileSchema: SchemaDefinition = {
|
||||
},
|
||||
{
|
||||
key: "postal_code",
|
||||
required: false,
|
||||
schema: { kind: "primitive", type: "string", required: false },
|
||||
},
|
||||
{
|
||||
key: "code_insee",
|
||||
required: false,
|
||||
schema: { kind: "primitive", type: "string", required: false },
|
||||
},
|
||||
{
|
||||
key: "target_yield",
|
||||
required: false,
|
||||
schema: { kind: "primitive", type: "number", required: false },
|
||||
},
|
||||
{
|
||||
key: "require_address",
|
||||
required: false,
|
||||
schema: { kind: "primitive", type: "boolean", required: false },
|
||||
},
|
||||
{
|
||||
key: "characteristics",
|
||||
required: false,
|
||||
schema: {
|
||||
kind: "array",
|
||||
required: false,
|
||||
@ -237,6 +279,7 @@ export const profileSchema: SchemaDefinition = {
|
||||
},
|
||||
{
|
||||
key: "poi_proximity",
|
||||
required: false,
|
||||
schema: {
|
||||
kind: "array",
|
||||
required: false,
|
||||
@ -246,34 +289,41 @@ export const profileSchema: SchemaDefinition = {
|
||||
},
|
||||
{
|
||||
key: "vision_requirements",
|
||||
required: false,
|
||||
schema: {
|
||||
kind: "object",
|
||||
required: false,
|
||||
fields: [
|
||||
{
|
||||
key: "description",
|
||||
required: false,
|
||||
schema: { kind: "primitive", type: "string", required: false },
|
||||
},
|
||||
{
|
||||
key: "kitchen",
|
||||
required: false,
|
||||
schema: {
|
||||
kind: "object",
|
||||
required: false,
|
||||
fields: [
|
||||
{
|
||||
key: "description",
|
||||
required: false,
|
||||
schema: { kind: "primitive", type: "string", required: false },
|
||||
},
|
||||
{
|
||||
key: "size",
|
||||
required: false,
|
||||
schema: { kind: "enum", options: visionSizeOptions, required: false },
|
||||
},
|
||||
{
|
||||
key: "open",
|
||||
required: false,
|
||||
schema: { kind: "primitive", type: "boolean", required: false },
|
||||
},
|
||||
{
|
||||
key: "appliances",
|
||||
required: false,
|
||||
schema: { kind: "primitive", type: "string", required: false },
|
||||
},
|
||||
...visionWeightFields,
|
||||
@ -282,20 +332,24 @@ export const profileSchema: SchemaDefinition = {
|
||||
},
|
||||
{
|
||||
key: "bathroom",
|
||||
required: false,
|
||||
schema: {
|
||||
kind: "object",
|
||||
required: false,
|
||||
fields: [
|
||||
{
|
||||
key: "description",
|
||||
required: false,
|
||||
schema: { kind: "primitive", type: "string", required: false },
|
||||
},
|
||||
{
|
||||
key: "size",
|
||||
required: false,
|
||||
schema: { kind: "enum", options: visionSizeOptions, required: false },
|
||||
},
|
||||
{
|
||||
key: "appliances",
|
||||
required: false,
|
||||
schema: { kind: "primitive", type: "string", required: false },
|
||||
},
|
||||
...visionWeightFields,
|
||||
@ -304,16 +358,19 @@ export const profileSchema: SchemaDefinition = {
|
||||
},
|
||||
{
|
||||
key: "living_room",
|
||||
required: false,
|
||||
schema: {
|
||||
kind: "object",
|
||||
required: false,
|
||||
fields: [
|
||||
{
|
||||
key: "description",
|
||||
required: false,
|
||||
schema: { kind: "primitive", type: "string", required: false },
|
||||
},
|
||||
{
|
||||
key: "size",
|
||||
required: false,
|
||||
schema: { kind: "enum", options: visionSizeOptions, required: false },
|
||||
},
|
||||
...visionWeightFields,
|
||||
@ -322,16 +379,19 @@ export const profileSchema: SchemaDefinition = {
|
||||
},
|
||||
{
|
||||
key: "bedrooms",
|
||||
required: false,
|
||||
schema: {
|
||||
kind: "object",
|
||||
required: false,
|
||||
fields: [
|
||||
{
|
||||
key: "description",
|
||||
required: false,
|
||||
schema: { kind: "primitive", type: "string", required: false },
|
||||
},
|
||||
{
|
||||
key: "min_count",
|
||||
required: false,
|
||||
schema: { kind: "primitive", type: "number", required: false },
|
||||
},
|
||||
...visionWeightFields,
|
||||
@ -340,20 +400,24 @@ export const profileSchema: SchemaDefinition = {
|
||||
},
|
||||
{
|
||||
key: "outdoor",
|
||||
required: false,
|
||||
schema: {
|
||||
kind: "object",
|
||||
required: false,
|
||||
fields: [
|
||||
{
|
||||
key: "description",
|
||||
required: false,
|
||||
schema: { kind: "primitive", type: "string", required: false },
|
||||
},
|
||||
{
|
||||
key: "required",
|
||||
required: false,
|
||||
schema: { kind: "primitive", type: "boolean", required: false },
|
||||
},
|
||||
{
|
||||
key: "types",
|
||||
required: false,
|
||||
schema: {
|
||||
kind: "array",
|
||||
required: false,
|
||||
@ -366,6 +430,7 @@ export const profileSchema: SchemaDefinition = {
|
||||
},
|
||||
{
|
||||
key: "note",
|
||||
required: false,
|
||||
schema: { kind: "primitive", type: "string", required: false },
|
||||
},
|
||||
],
|
||||
|
||||
@ -65,7 +65,16 @@ function buildArrayDefault(schema: SchemaArrayNode) {
|
||||
|
||||
function buildObjectDefault(schema: SchemaObjectNode) {
|
||||
return schema.fields.reduce<Record<string, unknown>>((accumulator, field) => {
|
||||
accumulator[field.key] = buildDefaultValue(field.schema);
|
||||
if (field.required === false) {
|
||||
return accumulator;
|
||||
}
|
||||
|
||||
const defaultValue = buildDefaultValue(field.schema);
|
||||
|
||||
if (defaultValue !== undefined) {
|
||||
accumulator[field.key] = defaultValue;
|
||||
}
|
||||
|
||||
return accumulator;
|
||||
}, {});
|
||||
}
|
||||
@ -129,8 +138,24 @@ function mergeObjectDefaults(schema: SchemaObjectNode, value: unknown) {
|
||||
const record = value as Record<string, unknown>;
|
||||
|
||||
return schema.fields.reduce<Record<string, unknown>>((accumulator, field) => {
|
||||
accumulator[field.key] = mergeDefaults(field.schema, record[field.key]);
|
||||
if (record[field.key] !== undefined) {
|
||||
const merged = mergeDefaults(field.schema, record[field.key]);
|
||||
|
||||
if (merged !== undefined) {
|
||||
accumulator[field.key] = merged;
|
||||
}
|
||||
|
||||
return accumulator;
|
||||
}
|
||||
|
||||
if (field.required !== false) {
|
||||
const defaultValue = buildDefaultValue(field.schema);
|
||||
|
||||
if (defaultValue !== undefined) {
|
||||
accumulator[field.key] = defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
return accumulator;
|
||||
}, {});
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user