count thing added

This commit is contained in:
Vitrixxl 2025-10-15 01:41:25 +02:00
parent 94f5d37f7a
commit ef417be648
8 changed files with 424 additions and 95 deletions

View File

@ -10,6 +10,11 @@
"allow_origin": "*",
"allow_credentials": true,
"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": {
@ -37,7 +42,7 @@
"Profile": {
"description": "Investment profile descriptor persisted in users_investmentprofile.",
"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": "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."},
@ -64,7 +69,7 @@
"Scraper": {
"description": "Scraper configuration persisted in scraper table.",
"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": "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."},
@ -133,7 +138,6 @@
"type": "object",
"required_fields": ["name", "criteria"],
"fields": {
"profile_id": {"type": "uuid", "nullable": false, "description": "Optional explicit UUID. Auto-generated when omitted."},
"name": {"type": "string", "nullable": false, "description": "Profile name."},
"description": {"type": "string", "nullable": true, "description": "Optional description. Use null to clear."},
"criteria": {"type": "json", "nullable": false, "description": "Required JSON criteria payload."},
@ -394,9 +398,8 @@
},
"body": {
"type": "object",
"required_fields": ["id"],
"required_fields": [],
"fields": {
"id": {"type": "string", "nullable": false, "description": "Scraper primary key."},
"params": {"type": "string", "nullable": true, "description": "Serialized parameter payload."},
"frequency": {"type": "string", "nullable": true, "description": "Execution cadence descriptor."},
"task_name": {"type": "string", "nullable": true, "description": "Linked task name."},
@ -481,6 +484,34 @@
"404": {"description": "Scraper not found."},
"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."}
}
}
]
}

View File

@ -3,7 +3,9 @@
from __future__ import annotations
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 uuid import UUID, uuid4
@ -11,13 +13,20 @@ import psycopg
from psycopg import sql
from psycopg.rows import dict_row
from psycopg.types.json import Json
from deepmerge import always_merger
from dotenv import load_dotenv
from flask import Flask, abort, jsonify, request, g
from flask_cors import CORS
import requests
load_dotenv()
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 = {
@ -57,6 +66,10 @@ if not API_TOKEN:
raise RuntimeError(
"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__)
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")
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:
if isinstance(value, UUID):
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")
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:
if not header_value:
abort(401, description="Missing bearer token")
@ -426,10 +502,7 @@ def get_profile(profile_id: str):
def create_profile():
payload = _get_json_body()
profile_identifier = payload.get("profile_id")
profile_uuid = (
_parse_uuid(profile_identifier, "profile_id") if profile_identifier else uuid4()
)
profile_uuid = uuid4()
name = _parse_string(payload.get("name"), "name")
description_value = payload.get("description")
@ -666,7 +739,7 @@ def get_scraper(scraper_id: str):
def create_scraper():
payload = _get_json_body()
scraper_id = _parse_string(payload.get("id"), "id")
scraper_id = str(uuid4())
data: Dict[str, Any] = {"id": scraper_id}
@ -735,5 +808,56 @@ def delete_scraper(scraper_id: str):
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__":
app.run(host="0.0.0.0", port=int(os.getenv("PORT", "8000")), debug=False)

View File

@ -2,3 +2,5 @@ Flask>=3.0.0,<4.0.0
python-dotenv>=1.0.0,<2.0.0
psycopg[binary]>=3.1,<4.0
Flask-Cors>=4.0.0,<5.0.0
requests>=2.31.0,<3.0.0
deepmerge>=1.1.0,<2.0.0

View File

@ -5,6 +5,7 @@
"name": "frontend",
"dependencies": {
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@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-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-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=="],

View File

@ -11,6 +11,7 @@
},
"dependencies": {
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",

View 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,
}

View File

@ -30,7 +30,6 @@ export interface Profile {
}
interface CreateProfilePayload {
profile_id?: string;
name: string;
description?: string | null;
criteria: JsonObject;
@ -39,7 +38,6 @@ interface CreateProfilePayload {
}
interface ProfileFormValues {
profile_id: string;
name: string;
description: string;
criteria: JsonObject;
@ -78,12 +76,6 @@ function transformPayload(values: ProfileFormValues): CreateProfilePayload {
is_active: values.is_active,
};
const profileId = values.profile_id.trim();
if (profileId) {
payload.profile_id = profileId;
}
const description = values.description.trim();
if (description) {
@ -109,7 +101,6 @@ const profileSchema = getSchema("profile");
function createDefaultValues(): ProfileFormValues {
return {
profile_id: "",
name: "",
description: "",
criteria: buildDefaultValue(profileSchema) as JsonObject,
@ -228,12 +219,7 @@ export function ProfilesTab() {
const updateProfileMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: ProfileFormValues }) => {
const payload = transformPayload(data);
const { profile_id: _ignored, ...body } = payload;
return api.put<Omit<CreateProfilePayload, "profile_id">, Profile>(
`/profiles/${id}`,
body
);
return api.put<CreateProfilePayload, Profile>(`/profiles/${id}`, payload);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["profiles"] });
@ -285,18 +271,6 @@ export function ProfilesTab() {
<CardContent>
<form className="space-y-6" onSubmit={onSubmit}>
<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">
<label className="text-sm font-medium" htmlFor="name">
Nom
@ -444,7 +418,6 @@ export function ProfilesTab() {
<table className="min-w-full table-fixed border-collapse text-sm">
<thead>
<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">Description</th>
<th className="border-b px-3 py-2 font-semibold">Criteres</th>
@ -456,10 +429,7 @@ export function ProfilesTab() {
<tbody>
{profiles.map((profile) => (
<tr key={profile.profile_id} className="align-top">
<td className="border-b px-3 py-2 font-mono text-xs">
{profile.profile_id}
</td>
<td className="border-b px-3 py-2">{profile.name}</td>
<td className="border-b px-3 py-2 font-medium">{profile.name}</td>
<td className="border-b px-3 py-2 text-muted-foreground">
{profile.description ?? "-"}
</td>
@ -496,20 +466,19 @@ export function ProfilesTab() {
const createdAtDate = profile.created_at
? new Date(profile.created_at)
: null;
const createdAtInput =
createdAtDate && !Number.isNaN(createdAtDate.getTime())
? createdAtDate.toISOString().slice(0, 16)
: "";
form.reset({
profile_id: profile.profile_id,
name: profile.name,
description: profile.description ?? "",
criteria: normalizeProfileCriteriaForForm(
profile.criteria
),
created_at: createdAtInput,
is_active: profile.is_active,
});
const createdAtInput =
createdAtDate && !Number.isNaN(createdAtDate.getTime())
? createdAtDate.toISOString().slice(0, 16)
: "";
form.reset({
name: profile.name,
description: profile.description ?? "",
criteria: normalizeProfileCriteriaForForm(
profile.criteria
),
created_at: createdAtInput,
is_active: profile.is_active,
});
}}
>
Modifier

View File

@ -16,6 +16,15 @@ import {
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { SchemaAwareJsonField } from "@/components/schema-builder";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { getSchema } from "@/schemas/loader";
import { buildDefaultValue } from "@/schemas/utils";
@ -35,7 +44,6 @@ export interface Scraper {
}
interface ScraperFormValues {
id: string;
params: JsonObject;
frequency: string;
task_name: string;
@ -50,7 +58,7 @@ interface ScraperFormValues {
}
interface CreateScraperPayload {
id: string;
id?: string;
params?: string | null;
frequency?: string | null;
task_name?: string | null;
@ -105,9 +113,7 @@ function parseInteger(value: string) {
}
function transformPayload(values: ScraperFormValues): CreateScraperPayload {
const payload: CreateScraperPayload = {
id: values.id.trim(),
};
const payload: CreateScraperPayload = {};
const paramsObject = values.params ?? {};
@ -159,7 +165,6 @@ function transformPayload(values: ScraperFormValues): CreateScraperPayload {
function createDefaultScraperValues(): ScraperFormValues {
return {
id: "",
params: buildDefaultValue(scraperSchema) as JsonObject,
frequency: "",
task_name: "",
@ -190,6 +195,7 @@ export function ScrapersTab() {
const [paramsValid, setParamsValid] = useState(true);
const [editingScraperId, setEditingScraperId] = useState<string | null>(null);
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [isCountDialogOpen, setIsCountDialogOpen] = useState(false);
const queryClient = useQueryClient();
const initialValues = useMemo(() => createDefaultScraperValues(), []);
const form = useForm<ScraperFormValues>({
@ -217,9 +223,7 @@ export function ScrapersTab() {
const updateScraperMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: ScraperFormValues }) => {
const payload = transformPayload(data);
const { id: _ignored, ...body } = payload;
return api.put<Omit<CreateScraperPayload, "id">, Scraper>(`/scrapers/${id}`, body);
return api.put<CreateScraperPayload, Scraper>(`/scrapers/${id}`, payload);
},
onSuccess: () => {
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) => {
setStatusMessage(null);
if (editingScraperId) {
@ -257,6 +271,19 @@ export function ScrapersTab() {
(updateScraperMutation.isError && getErrorMessage(updateScraperMutation.error)) ||
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]);
return (
@ -265,28 +292,21 @@ export function ScrapersTab() {
<CardHeader>
<CardTitle>Creer un scraper</CardTitle>
<CardDescription>
Definissez l'identifiant du scraper, la configuration et les parametres d'execution.
Definissez la configuration et les parametres d'execution du scraper.
</CardDescription>
</CardHeader>
<CardContent>
<form className="space-y-6" onSubmit={onSubmit}>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-1 md:col-span-2">
<label className="text-sm font-medium" htmlFor="scraper_id">
Identifiant
<label className="text-sm font-medium" htmlFor="task_name">
Nom du scraper
</label>
<Input
id="scraper_id"
placeholder="ex: scraper_immobilier"
aria-invalid={Boolean(form.formState.errors.id)}
disabled={Boolean(editingScraperId)}
{...form.register("id", { required: "L'identifiant est obligatoire." })}
id="task_name"
placeholder="Nom interne"
{...form.register("task_name")}
/>
{form.formState.errors.id ? (
<p className="text-destructive text-xs">
{form.formState.errors.id.message}
</p>
) : null}
</div>
<div className="space-y-1">
@ -300,17 +320,6 @@ export function ScrapersTab() {
/>
</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">
<label className="text-sm font-medium" htmlFor="property_types">
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>
) : null}
<Button
type="button"
variant="secondary"
disabled={countScraperMutation.isPending}
onClick={handleOpenCountDialog}
>
{countScraperMutation.isPending ? "Calcul..." : "Compter les resultats"}
</Button>
{editingScraperId ? (
<Button
type="button"
@ -477,9 +495,8 @@ export function ScrapersTab() {
<table className="min-w-full table-fixed border-collapse text-sm">
<thead>
<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">Tache</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">Limites</th>
@ -492,11 +509,10 @@ export function ScrapersTab() {
return (
<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">
{scraper.frequency ?? "-"}
</td>
<td className="border-b px-3 py-2">{scraper.task_name ?? "-"}</td>
<td className="border-b px-3 py-2">
{paramsDisplay ? (
<pre className="max-h-40 overflow-auto rounded bg-muted/50 p-2 text-xs">
@ -554,7 +570,6 @@ export function ScrapersTab() {
}
}
form.reset({
id: scraper.id,
params: parsedParams,
frequency: scraper.frequency ?? "",
task_name: scraper.task_name ?? "",
@ -598,6 +613,49 @@ export function ScrapersTab() {
)}
</CardContent>
</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>
);
}