add worker api to refresh data in the background
This commit is contained in:
parent
fc722b6b5f
commit
a7e0773c0a
12 changed files with 465 additions and 38 deletions
|
|
@ -1,7 +1,12 @@
|
||||||
|
from pathlib import Path
|
||||||
|
import queue
|
||||||
|
from threading import Thread
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
import uuid
|
||||||
from api.auth import get_current_user
|
from api.auth import get_current_user
|
||||||
from api.config import DEV_TIER_ORIGINS, PROD_TIER_ORIGINS
|
from api.config import DEV_TIER_ORIGINS, PROD_TIER_ORIGINS
|
||||||
from fastapi import Depends, FastAPI, Query
|
from api.worker import TaskStatus, dump_listings_worker, task_queue, task_results
|
||||||
|
from fastapi import Depends, FastAPI, HTTPException, Query
|
||||||
from api.auth import User
|
from api.auth import User
|
||||||
from models.listing import QueryParameters
|
from models.listing import QueryParameters
|
||||||
from repositories.listing_repository import ListingRepository
|
from repositories.listing_repository import ListingRepository
|
||||||
|
|
@ -10,9 +15,11 @@ from database import engine
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from ui_exporter import export_immoweb
|
from ui_exporter import export_immoweb
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
|
||||||
|
# Start worker thread
|
||||||
|
Thread(target=dump_listings_worker, daemon=True).start()
|
||||||
|
|
||||||
# Allow CORS (for React frontend)
|
# Allow CORS (for React frontend)
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
|
|
@ -39,3 +46,33 @@ async def get_listing_geojson(
|
||||||
repository, query_parameters=query_parameters, limit=None
|
repository, query_parameters=query_parameters, limit=None
|
||||||
)
|
)
|
||||||
return geojson_data
|
return geojson_data
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/refresh_listings")
|
||||||
|
async def refresh_listings(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
query_parameters: Annotated[QueryParameters, Query()],
|
||||||
|
) -> dict[str, str]:
|
||||||
|
# Submit processing task
|
||||||
|
task_id = str(uuid.uuid4())
|
||||||
|
task_results[task_id] = {"status": TaskStatus.QUEUED}
|
||||||
|
try:
|
||||||
|
task_queue.put_nowait(
|
||||||
|
(task_id, query_parameters),
|
||||||
|
)
|
||||||
|
except queue.Full:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=429,
|
||||||
|
detail="Already processing at maximum capacity. Please try again later",
|
||||||
|
)
|
||||||
|
return {"task_id": task_id}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/task_status")
|
||||||
|
async def get_task_status(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
task_id: str,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
if task_id not in task_results:
|
||||||
|
return {"status": "not_found"}
|
||||||
|
return task_results[task_id]
|
||||||
|
|
|
||||||
49
crawler/api/worker.py
Normal file
49
crawler/api/worker.py
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import asyncio
|
||||||
|
import enum
|
||||||
|
import importlib
|
||||||
|
from pathlib import Path
|
||||||
|
from queue import Queue
|
||||||
|
from database import engine
|
||||||
|
|
||||||
|
from models.listing import Listing, QueryParameters
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from repositories.listing_repository import ListingRepository
|
||||||
|
|
||||||
|
dump_listings_module = importlib.import_module("1_dump_listings")
|
||||||
|
|
||||||
|
# In-memory task queue and results store
|
||||||
|
task_queue = Queue(maxsize=1) # Disallow multiple in flight requests for now
|
||||||
|
task_results = {}
|
||||||
|
|
||||||
|
|
||||||
|
def dump_listings_worker() -> None:
|
||||||
|
return asyncio.run(_dump_listings_worker())
|
||||||
|
|
||||||
|
|
||||||
|
class TaskStatus(enum.StrEnum):
|
||||||
|
QUEUED = "queued"
|
||||||
|
PROCESSING = "processing"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
async def _dump_listings_worker() -> None: # global results is updated
|
||||||
|
"""Background worker that processes tasks"""
|
||||||
|
repository = ListingRepository(engine)
|
||||||
|
data_dir_path = Path("data/rs")
|
||||||
|
while True:
|
||||||
|
task_id, task_data = task_queue.get()
|
||||||
|
task_results[task_id] = {"status": TaskStatus.PROCESSING}
|
||||||
|
query_parameters = task_data
|
||||||
|
try:
|
||||||
|
new_listings = await dump_listings_module.dump_listings_full(
|
||||||
|
query_parameters, repository, data_dir_path
|
||||||
|
)
|
||||||
|
task_results[task_id] = {
|
||||||
|
"status": "completed",
|
||||||
|
"result": f"Fetched {len(new_listings)} new listings for query {task_data}",
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
task_results[task_id] = {"status": TaskStatus.FAILED, "error": str(e)}
|
||||||
|
finally:
|
||||||
|
task_queue.task_done()
|
||||||
57
crawler/frontend/package-lock.json
generated
57
crawler/frontend/package-lock.json
generated
|
|
@ -10,7 +10,9 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.1.1",
|
"@hookform/resolvers": "^5.1.1",
|
||||||
"@radix-ui/react-dialog": "^1.1.14",
|
"@radix-ui/react-dialog": "^1.1.14",
|
||||||
|
"@radix-ui/react-hover-card": "^1.1.14",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-progress": "^1.1.7",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.9",
|
"@radix-ui/react-scroll-area": "^1.2.9",
|
||||||
"@radix-ui/react-select": "^2.2.5",
|
"@radix-ui/react-select": "^2.2.5",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
|
|
@ -1108,6 +1110,37 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-hover-card": {
|
||||||
|
"version": "1.1.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.14.tgz",
|
||||||
|
"integrity": "sha512-CPYZ24Mhirm+g6D8jArmLzjYu4Eyg3TTUHswR26QgzXBHBe64BO/RHOJKzmF/Dxb4y4f9PKyJdwm/O/AhNkb+Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.2",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-dismissable-layer": "1.1.10",
|
||||||
|
"@radix-ui/react-popper": "1.2.7",
|
||||||
|
"@radix-ui/react-portal": "1.1.9",
|
||||||
|
"@radix-ui/react-presence": "1.1.4",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2"
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-id": {
|
"node_modules/@radix-ui/react-id": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
|
||||||
|
|
@ -1252,6 +1285,30 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-progress": {
|
||||||
|
"version": "1.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz",
|
||||||
|
"integrity": "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-primitive": "2.1.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"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-scroll-area": {
|
"node_modules/@radix-ui/react-scroll-area": {
|
||||||
"version": "1.2.9",
|
"version": "1.2.9",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.9.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.9.tgz",
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,9 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.1.1",
|
"@hookform/resolvers": "^5.1.1",
|
||||||
"@radix-ui/react-dialog": "^1.1.14",
|
"@radix-ui/react-dialog": "^1.1.14",
|
||||||
|
"@radix-ui/react-hover-card": "^1.1.14",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-progress": "^1.1.7",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.9",
|
"@radix-ui/react-scroll-area": "^1.2.9",
|
||||||
"@radix-ui/react-select": "^2.2.5",
|
"@radix-ui/react-select": "^2.2.5",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useEffect, useState } from 'react';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
import { AppSidebar } from './AppSidebar';
|
import { AppSidebar } from './AppSidebar';
|
||||||
import { getUser, handleCallback, logout } from './auth/authService';
|
import { getUser, handleCallback, logout } from './auth/authService';
|
||||||
|
import ActiveQuery from './components/ActiveQuery';
|
||||||
import LoginModal from './components/LoginModal';
|
import LoginModal from './components/LoginModal';
|
||||||
import { Map } from './components/Map';
|
import { Map } from './components/Map';
|
||||||
import { Parameters, type ParameterValues } from './components/Parameters';
|
import { Parameters, type ParameterValues } from './components/Parameters';
|
||||||
|
|
@ -13,6 +14,7 @@ import { SidebarInset, SidebarProvider, SidebarTrigger } from './components/ui/s
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [listingData, setListingData] = useState({});
|
const [listingData, setListingData] = useState({});
|
||||||
|
const [taskID, setTaskID] = useState<string | null>(null);
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -29,9 +31,8 @@ function App() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const [isParametersModalOpen, setIsParametersModalOpen] = useState(true)
|
const [isParametersModalOpen, setIsParametersModalOpen] = useState(true)
|
||||||
const [error, setError] = useState('')
|
|
||||||
const [queryParameters, setQueryParameters] = useState<ParameterValues | null>(null)
|
const [queryParameters, setQueryParameters] = useState<ParameterValues | null>(null)
|
||||||
const fetchData = async (parameters: ParameterValues) => {
|
const fetchData = async (baseQueyrUri: string, parameters: ParameterValues, method: string = 'GET') => {
|
||||||
const accessToken = user?.access_token;
|
const accessToken = user?.access_token;
|
||||||
const queryString = new URLSearchParams();
|
const queryString = new URLSearchParams();
|
||||||
queryString.append('listing_type', parameters.listing_type)
|
queryString.append('listing_type', parameters.listing_type)
|
||||||
|
|
@ -51,34 +52,39 @@ function App() {
|
||||||
queryString.append("min_sqm", parameters.min_sqm.toString());
|
queryString.append("min_sqm", parameters.min_sqm.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const response = await fetch(baseQueyrUri + '?' + queryString,
|
||||||
try {
|
{
|
||||||
const response = await fetch("/api/listing_geojson?" + queryString,
|
method: method,
|
||||||
{
|
headers: {
|
||||||
method: 'GET',
|
'Authorization': `Bearer ${accessToken}`, // Pass the token
|
||||||
headers: {
|
'Content-Type': 'application/json',
|
||||||
'Authorization': `Bearer ${accessToken}`, // Pass the token
|
},
|
||||||
'Content-Type': 'application/json',
|
}
|
||||||
},
|
);
|
||||||
}
|
if (!response.ok) {
|
||||||
);
|
throw new Error('Error: ' + response.status);
|
||||||
if (!response.ok) throw new Error('Error: ' + response.json());
|
|
||||||
const data: Response = await response.json();
|
|
||||||
return data;
|
|
||||||
} catch (err) {
|
|
||||||
setError('Failed to fetch data: ' + err);
|
|
||||||
alert(JSON.stringify(err))
|
|
||||||
} finally {
|
|
||||||
}
|
}
|
||||||
|
const data: Response = await response.json();
|
||||||
|
return data;
|
||||||
};
|
};
|
||||||
const onSubmit = async (parameters: ParameterValues) => {
|
const onSubmit = async (action: 'fetch-data' | 'visualize', parameters: ParameterValues) => {
|
||||||
// Fetch listing data
|
// Fetch listing data
|
||||||
setQueryParameters(parameters)
|
setQueryParameters(parameters)
|
||||||
const data = await fetchData(parameters);
|
setIsParametersModalOpen(false)
|
||||||
console.log(data)
|
let data = null;
|
||||||
if (data) {
|
if (action === 'visualize') {
|
||||||
setListingData(data);
|
data = await fetchData("/api/listing_geojson", parameters);
|
||||||
|
if (data) {
|
||||||
|
setListingData(data);
|
||||||
|
}
|
||||||
|
} else if (action === 'fetch-data') {
|
||||||
|
data = await fetchData("/api/refresh_listings", parameters, 'POST');
|
||||||
|
if (data) {
|
||||||
|
// @ts-expect-error
|
||||||
|
setTaskID(data.task_id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
console.log(data)
|
||||||
setIsParametersModalOpen(false)
|
setIsParametersModalOpen(false)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
@ -116,6 +122,7 @@ function App() {
|
||||||
<h1>Welcome, {user.profile.name}!</h1>
|
<h1>Welcome, {user.profile.name}!</h1>
|
||||||
<Button onClick={logout}>Logout</Button>
|
<Button onClick={logout}>Logout</Button>
|
||||||
<Parameters onSubmit={onSubmit} isOpen={isParametersModalOpen} />
|
<Parameters onSubmit={onSubmit} isOpen={isParametersModalOpen} />
|
||||||
|
<ActiveQuery taskID={taskID} />
|
||||||
</div>
|
</div>
|
||||||
{Object.keys(listingData).length > 0 &&
|
{Object.keys(listingData).length > 0 &&
|
||||||
<div className="flex-1 w-full relative" style={{ minHeight: 0, marginBottom: '8rem' }}>
|
<div className="flex-1 w-full relative" style={{ minHeight: 0, marginBottom: '8rem' }}>
|
||||||
|
|
|
||||||
138
crawler/frontend/src/components/ActiveQuery.tsx
Normal file
138
crawler/frontend/src/components/ActiveQuery.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
import { getUser } from '@/auth/authService';
|
||||||
|
import type { User } from 'oidc-client-ts';
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { HoverCard, HoverCardContent, HoverCardTrigger } from './ui/hover-card';
|
||||||
|
import { Progress } from './ui/progress';
|
||||||
|
|
||||||
|
interface ModalProps {
|
||||||
|
taskID: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchTaskStatus = async (user: User, taskID: string) => {
|
||||||
|
const accessToken = user?.access_token;
|
||||||
|
const response = await fetch(`/api/task_status?task_id=${taskID}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${accessToken}`, // Pass the token
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch task status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data =
|
||||||
|
await response.json();
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
enum TaskStatus {
|
||||||
|
QUEUED = 'queued',
|
||||||
|
PROCESSING = 'processing',
|
||||||
|
COMPLETED = 'completed',
|
||||||
|
FAILED = 'failed',
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskStatusToProgress = (taskStatus: TaskStatus): number => {
|
||||||
|
switch (taskStatus) {
|
||||||
|
case TaskStatus.QUEUED:
|
||||||
|
return 0.33; // Queued status
|
||||||
|
case TaskStatus.PROCESSING:
|
||||||
|
return 0.66; // Processing status
|
||||||
|
case TaskStatus.COMPLETED:
|
||||||
|
return 1.0; // Completed status
|
||||||
|
default:
|
||||||
|
throw new Error('Unknown task status: ' + status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTaskStatus = (status: string): TaskStatus => {
|
||||||
|
switch (status.toLowerCase()) {
|
||||||
|
case 'queued':
|
||||||
|
return TaskStatus.QUEUED;
|
||||||
|
case 'processing':
|
||||||
|
return TaskStatus.PROCESSING;
|
||||||
|
case 'completed':
|
||||||
|
return TaskStatus.COMPLETED;
|
||||||
|
case 'failed':
|
||||||
|
return TaskStatus.FAILED;
|
||||||
|
default:
|
||||||
|
throw new Error('Unknown task status: ' + status);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ActiveQuery: React.FC<ModalProps> = ({
|
||||||
|
taskID
|
||||||
|
}) => {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
getUser().then(setUser);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [progressPercentage, setProgressPercentage] = useState<number>(0);
|
||||||
|
const [taskStatus, setTaskStatus] = useState<TaskStatus | null>(TaskStatus.QUEUED);
|
||||||
|
const [lastUpdateTime, setLastUpdateTime] = useState<Date>(new Date());
|
||||||
|
|
||||||
|
// fetch status periodically
|
||||||
|
// maybe move to ws one day
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval
|
||||||
|
(async () => {
|
||||||
|
if (!user || !taskID) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let data = null
|
||||||
|
try {
|
||||||
|
data = await fetchTaskStatus(user, taskID);
|
||||||
|
} catch (error: any) {
|
||||||
|
clearInterval(interval);
|
||||||
|
setTaskStatus(TaskStatus.FAILED)
|
||||||
|
alert(error)
|
||||||
|
}
|
||||||
|
if (!data) {
|
||||||
|
clearInterval(interval);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLastUpdateTime(new Date());
|
||||||
|
const taskStatus = getTaskStatus(data.status);
|
||||||
|
if (taskStatus === TaskStatus.FAILED) {
|
||||||
|
clearInterval(interval);
|
||||||
|
throw new Error('Task failed');
|
||||||
|
}
|
||||||
|
setTaskStatus(taskStatus);
|
||||||
|
const progress = taskStatusToProgress(taskStatus);
|
||||||
|
setProgressPercentage(progress * 100);
|
||||||
|
if (taskStatus === TaskStatus.COMPLETED) {
|
||||||
|
clearInterval(interval);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}, 5000); // every 5 seconds
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [taskID]);
|
||||||
|
|
||||||
|
if (!taskID) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<HoverCard>
|
||||||
|
<HoverCardTrigger>
|
||||||
|
{taskStatus && <p>Task status: {taskStatus} </p>}
|
||||||
|
<Progress value={progressPercentage} />
|
||||||
|
</HoverCardTrigger>
|
||||||
|
<HoverCardContent>
|
||||||
|
Task ID: {taskID}
|
||||||
|
<br />
|
||||||
|
Last updated: {lastUpdateTime.toLocaleString()}
|
||||||
|
</HoverCardContent>
|
||||||
|
</HoverCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ActiveQuery;
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// // @ts-nocheck
|
// @ts-nocheck
|
||||||
import crossfilter from "crossfilter2";
|
import crossfilter from "crossfilter2";
|
||||||
import * as d3 from "d3";
|
import * as d3 from "d3";
|
||||||
import mapboxgl from "mapbox-gl";
|
import mapboxgl from "mapbox-gl";
|
||||||
|
|
@ -195,7 +195,7 @@ export function Map(
|
||||||
.call(xAxis);
|
.call(xAxis);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openListingsDialog(longtitude, latitude) {
|
function openListingsDialog(longtitude: number, latitude: number) {
|
||||||
const searchBuffer = 0.001 // ~100m
|
const searchBuffer = 0.001 // ~100m
|
||||||
const properties = heatmap._tree.search({
|
const properties = heatmap._tree.search({
|
||||||
minX: longtitude - searchBuffer,
|
minX: longtitude - searchBuffer,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { DialogTitle } from "@radix-ui/react-dialog";
|
import { DialogTitle } from "@radix-ui/react-dialog";
|
||||||
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
|
|
@ -34,13 +35,13 @@ export interface ParameterValues {
|
||||||
export function Parameters(
|
export function Parameters(
|
||||||
props: {
|
props: {
|
||||||
isOpen: boolean,
|
isOpen: boolean,
|
||||||
onSubmit: (fromValues: ParameterValues) => void,
|
onSubmit: (action: 'fetch-data' | 'visualize', fromValues: ParameterValues) => void,
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
} = useForm<ParameterValues>()
|
} = useForm<ParameterValues>()
|
||||||
// const onSubmit: SubmitHandler<ParameterValues> = (data) => console.log(data)
|
const [action, setAction] = useState<'fetch-data' | 'visualize' | null>(null)
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
metric: z.nativeEnum(Metric, { required_error: "Metric is required" }),
|
metric: z.nativeEnum(Metric, { required_error: "Metric is required" }),
|
||||||
|
|
@ -48,7 +49,7 @@ export function Parameters(
|
||||||
min_bedrooms: z.number().min(1).max(10).optional(),
|
min_bedrooms: z.number().min(1).max(10).optional(),
|
||||||
max_bedrooms: z.number().min(1).max(10).optional(),
|
max_bedrooms: z.number().min(1).max(10).optional(),
|
||||||
max_price: z.number().optional(),
|
max_price: z.number().optional(),
|
||||||
min_price: z.number().optional(),
|
min_price: z.number().min(0).optional(),
|
||||||
min_sqm: z.number().optional(),
|
min_sqm: z.number().optional(),
|
||||||
})
|
})
|
||||||
const form = useForm<z.infer<typeof formSchema>>({
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
|
|
@ -58,7 +59,7 @@ export function Parameters(
|
||||||
min_bedrooms: 1,
|
min_bedrooms: 1,
|
||||||
max_bedrooms: 3,
|
max_bedrooms: 3,
|
||||||
max_price: 3000,
|
max_price: 3000,
|
||||||
min_price: 0,
|
min_price: 2000,
|
||||||
min_sqm: 0,
|
min_sqm: 0,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -67,11 +68,15 @@ export function Parameters(
|
||||||
// Do something with the form values.
|
// Do something with the form values.
|
||||||
// ✅ This will be type-safe and validated.
|
// ✅ This will be type-safe and validated.
|
||||||
console.log(values)
|
console.log(values)
|
||||||
props.onSubmit(values)
|
if (action) {
|
||||||
|
props.onSubmit(action, values)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return <>
|
return <>
|
||||||
|
{/* <Dialog open={props.isOpen} > */}
|
||||||
<Dialog >
|
<Dialog >
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="outline">Open Parameters</Button>
|
<Button variant="outline">Open Parameters</Button>
|
||||||
|
|
@ -195,7 +200,8 @@ export function Parameters(
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button type="submit">Submit</Button>
|
<Button type="submit" value={"visualize"} onClick={() => setAction("visualize")}>Visualize</Button>
|
||||||
|
<Button type="submit" value={"fetch-data"} onClick={() => setAction("fetch-data")}>Fetch data</Button>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
|
||||||
46
crawler/frontend/src/components/ui/badge.tsx
Normal file
46
crawler/frontend/src/components/ui/badge.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||||
|
secondary:
|
||||||
|
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span"> &
|
||||||
|
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot : "span"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="badge"
|
||||||
|
className={cn(badgeVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
42
crawler/frontend/src/components/ui/hover-card.tsx
Normal file
42
crawler/frontend/src/components/ui/hover-card.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function HoverCard({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
|
||||||
|
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function HoverCardTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HoverCardContent({
|
||||||
|
className,
|
||||||
|
align = "center",
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
|
||||||
|
<HoverCardPrimitive.Content
|
||||||
|
data-slot="hover-card-content"
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</HoverCardPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||||
29
crawler/frontend/src/components/ui/progress.tsx
Normal file
29
crawler/frontend/src/components/ui/progress.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Progress({
|
||||||
|
className,
|
||||||
|
value,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<ProgressPrimitive.Root
|
||||||
|
data-slot="progress"
|
||||||
|
className={cn(
|
||||||
|
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ProgressPrimitive.Indicator
|
||||||
|
data-slot="progress-indicator"
|
||||||
|
className="bg-primary h-full w-full flex-1 transition-all"
|
||||||
|
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||||
|
/>
|
||||||
|
</ProgressPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Progress }
|
||||||
|
|
@ -122,9 +122,11 @@ def cli(ctx, data_dir: str):
|
||||||
|
|
||||||
@cli.command()
|
@cli.command()
|
||||||
@listing_filter_options
|
@listing_filter_options
|
||||||
|
@click.option("--full", is_flag=True)
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def dump_listings(
|
def dump_listings(
|
||||||
ctx: click.core.Context,
|
ctx: click.core.Context,
|
||||||
|
full: bool,
|
||||||
district: list[str],
|
district: list[str],
|
||||||
min_bedrooms: int,
|
min_bedrooms: int,
|
||||||
max_bedrooms: int,
|
max_bedrooms: int,
|
||||||
|
|
@ -148,6 +150,9 @@ def dump_listings(
|
||||||
let_date_available_from=available_from,
|
let_date_available_from=available_from,
|
||||||
last_seen_days=last_seen_days,
|
last_seen_days=last_seen_days,
|
||||||
min_sqm=min_sqm,
|
min_sqm=min_sqm,
|
||||||
|
radius=0,
|
||||||
|
page_size=500,
|
||||||
|
max_days_since_added=14,
|
||||||
)
|
)
|
||||||
click.echo(
|
click.echo(
|
||||||
f"Running dump_listings for districts {district}, data dir {data_dir} and parameters: "
|
f"Running dump_listings for districts {district}, data dir {data_dir} and parameters: "
|
||||||
|
|
@ -155,9 +160,18 @@ def dump_listings(
|
||||||
)
|
)
|
||||||
data_dir_path = pathlib.Path(data_dir)
|
data_dir_path = pathlib.Path(data_dir)
|
||||||
repository = ListingRepository(engine=engine)
|
repository = ListingRepository(engine=engine)
|
||||||
asyncio.run(
|
if not full: # only listings
|
||||||
dump_listings_module.dump_listings(query_parameters, repository, data_dir_path)
|
asyncio.run(
|
||||||
)
|
dump_listings_module.dump_listings(
|
||||||
|
query_parameters, repository, data_dir_path
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else: # include images, floorplan detection etc.
|
||||||
|
asyncio.run(
|
||||||
|
dump_listings_module.dump_listings_full(
|
||||||
|
query_parameters, repository, data_dir_path
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@cli.command()
|
@cli.command()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue