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
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)

View File

@ -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.")];
}

View File

@ -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,
});

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.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. |

View File

@ -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 },
},
],

View File

@ -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;
}, {});
}