fully fonctionnal

This commit is contained in:
Vitrixxl 2025-10-14 17:44:54 +02:00
parent 07ac6c422f
commit 94f5d37f7a
6 changed files with 230 additions and 8 deletions

View File

@ -10,6 +10,7 @@ from uuid import UUID, uuid4
import psycopg import psycopg
from psycopg import sql from psycopg import sql
from psycopg.rows import dict_row from psycopg.rows import dict_row
from psycopg.types.json import Json
from dotenv import load_dotenv from dotenv import load_dotenv
from flask import Flask, abort, jsonify, request, g from flask import Flask, abort, jsonify, request, g
from flask_cors import CORS 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: 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 return value
abort(400, description=f"Field '{field_name}' must be valid JSON data") 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 (): for field in datetime_fields or ():
result[field] = _isoformat(result.get(field)) result[field] = _isoformat(result.get(field))
for field, value in list(result.items()): for field, value in list(result.items()):
if isinstance(value, Json):
result[field] = value.data
if isinstance(value, UUID): if isinstance(value, UUID):
result[field] = str(value) result[field] = str(value)
return result return result
@ -244,6 +251,12 @@ def _insert_row(
) -> Mapping[str, Any]: ) -> Mapping[str, Any]:
if not data: if not data:
raise ValueError("Cannot insert without 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( query = sql.SQL(
"INSERT INTO {table} ({columns}) VALUES ({values}) RETURNING {returning}" "INSERT INTO {table} ({columns}) VALUES ({values}) RETURNING {returning}"
).format( ).format(
@ -252,7 +265,7 @@ def _insert_row(
values=_placeholders(data.keys()), values=_placeholders(data.keys()),
returning=_columns_sql(returning), returning=_columns_sql(returning),
) )
row = _fetch_one(query, data) row = _fetch_one(query, adapted_data)
if row is None: if row is None:
raise RuntimeError("Insert statement did not return a row") raise RuntimeError("Insert statement did not return a row")
return row return row
@ -286,6 +299,9 @@ def _update_row(
) )
params: Dict[str, Any] = dict(data) params: Dict[str, Any] = dict(data)
params["identifier"] = identifier_value 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) return _fetch_one(query, params)

View File

@ -55,7 +55,17 @@ function validateNode(schema: SchemaNode, value: unknown, path = ""): SchemaVali
} }
function validatePrimitive(schema: SchemaPrimitiveNode, value: unknown, path: string) { 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 return schema.required === false
? [] ? []
: [createError(path, "Valeur requise.")]; : [createError(path, "Valeur requise.")];
@ -84,11 +94,19 @@ function validatePrimitive(schema: SchemaPrimitiveNode, value: unknown, path: st
} }
function validateEnum(schema: SchemaEnumNode, value: unknown, path: string) { 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 (schema.multiple) {
if (!Array.isArray(value)) { if (!Array.isArray(value)) {
return [createError(path, "Selection multiple attendue.")]; 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)); const invalid = value.filter((item) => !schema.options.includes(item as string));
if (invalid.length > 0) { if (invalid.length > 0) {
@ -110,6 +128,10 @@ function validateEnum(schema: SchemaEnumNode, value: unknown, path: string) {
} }
function validateRange(schema: SchemaRangeNode, 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") { if (!value || typeof value !== "object") {
return [createError(path, "Objet {min,max} attendu.")]; 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) { 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)) { if (!Array.isArray(value)) {
return [createError(path, "Tableau attendu.")]; 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))); return value.flatMap((item, index) => validateNode(schema.element, item, makeIndexPath(path, index)));
} }
function validateObject(schema: SchemaObjectNode, value: unknown, path: string) { 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") { if (!value || typeof value !== "object") {
return [createError(path, "Objet attendu.")]; return [createError(path, "Objet attendu.")];
} }

View File

