count thing added
This commit is contained in:
parent
94f5d37f7a
commit
ef417be648
@ -10,6 +10,11 @@
|
|||||||
"allow_origin": "*",
|
"allow_origin": "*",
|
||||||
"allow_credentials": true,
|
"allow_credentials": true,
|
||||||
"notes": "Server responds to OPTIONS preflight without authentication and accepts credentialed requests from any origin."
|
"notes": "Server responds to OPTIONS preflight without authentication and accepts credentialed requests from any origin."
|
||||||
|
},
|
||||||
|
"environment": {
|
||||||
|
"API_TOKEN": "Bearer token expected on every request.",
|
||||||
|
"FLUXIMMO_API_KEY": "Credential forwarded to Fluximmo analytics count endpoint.",
|
||||||
|
"FLUXIMMO_COUNT_URL": "Optional override for Fluximmo count API base URL (defaults to https://analytics.fluximmo.io/properties/count)."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"authentication": {
|
"authentication": {
|
||||||
@ -37,7 +42,7 @@
|
|||||||
"Profile": {
|
"Profile": {
|
||||||
"description": "Investment profile descriptor persisted in users_investmentprofile.",
|
"description": "Investment profile descriptor persisted in users_investmentprofile.",
|
||||||
"fields": [
|
"fields": [
|
||||||
{"name": "profile_id", "type": "uuid", "nullable": false, "read_only": false, "description": "Primary key. Optional on create; autogenerated when omitted."},
|
{"name": "profile_id", "type": "uuid", "nullable": false, "read_only": true, "description": "Primary key generated server-side."},
|
||||||
{"name": "name", "type": "string", "nullable": false, "read_only": false, "description": "Human-readable profile label."},
|
{"name": "name", "type": "string", "nullable": false, "read_only": false, "description": "Human-readable profile label."},
|
||||||
{"name": "description", "type": "string", "nullable": true, "read_only": false, "description": "Optional free-form description. Empty string preserved."},
|
{"name": "description", "type": "string", "nullable": true, "read_only": false, "description": "Optional free-form description. Empty string preserved."},
|
||||||
{"name": "criteria", "type": "json", "nullable": false, "read_only": false, "description": "Arbitrary JSON object/array describing selection rules."},
|
{"name": "criteria", "type": "json", "nullable": false, "read_only": false, "description": "Arbitrary JSON object/array describing selection rules."},
|
||||||
@ -64,7 +69,7 @@
|
|||||||
"Scraper": {
|
"Scraper": {
|
||||||
"description": "Scraper configuration persisted in scraper table.",
|
"description": "Scraper configuration persisted in scraper table.",
|
||||||
"fields": [
|
"fields": [
|
||||||
{"name": "id", "type": "string", "nullable": false, "read_only": false, "description": "Primary key identifier."},
|
{"name": "id", "type": "uuid", "nullable": false, "read_only": true, "description": "Primary key generated server-side."},
|
||||||
{"name": "params", "type": "string", "nullable": true, "read_only": false, "description": "Serialized scraper parameters."},
|
{"name": "params", "type": "string", "nullable": true, "read_only": false, "description": "Serialized scraper parameters."},
|
||||||
{"name": "last_seen_days", "type": "integer", "nullable": true, "read_only": false, "description": "Number of days since listings were last seen."},
|
{"name": "last_seen_days", "type": "integer", "nullable": true, "read_only": false, "description": "Number of days since listings were last seen."},
|
||||||
{"name": "first_seen_days", "type": "integer", "nullable": true, "read_only": false, "description": "Number of days since listings were first seen."},
|
{"name": "first_seen_days", "type": "integer", "nullable": true, "read_only": false, "description": "Number of days since listings were first seen."},
|
||||||
@ -133,7 +138,6 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"required_fields": ["name", "criteria"],
|
"required_fields": ["name", "criteria"],
|
||||||
"fields": {
|
"fields": {
|
||||||
"profile_id": {"type": "uuid", "nullable": false, "description": "Optional explicit UUID. Auto-generated when omitted."},
|
|
||||||
"name": {"type": "string", "nullable": false, "description": "Profile name."},
|
"name": {"type": "string", "nullable": false, "description": "Profile name."},
|
||||||
"description": {"type": "string", "nullable": true, "description": "Optional description. Use null to clear."},
|
"description": {"type": "string", "nullable": true, "description": "Optional description. Use null to clear."},
|
||||||
"criteria": {"type": "json", "nullable": false, "description": "Required JSON criteria payload."},
|
"criteria": {"type": "json", "nullable": false, "description": "Required JSON criteria payload."},
|
||||||
@ -394,9 +398,8 @@
|
|||||||
},
|
},
|
||||||
"body": {
|
"body": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required_fields": ["id"],
|
"required_fields": [],
|
||||||
"fields": {
|
"fields": {
|
||||||
"id": {"type": "string", "nullable": false, "description": "Scraper primary key."},
|
|
||||||
"params": {"type": "string", "nullable": true, "description": "Serialized parameter payload."},
|
"params": {"type": "string", "nullable": true, "description": "Serialized parameter payload."},
|
||||||
"frequency": {"type": "string", "nullable": true, "description": "Execution cadence descriptor."},
|
"frequency": {"type": "string", "nullable": true, "description": "Execution cadence descriptor."},
|
||||||
"task_name": {"type": "string", "nullable": true, "description": "Linked task name."},
|
"task_name": {"type": "string", "nullable": true, "description": "Linked task name."},
|
||||||
@ -481,6 +484,34 @@
|
|||||||
"404": {"description": "Scraper not found."},
|
"404": {"description": "Scraper not found."},
|
||||||
"503": {"description": "Database unavailable."}
|
"503": {"description": "Database unavailable."}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"operation_id": "countScraperProperties",
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/scrapers/count",
|
||||||
|
"authenticated": true,
|
||||||
|
"description": "Return the Fluximmo property count derived from a scraper configuration.",
|
||||||
|
"request": {
|
||||||
|
"headers": {
|
||||||
|
"Content-Type": {"type": "string", "required": true, "example": "application/json"},
|
||||||
|
"Authorization": {"type": "string", "required": true, "description": "Bearer <API_TOKEN>"}
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"type": "object",
|
||||||
|
"required_fields": ["params"],
|
||||||
|
"fields": {
|
||||||
|
"params": {"type": "object", "nullable": false, "description": "Base Fluximmo search filters. Accepts a JSON object or string-encoded JSON."},
|
||||||
|
"first_seen_days": {"type": "integer", "nullable": true, "description": "Days before now for firstSeenAt.min."},
|
||||||
|
"last_seen_days": {"type": "integer", "nullable": true, "description": "Days before now for lastSeenAt.max."}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {"description": "Count retrieved.", "body": {"type": "object", "fields": {"count": {"type": "integer", "nullable": false}}}},
|
||||||
|
"400": {"description": "Validation failure."},
|
||||||
|
"401": {"description": "Missing or invalid bearer token."},
|
||||||
|
"502": {"description": "Fluximmo upstream error."}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
136
backend/app.py
136
backend/app.py
@ -3,7 +3,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from datetime import datetime, timezone
|
import json
|
||||||
|
from copy import deepcopy
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any, Dict, Iterable, List, Mapping, MutableMapping
|
from typing import Any, Dict, Iterable, List, Mapping, MutableMapping
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
@ -11,13 +13,20 @@ 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 psycopg.types.json import Json
|
||||||
|
from deepmerge import always_merger
|
||||||
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
|
||||||
|
import requests
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
API_TOKEN = os.getenv("API_TOKEN")
|
API_TOKEN = os.getenv("API_TOKEN")
|
||||||
|
FLUXIMMO_API_KEY = os.getenv("FLUXIMMO_API_KEY")
|
||||||
|
FLUXIMMO_COUNT_URL = os.getenv(
|
||||||
|
"FLUXIMMO_COUNT_URL",
|
||||||
|
"https://analytics.fluximmo.io/properties/count",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
REQUIRED_DB_SETTINGS = {
|
REQUIRED_DB_SETTINGS = {
|
||||||
@ -57,6 +66,10 @@ if not API_TOKEN:
|
|||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"API_TOKEN missing from environment. Did you configure the .env file?"
|
"API_TOKEN missing from environment. Did you configure the .env file?"
|
||||||
)
|
)
|
||||||
|
if not FLUXIMMO_API_KEY:
|
||||||
|
raise RuntimeError(
|
||||||
|
"FLUXIMMO_API_KEY missing from environment. Did you configure the .env file?"
|
||||||
|
)
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
CORS(app, resources={r"/*": {"origins": "*"}}, supports_credentials=True)
|
CORS(app, resources={r"/*": {"origins": "*"}}, supports_credentials=True)
|
||||||
@ -177,6 +190,15 @@ def _parse_int(value: Any, field_name: str) -> int:
|
|||||||
abort(400, description=f"Field '{field_name}' must be an integer")
|
abort(400, description=f"Field '{field_name}' must be an integer")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_optional_int(value: Any, field_name: str) -> int | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return _parse_int(value, field_name)
|
||||||
|
except Exception:
|
||||||
|
abort(400, description=f"Field '{field_name}' must be an integer or null")
|
||||||
|
|
||||||
|
|
||||||
def _parse_uuid(value: Any, field_name: str) -> UUID:
|
def _parse_uuid(value: Any, field_name: str) -> UUID:
|
||||||
if isinstance(value, UUID):
|
if isinstance(value, UUID):
|
||||||
return value
|
return value
|
||||||
@ -188,6 +210,60 @@ def _parse_uuid(value: Any, field_name: str) -> UUID:
|
|||||||
abort(400, description=f"Field '{field_name}' must be a valid UUID")
|
abort(400, description=f"Field '{field_name}' must be a valid UUID")
|
||||||
|
|
||||||
|
|
||||||
|
def _load_scraper_params(value: Any) -> Dict[str, Any]:
|
||||||
|
if value is None:
|
||||||
|
return {}
|
||||||
|
if isinstance(value, Json):
|
||||||
|
value = value.data
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return deepcopy(value)
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
parsed = json.loads(value)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
abort(400, description="Field 'params' must contain valid JSON")
|
||||||
|
if isinstance(parsed, dict):
|
||||||
|
return parsed
|
||||||
|
abort(400, description="Field 'params' must decode to an object")
|
||||||
|
abort(400, description="Field 'params' must be a JSON object or string")
|
||||||
|
|
||||||
|
|
||||||
|
def build_scraper_params(
|
||||||
|
params: Dict[str, Any],
|
||||||
|
first_seen_days: int | None,
|
||||||
|
last_seen_days: int | None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
dynamic_params: Dict[str, Any] = {"meta": {}}
|
||||||
|
|
||||||
|
if first_seen_days is not None:
|
||||||
|
dynamic_params["meta"]["firstSeenAt"] = {
|
||||||
|
"min": (
|
||||||
|
(now - timedelta(days=first_seen_days))
|
||||||
|
.replace(microsecond=0)
|
||||||
|
.isoformat()
|
||||||
|
.replace("+00:00", "Z")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if last_seen_days is not None:
|
||||||
|
dynamic_params["meta"]["lastSeenAt"] = {
|
||||||
|
"max": (
|
||||||
|
(now - timedelta(days=last_seen_days))
|
||||||
|
.replace(microsecond=0)
|
||||||
|
.isoformat()
|
||||||
|
.replace("+00:00", "Z")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if not dynamic_params["meta"]:
|
||||||
|
dynamic_params.pop("meta")
|
||||||
|
|
||||||
|
base = deepcopy(params)
|
||||||
|
merged = always_merger.merge(base, dynamic_params)
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
def _require_bearer_token(header_value: str | None) -> str:
|
def _require_bearer_token(header_value: str | None) -> str:
|
||||||
if not header_value:
|
if not header_value:
|
||||||
abort(401, description="Missing bearer token")
|
abort(401, description="Missing bearer token")
|
||||||
@ -426,10 +502,7 @@ def get_profile(profile_id: str):
|
|||||||
def create_profile():
|
def create_profile():
|
||||||
payload = _get_json_body()
|
payload = _get_json_body()
|
||||||
|
|
||||||
profile_identifier = payload.get("profile_id")
|
profile_uuid = uuid4()
|
||||||
profile_uuid = (
|
|
||||||
_parse_uuid(profile_identifier, "profile_id") if profile_identifier else uuid4()
|
|
||||||
)
|
|
||||||
|
|
||||||
name = _parse_string(payload.get("name"), "name")
|
name = _parse_string(payload.get("name"), "name")
|
||||||
description_value = payload.get("description")
|
description_value = payload.get("description")
|
||||||
@ -666,7 +739,7 @@ def get_scraper(scraper_id: str):
|
|||||||
def create_scraper():
|
def create_scraper():
|
||||||
payload = _get_json_body()
|
payload = _get_json_body()
|
||||||
|
|
||||||
scraper_id = _parse_string(payload.get("id"), "id")
|
scraper_id = str(uuid4())
|
||||||
|
|
||||||
data: Dict[str, Any] = {"id": scraper_id}
|
data: Dict[str, Any] = {"id": scraper_id}
|
||||||
|
|
||||||
@ -735,5 +808,56 @@ def delete_scraper(scraper_id: str):
|
|||||||
return "", 204
|
return "", 204
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/scrapers/count")
|
||||||
|
def count_scraper_properties():
|
||||||
|
payload = _get_json_body()
|
||||||
|
|
||||||
|
base_params = _load_scraper_params(payload.get("params"))
|
||||||
|
first_seen_days = _parse_optional_int(
|
||||||
|
payload.get("first_seen_days"), "first_seen_days"
|
||||||
|
)
|
||||||
|
last_seen_days = _parse_optional_int(
|
||||||
|
payload.get("last_seen_days"), "last_seen_days"
|
||||||
|
)
|
||||||
|
|
||||||
|
query_filters = build_scraper_params(base_params, first_seen_days, last_seen_days)
|
||||||
|
flux_payload = {"query": query_filters}
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"x-api-key": FLUXIMMO_API_KEY,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
FLUXIMMO_COUNT_URL, json=flux_payload, headers=headers, timeout=15
|
||||||
|
)
|
||||||
|
except requests.RequestException:
|
||||||
|
abort(502, description="Fluximmo request failed")
|
||||||
|
|
||||||
|
if response.status_code >= 400:
|
||||||
|
try:
|
||||||
|
detail = response.json()
|
||||||
|
except ValueError:
|
||||||
|
detail = response.text
|
||||||
|
abort(502, description=f"Fluximmo error: {detail}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response_data = response.json()
|
||||||
|
except ValueError:
|
||||||
|
abort(502, description="Fluximmo response was not JSON")
|
||||||
|
|
||||||
|
count = response_data.get("data", {}).get("count")
|
||||||
|
if count is None:
|
||||||
|
abort(502, description="Fluximmo response missing count")
|
||||||
|
|
||||||
|
try:
|
||||||
|
count_value = int(count)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
abort(502, description="Fluximmo count is not an integer")
|
||||||
|
|
||||||
|
return jsonify({"count": count_value})
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app.run(host="0.0.0.0", port=int(os.getenv("PORT", "8000")), debug=False)
|
app.run(host="0.0.0.0", port=int(os.getenv("PORT", "8000")), debug=False)
|
||||||
|
|||||||
@ -2,3 +2,5 @@ Flask>=3.0.0,<4.0.0
|
|||||||
python-dotenv>=1.0.0,<2.0.0
|
python-dotenv>=1.0.0,<2.0.0
|
||||||
psycopg[binary]>=3.1,<4.0
|
psycopg[binary]>=3.1,<4.0
|
||||||
Flask-Cors>=4.0.0,<5.0.0
|
Flask-Cors>=4.0.0,<5.0.0
|
||||||
|
requests>=2.31.0,<3.0.0
|
||||||
|
deepmerge>=1.1.0,<2.0.0
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
@ -157,6 +158,8 @@
|
|||||||
|
|
||||||
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="],
|
||||||
|
|
||||||
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="],
|
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="],
|
||||||
|
|
||||||
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
|
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
|||||||
141
frontend/src/components/ui/dialog.tsx
Normal file
141
frontend/src/components/ui/dialog.tsx
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Dialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPortal data-slot="dialog-portal">
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
data-slot="dialog-close"
|
||||||
|
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn("text-lg leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
}
|
||||||
@ -30,7 +30,6 @@ export interface Profile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface CreateProfilePayload {
|
interface CreateProfilePayload {
|
||||||
profile_id?: string;
|
|
||||||
name: string;
|
name: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
criteria: JsonObject;
|
criteria: JsonObject;
|
||||||
@ -39,7 +38,6 @@ interface CreateProfilePayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ProfileFormValues {
|
interface ProfileFormValues {
|
||||||
profile_id: string;
|
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
criteria: JsonObject;
|
criteria: JsonObject;
|
||||||
@ -78,12 +76,6 @@ function transformPayload(values: ProfileFormValues): CreateProfilePayload {
|
|||||||
is_active: values.is_active,
|
is_active: values.is_active,
|
||||||
};
|
};
|
||||||
|
|
||||||
const profileId = values.profile_id.trim();
|
|
||||||
|
|
||||||
if (profileId) {
|
|
||||||
payload.profile_id = profileId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const description = values.description.trim();
|
const description = values.description.trim();
|
||||||
|
|
||||||
if (description) {
|
if (description) {
|
||||||
@ -109,7 +101,6 @@ const profileSchema = getSchema("profile");
|
|||||||
|
|
||||||
function createDefaultValues(): ProfileFormValues {
|
function createDefaultValues(): ProfileFormValues {
|
||||||
return {
|
return {
|
||||||
profile_id: "",
|
|
||||||
name: "",
|
name: "",
|
||||||
description: "",
|
description: "",
|
||||||
criteria: buildDefaultValue(profileSchema) as JsonObject,
|
criteria: buildDefaultValue(profileSchema) as JsonObject,
|
||||||
@ -228,12 +219,7 @@ export function ProfilesTab() {
|
|||||||
const updateProfileMutation = useMutation({
|
const updateProfileMutation = useMutation({
|
||||||
mutationFn: ({ id, data }: { id: string; data: ProfileFormValues }) => {
|
mutationFn: ({ id, data }: { id: string; data: ProfileFormValues }) => {
|
||||||
const payload = transformPayload(data);
|
const payload = transformPayload(data);
|
||||||
const { profile_id: _ignored, ...body } = payload;
|
return api.put<CreateProfilePayload, Profile>(`/profiles/${id}`, payload);
|
||||||
|
|
||||||
return api.put<Omit<CreateProfilePayload, "profile_id">, Profile>(
|
|
||||||
`/profiles/${id}`,
|
|
||||||
body
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["profiles"] });
|
queryClient.invalidateQueries({ queryKey: ["profiles"] });
|
||||||
@ -285,18 +271,6 @@ export function ProfilesTab() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<form className="space-y-6" onSubmit={onSubmit}>
|
<form className="space-y-6" onSubmit={onSubmit}>
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="text-sm font-medium" htmlFor="profile_id">
|
|
||||||
Identifiant (optionnel)
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
id="profile_id"
|
|
||||||
placeholder="UUID personnalise"
|
|
||||||
disabled={Boolean(editingProfileId)}
|
|
||||||
{...form.register("profile_id")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-sm font-medium" htmlFor="name">
|
<label className="text-sm font-medium" htmlFor="name">
|
||||||
Nom
|
Nom
|
||||||
@ -444,7 +418,6 @@ export function ProfilesTab() {
|
|||||||
<table className="min-w-full table-fixed border-collapse text-sm">
|
<table className="min-w-full table-fixed border-collapse text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-left">
|
<tr className="text-left">
|
||||||
<th className="border-b px-3 py-2 font-semibold">ID</th>
|
|
||||||
<th className="border-b px-3 py-2 font-semibold">Nom</th>
|
<th className="border-b px-3 py-2 font-semibold">Nom</th>
|
||||||
<th className="border-b px-3 py-2 font-semibold">Description</th>
|
<th className="border-b px-3 py-2 font-semibold">Description</th>
|
||||||
<th className="border-b px-3 py-2 font-semibold">Criteres</th>
|
<th className="border-b px-3 py-2 font-semibold">Criteres</th>
|
||||||
@ -456,10 +429,7 @@ export function ProfilesTab() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{profiles.map((profile) => (
|
{profiles.map((profile) => (
|
||||||
<tr key={profile.profile_id} className="align-top">
|
<tr key={profile.profile_id} className="align-top">
|
||||||
<td className="border-b px-3 py-2 font-mono text-xs">
|
<td className="border-b px-3 py-2 font-medium">{profile.name}</td>
|
||||||
{profile.profile_id}
|
|
||||||
</td>
|
|
||||||
<td className="border-b px-3 py-2">{profile.name}</td>
|
|
||||||
<td className="border-b px-3 py-2 text-muted-foreground">
|
<td className="border-b px-3 py-2 text-muted-foreground">
|
||||||
{profile.description ?? "-"}
|
{profile.description ?? "-"}
|
||||||
</td>
|
</td>
|
||||||
@ -496,20 +466,19 @@ export function ProfilesTab() {
|
|||||||
const createdAtDate = profile.created_at
|
const createdAtDate = profile.created_at
|
||||||
? new Date(profile.created_at)
|
? new Date(profile.created_at)
|
||||||
: null;
|
: null;
|
||||||
const createdAtInput =
|
const createdAtInput =
|
||||||
createdAtDate && !Number.isNaN(createdAtDate.getTime())
|
createdAtDate && !Number.isNaN(createdAtDate.getTime())
|
||||||
? createdAtDate.toISOString().slice(0, 16)
|
? createdAtDate.toISOString().slice(0, 16)
|
||||||
: "";
|
: "";
|
||||||
form.reset({
|
form.reset({
|
||||||
profile_id: profile.profile_id,
|
name: profile.name,
|
||||||
name: profile.name,
|
description: profile.description ?? "",
|
||||||
description: profile.description ?? "",
|
criteria: normalizeProfileCriteriaForForm(
|
||||||
criteria: normalizeProfileCriteriaForForm(
|
profile.criteria
|
||||||
profile.criteria
|
),
|
||||||
),
|
created_at: createdAtInput,
|
||||||
created_at: createdAtInput,
|
is_active: profile.is_active,
|
||||||
is_active: profile.is_active,
|
});
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Modifier
|
Modifier
|
||||||
|
|||||||
@ -16,6 +16,15 @@ import {
|
|||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { SchemaAwareJsonField } from "@/components/schema-builder";
|
import { SchemaAwareJsonField } from "@/components/schema-builder";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
import { getSchema } from "@/schemas/loader";
|
import { getSchema } from "@/schemas/loader";
|
||||||
import { buildDefaultValue } from "@/schemas/utils";
|
import { buildDefaultValue } from "@/schemas/utils";
|
||||||
|
|
||||||
@ -35,7 +44,6 @@ export interface Scraper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ScraperFormValues {
|
interface ScraperFormValues {
|
||||||
id: string;
|
|
||||||
params: JsonObject;
|
params: JsonObject;
|
||||||
frequency: string;
|
frequency: string;
|
||||||
task_name: string;
|
task_name: string;
|
||||||
@ -50,7 +58,7 @@ interface ScraperFormValues {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface CreateScraperPayload {
|
interface CreateScraperPayload {
|
||||||
id: string;
|
id?: string;
|
||||||
params?: string | null;
|
params?: string | null;
|
||||||
frequency?: string | null;
|
frequency?: string | null;
|
||||||
task_name?: string | null;
|
task_name?: string | null;
|
||||||
@ -105,9 +113,7 @@ function parseInteger(value: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function transformPayload(values: ScraperFormValues): CreateScraperPayload {
|
function transformPayload(values: ScraperFormValues): CreateScraperPayload {
|
||||||
const payload: CreateScraperPayload = {
|
const payload: CreateScraperPayload = {};
|
||||||
id: values.id.trim(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const paramsObject = values.params ?? {};
|
const paramsObject = values.params ?? {};
|
||||||
|
|
||||||
@ -159,7 +165,6 @@ function transformPayload(values: ScraperFormValues): CreateScraperPayload {
|
|||||||
|
|
||||||
function createDefaultScraperValues(): ScraperFormValues {
|
function createDefaultScraperValues(): ScraperFormValues {
|
||||||
return {
|
return {
|
||||||
id: "",
|
|
||||||
params: buildDefaultValue(scraperSchema) as JsonObject,
|
params: buildDefaultValue(scraperSchema) as JsonObject,
|
||||||
frequency: "",
|
frequency: "",
|
||||||
task_name: "",
|
task_name: "",
|
||||||
@ -190,6 +195,7 @@ export function ScrapersTab() {
|
|||||||
const [paramsValid, setParamsValid] = useState(true);
|
const [paramsValid, setParamsValid] = useState(true);
|
||||||
const [editingScraperId, setEditingScraperId] = useState<string | null>(null);
|
const [editingScraperId, setEditingScraperId] = useState<string | null>(null);
|
||||||
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
||||||
|
const [isCountDialogOpen, setIsCountDialogOpen] = useState(false);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const initialValues = useMemo(() => createDefaultScraperValues(), []);
|
const initialValues = useMemo(() => createDefaultScraperValues(), []);
|
||||||
const form = useForm<ScraperFormValues>({
|
const form = useForm<ScraperFormValues>({
|
||||||
@ -217,9 +223,7 @@ export function ScrapersTab() {
|
|||||||
const updateScraperMutation = useMutation({
|
const updateScraperMutation = useMutation({
|
||||||
mutationFn: ({ id, data }: { id: string; data: ScraperFormValues }) => {
|
mutationFn: ({ id, data }: { id: string; data: ScraperFormValues }) => {
|
||||||
const payload = transformPayload(data);
|
const payload = transformPayload(data);
|
||||||
const { id: _ignored, ...body } = payload;
|
return api.put<CreateScraperPayload, Scraper>(`/scrapers/${id}`, payload);
|
||||||
|
|
||||||
return api.put<Omit<CreateScraperPayload, "id">, Scraper>(`/scrapers/${id}`, body);
|
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["scrapers"] });
|
queryClient.invalidateQueries({ queryKey: ["scrapers"] });
|
||||||
@ -243,6 +247,16 @@ export function ScrapersTab() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type ScraperCountResponse = { count: number };
|
||||||
|
|
||||||
|
const countScraperMutation = useMutation({
|
||||||
|
mutationFn: (values: ScraperFormValues) =>
|
||||||
|
api.post<CreateScraperPayload, ScraperCountResponse>(
|
||||||
|
"/scrapers/count",
|
||||||
|
transformPayload(values)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
const onSubmit = form.handleSubmit(async (values) => {
|
const onSubmit = form.handleSubmit(async (values) => {
|
||||||
setStatusMessage(null);
|
setStatusMessage(null);
|
||||||
if (editingScraperId) {
|
if (editingScraperId) {
|
||||||
@ -257,6 +271,19 @@ export function ScrapersTab() {
|
|||||||
(updateScraperMutation.isError && getErrorMessage(updateScraperMutation.error)) ||
|
(updateScraperMutation.isError && getErrorMessage(updateScraperMutation.error)) ||
|
||||||
null;
|
null;
|
||||||
|
|
||||||
|
const handleOpenCountDialog = async () => {
|
||||||
|
setStatusMessage(null);
|
||||||
|
const isFormValid = await form.trigger();
|
||||||
|
|
||||||
|
if (!isFormValid || !paramsValid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
countScraperMutation.reset();
|
||||||
|
setIsCountDialogOpen(true);
|
||||||
|
countScraperMutation.mutate(form.getValues());
|
||||||
|
};
|
||||||
|
|
||||||
const scrapers = useMemo(() => scrapersQuery.data ?? [], [scrapersQuery.data]);
|
const scrapers = useMemo(() => scrapersQuery.data ?? [], [scrapersQuery.data]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -265,28 +292,21 @@ export function ScrapersTab() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Creer un scraper</CardTitle>
|
<CardTitle>Creer un scraper</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Definissez l'identifiant du scraper, la configuration et les parametres d'execution.
|
Definissez la configuration et les parametres d'execution du scraper.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form className="space-y-6" onSubmit={onSubmit}>
|
<form className="space-y-6" onSubmit={onSubmit}>
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-1 md:col-span-2">
|
<div className="space-y-1 md:col-span-2">
|
||||||
<label className="text-sm font-medium" htmlFor="scraper_id">
|
<label className="text-sm font-medium" htmlFor="task_name">
|
||||||
Identifiant
|
Nom du scraper
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
id="scraper_id"
|
id="task_name"
|
||||||
placeholder="ex: scraper_immobilier"
|
placeholder="Nom interne"
|
||||||
aria-invalid={Boolean(form.formState.errors.id)}
|
{...form.register("task_name")}
|
||||||
disabled={Boolean(editingScraperId)}
|
|
||||||
{...form.register("id", { required: "L'identifiant est obligatoire." })}
|
|
||||||
/>
|
/>
|
||||||
{form.formState.errors.id ? (
|
|
||||||
<p className="text-destructive text-xs">
|
|
||||||
{form.formState.errors.id.message}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@ -300,17 +320,6 @@ export function ScrapersTab() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="text-sm font-medium" htmlFor="task_name">
|
|
||||||
Nom de tache
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
id="task_name"
|
|
||||||
placeholder="Nom interne"
|
|
||||||
{...form.register("task_name")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-sm font-medium" htmlFor="property_types">
|
<label className="text-sm font-medium" htmlFor="property_types">
|
||||||
Types de biens
|
Types de biens
|
||||||
@ -438,6 +447,15 @@ export function ScrapersTab() {
|
|||||||
<p className="text-destructive text-sm">Corrigez les erreurs du schema avant l'envoi.</p>
|
<p className="text-destructive text-sm">Corrigez les erreurs du schema avant l'envoi.</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={countScraperMutation.isPending}
|
||||||
|
onClick={handleOpenCountDialog}
|
||||||
|
>
|
||||||
|
{countScraperMutation.isPending ? "Calcul..." : "Compter les resultats"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
{editingScraperId ? (
|
{editingScraperId ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@ -477,9 +495,8 @@ export function ScrapersTab() {
|
|||||||
<table className="min-w-full table-fixed border-collapse text-sm">
|
<table className="min-w-full table-fixed border-collapse text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-left">
|
<tr className="text-left">
|
||||||
<th className="border-b px-3 py-2 font-semibold">ID</th>
|
<th className="border-b px-3 py-2 font-semibold">Nom</th>
|
||||||
<th className="border-b px-3 py-2 font-semibold">Frequence</th>
|
<th className="border-b px-3 py-2 font-semibold">Frequence</th>
|
||||||
<th className="border-b px-3 py-2 font-semibold">Tache</th>
|
|
||||||
<th className="border-b px-3 py-2 font-semibold">Parametres</th>
|
<th className="border-b px-3 py-2 font-semibold">Parametres</th>
|
||||||
<th className="border-b px-3 py-2 font-semibold">Etat</th>
|
<th className="border-b px-3 py-2 font-semibold">Etat</th>
|
||||||
<th className="border-b px-3 py-2 font-semibold">Limites</th>
|
<th className="border-b px-3 py-2 font-semibold">Limites</th>
|
||||||
@ -492,11 +509,10 @@ export function ScrapersTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={scraper.id} className="align-top">
|
<tr key={scraper.id} className="align-top">
|
||||||
<td className="border-b px-3 py-2 font-mono text-xs">{scraper.id}</td>
|
<td className="border-b px-3 py-2 font-medium">{scraper.task_name ?? "-"}</td>
|
||||||
<td className="border-b px-3 py-2 text-muted-foreground">
|
<td className="border-b px-3 py-2 text-muted-foreground">
|
||||||
{scraper.frequency ?? "-"}
|
{scraper.frequency ?? "-"}
|
||||||
</td>
|
</td>
|
||||||
<td className="border-b px-3 py-2">{scraper.task_name ?? "-"}</td>
|
|
||||||
<td className="border-b px-3 py-2">
|
<td className="border-b px-3 py-2">
|
||||||
{paramsDisplay ? (
|
{paramsDisplay ? (
|
||||||
<pre className="max-h-40 overflow-auto rounded bg-muted/50 p-2 text-xs">
|
<pre className="max-h-40 overflow-auto rounded bg-muted/50 p-2 text-xs">
|
||||||
@ -554,7 +570,6 @@ export function ScrapersTab() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
form.reset({
|
form.reset({
|
||||||
id: scraper.id,
|
|
||||||
params: parsedParams,
|
params: parsedParams,
|
||||||
frequency: scraper.frequency ?? "",
|
frequency: scraper.frequency ?? "",
|
||||||
task_name: scraper.task_name ?? "",
|
task_name: scraper.task_name ?? "",
|
||||||
@ -598,6 +613,49 @@ export function ScrapersTab() {
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={isCountDialogOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setIsCountDialogOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
countScraperMutation.reset();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Estimation du nombre d'annonces</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Calcul base sur la configuration actuelle du scraper.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="min-h-[80px]">
|
||||||
|
{countScraperMutation.isPending ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Calcul en cours...</p>
|
||||||
|
) : countScraperMutation.isError ? (
|
||||||
|
<p className="text-destructive text-sm">
|
||||||
|
{getErrorMessage(countScraperMutation.error)}
|
||||||
|
</p>
|
||||||
|
) : countScraperMutation.data ? (
|
||||||
|
<p className="text-sm">
|
||||||
|
Ce scraper correspond a
|
||||||
|
<span className="font-semibold"> {countScraperMutation.data.count} </span>
|
||||||
|
annonce(s) potentielle(s).
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Aucun calcul n'a encore ete effectue.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button variant="outline">Fermer</Button>
|
||||||
|
</DialogClose>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user