diff --git a/.github/workflows/style.yml b/.github/workflows/style.yml index 8d8ad6b..8ebab5f 100644 --- a/.github/workflows/style.yml +++ b/.github/workflows/style.yml @@ -78,7 +78,7 @@ jobs: black --version black --check --diff . - bandit: + bandit: runs-on: ${{ matrix.os }} strategy: matrix: diff --git a/.gitignore b/.gitignore index 7ecb179..e07340a 100644 --- a/.gitignore +++ b/.gitignore @@ -109,7 +109,7 @@ venv/ ENV/ env.bak/ venv.bak/ - +venv* # Spyder project settings .spyderproject .spyproject diff --git a/Makefile b/Makefile index c838d22..d595b8a 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,7 @@ style: build: poetry export -f requirements.txt --without-hashes --output requirements.txt docker build . -t pyronear/pyro-platform:latest - + # Run the docker for production run: poetry export -f requirements.txt --without-hashes --output requirements.txt @@ -25,6 +25,9 @@ run_dev: poetry export -f requirements.txt --without-hashes --output requirements.txt docker compose -f docker-compose-dev.yml up -d --build +run_local: + python app/index.py --host 0.0.0.0 --port 8050 + # Run the docker stop: docker compose down diff --git a/README.md b/README.md index 9e15a78..39978c6 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,36 @@ The building blocks of our wildfire detection & monitoring API. ## Quick Tour +### Pyro API +1. Hosted by pyronear +You can use apidev.pyronear.org/docs if you don't need to modify the API for your task + +What to do : +=> you need to ask an administrator to create a user for you. Better to have an admin user in order to be able to create cameras & detections with it. +=> check that the version of the dev API will be fitter to your need. +=> modify the API_URL var env in your .env + +2. Locally +You can use the pyro-devops project in two different ways : +=> by building the pyro-platform image and launch the full development environment with the command : +```shell +make run +``` +=> by launching the development environment without the platform : +```shell +make run-engine +``` +adding this line in your /etc/hosts : +``` +127.0.0.1 www.localstack.com localstack +``` +after that you can set up the .env of the pyro-platform project according to the values contained in the .env of the pyro-devops project +And launch your project according to the section below "Directly in python" + -### Running/stopping the service +### Running/stopping the service +1. Dockerized You can run the app container using this command for dev purposes: ```shell @@ -27,23 +54,15 @@ In order to stop the service, run: make stop ``` -If you need to launch the pyro-api in your development environment you can use the pyro-devops project. -You can use it in two different ways : -=> by building the pyro-platform image and launch the full development environment with the command : -```shell -make run -``` -=> by launching the development environment without the platform : +This dockerized setup won't work with an API launch thanks to the pyro-devops projet + +2. Directly in python +Set up your .env + ```shell -make run-engine -``` -adding this line in your /etc/hosts : -``` -127.0.0.1 www.localstack.com localstack -``` -and launching your project locally : -``` -python3 app/index.py +pip install -r requirements.txt +pip install --no-cache-dir git+https://github.com/pyronear/pyro-api.git@ce7bf66d1624fcb615daee567dfa77d7d5bca487#subdirectory=client +python app/index.py --host 0.0.0.0 --port 8050 ``` ## Installation diff --git a/app/assets/css/style.css b/app/assets/css/style.css index 69efe08..dae0e37 100644 --- a/app/assets/css/style.css +++ b/app/assets/css/style.css @@ -35,13 +35,13 @@ a.no-underline { /* Common style for containers and panels */ .common-style { border: 2px solid #044448; - border-radius: 10px; + border-radius: 10px; background-color: rgba(4, 68, 72, 0.1); } .common-style-slider { border: 2px solid #044448; - border-radius: 10px; + border-radius: 10px; background-color: rgba(4, 68, 72, 0.1); margin-top: 10px; } diff --git a/app/callbacks/data_callbacks.py b/app/callbacks/data_callbacks.py index 149264c..87340aa 100644 --- a/app/callbacks/data_callbacks.py +++ b/app/callbacks/data_callbacks.py @@ -4,6 +4,7 @@ # See LICENSE or go to for full license details. import json +from datetime import datetime, timedelta import dash import logging_config @@ -15,48 +16,26 @@ from pyroclient import Client import config as cfg -from services import api_client, call_api -from utils.data import ( - convert_time, - past_ndays_api_events, - process_bbox, - read_stored_DataFrame, -) +from services import instantiate_token +from utils.data import process_bbox logger = logging_config.configure_logging(cfg.DEBUG, cfg.SENTRY_DSN) @app.callback( [ - Output("user_credentials", "data"), - Output("user_headers", "data"), + Output("client_token", "data"), Output("form_feedback_area", "children"), ], Input("send_form_button", "n_clicks"), [ State("username_input", "value"), State("password_input", "value"), - State("user_headers", "data"), + State("client_token", "data"), ], ) -def login_callback(n_clicks, username, password, user_headers): - """ - Callback to handle user login. - - Parameters: - n_clicks (int): Number of times the login button has been clicked. - username (str or None): The value entered in the username input field. - password (str or None): The value entered in the password input field. - user_headers (dict or None): Existing user headers, if any, containing authentication details. - - This function is triggered when the login button is clicked. It verifies the provided username and password, - attempts to authenticate the user via the API, and updates the user credentials and headers. - If authentication fails or credentials are missing, it provides appropriate feedback. - - Returns: - dash.dependencies.Output: Updated user credentials and headers, and form feedback. - """ - if user_headers is not None: +def login_callback(n_clicks, username, password, client_token): + if client_token is not None: return dash.no_update, dash.no_update, dash.no_update if n_clicks: @@ -74,79 +53,165 @@ def login_callback(n_clicks, username, password, user_headers): else: # This is the route of the API that we are going to use for the credential check try: - client = Client(cfg.API_URL, username, password) + client = instantiate_token(username, password) return ( - {"username": username, "password": password}, - client.headers, + client.token, dash.no_update, ) except Exception: # This if statement is verified if credentials are invalid form_feedback.append(html.P("Nom d'utilisateur et/ou mot de passe erroné.")) - return dash.no_update, dash.no_update, form_feedback + return dash.no_update, form_feedback raise PreventUpdate @app.callback( [ - Output("store_api_alerts_data", "data"), + Output("store_wildfires_data", "data"), + Output("store_detections_data", "data"), + Output("media_url", "data"), + Output("trigger_no_wildfires", "data"), + Output("previous_time_event", "data"), ], - [Input("main_api_fetch_interval", "n_intervals"), Input("user_credentials", "data")], + [Input("main_api_fetch_interval", "n_intervals")], [ - State("store_api_alerts_data", "data"), - State("user_headers", "data"), + State("client_token", "data"), + State("media_url", "data"), + State("store_wildfires_data", "data"), + State("previous_time_event", "data"), ], prevent_initial_call=True, ) -def api_watcher(n_intervals, user_credentials, local_alerts, user_headers): +def data_transform(n_intervals, client_token, media_url, store_wildfires_data, previous_time_event): """ - Callback to periodically fetch alerts data from the API. + Fetches and processes live wildfire and detection data from the API at regular intervals. + + This callback periodically checks for new wildfire and detection data from the API. + It processes the new data, updates local storage with the latest information, + and prepares it for displaying in the application. Parameters: - n_intervals (int): Number of times the interval has been triggered. - user_credentials (dict or None): Current user credentials for API authentication. - local_alerts (dict or None): Locally stored alerts data, serialized as JSON. - user_headers (dict or None): Current user headers containing authentication details. + - n_intervals (int): Number of intervals passed since the start of the app, + used to trigger the periodic update. + - client_token (str): Client token for API calls - This function is triggered at specified intervals and when user credentials are updated. - It retrieves unacknowledged events from the API, processes the data, and stores it locally. - If the local data matches the API data, no updates are made. Returns: - dash.dependencies.Output: Serialized JSON data of alerts and a flag indicating if data is loaded. + - json: Updated wildfires data in JSON format. + - json: Updated detections data in JSON format. """ - if user_headers is None: + if client_token is None: raise PreventUpdate - user_token = user_headers["Authorization"].split(" ")[1] - api_client.token = user_token - # Read local data - local_alerts, alerts_data_loaded = read_stored_DataFrame(local_alerts) logger.info("Start Fetching the events") + # Fetch Detections + # Use the last event time or default to yesterday + if previous_time_event is None: + previous_time_event = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d_%H:%M:%S") + else: + previous_time_event = pd.to_datetime(previous_time_event).strftime("%Y-%m-%d_%H:%M:%S") - # Fetch events - api_alerts = pd.DataFrame(call_api(api_client.get_unacknowledged_events, user_credentials)()) - api_alerts["created_at"] = convert_time(api_alerts) - api_alerts = past_ndays_api_events(api_alerts, n_days=0) - - if len(api_alerts) == 0: + api_client = Client(client_token, cfg.API_URL) + response = api_client.fetch_unlabeled_detections(from_date=previous_time_event) + api_detections = pd.DataFrame(response.json()) + previous_time_event = api_detections["created_at"].max() + if api_detections.empty: return [ + json.dumps( + { + "data": store_wildfires_data, + "data_loaded": False, + } + ), json.dumps( { "data": pd.DataFrame().to_json(orient="split"), - "data_loaded": True, + "data_loaded": False, } - ) + ), + [], + True, + previous_time_event, ] + # Find ongoing detections for the wildfires started within 30 minutes; + # after that, any new detection is part of a new wildfire + api_detections["created_at"] = pd.to_datetime(api_detections["created_at"]) + + # Trier les détections par "created_at" + api_detections = api_detections.sort_values(by="created_at") + + # Initialiser la liste pour les wildfires + cameras = pd.DataFrame(api_client.fetch_cameras().json()) + api_detections["lat"] = None + api_detections["lon"] = None + api_detections["wildfire_id"] = None + api_detections["processed_loc"] = None + api_detections["processed_loc"] = api_detections["bboxes"].apply(process_bbox) + + wildfires_dict = json.loads(store_wildfires_data)["data"] + # Load existing wildfires data + if wildfires_dict != {}: + id_counter = ( + max(wildfire["id"] for camera_wildfires in wildfires_dict.values() for wildfire in camera_wildfires) + 1 + ) else: - api_alerts["processed_loc"] = api_alerts["localization"].apply(process_bbox) - if alerts_data_loaded and not local_alerts.empty: - aligned_api_alerts, aligned_local_alerts = api_alerts["alert_id"].align(local_alerts["alert_id"]) - if all(aligned_api_alerts == aligned_local_alerts): - return [dash.no_update] - - return [json.dumps({"data": api_alerts.to_json(orient="split"), "data_loaded": True})] + wildfires_dict = {} + id_counter = 1 + + last_detection_time_per_camera: dict[int, str] = {} + media_dict = api_detections.set_index("id")["url"].to_dict() + + # Parcourir les détections pour les regrouper en wildfires + for i, detection in api_detections.iterrows(): + camera_id = api_detections.at[i, "camera_id"] + camera = cameras.loc[cameras["id"] == camera_id] + camera = camera.iloc[0] # Ensure camera is a Series + api_detections.at[i, "lat"] = camera["lat"] + api_detections.at[i, "lon"] = camera["lon"] + + media_url[detection["id"]] = media_dict[detection["id"]] + + if camera_id not in wildfires_dict: + wildfires_dict.setdefault(camera_id, []) + last_detection_time_per_camera.setdefault(camera_id, "") + # Initialize the first wildfire for this camera + wildfire = { + "id": id_counter, + "camera_name": camera["name"], + "created_at": detection["created_at"].strftime("%Y-%m-%d %H:%M:%S"), + "detection_ids": [detection["id"]], + } + wildfires_dict[camera_id] = [wildfire] + id_counter += 1 + else: + time_diff = detection["created_at"] - last_detection_time_per_camera[camera_id] + + if time_diff <= pd.Timedelta(minutes=30): + # Si la différence de temps est inférieure à 30 minutes, ajouter à l'actuel wildfire + wildfires_dict[camera_id][-1]["detection_ids"].append(detection["id"]) + else: + # Initialize a new wildfire for this camera + wildfire = { + "id": id_counter, + "camera_name": camera["name"], + "created_at": detection["created_at"].strftime("%Y-%m-%d %H:%M:%S"), + "detection_ids": [detection["id"]], + } + wildfires_dict[camera_id].append(wildfire) + id_counter += 1 + api_detections.at[i, "wildfire_id"] = wildfires_dict[camera_id][-1]["id"] + last_detection_time_per_camera[camera_id] = detection["created_at"] + + wildfires_dict = {int(k): v for k, v in wildfires_dict.items()} + # Convertir la liste des wildfires en DataFrame + return [ + json.dumps({"data": wildfires_dict, "data_loaded": True}), + json.dumps({"data": api_detections.to_json(orient="split"), "data_loaded": True}), + media_url, + dash.no_update, + previous_time_event, + ] diff --git a/app/callbacks/display_callbacks.py b/app/callbacks/display_callbacks.py index 35db6d4..ece9df6 100644 --- a/app/callbacks/display_callbacks.py +++ b/app/callbacks/display_callbacks.py @@ -3,118 +3,173 @@ # This program is licensed under the Apache License 2.0. # See LICENSE or go to for full license details. - import ast import json from typing import List import dash import logging_config -import numpy as np import pandas as pd from dash import html from dash.dependencies import ALL, Input, Output, State from dash.exceptions import PreventUpdate from main import app +from pyroclient import Client import config as cfg -from services import api_client, call_api from utils.data import read_stored_DataFrame -from utils.display import build_vision_polygon, create_event_list_from_alerts +from utils.display import build_vision_polygon, create_wildfire_list_from_df logger = logging_config.configure_logging(cfg.DEBUG, cfg.SENTRY_DSN) -# Create event list @app.callback( - Output("alert-list-container", "children"), + Output("modal-loading", "is_open"), + [Input("media_url", "data"), Input("client_token", "data"), Input("trigger_no_wildfires", "data")], + State("store_detections_data", "data"), + prevent_initial_call=True, +) +def toggle_modal(media_url, client_token, trigger_no_wildfires, local_detections): + """ + Toggles the visibility of the loading modal based on the presence of media URLs and the state of detections data. + + This function is triggered by changes in the media URLs, user headers, or a trigger indicating no wildfires. + It checks the current state of detections data and decides whether to display the loading modal. + + Parameters: + - media_url (dict): Dictionary containing media URLs for detections. + - client_token (str): Token used for API requests + - trigger_no_wildfires (bool): Trigger indicating whether there are no wildfires to process. + - local_detections (json): JSON formatted data containing current detections information. + + Returns: + - bool: True to show the modal, False to hide it. The modal is shown if detections data is not loaded and there are no media URLs; hidden otherwise. + """ + if trigger_no_wildfires: + return False + if client_token is None: + raise PreventUpdate + local_detections, detections_data_loaded = read_stored_DataFrame(local_detections) + return True if not detections_data_loaded and len(media_url.keys()) == 0 else False + + +# Createwildfire list +@app.callback( + Output("wildfire-list-container", "children"), [ - Input("store_api_alerts_data", "data"), + Input("store_wildfires_data", "data"), Input("to_acknowledge", "data"), ], + State("media_url", "data"), + prevent_initial_call=True, ) -def update_event_list(api_alerts, to_acknowledge): +def update_wildfire_list(store_wildfires_data, to_acknowledge, media_url): """ - Updates the event list based on changes in the events data or acknowledgement actions. + Updates the wildfire list based on changes in the wildfires data or acknowledgement actions. Parameters: - - api_alerts (json): JSON formatted data containing current alerts information. - - to_acknowledge (int): Event ID that is being acknowledged. + - local_wildfires (json): JSON formatted data containing current wildfire information. + - to_acknowledge (int): wildfire ID that is being acknowledged. + - media_url (dict): Dictionary containing media URLs for detections. Returns: - - html.Div: A Div containing the updated list of alerts. + - html.Div: A Div containing the updated list of detections. """ - api_alerts, event_data_loaded = read_stored_DataFrame(api_alerts) - if not event_data_loaded: + trigger_id = dash.callback_context.triggered[0]["prop_id"].split(".")[0] + + if trigger_id == "to_acknowledge" and str(to_acknowledge) not in media_url.keys(): raise PreventUpdate - if len(api_alerts): - # Drop acknowledge event for faster update - api_alerts = api_alerts[~api_alerts["id"].isin([to_acknowledge])] + json_wildfire = json.loads(store_wildfires_data) + wildfires_dict = json_wildfire["data"] + data_loaded = json_wildfire["data_loaded"] + + if not data_loaded: + raise PreventUpdate - # Drop event with less than 5 alerts or less then 2 bbox - drop_event = [] - for event_id in np.unique(api_alerts["id"].values): - event_alerts = api_alerts[api_alerts["id"] == event_id] - if np.sum([len(box) > 2 for box in event_alerts["localization"]]) < 2 or len(event_alerts) < 5: - drop_event.append(event_id) + wildfires_list = [] - api_alerts = api_alerts[~api_alerts["id"].isin([drop_event])] + if len(wildfires_dict): + for camera_key, wildfires_item in wildfires_dict.items(): + wildfires_dict[camera_key] = [wf for wf in wildfires_item if wf["id"] != to_acknowledge] + for wildfires_item in wildfires_dict.values(): + wildfires_list.extend(wildfires_item) - return create_event_list_from_alerts(api_alerts) + return create_wildfire_list_from_df(pd.DataFrame(wildfires_list)) -# Select the event id +# Select the wildfire id @app.callback( [ - Output({"type": "event-button", "index": ALL}, "style"), - Output("event_id_on_display", "data"), + Output({"type": "wildfire-button", "index": ALL}, "style"), + Output("wildfire_id_on_display", "data"), Output("auto-move-button", "n_clicks"), ], [ - Input({"type": "event-button", "index": ALL}, "n_clicks"), + Input({"type": "wildfire-button", "index": ALL}, "n_clicks"), + Input("to_acknowledge", "data"), ], [ - State({"type": "event-button", "index": ALL}, "id"), - State("store_api_alerts_data", "data"), - State("event_id_on_display", "data"), + State("media_url", "data"), + State({"type": "wildfire-button", "index": ALL}, "id"), + State("store_detections_data", "data"), + State("wildfire_id_on_display", "data"), ], prevent_initial_call=True, ) -def select_event_with_button(n_clicks, button_ids, local_alerts, event_id_on_display): +def select_wildfire_with_button( + n_clicks, to_acknowledge, media_url, button_ids, local_detections, wildfire_id_on_display +): """ - Handles event selection through button clicks. + Handles wildfire selection through button clicks. Parameters: - - n_clicks (list): List of click counts for each event button. - - button_ids (list): List of button IDs corresponding to events. - - local_alerts (json): JSON formatted data containing current alert information. - - event_id_on_display (int): Currently displayed event ID. + - n_clicks (list): List of click counts for eachwildfire button. + - to_acknowledge (int): Wildfire ID that is being acknowledged. + - media_url (dict): Dictionary containing media URLs for detections. + - button_ids (list): List of button IDs corresponding to wildfires. + - local_detections (json): JSON formatted data containing current detection information. + - wildfire_id_on_display (int): Currently displayed wildfire ID. Returns: - - list: List of styles for event buttons. - - int: ID of the event to display. - - int: Number of clicks for the auto-move button reset. + - list: List of styles for wildfire buttons. + - int: ID of the wildfire to display. """ ctx = dash.callback_context - local_alerts, alerts_data_loaded = read_stored_DataFrame(local_alerts) - if len(local_alerts) == 0: + local_detections, detections_data_loaded = read_stored_DataFrame(local_detections) + if len(local_detections) == 0: return [[], 0, 1] - if not alerts_data_loaded: + if not detections_data_loaded: raise PreventUpdate - # Extracting the index of the clicked button - button_id = ctx.triggered[0]["prop_id"].split(".")[0] - if button_id: - button_index = json.loads(button_id)["index"] + trigger_id = ctx.triggered[0]["prop_id"].split(".")[0] + + if trigger_id == "to_acknowledge": + idx = local_detections[~local_detections["wildfire_id"].isin([to_acknowledge])]["wildfire_id"].values + if len(idx) == 0: + button_index = 0 # No more images available + else: + button_index = idx[0] + else: - if len(button_ids): - button_index = button_ids[0]["index"] + # Extracting the index of the clicked button + button_id = ctx.triggered[0]["prop_id"].split(".")[0] + if button_id: + button_index = json.loads(button_id)["index"] else: button_index = 0 + nb_clicks = ctx.triggered[0]["value"] # check if the button was clicked or just initialized (=0) + + if ( + nb_clicks == 0 + and wildfire_id_on_display > 0 + and wildfire_id_on_display in local_detections["wildfire_id"].values + ): + button_index = wildfire_id_on_display + # Highlight the button styles = [] for button in button_ids: @@ -143,43 +198,46 @@ def select_event_with_button(n_clicks, button_ids, local_alerts, event_id_on_dis return [styles, button_index, 1] -# Get event_id data +# Get wildfire_id data @app.callback( - Output("alert_on_display", "data"), - Input("event_id_on_display", "data"), - State("store_api_alerts_data", "data"), + Output("detection_on_display", "data"), + Input("wildfire_id_on_display", "data"), + State("store_detections_data", "data"), + State("store_wildfires_data", "data"), prevent_initial_call=True, ) -def update_display_data(event_id_on_display, local_alerts): +def update_display_data(wildfire_id_on_display, local_detections, store_wildfires_data): """ - Updates the display data based on the currently selected event ID. + Updates the display data based on the currently selected wildfire ID. Parameters: - - event_id_on_display (int): Currently displayed event ID. - - local_alerts (json): JSON formatted data containing current alert information. + - wildfire_id_on_display (int): Currently displayed wildfire ID. + - local_detections (json): JSON formatted data containing current detection information. Returns: - - json: JSON formatted data for the selected event. + - json: JSON formatted data for the selected wildfire. """ - local_alerts, data_loaded = read_stored_DataFrame(local_alerts) + local_detections, data_detections_loaded = read_stored_DataFrame(local_detections) + wildfires_dict = json.loads(store_wildfires_data)["data"] + data_wildfires_loaded = json.loads(store_wildfires_data)["data_loaded"] - if not data_loaded: + if not data_detections_loaded or not data_wildfires_loaded: raise PreventUpdate - if event_id_on_display == 0: + if wildfire_id_on_display == 0: return json.dumps( { "data": pd.DataFrame().to_json(orient="split"), - "data_loaded": True, + "data_loaded": False, } ) else: - if event_id_on_display == 0: - event_id_on_display = local_alerts["id"].values[0] - - alert_on_display = local_alerts[local_alerts["id"] == event_id_on_display] - - return json.dumps({"data": alert_on_display.to_json(orient="split"), "data_loaded": True}) + for wildfires_item in wildfires_dict.values(): + for wildfire in wildfires_item: + if wildfire["id"] == wildfire_id_on_display: + detection_ids = wildfire["detection_ids"] + detection_on_display = local_detections[local_detections["id"].isin(detection_ids)] + return json.dumps({"data": detection_on_display.to_json(orient="split"), "data_loaded": True}) @app.callback( @@ -188,34 +246,34 @@ def update_display_data(event_id_on_display, local_alerts): Output("bbox-container", "children"), # Output for the bounding box Output("image-slider", "max"), ], - [Input("image-slider", "value"), Input("alert_on_display", "data")], + [Input("image-slider", "value"), Input("detection_on_display", "data")], [ - State("alert-list-container", "children"), + State("media_url", "data"), + State("wildfire-list-container", "children"), ], prevent_initial_call=True, ) -def update_image_and_bbox(slider_value, alert_data, alert_list): +def update_image_and_bbox(slider_value, detection_data, media_url, wildfire_list): """ Updates the image and bounding box display based on the slider value. Parameters: - slider_value (int): Current value of the image slider. - - alert_data (json): JSON formatted data for the selected event. - - alert_list (list): List of ongoing alerts. + - detection_data (json): JSON formatted data for the selected wildfire. + - media_url (dict): Dictionary containing media URLs for detections. Returns: - - html.Img: An image element displaying the selected alert image. + - html.Img: An image element displaying the selected detection image. - list: A list of html.Div elements representing bounding boxes. - - int: Maximum value for the image slider. """ img_src = "" bbox_style = {} bbox_divs: List[html.Div] = [] # This will contain the bounding box as an html.Div - alert_data, data_loaded = read_stored_DataFrame(alert_data) + detection_data, data_loaded = read_stored_DataFrame(detection_data) if not data_loaded: raise PreventUpdate - if len(alert_list) == 0: + if len(wildfire_list) == 0: img_html = html.Img( src="./assets/images/no-alert-default.png", className="common-style", @@ -224,44 +282,32 @@ def update_image_and_bbox(slider_value, alert_data, alert_list): return img_html, bbox_divs, 0 # Filter images with non-empty URLs - images, boxes = zip( - *( - (alert["media_url"], alert["processed_loc"]) - for _, alert in alert_data.iterrows() - if alert["media_url"] # Only include if media_url is not empty - ) - ) - - if not images: - img_html = html.Img( - src="./assets/images/no-alert-default.png", - className="common-style", - style={"width": "100%", "height": "auto"}, - ) - return img_html, bbox_divs, 0 + images = [] + if str(detection_data["id"].values[0]) not in media_url.keys(): + raise PreventUpdate - # Ensure slider_value is within the range of available images - slider_value = slider_value % len(images) - img_src = images[slider_value] - images_bbox_list = boxes[slider_value] - - img_src = images[slider_value] - images_bbox_list = boxes[slider_value] - - if len(images_bbox_list): - # Calculate the position and size of the bounding box - x0, y0, width, height = images_bbox_list[0] # first box for now - - # Create the bounding box style - bbox_style = { - "position": "absolute", - "left": f"{x0}%", # Left position based on image width - "top": f"{y0}%", # Top position based on image height - "width": f"{width}%", # Width based on image width - "height": f"{height}%", # Height based on image height - "border": "2px solid red", - "zIndex": "10", - } + for _, detection in detection_data.iterrows(): + images.append(media_url[str(detection["id"])]) + boxes = detection_data["processed_loc"].tolist() + + if slider_value < len(images): + img_src = images[slider_value] + images_bbox_list = boxes[slider_value] + + if len(images_bbox_list): + # Calculate the position and size of the bounding box + x0, y0, width, height = images_bbox_list[0] # first box for now + + # Create the bounding box style + bbox_style = { + "position": "absolute", + "left": f"{x0}%", # Left position based on image width + "top": f"{y0}%", # Top position based on image height + "width": f"{width}%", # Width based on image width + "height": f"{height}%", # Height based on image height + "border": "2px solid red", + "zIndex": "10", + } # Create a div that represents the bounding box bbox_div = html.Div(style=bbox_style) @@ -333,11 +379,14 @@ def toggle_auto_move(n_clicks, data): State("image-slider", "value"), State("image-slider", "max"), State("auto-move-button", "n_clicks"), - State("alert-list-container", "children"), + State("wildfire_id_on_display", "data"), + State("store_wildfires_data", "data"), ], prevent_initial_call=True, ) -def auto_move_slider(n_intervals, current_value, max_value, auto_move_clicks, alert_list): +def auto_move_slider( + n_intervals, current_value, max_value, auto_move_clicks, wildfire_id_on_display, store_wildfires_data +): """ Automatically moves the image slider based on a regular interval and the current auto-move state. @@ -346,12 +395,24 @@ def auto_move_slider(n_intervals, current_value, max_value, auto_move_clicks, al - current_value (int): Current value of the image slider. - max_value (int): Maximum value of the image slider. - auto_move_clicks (int): Number of clicks on the auto-move button. - - alert_list (list): List of ongoing alerts. + - detection_list(list) : Ongoing detection list Returns: - int: Updated value for the image slider. """ - if auto_move_clicks % 2 != 0 and len(alert_list): # Auto-move is active and there is ongoing alerts + json_wildfire = json.loads(store_wildfires_data) + wildfires_dict = json_wildfire["data"] + data_loaded = json_wildfire["data_loaded"] + + if data_loaded and wildfire_id_on_display != 0: + for wildfires_item in wildfires_dict.values(): + for wildfire in wildfires_item: + if wildfire["id"] == wildfire_id_on_display: + detection_ids_list = wildfire["detection_ids"] + else: + detection_ids_list = [] + + if auto_move_clicks % 2 != 0 and len(detection_ids_list): # Auto-move is active and there is ongoing detections return (current_value + 1) % (max_value + 1) else: raise PreventUpdate @@ -360,28 +421,37 @@ def auto_move_slider(n_intervals, current_value, max_value, auto_move_clicks, al @app.callback( Output("download-link", "href"), [Input("image-slider", "value")], - [State("alert_on_display", "data")], + [State("wildfire_id_on_display", "data"), State("store_wildfires_data", "data"), State("media_url", "data")], prevent_initial_call=True, ) -def update_download_link(slider_value, alert_data): +def update_download_link(slider_value, wildfire_id_on_display, store_wildfires_data, media_url): """ Updates the download link for the currently displayed image. Parameters: - slider_value (int): Current value of the image slider. - - alert_data (json): JSON formatted data for the selected event. + - detection_data (json): JSON formatted data for the selected wildfire. + - media_url (dict): Dictionary containing media URLs for detections. Returns: - str: URL for downloading the current image. """ - alert_data, data_loaded = read_stored_DataFrame(alert_data) - if data_loaded and len(alert_data): + json_wildfire = json.loads(store_wildfires_data) + wildfires_dict = json_wildfire["data"] + data_loaded = json_wildfire["data_loaded"] + for wildfires_item in wildfires_dict.values(): + for wildfire in wildfires_item: + if wildfire["id"] == wildfire_id_on_display: + detection_ids_list = wildfire["detection_ids"] + + if data_loaded and len(detection_ids_list): try: - return alert_data["media_url"].values[slider_value] + detection_id = detection_ids_list[slider_value] + if str(detection_id) in media_url.keys(): + return media_url[str(detection_id)] except Exception as e: logger.info(e) - logger.info(f"Size of the alert_data dataframe: {alert_data.size}") - + logger.info(f"Size of the detections list: {len(detection_ids_list)}") return "" # Return empty string if no image URL is available @@ -399,69 +469,77 @@ def update_download_link(slider_value, alert_data): Output("alert-information", "style"), Output("slider-container", "style"), ], - Input("alert_on_display", "data"), + Input("detection_on_display", "data"), + [State("store_wildfires_data", "data"), State("wildfire_id_on_display", "data")], prevent_initial_call=True, ) -def update_map_and_alert_info(alert_data): +def update_map_and_alert_info(detection_data, store_wildfires_data, wildfire_id_on_display): """ - Updates the map's vision polygons, center, and alert information based on the current alert data. - + Updates the map's vision polygons, center, and alert information based on the current alert data. + ) Parameters: - - alert_data (json): JSON formatted data for the selected event. + - detection_data (json): JSON formatted data for the selecte dwildfire. Returns: - - list: List of vision polygon elements to be displayed on the map. - - list: New center coordinates for the map. - - list: List of vision polygon elements to be displayed on the modal map. - - list: New center coordinates for the modal map. - - str: Camera information for the alert. - - str: Location information for the alert. - - str: Detection angle for the alert. - - str: Date of the alert. - - dict: Style settings for alert information. - - dict: Style settings for the slider container. + - list: List of vision polygon elements to be displayed on the map. + - list: New center coordinates for the map. + - list: List of vision polygon elements to be displayed on the modal map. + - list: New center coordinates for the modal map. + - str: Camera information for the alert. + - str: Location information for the alert. + - str: Detection angle for the alert. + - str: Date of the alert. """ - alert_data, data_loaded = read_stored_DataFrame(alert_data) + detection_data, data_loaded = read_stored_DataFrame(detection_data) if not data_loaded: raise PreventUpdate - if not alert_data.empty: - # Convert the 'localization' column to a list (empty lists if the original value was '[]'). - alert_data["localization"] = alert_data["localization"].apply( + if not detection_data.empty: + json_wildfire = json.loads(store_wildfires_data) + wildfires_dict = json_wildfire["data"] + data_loaded = json_wildfire["data_loaded"] + if not data_loaded: + raise PreventUpdate + + # Convert the 'bboxes' column to a list (empty lists if the original value was '[]'). + detection_data["bboxes"] = detection_data["bboxes"].apply( lambda x: ast.literal_eval(x) if isinstance(x, str) and x.strip() != "[]" else [] - ) + ) # WHY? - # Filter out rows where 'localization' is not empty and get the last one. + # Filter out rows where 'bboxes' is not empty and get the last one. # If all are empty, then simply get the last row of the DataFrame. - row_with_localization = ( - alert_data[alert_data["localization"].astype(bool)].iloc[-1] - if not alert_data[alert_data["localization"].astype(bool)].empty - else alert_data.iloc[-1] + row_with_bboxes = ( + detection_data[detection_data["bboxes"].astype(bool)].iloc[-1] + if not detection_data[detection_data["bboxes"].astype(bool)].empty + else detection_data.iloc[-1] ) polygon, detection_azimuth = build_vision_polygon( - site_lat=row_with_localization["lat"], - site_lon=row_with_localization["lon"], - azimuth=row_with_localization["device_azimuth"], + site_lat=row_with_bboxes["lat"], + site_lon=row_with_bboxes["lon"], + azimuth=row_with_bboxes["azimuth"], opening_angle=cfg.CAM_OPENING_ANGLE, dist_km=cfg.CAM_RANGE_KM, - localization=row_with_localization["processed_loc"], + bboxes=row_with_bboxes["processed_loc"], ) - date_val = row_with_localization["created_at"] - cam_name = f"{row_with_localization['device_login'][:-2].replace('_', ' ')} - {int(row_with_localization['device_azimuth'])}°" + for wildfires_item in wildfires_dict.values(): + for wildfire in wildfires_item: + if wildfire["id"] == wildfire_id_on_display: + date_val = wildfire["created_at"] + cam_name = wildfire["camera_name"] camera_info = f"Camera: {cam_name}" - location_info = f"Station localisation: {row_with_localization['lat']:.4f}, {row_with_localization['lon']:.4f}" + location_info = f"Localisation: {row_with_bboxes['lat']:.4f}, {row_with_bboxes['lon']:.4f}" angle_info = f"Azimuth de detection: {detection_azimuth}°" date_info = f"Date: {date_val}" return ( polygon, - [row_with_localization["lat"], row_with_localization["lon"]], + [row_with_bboxes["lat"], row_with_bboxes["lon"]], polygon, - [row_with_localization["lat"], row_with_localization["lon"]], + [row_with_bboxes["lat"], row_with_bboxes["lon"]], camera_info, location_info, angle_info, @@ -488,33 +566,37 @@ def update_map_and_alert_info(alert_data): Output("to_acknowledge", "data"), [Input("acknowledge-button", "n_clicks")], [ - State("event_id_on_display", "data"), - State("user_headers", "data"), - State("user_credentials", "data"), + State("store_wildfires_data", "data"), + State("wildfire_id_on_display", "data"), + State("client_token", "data"), ], prevent_initial_call=True, ) -def acknowledge_event(n_clicks, event_id_on_display, user_headers, user_credentials): +def acknowledge_wildfire(n_clicks, store_wildfires_data, wildfire_id_on_display, client_token): """ - Acknowledges the selected event and updates the state to reflect this. + Acknowledges the selected wildfire and updates the state to reflect this. Parameters: - n_clicks (int): Number of clicks on the acknowledge button. - - event_id_on_display (int): Currently displayed event ID. - - user_headers (dict): User authorization headers for API requests. - - user_credentials (tuple): User credentials (username, password). + -wildfire_id_on_display (int): Currently displayedwildfire ID. + - client_token (str): Token used for API requests. Returns: - - int: The ID of the event that has been acknowledged. + - int: The ID of the wildfire that has been acknowledged. """ - if event_id_on_display == 0 or n_clicks == 0: + if wildfire_id_on_display == 0 or n_clicks == 0: raise PreventUpdate + json_wildfire = json.loads(store_wildfires_data) + wildfires_dict = json_wildfire["data"] + wildfire_id = int(wildfire_id_on_display) + for wildfires_item in wildfires_dict.values(): + for wildfire in wildfires_item: + if wildfire["id"] == wildfire_id: + detection_ids_list = wildfire["detection_ids"] + for detection_id in detection_ids_list: + Client(client_token, cfg.API_URL).label_detection(detection_id=detection_id, is_wildfire=False) - user_token = user_headers["Authorization"].split(" ")[1] - api_client.token = user_token - call_api(api_client.acknowledge_event, user_credentials)(event_id=int(event_id_on_display)) - - return event_id_on_display + return wildfire_id_on_display # Modal issue let's add this later @@ -525,16 +607,6 @@ def acknowledge_event(n_clicks, event_id_on_display, user_headers, user_credenti prevent_initial_call=True, ) def toggle_fullscreen_map(n_clicks_open, is_open): - """ - Toggles the fullscreen map modal based on button clicks. - - Parameters: - - n_clicks_open (int): Number of clicks on the map button to toggle modal. - - is_open (bool): Current state of the map modal. - - Returns: - - bool: New state of the map modal (open/close). - """ if n_clicks_open: return not is_open # Toggle the modal return is_open # Keep the current state @@ -544,19 +616,10 @@ def toggle_fullscreen_map(n_clicks_open, is_open): @app.callback( Output("map", "zoom"), [ - Input({"type": "event-button", "index": ALL}, "n_clicks"), + Input({"type": "wildfire-button", "index": ALL}, "n_clicks"), ], ) def reset_zoom(n_clicks): - """ - Resets the zoom level of the map when an event button is clicked. - - Parameters: - - n_clicks (list): List of click counts for each event button. - - Returns: - - int: Reset zoom level for the map. - """ if n_clicks: return 10 # Reset zoom level to 10 return dash.no_update diff --git a/app/components/alerts.py b/app/components/detections.py similarity index 52% rename from app/components/alerts.py rename to app/components/detections.py index 8b56a9a..4343cf7 100644 --- a/app/components/alerts.py +++ b/app/components/detections.py @@ -6,20 +6,20 @@ from dash import html -def create_event_list(): +def create_wildfire_list(): """ - Creates a container for the alert list with a fixed height and scrollable content. + Creates a container for the detection list with a fixed height and scrollable content. This function generates a Dash HTML Div element containing a header and an empty container. - The empty container ('alert-list-container') is meant to be populated with alert buttons + The empty container ('wildfire-list-container') is meant to be populated with detection buttons dynamically via a callback. The container has a fixed height and is scrollable, allowing - users to browse through a potentially long list of alerts. + users to browse through a potentially long list of detections. Returns: - - dash.html.Div: A Div element containing the header and the empty container for alert buttons. + - dash.html.Div: A Div element containing the header and the empty container for detection buttons. """ - # Set a fixed height for the alert list container and enable scrolling - event_list_style = { + # Set a fixed height for the detection list container and enable scrolling + wildfire_list_style = { "height": "calc(100vh - 120px)", # Adjust the height as required "overflowY": "scroll", # Enable vertical scrolling "padding": "10px", @@ -27,6 +27,7 @@ def create_event_list(): return html.Div( [ - html.Div(id="alert-list-container", style=event_list_style, children=[]), # Empty container + html.H1("Detections en cours", style={"textAlign": "center", "fontSize": "30px"}), + html.Div(id="wildfire-list-container", style=wildfire_list_style, children=[]), # Empty container ] ) diff --git a/app/index.py b/app/index.py index eb0039d..ee373df 100644 --- a/app/index.py +++ b/app/index.py @@ -8,7 +8,7 @@ import callbacks.display_callbacks # noqa: F401 import logging_config from dash import html -from dash.dependencies import Input, Output, State +from dash.dependencies import Input, Output from layouts.main_layout import get_main_layout from main import app @@ -26,22 +26,20 @@ # Manage Pages @app.callback( Output("page-content", "children"), - [Input("url", "pathname"), Input("user_headers", "data")], - State("user_credentials", "data"), + [Input("url", "pathname"), Input("client_token", "data")], ) -def display_page(pathname, user_headers, user_credentials): +def display_page(pathname, client_token): logger.debug( - "display_page called with pathname: %s, user_headers: %s, user_credentials: %s", + "display_page called with pathname: %s, user_credentials: %s", pathname, - user_headers, - user_credentials, + {cfg.API_LOGIN, cfg.API_PWD}, ) - if user_headers is None: - logger.info("No user headers found, showing login layout.") + if client_token is None: + logger.info("No token found, showing login layout.") return login_layout() if pathname == "/" or pathname is None: logger.info("Showing homepage layout.") - return homepage_layout(user_headers, user_credentials) + return homepage_layout(client_token) else: logger.warning("Unable to find page for pathname: %s", pathname) return html.Div([html.P("Unable to find this page.", className="alert alert-warning")]) diff --git a/app/layouts/main_layout.py b/app/layouts/main_layout.py index 7940ca2..7560f5c 100644 --- a/app/layouts/main_layout.py +++ b/app/layouts/main_layout.py @@ -5,24 +5,19 @@ import json +import dash_bootstrap_components as dbc import pandas as pd from dash import dcc, html -from pyroclient import Client import config as cfg from components.navbar import Navbar -from services import api_client +from services import instantiate_token if not cfg.LOGIN: - client = Client(cfg.API_URL, cfg.API_LOGIN, cfg.API_PWD) - user_headers = client.headers - user_token = user_headers["Authorization"].split(" ")[1] - api_client.token = user_token - user_credentials = {"username": cfg.API_LOGIN, "password": cfg.API_PWD} - + api_client = instantiate_token() + token = api_client.token else: - user_credentials = {} - user_headers = None + token = None def get_main_layout(): @@ -37,7 +32,17 @@ def get_main_layout(): ), dcc.Interval(id="main_api_fetch_interval", interval=30 * 1000), dcc.Store( - id="store_api_alerts_data", + id="store_wildfires_data", + storage_type="session", + data=json.dumps( + { + "data": {}, + "data_loaded": False, + } + ), + ), + dcc.Store( + id="store_detections_data", storage_type="session", data=json.dumps( { @@ -46,8 +51,9 @@ def get_main_layout(): } ), ), + dcc.Store(id="last_displayed_wildfire_id", storage_type="session"), dcc.Store( - id="alert_on_display", + id="detection_on_display", storage_type="session", data=json.dumps( { @@ -56,15 +62,45 @@ def get_main_layout(): } ), ), - dcc.Store(id="event_id_on_display", data=0), + dcc.Store( + id="media_url", + storage_type="session", + data={}, + ), + dcc.Store(id="wildfire_id_on_display", data=0), dcc.Store(id="auto-move-state", data={"active": True}), # Add this to your app.layout dcc.Store(id="bbox_visibility", data={"visible": True}), + dbc.Modal( + [ + dbc.ModalBody( + [ + html.Div( + [ + html.Div( + className="spinner-border text-primary", + role="status", + ), + html.Div( + "Chargement des données...", + className="ml-2 d-inline-block align-top", + ), # Ensure text is inline with spinner + ], + className="d-flex align-items-center justify-content-center", # Center both spinner and text + ) + ], + style={"textAlign": "center"}, # Center the modal body content + ) + ], + id="modal-loading", + centered=True, + is_open=False, + ), # Storage components saving the user's headers and credentials - dcc.Store(id="user_headers", storage_type="session", data=user_headers), + dcc.Store(id="client_token", storage_type="session", data=token), # [TEMPORARY FIX] Storing the user's credentials to refresh the token when needed - dcc.Store(id="user_credentials", storage_type="session", data=user_credentials), dcc.Store(id="to_acknowledge", data=0), - dcc.Store(id="trigger_no_events", data=False), + dcc.Store(id="trigger_no_wildfires", data=False), + dcc.Store(id="previous_time_event", data=None), ] ) diff --git a/app/pages/homepage.py b/app/pages/homepage.py index 6a767bb..a607da2 100644 --- a/app/pages/homepage.py +++ b/app/pages/homepage.py @@ -7,19 +7,21 @@ import dash_bootstrap_components as dbc from dash import Dash, dcc, html -from components.alerts import create_event_list -from utils.display import build_alerts_map +from components.detections import create_wildfire_list +from utils.display import build_detections_map app = Dash(__name__, external_stylesheets=[dbc.themes.BOOTSTRAP]) app.css.append_css({"external_url": "/assets/style.css"}) -def homepage_layout(user_headers, user_credentials): +def homepage_layout(client_token): return dbc.Container( [ dbc.Row( [ - dbc.Col([create_event_list()], width=2, className="mb-4"), + # Column for the alert list + dbc.Col(create_wildfire_list(), width=2, className="mb-4"), + # Column for the image dbc.Col( [ html.Div( @@ -113,7 +115,7 @@ def homepage_layout(user_headers, user_credentials): ), dbc.Row( dbc.Col( - build_alerts_map(user_headers, user_credentials), + build_detections_map(client_token), className="common-style", style={ "position": "relative", @@ -149,7 +151,7 @@ def homepage_layout(user_headers, user_credentials): [ dbc.ModalHeader("Carte"), dbc.ModalBody( - build_alerts_map(user_headers, user_credentials, id_suffix="-md"), + build_detections_map(client_token, id_suffix="-md"), ), ], id="map-modal", diff --git a/app/services/__init__.py b/app/services/__init__.py index 061b12e..7815e66 100644 --- a/app/services/__init__.py +++ b/app/services/__init__.py @@ -1,3 +1,3 @@ -from .api import api_client, call_api +from .api import instantiate_token -__all__ = ["api_client", "call_api"] +__all__ = ["instantiate_token"] diff --git a/app/services/api.py b/app/services/api.py index 204fc50..490f8e7 100644 --- a/app/services/api.py +++ b/app/services/api.py @@ -3,49 +3,36 @@ # This program is licensed under the Apache License 2.0. # See LICENSE or go to for full license details. -from functools import wraps -from typing import Callable, Dict +from typing import Optional +from urllib.parse import urljoin +import requests from pyroclient import Client import config as cfg -__all__ = ["api_client", "call_api"] +__all__ = ["instantiate_token"] -if any(not isinstance(val, str) for val in [cfg.API_URL, cfg.API_LOGIN, cfg.API_PWD]): - raise ValueError("The following environment variables need to be set: 'API_URL', 'API_LOGIN', 'API_PWD'") +def instantiate_token(login: Optional[str] = None, passwrd: Optional[str] = None): + if not cfg.LOGIN: + if any(not isinstance(val, str) for val in [cfg.API_URL, cfg.API_LOGIN, cfg.API_PWD]): + raise ValueError("The following environment variables need to be set: 'API_URL', 'API_LOGIN', 'API_PWD'") + else: + access_token = requests.post( + urljoin(cfg.API_URL, "/api/v1/login/creds"), + data={"username": cfg.API_LOGIN, "password": cfg.API_PWD}, + timeout=5, + ).json()["access_token"] -api_client = Client(cfg.API_URL, cfg.API_LOGIN, cfg.API_PWD) + api_client = Client(access_token, cfg.API_URL) + return api_client + access_token = requests.post( + urljoin(cfg.API_URL, "/api/v1/login/creds"), + data={"username": login, "password": passwrd}, + timeout=5, + ).json()["access_token"] -def call_api(func: Callable, user_credentials: Dict[str, str]) -> Callable: - """Decorator to call API method and renew the token if needed. Usage: - - result = call_api(my_func, user_credentials)(1, 2, verify=False) - - Instead of: - - response = my_func(1, verify=False) - if response.status_code == 401: - api_client.refresh_token(user_credentials["username"], user_credentials["password"]) - response = my_func(1, verify=False) - result = response.json() - - Args: - func: function that calls API method - user_credentials: a dictionary with two keys, the username and password for authentication - - Returns: decorated function, to be called with positional and keyword arguments - """ - - @wraps(func) - def wrapper(*args, **kwargs): - response = func(*args, **kwargs) - if response.status_code == 401: - api_client.refresh_token(user_credentials["username"], user_credentials["password"]) - response = func(*args, **kwargs) - assert response.status_code // 100 == 2, response.text - return response.json() - - return wrapper + api_client = Client(access_token, cfg.API_URL) + return api_client diff --git a/app/utils/data.py b/app/utils/data.py index dd60cbc..31144f3 100644 --- a/app/utils/data.py +++ b/app/utils/data.py @@ -92,34 +92,3 @@ def process_bbox(input_str): new_boxes.append([x0 * 100, y0 * 100, width * 100, height * 100]) return new_boxes - - -def past_ndays_api_events(api_events, n_days=0): - """ - Filters the given live events to retain only those within the past n days. - - Args: - api_events (pd.Dataframe): DataFrame containing live events data. It must have a "created_at" column - indicating the datetime of the event. - n_days (int, optional): Specifies the number of days into the past to retain events. Defaults to 0. - - Returns: - pd.DataFrame: A filtered DataFrame containing only events from the past n_days. - """ - # Ensure the column is in datetime format - api_events["created_at"] = pd.to_datetime(api_events["created_at"]) - - # Define the end date (now) for the filter - end_date = pd.Timestamp.now() - - if n_days == 0: - # When n_days is 0, adjust start_date to the beginning of today to include today's events - start_date = end_date.normalize() - else: - # For n_days > 0 - start_date = end_date - pd.Timedelta(days=n_days) - - # Filter events from the past n days, including all events from today when n_days is 0 - api_events = api_events[(api_events["created_at"] > start_date) | (api_events["created_at"] == start_date)] - - return api_events diff --git a/app/utils/display.py b/app/utils/display.py index 092008d..a3295f5 100644 --- a/app/utils/display.py +++ b/app/utils/display.py @@ -5,14 +5,14 @@ import dash_leaflet as dl +import pandas as pd import requests from dash import html from geopy import Point from geopy.distance import geodesic +from pyroclient import Client import config as cfg -from services import api_client -from utils.sites import get_sites DEPARTMENTS = requests.get(cfg.GEOJSON_FILE, timeout=10).json() @@ -34,12 +34,12 @@ def build_departments_geojson(): return geojson -def calculate_new_polygon_parameters(azimuth, opening_angle, localization): +def calculate_new_polygon_parameters(azimuth, opening_angle, bboxes): """ - This function compute the vision polygon parameters based on localization + This function compute the vision polygon parameters based on bboxes """ - # Assuming localization is in the format [x0, y0, x1, y1, confidence] - x0, _, width, _ = localization + # Assuming bboxes is in the format [x0, y0, x1, y1, confidence] + x0, _, width, _ = bboxes xc = (x0 + width / 2) / 100 # New azimuth @@ -51,7 +51,7 @@ def calculate_new_polygon_parameters(azimuth, opening_angle, localization): return int(new_azimuth) % 360, int(new_opening_angle) -def build_sites_markers(user_headers, user_credentials): +def build_cameras_markers(token: str): """ This function reads the site markers by making the API, that contains all the information about the sites equipped with detection units. @@ -70,17 +70,15 @@ def build_sites_markers(user_headers, user_credentials): "popupAnchor": [0, -20], # Point from which the popup should open relative to the iconAnchor } - user_token = user_headers["Authorization"].split(" ")[1] - api_client.token = user_token + cameras = pd.DataFrame(Client(token, cfg.API_URL).fetch_cameras().json()) - client_sites = get_sites(user_credentials) markers = [] - for _, site in client_sites.iterrows(): - site_id = site["id"] - lat = round(site["lat"], 4) - lon = round(site["lon"], 4) - site_name = site["name"].replace("_", " ").title() + for _, camera in cameras.iterrows(): + site_id = camera["id"] + lat = round(camera["lat"], 4) + lon = round(camera["lon"], 4) + site_name = camera["name"].replace("_", " ").title() markers.append( dl.Marker( id=f"site_{site_id}", # Necessary to set an id for each marker to receive callbacks @@ -99,15 +97,15 @@ def build_sites_markers(user_headers, user_credentials): ) # We group all dl.Marker objects in a dl.MarkerClusterGroup object and return it - return markers, client_sites + return markers, cameras -def build_vision_polygon(site_lat, site_lon, azimuth, opening_angle, dist_km, localization=None): +def build_vision_polygon(site_lat, site_lon, azimuth, opening_angle, dist_km, bboxes=None): """ Create a vision polygon using dl.Polygon. This polygon is placed on the map using alerts data. """ - if len(localization): - azimuth, opening_angle = calculate_new_polygon_parameters(azimuth, opening_angle, localization[0]) + if len(bboxes): + azimuth, opening_angle = calculate_new_polygon_parameters(azimuth, opening_angle, bboxes[0]) # The center corresponds the point from which the vision angle "starts" center = [site_lat, site_lon] @@ -137,7 +135,7 @@ def build_vision_polygon(site_lat, site_lon, azimuth, opening_angle, dist_km, lo return polygon, azimuth -def build_alerts_map(user_headers, user_credentials, id_suffix=""): +def build_detections_map(client_token, id_suffix=""): """ The following function mobilises functions defined hereabove or in the utils module to instantiate and return a dl.Map object, corresponding to the "Alerts and Infrastructure" view. @@ -150,12 +148,12 @@ def build_alerts_map(user_headers, user_credentials, id_suffix=""): "height": "100%", } - markers, client_sites = build_sites_markers(user_headers, user_credentials) + markers, cameras = build_cameras_markers(client_token) map_object = dl.Map( center=[ - client_sites["lat"].median(), - client_sites["lon"].median(), + cameras["lat"].median(), + cameras["lon"].median(), ], # Determines the point around which the map is centered zoom=10, # Determines the initial level of zoom around the center point children=[ @@ -171,23 +169,24 @@ def build_alerts_map(user_headers, user_credentials, id_suffix=""): return map_object -def create_event_list_from_alerts(api_events): +def create_wildfire_list_from_df(wildfires): """ - This function build the list of events on the left based on event data + This function build the list of wildfires on the left based on wildfire data """ - if api_events.empty: + if wildfires.empty: return [] - filtered_events = api_events.sort_values("created_at").drop_duplicates("id", keep="last")[::-1] + + filtered_wildfires = wildfires.sort_values("created_at").drop_duplicates("id", keep="last")[::-1] return [ html.Button( - id={"type": "event-button", "index": event["id"]}, + id={"type": "wildfire-button", "index": wildfire["id"]}, children=[ html.Div( - f"{event['device_login'][:-2].replace('_', ' ')} - {int(event['device_azimuth'])}°", + f"{wildfire['camera_name']}", style={"fontWeight": "bold"}, ), - html.Div(event["created_at"].strftime("%Y-%m-%d %H:%M")), + html.Div(wildfire["created_at"]), ], n_clicks=0, style={ @@ -198,5 +197,5 @@ def create_event_list_from_alerts(api_events): "width": "100%", }, ) - for _, event in filtered_events.iterrows() + for _, wildfire in filtered_wildfires.iterrows() ] diff --git a/app/utils/sites.py b/app/utils/sites.py deleted file mode 100644 index 7111530..0000000 --- a/app/utils/sites.py +++ /dev/null @@ -1,38 +0,0 @@ -# Copyright (C) 2020-2024, Pyronear. - -# This program is licensed under the Apache License 2.0. -# See LICENSE or go to for full license details. - -from typing import Any, Dict, Optional - -import pandas as pd -import requests - -import config as cfg - - -def get_token(api_url: str, login: str, pwd: str) -> str: - response = requests.post(f"{api_url}/login/access-token", data={"username": login, "password": pwd}, timeout=3) - if response.status_code != 200: - raise ValueError(response.json()["detail"]) - return response.json()["access_token"] - - -def api_request(method_type: str, route: str, headers=Dict[str, str], payload: Optional[Dict[str, Any]] = None): - kwargs = {"json": payload} if isinstance(payload, dict) else {} - - response = getattr(requests, method_type)(route, headers=headers, **kwargs) - return response.json() - - -def get_sites(user_credentials): - api_url = cfg.API_URL.rstrip("/") - superuser_login = user_credentials["username"] - superuser_pwd = user_credentials["password"] - - superuser_auth = { - "Authorization": f"Bearer {get_token(api_url, superuser_login, superuser_pwd)}", - "Content-Type": "application/json", - } - api_sites = api_request("get", f"{api_url}/sites/", superuser_auth) - return pd.DataFrame(api_sites) diff --git a/docker-compose.yml b/docker-compose.yml index fd6e4c4..8f8f1b9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -46,4 +46,4 @@ services: networks: web: - external: true \ No newline at end of file + external: true diff --git a/pyproject.toml b/pyproject.toml index b206137..1e70613 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ dash = ">=2.14.0" dash-bootstrap-components = ">=1.5.0" dash-leaflet = "^0.1.4" pandas = ">=2.1.4" -pyroclient = { git = "https://github.com/pyronear/pyro-api.git", branch = "old-production", subdirectory = "client" } +pyroclient = { git = "https://github.com/pyronear/pyro-api.git", rev = "a46f5a00869049ffd1a8bb920ac685e44f18deb5", subdirectory = "client" } python-dotenv = ">=1.0.0" geopy = ">=2.4.0" diff --git a/traefik.toml b/traefik.toml index 30232c4..d17c4e7 100644 --- a/traefik.toml +++ b/traefik.toml @@ -25,4 +25,4 @@ email = "contact@pyronear.org" storage = "acme.json" caServer = "https://acme-v02.api.letsencrypt.org/directory" - [certificatesResolvers.default.acme.tlsChallenge] \ No newline at end of file + [certificatesResolvers.default.acme.tlsChallenge]