@ -74,7 +74,7 @@ function getErrorMessage(error: unknown) {
function transformPayload(values: ProfileFormValues): CreateProfilePayload { function transformPayload(values: ProfileFormValues): CreateProfilePayload {
const payload: CreateProfilePayload = { const payload: CreateProfilePayload = {
name: values.name.trim(), name: values.name.trim(),
criteria: values.criteria, criteria: cleanupCriteriaForSubmit(values.criteria),
is_active: values.is_active, 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() { export function ProfilesTab() {
const [criteriaValid, setCriteriaValid] = useState(true); const [criteriaValid, setCriteriaValid] = useState(true);
const [editingProfileId, setEditingProfileId] = useState<string | null>(null); const [editingProfileId, setEditingProfileId] = useState<string | null>(null);
@ -425,7 +504,9 @@ export function ProfilesTab() {
profile_id: profile.profile_id, profile_id: profile.profile_id,
name: profile.name, name: profile.name,
description: profile.description ?? "", description: profile.description ?? "",
criteria: profile.criteria, criteria: normalizeProfileCriteriaForForm(
profile.criteria
),
created_at: createdAtInput, created_at: createdAtInput,
is_active: profile.is_active, is_active: profile.is_active,
}); });

View File

@ -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.min_bathrooms | number | | Minimum required bathroom count. |
| profile.property_type | array<string> | | List of desired property categories (apartment, house, etc.). | | 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.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.city | string | | Single target city name. |
| profile.cities | array<string> | | List of acceptable cities. | | profile.cities | array<string> | | List of acceptable cities. |
| profile.postal_code | string | | Target postal code. | | profile.postal_code | string | | Target postal code. |

View File

@ -36,6 +36,7 @@ const characteristicsSchema: SchemaObjectNode = {
fields: [ fields: [
{ {
key: "type", key: "type",
required: false,
schema: { schema: {
kind: "enum", kind: "enum",
options: ["any", "all", "none"], options: ["any", "all", "none"],
@ -46,6 +47,7 @@ const characteristicsSchema: SchemaObjectNode = {
}, },
{ {
key: "description", key: "description",
required: false,
schema: { schema: {
kind: "primitive", kind: "primitive",
type: "string", type: "string",
@ -55,6 +57,7 @@ const characteristicsSchema: SchemaObjectNode = {
}, },
{ {
key: "items", key: "items",
required: false,
schema: { schema: {
kind: "array", kind: "array",
required: false, required: false,
@ -76,6 +79,7 @@ const poiConstraintSchema: SchemaObjectNode = {
fields: [ fields: [
{ {
key: "poi_category", key: "poi_category",
required: false,
schema: { schema: {
kind: "primitive", kind: "primitive",
type: "string", type: "string",
@ -85,34 +89,42 @@ const poiConstraintSchema: SchemaObjectNode = {
}, },
{ {
key: "max_walk_time_minutes", key: "max_walk_time_minutes",
required: false,
schema: { kind: "primitive", type: "number", required: false }, schema: { kind: "primitive", type: "number", required: false },
}, },
{ {
key: "max_car_time_minutes", key: "max_car_time_minutes",
required: false,
schema: { kind: "primitive", type: "number", required: false }, schema: { kind: "primitive", type: "number", required: false },
}, },
{ {
key: "max_transport_time_minutes", key: "max_transport_time_minutes",
required: false,
schema: { kind: "primitive", type: "number", required: false }, schema: { kind: "primitive", type: "number", required: false },
}, },
{ {
key: "max_bike_time_minutes", key: "max_bike_time_minutes",
required: false,
schema: { kind: "primitive", type: "number", required: false }, schema: { kind: "primitive", type: "number", required: false },
}, },
{ {
key: "max_distance_meters", key: "max_distance_meters",
required: false,
schema: { kind: "primitive", type: "number", required: false }, schema: { kind: "primitive", type: "number", required: false },
}, },
{ {
key: "lift_type", key: "lift_type",
required: false,
schema: { kind: "primitive", type: "string", required: false }, schema: { kind: "primitive", type: "string", required: false },
}, },
{ {
key: "domain_min_altitude", key: "domain_min_altitude",
required: false,
schema: { kind: "primitive", type: "number", required: false }, schema: { kind: "primitive", type: "number", required: false },
}, },
{ {
key: "min_rating", key: "min_rating",
required: false,
schema: { kind: "primitive", type: "number", required: false }, schema: { kind: "primitive", type: "number", required: false },
}, },
], ],
@ -121,6 +133,7 @@ const poiConstraintSchema: SchemaObjectNode = {
const visionWeightFields: SchemaObjectNode["fields"] = [ const visionWeightFields: SchemaObjectNode["fields"] = [
{ {
key: "weight", key: "weight",
required: false,
schema: { schema: {
kind: "primitive", kind: "primitive",
type: "number", type: "number",
@ -129,6 +142,7 @@ const visionWeightFields: SchemaObjectNode["fields"] = [
}, },
{ {
key: "missing_penalty", key: "missing_penalty",
required: false,
schema: { schema: {
kind: "primitive", kind: "primitive",
type: "number", type: "number",
@ -143,6 +157,7 @@ export const profileSchema: SchemaDefinition = {
fields: [ fields: [
{ {
key: "transaction_type", key: "transaction_type",
required: false,
schema: { schema: {
kind: "enum", kind: "enum",
options: transactionOptions, options: transactionOptions,
@ -153,34 +168,42 @@ export const profileSchema: SchemaDefinition = {
}, },
{ {
key: "min_price", key: "min_price",
required: false,
schema: { kind: "primitive", type: "number", required: false }, schema: { kind: "primitive", type: "number", required: false },
}, },
{ {
key: "max_price", key: "max_price",
required: false,
schema: { kind: "primitive", type: "number", required: false }, schema: { kind: "primitive", type: "number", required: false },
}, },
{ {
key: "min_size", key: "min_size",
required: false,
schema: { kind: "primitive", type: "number", required: false }, schema: { kind: "primitive", type: "number", required: false },
}, },
{ {
key: "max_size", key: "max_size",
required: false,
schema: { kind: "primitive", type: "number", required: false }, schema: { kind: "primitive", type: "number", required: false },
}, },
{ {
key: "min_bedrooms", key: "min_bedrooms",
required: false,
schema: { kind: "primitive", type: "number", required: false }, schema: { kind: "primitive", type: "number", required: false },
}, },
{ {
key: "max_bedrooms", key: "max_bedrooms",
required: false,
schema: { kind: "primitive", type: "number", required: false }, schema: { kind: "primitive", type: "number", required: false },
}, },
{ {
key: "min_bathrooms", key: "min_bathrooms",
required: false,
schema: { kind: "primitive", type: "number", required: false }, schema: { kind: "primitive", type: "number", required: false },
}, },
{ {
key: "property_type", key: "property_type",
required: false,
schema: { schema: {
kind: "array", kind: "array",
required: false, required: false,
@ -190,6 +213,7 @@ export const profileSchema: SchemaDefinition = {
}, },
{ {
key: "dpe_classes", key: "dpe_classes",
required: false,
schema: { schema: {
kind: "enum", kind: "enum",
options: dpeClassOptions, options: dpeClassOptions,
@ -198,12 +222,25 @@ export const profileSchema: SchemaDefinition = {
description: "Accepted energy classes.", 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", key: "city",
required: false,
schema: { kind: "primitive", type: "string", required: false }, schema: { kind: "primitive", type: "string", required: false },
}, },
{ {
key: "cities", key: "cities",
required: false,
schema: { schema: {
kind: "array", kind: "array",
required: false, required: false,
@ -212,22 +249,27 @@ export const profileSchema: SchemaDefinition = {
}, },
{ {
key: "postal_code", key: "postal_code",
required: false,
schema: { kind: "primitive", type: "string", required: false }, schema: { kind: "primitive", type: "string", required: false },
}, },
{ {
key: "code_insee", key: "code_insee",
required: false,
schema: { kind: "primitive", type: "string", required: false }, schema: { kind: "primitive", type: "string", required: false },
}, },
{ {
key: "target_yield", key: "target_yield",
required: false,
schema: { kind: "primitive", type: "number", required: false }, schema: { kind: "primitive", type: "number", required: false },
}, },
{ {
key: "require_address", key: "require_address",
required: false,
schema: { kind: "primitive", type: "boolean", required: false }, schema: { kind: "primitive", type: "boolean", required: false },
}, },
{ {
key: "characteristics", key: "characteristics",
required: false,
schema: { schema: {
kind: "array", kind: "array",
required: false, required: false,
@ -237,6 +279,7 @@ export const profileSchema: SchemaDefinition = {
}, },
{ {
key: "poi_proximity", key: "poi_proximity",
required: false,
schema: { schema: {
kind: "array", kind: "array",
required: false, required: false,
@ -246,34 +289,41 @@ export const profileSchema: SchemaDefinition = {
}, },
{ {
key: "vision_requirements", key: "vision_requirements",
required: false,
schema: { schema: {
kind: "object", kind: "object",
required: false, required: false,
fields: [ fields: [
{ {
key: "description", key: "description",
required: false,
schema: { kind: "primitive", type: "string", required: false }, schema: { kind: "primitive", type: "string", required: false },
}, },
{ {
key: "kitchen", key: "kitchen",
required: false,
schema: { schema: {
kind: "object", kind: "object",
required: false, required: false,
fields: [ fields: [
{ {
key: "description", key: "description",
required: false,
schema: { kind: "primitive", type: "string", required: false }, schema: { kind: "primitive", type: "string", required: false },
}, },
{ {
key: "size", key: "size",
required: false,
schema: { kind: "enum", options: visionSizeOptions, required: false }, schema: { kind: "enum", options: visionSizeOptions, required: false },
}, },
{ {
key: "open", key: "open",
required: false,
schema: { kind: "primitive", type: "boolean", required: false }, schema: { kind: "primitive", type: "boolean", required: false },
}, },
{ {
key: "appliances", key: "appliances",
required: false,
schema: { kind: "primitive", type: "string", required: false }, schema: { kind: "primitive", type: "string", required: false },
}, },
...visionWeightFields, ...visionWeightFields,
@ -282,20 +332,24 @@ export const profileSchema: SchemaDefinition = {
}, },
{ {
key: "bathroom", key: "bathroom",
required: false,
schema: { schema: {
kind: "object", kind: "object",
required: false, required: false,
fields: [ fields: [
{ {
key: "description", key: "description",
required: false,
schema: { kind: "primitive", type: "string", required: false }, schema: { kind: "primitive", type: "string", required: false },
}, },
{ {
key: "size", key: "size",
required: false,
schema: { kind: "enum", options: visionSizeOptions, required: false }, schema: { kind: "enum", options: visionSizeOptions, required: false },
}, },
{ {
key: "appliances", key: "appliances",
required: false,
schema: { kind: "primitive", type: "string", required: false }, schema: { kind: "primitive", type: "string", required: false },
}, },
...visionWeightFields, ...visionWeightFields,
@ -304,16 +358,19 @@ export const profileSchema: SchemaDefinition = {
}, },
{ {
key: "living_room", key: "living_room",
required: false,
schema: { schema: {
kind: "object", kind: "object",
required: false, required: false,
fields: [ fields: [
{ {
key: "description", key: "description",
required: false,
schema: { kind: "primitive", type: "string", required: false }, schema: { kind: "primitive", type: "string", required: false },
}, },
{ {
key: "size", key: "size",
required: false,
schema: { kind: "enum", options: visionSizeOptions, required: false }, schema: { kind: "enum", options: visionSizeOptions, required: false },
}, },
...visionWeightFields, ...visionWeightFields,
@ -322,16 +379,19 @@ export const profileSchema: SchemaDefinition = {
}, },
{ {
key: "bedrooms", key: "bedrooms",
required: false,
schema: { schema: {
kind: "object", kind: "object",
required: false, required: false,
fields: [ fields: [
{ {
key: "description", key: "description",
required: false,
schema: { kind: "primitive", type: "string", required: false }, schema: { kind: "primitive", type: "string", required: false },
}, },
{ {
key: "min_count", key: "min_count",
required: false,
schema: { kind: "primitive", type: "number", required: false }, schema: { kind: "primitive", type: "number", required: false },
}, },
...visionWeightFields, ...visionWeightFields,
@ -340,20 +400,24 @@ export const profileSchema: SchemaDefinition = {
}, },
{ {
key: "outdoor", key: "outdoor",
required: false,
schema: { schema: {
kind: "object", kind: "object",
required: false, required: false,
fields: [ fields: [
{ {
key: "description", key: "description",
required: false,
schema: { kind: "primitive", type: "string", required: false }, schema: { kind: "primitive", type: "string", required: false },
}, },
{ {
key: "required", key: "required",
required: false,
schema: { kind: "primitive", type: "boolean", required: false }, schema: { kind: "primitive", type: "boolean", required: false },
}, },
{ {
key: "types", key: "types",
required: false,
schema: { schema: {
kind: "array", kind: "array",
required: false, required: false,
@ -366,6 +430,7 @@ export const profileSchema: SchemaDefinition = {
}, },
{ {
key: "note", key: "note",
required: false,
schema: { kind: "primitive", type: "string", required: false }, schema: { kind: "primitive", type: "string", required: false },
}, },
], ],

View File

@ -65,7 +65,16 @@ function buildArrayDefault(schema: SchemaArrayNode) {
function buildObjectDefault(schema: SchemaObjectNode) { function buildObjectDefault(schema: SchemaObjectNode) {
return schema.fields.reduce<Record<string, unknown>>((accumulator, field) => { 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; return accumulator;
}, {}); }, {});
} }
@ -129,8 +138,24 @@ function mergeObjectDefaults(schema: SchemaObjectNode, value: unknown) {
const record = value as Record<string, unknown>; const record = value as Record<string, unknown>;
return schema.fields.reduce<Record<string, unknown>>((accumulator, field) => { 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; return accumulator;
}, {}); }, {});
} }