diff --git a/src/backend/app/config.py b/src/backend/app/config.py index d1b5dc90..6144ceb9 100644 --- a/src/backend/app/config.py +++ b/src/backend/app/config.py @@ -90,7 +90,7 @@ def assemble_db_connection(cls, v: Optional[str], info: ValidationInfo) -> Any: ALGORITHM: str = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 60 * 24 * 1 # 1 day REFRESH_TOKEN_EXPIRE_MINUTES: int = 60 * 60 * 24 * 8 # 8 day - + RESET_PASSWORD_TOKEN_EXPIRE_MINUTES: int = 5 GOOGLE_CLIENT_ID: str GOOGLE_CLIENT_SECRET: str GOOGLE_LOGIN_REDIRECT_URI: str = "http://localhost:8000" diff --git a/src/backend/app/email_templates/password/password_reset.html b/src/backend/app/email_templates/password/password_reset.html new file mode 100644 index 00000000..8adba82f --- /dev/null +++ b/src/backend/app/email_templates/password/password_reset.html @@ -0,0 +1,87 @@ + + + + + + + + +
+
+

Password Reset Request

+
+
+

Hi {{email}},

+

+ We received a request to reset your password. Please click the button + below to reset it: +

+ Reset Password +

If you didn’t request this, please ignore this email.

+

Thank you,
The {{project_name}} Team

+
+ +
+ + diff --git a/src/backend/app/projects/project_routes.py b/src/backend/app/projects/project_routes.py index acf282b5..29c70f12 100644 --- a/src/backend/app/projects/project_routes.py +++ b/src/backend/app/projects/project_routes.py @@ -19,7 +19,6 @@ from psycopg import Connection from shapely.geometry import shape, mapping from shapely.ops import unary_union - from app.projects import project_schemas, project_deps, project_logic from app.db import database from app.models.enums import HTTPStatus diff --git a/src/backend/app/tasks/task_routes.py b/src/backend/app/tasks/task_routes.py index 890ad7fa..aca4e1fb 100644 --- a/src/backend/app/tasks/task_routes.py +++ b/src/backend/app/tasks/task_routes.py @@ -206,6 +206,7 @@ async def new_event( db, project["author_id"] ) html_content = render_email_template( + folder_name="mapping", template_name="requests.html", context={ "name": author["name"], @@ -240,6 +241,7 @@ async def new_event( db, requested_user_id ) html_content = render_email_template( + folder_name="mapping", template_name="approved_or_rejected.html", context={ "email_subject": "Mapping Request Approved", @@ -286,6 +288,7 @@ async def new_event( db, requested_user_id ) html_content = render_email_template( + folder_name="mapping", template_name="approved_or_rejected.html", context={ "email_subject": "Mapping Request Rejected", diff --git a/src/backend/app/users/user_deps.py b/src/backend/app/users/user_deps.py index ae6b4af9..82984d2c 100644 --- a/src/backend/app/users/user_deps.py +++ b/src/backend/app/users/user_deps.py @@ -4,6 +4,8 @@ from app.users.auth import Auth from app.users.user_schemas import AuthUser from loguru import logger as log +from datetime import datetime, timedelta +import jwt async def init_google_auth(): @@ -50,3 +52,14 @@ async def login_required( raise HTTPException(status_code=401, detail="Access token not valid") from e return AuthUser(**user) + + +def create_reset_password_token(email: str): + expire = datetime.now() + timedelta( + minutes=settings.RESET_PASSWORD_TOKEN_EXPIRE_MINUTES + ) + to_encode = {"sub": email, "exp": expire} + encoded_jwt = jwt.encode( + to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM + ) + return encoded_jwt diff --git a/src/backend/app/users/user_routes.py b/src/backend/app/users/user_routes.py index bb8e2467..fdd8384f 100644 --- a/src/backend/app/users/user_routes.py +++ b/src/backend/app/users/user_routes.py @@ -1,11 +1,13 @@ import os +import jwt from app.users import user_schemas from app.users import user_deps from app.users import user_logic -from fastapi import APIRouter, HTTPException, Depends, Request +from fastapi import APIRouter, HTTPException, Depends, Request, BackgroundTasks from typing import Annotated from fastapi.security import OAuth2PasswordRequestForm from app.users.user_schemas import ( + DbUser, Token, UserProfileIn, AuthUser, @@ -17,6 +19,8 @@ from psycopg import Connection from fastapi.responses import JSONResponse from loguru import logger as log +from pydantic import EmailStr +from app.utils import send_reset_password_email if settings.DEBUG: @@ -38,18 +42,18 @@ async def login_access_token( """ OAuth2 compatible token login, get an access token for future requests """ - user = await user_deps.authenticate(db, form_data.username, form_data.password) + user = await user_logic.authenticate(db, form_data.username, form_data.password) if not user: raise HTTPException(status_code=400, detail="Incorrect email or password") - elif not user.is_active: + elif not user.get("is_active"): raise HTTPException(status_code=400, detail="Inactive user") user_info = { - "id": user.id, - "email": user.email_address, - "name": user.name, - "profile_img": user.profile_img, + "id": user.get("id"), + "email": user.get("email_address"), + "name": user.get("name"), + "profile_img": user.get("profile_img"), } access_token, refresh_token = await user_logic.create_access_token(user_info) @@ -160,3 +164,71 @@ async def my_data( user_info_dict.update(has_user_profile.model_dump()) return user_info_dict + + +@router.post("/forgot-password/") +async def forgot_password( + db: Annotated[Connection, Depends(database.get_db)], + email: EmailStr, + background_tasks: BackgroundTasks, +): + user = await DbUser.get_user_by_email(db, email) + token = user_deps.create_reset_password_token(user["email_address"]) + # Store the token in the database (or other storage mechanism) FIXME it is necessary to save reset password token. + # user["reset_password_token"] = token + background_tasks.add_task(send_reset_password_email, user["email_address"], token) + + return JSONResponse( + content={"detail": "Password reset email sent"}, status_code=200 + ) + + +@router.post("/reset-password/") +async def reset_password( + db: Annotated[Connection, Depends(database.get_db)], token: str, new_password: str +): + try: + payload = jwt.decode( + token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM] + ) + email = payload.get("sub") + if email is None: + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, detail="Invalid token" + ) + + user = await DbUser.get_user_by_email(db, email) + if not user: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="User not found" + ) + + # Update password within a transaction + async with db.transaction(): + async with db.cursor() as cur: + await cur.execute( + """ + UPDATE users + SET password = %(password)s + WHERE id = %(user_id)s; + """, + { + "password": user_logic.get_password_hash(new_password), + "user_id": user.get("id"), + }, + ) + + except jwt.ExpiredSignatureError: + raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Token expired") + except jwt.JWTError: + raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Invalid token") + except Exception as e: + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail=f"An error occurred: {str(e)}", + ) + + return JSONResponse( + content={"detail": "Your password has been successfully reset!"}, + status_code=200, + ) diff --git a/src/backend/app/users/user_schemas.py b/src/backend/app/users/user_schemas.py index f58fb40d..fb61a6fd 100644 --- a/src/backend/app/users/user_schemas.py +++ b/src/backend/app/users/user_schemas.py @@ -1,5 +1,5 @@ import uuid -from app.models.enums import State, UserRole +from app.models.enums import HTTPStatus, State, UserRole from pydantic import BaseModel, EmailStr, ValidationInfo, Field from pydantic.functional_validators import field_validator from typing import Optional @@ -253,12 +253,23 @@ async def get_user_by_id(db: Connection, id: str) -> dict[str, Any] | None: return result if result else None @staticmethod - async def get_user_by_email(db: Connection, email: str) -> dict[str, Any] | None: + async def get_user_by_email(db: Connection, email: str) -> dict[str, Any]: query = "SELECT * FROM users WHERE email_address = %s LIMIT 1;" - async with db.cursor() as cur: - await cur.execute(query, (email,)) - result = await cur.fetchone() - return result if result else None + try: + async with db.cursor(row_factory=dict_row) as cur: + await cur.execute(query, (email,)) + result = await cur.fetchone() + if result is None: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="User with this email does not exist", + ) + return result + except Exception: + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="An error occurred while querying the database.", + ) @staticmethod async def get_requested_user_id( diff --git a/src/backend/app/utils.py b/src/backend/app/utils.py index 8998fa1c..ae30711f 100644 --- a/src/backend/app/utils.py +++ b/src/backend/app/utils.py @@ -367,31 +367,24 @@ class EmailData: subject: str -def render_email_template(template_name: str, context: dict[str, Any]) -> str: +def render_email_template( + folder_name: str, template_name: str, context: dict[str, Any] +) -> str: """ - Render an email template with the given context. + Render an email template with the given context from the specified folder. Args: + folder_name (str): The folder containing the template file. template_name (str): The name of the template file to be rendered. context (dict[str, Any]): A dictionary containing the context variables to be used in the template. Returns: str: The rendered HTML content of the email template. - - Example: - html_content = render_email_template( - template_name="welcome_email.html", - context={"username": "John Doe", "welcome_message": "Welcome to our service!"} - ) - - This function reads the specified email template from the 'email-templates' directory, - then uses the `Template` class from the `jinja2` library to render the template with - the provided context variables. """ - - template_str = ( - Path(__file__).parent / "email_templates" / "mapping" / template_name - ).read_text() + template_path = ( + Path(__file__).parent / "email_templates" / folder_name / template_name + ) + template_str = template_path.read_text() html_content = Template(template_str).render(context) return html_content @@ -438,6 +431,7 @@ async def send_notification_email(email_to, subject, html_content): def test_email(email_to: str, subject: str = "Test email") -> None: html_content = render_email_template( + folder_name="mapping", template_name="email_template.html", context={"project_name": settings.APP_NAME, "email": email_to}, ) @@ -445,3 +439,34 @@ def test_email(email_to: str, subject: str = "Test email") -> None: send_notification_email( email_to=email_to, subject=subject, html_content=html_content ) + + +async def send_reset_password_email(email: str, token: str): + reset_link = f"{settings.FRONTEND_URL}/reset-password?token={token}" + + context = { + "reset_link": reset_link, + "project_name": settings.EMAILS_FROM_NAME, + "email": email, + } + + html_content = render_email_template("password", "password_reset.html", context) + + message = MIMEText(html_content, "html") + message["From"] = formataddr( + (settings.EMAILS_FROM_NAME, settings.EMAILS_FROM_EMAIL) + ) + message["To"] = email + message["Subject"] = "Password Reset Request" + + try: + log.debug("Sending email message") + await send_email( + message, + hostname=settings.SMTP_HOST, + port=settings.SMTP_PORT, + username=settings.SMTP_USER, + password=settings.SMTP_PASSWORD, + ) + except Exception as e: + log.error(f"Error sending email: {e}") diff --git a/src/backend/app/waypoints/waypoint_logic.py b/src/backend/app/waypoints/waypoint_logic.py index f581ac3c..efe53d23 100644 --- a/src/backend/app/waypoints/waypoint_logic.py +++ b/src/backend/app/waypoints/waypoint_logic.py @@ -1,475 +1,42 @@ -import os -import uuid -import zipfile -import xml.etree.ElementTree as ET -from shapely.geometry import Polygon -from app.models.enums import DroneType -from math import radians, sin, cos, sqrt, atan2 -from xml.etree.ElementTree import Element +from pyproj import Transformer +from shapely.ops import transform +from shapely.geometry import shape +from geojson_pydantic import Polygon, Point -def haversine_distance(coord1, coord2): - # Haversine formula for great-circle distance - lon1, lat1 = map(radians, coord1) - lon2, lat2 = map(radians, coord2) - - dlat = lat2 - lat1 - dlon = lon2 - lon1 - - a = sin(dlat / 2) ** 2 + cos(lat1) * cos(lat2) * sin(dlon / 2) ** 2 - c = 2 * atan2(sqrt(a), sqrt(1 - a)) - - # Radius of Earth in kilometers - radius = 6371.0 - distance = radius * c - - return distance - - -async def get_drone_specs(drone_type: DroneType): - if drone_type == DroneType.DJI_MINI_4_PRO: - drone_specs = { - "sensor_width": 9.6, # mm - "sensor_height": 7.2, # mm - "sensor_diagonal": 12.0, # mm - "image_width": 4032, # pixels - "image_height": 3024, # pixels - "focal_length": 6.7, # mm - "sensor_size": "1/1.3", - } - else: - drone_specs = None - return drone_specs - - -async def calculate_gsd(drone_type: DroneType, flight_altitude: int): - drone_specs = await get_drone_specs(drone_type) - - # gsd = (sensor_width * flight_altitude * 100) / (focal_length * image_width) - gsd = (drone_specs["sensor_width"] * flight_altitude * 100) / ( - drone_specs["focal_length"] * drone_specs["image_width"] - ) - return gsd # (cm/pixel) - - -async def calculate_drone_flying_speed( - flight_altitude: float, drone_type: DroneType, image_interval: int, overlap: float +def check_point_within_buffer( + point: Point, polygon_geojson: Polygon, buffer_distance: float ): - # Calculate the ground sampling distance (GSD) - gsd = await calculate_gsd(drone_type, flight_altitude) - - drone_specs = await get_drone_specs(drone_type) - - # calculate vertical image footprint - vertical_image_footprint = (gsd * drone_specs["image_height"]) / 100 - - # Calculate the drone flying speed - drone_flying_speed = (vertical_image_footprint / (100 / (100 - overlap))) / ( - image_interval + 0.1 - ) - - return drone_flying_speed - - -async def calculate_distance_between_2_lines( - overlap: float, drone_type: DroneType, flight_altitude: float -): - gsd = await calculate_gsd(drone_type, flight_altitude) - drone_specs = await get_drone_specs(drone_type) - image_width_in_pixels = drone_specs["image_width"] - overlap_fraction = overlap / 100 - image_width_in_cm = gsd * image_width_in_pixels - overlap_distance_in_cm = image_width_in_cm * (1 - overlap_fraction) - return overlap_distance_in_cm / 100 # return in metre - - -def zip_directory(directory_path, zip_path): - with zipfile.ZipFile(zip_path, "w") as zipf: - for root, dirs, files in os.walk(directory_path): - for file in files: - zipf.write( - os.path.join(root, file), - os.path.relpath( - os.path.join(root, file), os.path.join(directory_path, "..") - ), - ) - - -def create_zip_file(waylines_path_uid): - # Create the wpmz folder if it doesn't exist - wpmz_path = f"/tmp/{waylines_path_uid}/wpmz" - os.makedirs(wpmz_path, exist_ok=True) - - import xml.etree.ElementTree as ET - - xml_string = """ - - - fly - 1702051864938 - 1702051864938 - - safely - noAction - executeLostAction - hover - 2.5 - - 68 - 0 - - - - """ + Check if a point is within the buffer of a polygon. - # Parse the XML string - root = ET.fromstring(xml_string) - - # Create an ElementTree object - tree = ET.ElementTree(root) - - # Write the ElementTree object to a file - with open(f"{wpmz_path}/template.kml", "wb") as file: - tree.write(file, encoding="utf-8", xml_declaration=True) - - # Read content of template.kml - with open(f"/tmp/{waylines_path_uid}/waylines.wpml", "r") as f: - wpml_content = f.read() - - with open(f"{wpmz_path}/waylines.wpml", "w") as f: - f.write(wpml_content) - - # Create a Zip file containing the contents of the wpmz folder directly - output_file_name = f"/tmp/{waylines_path_uid}/output.kmz" - zip_directory(wpmz_path, output_file_name) - - return output_file_name - - -def take_photo_action(action_group_element: Element, index: str): - action = ET.SubElement(action_group_element, "wpml:action") - action_id = ET.SubElement(action, "wpml:actionId") - action_id.text = str(index) - action_actuator_func = ET.SubElement(action, "wpml:actionActuatorFunc") - action_actuator_func.text = "takePhoto" - action_actuator_func_param = ET.SubElement(action, "wpml:actionActuatorFuncParam") - payload_position_index = ET.SubElement( - action_actuator_func_param, "wpml:payloadPositionIndex" - ) - payload_position_index.text = "0" - - -def gimble_rotate_action( - action_group_element: Element, index: str, gimble_pitch_rotate_angle: str -): - action1 = ET.SubElement(action_group_element, "wpml:action") - action1_id = ET.SubElement(action1, "wpml:actionId") - action1_id.text = str(index) - action1_actuator_func = ET.SubElement(action1, "wpml:actionActuatorFunc") - action1_actuator_func.text = "gimbalRotate" - action1_actuator_func_param = ET.SubElement(action1, "wpml:actionActuatorFuncParam") - gimbal_heading_yaw_base = ET.SubElement( - action1_actuator_func_param, "wpml:gimbalHeadingYawBase" - ) - gimbal_heading_yaw_base.text = "aircraft" - gimbal_rotate_mode = ET.SubElement( - action1_actuator_func_param, "wpml:gimbalRotateMode" - ) - gimbal_rotate_mode.text = "absoluteAngle" - gimbal_pitch_rotate_enable = ET.SubElement( - action1_actuator_func_param, "wpml:gimbalPitchRotateEnable" - ) - gimbal_pitch_rotate_enable.text = "1" - gimbal_pitch_rotate_angle = ET.SubElement( - action1_actuator_func_param, "wpml:gimbalPitchRotateAngle" - ) - gimbal_pitch_rotate_angle.text = str(gimble_pitch_rotate_angle) - gimbal_roll_rotate_enable = ET.SubElement( - action1_actuator_func_param, "wpml:gimbalRollRotateEnable" - ) - gimbal_roll_rotate_enable.text = "0" - gimbal_roll_rotate_angle = ET.SubElement( - action1_actuator_func_param, "wpml:gimbalRollRotateAngle" - ) - gimbal_roll_rotate_angle.text = "0" - gimbal_yaw_rotate_enable = ET.SubElement( - action1_actuator_func_param, "wpml:gimbalYawRotateEnable" - ) - gimbal_yaw_rotate_enable.text = "0" - gimbal_yaw_rotate_angle = ET.SubElement( - action1_actuator_func_param, "wpml:gimbalYawRotateAngle" - ) - gimbal_yaw_rotate_angle.text = "0" - gimbal_rotate_time_enable = ET.SubElement( - action1_actuator_func_param, "wpml:gimbalRotateTimeEnable" - ) - gimbal_rotate_time_enable.text = "0" - gimbal_rotate_time = ET.SubElement( - action1_actuator_func_param, "wpml:gimbalRotateTime" - ) - gimbal_rotate_time.text = "0" - payload_position_index = ET.SubElement( - action1_actuator_func_param, "wpml:payloadPositionIndex" - ) - payload_position_index.text = "0" - - -def create_placemark( - index, - coordinates, - execute_height, - waypoint_speed, - waypoint_heading_angle, - gimble_angle, - take_photo: bool = False, -): - placemark = ET.Element("Placemark") - - point = ET.SubElement(placemark, "Point") - coordinates_elem = ET.SubElement(point, "coordinates") - coordinates_elem.text = coordinates - - wpml_index = ET.SubElement(placemark, "wpml:index") - wpml_index.text = str(index) - - execute_height_elem = ET.SubElement(placemark, "wpml:executeHeight") - execute_height_elem.text = str(execute_height) - - waypoint_speed_elem = ET.SubElement(placemark, "wpml:waypointSpeed") - waypoint_speed_elem.text = str(waypoint_speed) + Parameters: + - point_coords: tuple (lon, lat) for the point's coordinates + - polygon_coords: list of (lon, lat) tuples for the polygon's coordinates + - buffer_distance: buffer distance in meters - waypoint_heading_param = ET.SubElement(placemark, "wpml:waypointHeadingParam") - wpml_waypoint_heading_mode = ET.SubElement( - waypoint_heading_param, "wpml:waypointHeadingMode" - ) - wpml_waypoint_heading_mode.text = "followWayline" - wpml_waypoint_heading_angle = ET.SubElement( - waypoint_heading_param, "wpml:waypointHeadingAngle" - ) - wpml_waypoint_heading_angle.text = str(waypoint_heading_angle) - wpml_waypoint_poi_point = ET.SubElement( - waypoint_heading_param, "wpml:waypointPoiPoint" - ) - wpml_waypoint_poi_point.text = "0.000000,0.000000,0.000000" - wpml_waypoint_heading_angle_enable = ET.SubElement( - waypoint_heading_param, "wpml:waypointHeadingAngleEnable" - ) - wpml_waypoint_heading_angle_enable.text = "1" - wpml_waypoint_heading_path_mode = ET.SubElement( - waypoint_heading_param, "wpml:waypointHeadingPathMode" - ) - wpml_waypoint_heading_path_mode.text = "followBadArc" - - wpml_waypoint_turn_param = ET.SubElement(placemark, "wpml:waypointTurnParam") - wpml_waypoint_turn_mode = ET.SubElement( - wpml_waypoint_turn_param, "wpml:waypointTurnMode" - ) - wpml_waypoint_turn_mode.text = "toPointAndStopWithContinuityCurvature" - wpml_waypoint_turn_damping_dist = ET.SubElement( - wpml_waypoint_turn_param, "wpml:waypointTurnDampingDist" - ) - wpml_waypoint_turn_damping_dist.text = "0" - - use_straight_line = ET.SubElement(placemark, "wpml:useStraightLine") - use_straight_line.text = "0" - - action_group1 = ET.SubElement(placemark, "wpml:actionGroup") - action_group1_id = ET.SubElement(action_group1, "wpml:actionGroupId") - action_group1_id.text = "1" - action_group1_start = ET.SubElement(action_group1, "wpml:actionGroupStartIndex") - action_group1_start.text = str(index) # Start index - action_group1_end = ET.SubElement(action_group1, "wpml:actionGroupEndIndex") - action_group1_end.text = str(index) # End Index - action_group1_mode = ET.SubElement(action_group1, "wpml:actionGroupMode") - action_group1_mode.text = "parallel" - action_group1_trigger = ET.SubElement(action_group1, "wpml:actionTrigger") - action_group1_trigger_type = ET.SubElement( - action_group1_trigger, "wpml:actionTriggerType" - ) - action_group1_trigger_type.text = "reachPoint" - - if take_photo: - # Take photo action - take_photo_action(action_group1, "1") - else: - # Gimble rotate action - gimble_rotate_action(action_group1, "1", "-90") - - action_group2 = ET.SubElement(placemark, "wpml:actionGroup") - action_group2_id = ET.SubElement(action_group2, "wpml:actionGroupId") - action_group2_id.text = "2" # Not always 2 - action_group2_start = ET.SubElement(action_group2, "wpml:actionGroupStartIndex") - action_group2_start.text = "0" - action_group2_end = ET.SubElement(action_group2, "wpml:actionGroupEndIndex") - action_group2_end.text = "1" - action_group2_mode = ET.SubElement(action_group2, "wpml:actionGroupMode") - action_group2_mode.text = "parallel" - action_group2_trigger = ET.SubElement(action_group2, "wpml:actionTrigger") - action_group2_trigger_type = ET.SubElement( - action_group2_trigger, "wpml:actionTriggerType" - ) - action_group2_trigger_type.text = "reachPoint" - action2 = ET.SubElement(action_group2, "wpml:action") - action2_id = ET.SubElement(action2, "wpml:actionId") - action2_id.text = "2" # Not always 2 - action2_actuator_func = ET.SubElement(action2, "wpml:actionActuatorFunc") - action2_actuator_func.text = "gimbalEvenlyRotate" - action2_actuator_func_param = ET.SubElement(action2, "wpml:actionActuatorFuncParam") - gimbal_pitch_rotate_angle2 = ET.SubElement( - action2_actuator_func_param, "wpml:gimbalPitchRotateAngle" - ) - gimbal_pitch_rotate_angle2.text = str(gimble_angle) - payload_position_index2 = ET.SubElement( - action2_actuator_func_param, "wpml:payloadPositionIndex" - ) - payload_position_index2.text = "0" - - return placemark - - -def create_mission_config(finish_action_value): - mission_config = ET.Element("wpml:missionConfig") - - fly_to_wayline_mode = ET.SubElement(mission_config, "wpml:flyToWaylineMode") - fly_to_wayline_mode.text = "safely" - - finish_action = ET.SubElement(mission_config, "wpml:finishAction") - finish_action.text = str(finish_action_value) - - exit_on_rc_lost = ET.SubElement(mission_config, "wpml:exitOnRCLost") - exit_on_rc_lost.text = "executeLostAction" - - execute_rc_lost_action = ET.SubElement(mission_config, "wpml:executeRCLostAction") - execute_rc_lost_action.text = "hover" - - global_transitional_speed = ET.SubElement( - mission_config, "wpml:globalTransitionalSpeed" - ) - global_transitional_speed.text = "2.5" - - drone_info = ET.SubElement(mission_config, "wpml:droneInfo") - drone_enum_value = ET.SubElement(drone_info, "wpml:droneEnumValue") - drone_enum_value.text = "68" - drone_sub_enum_value = ET.SubElement(drone_info, "wpml:droneSubEnumValue") - drone_sub_enum_value.text = "0" - - return mission_config - - -def create_folder(placemarks, generate_each_points): - folder = ET.Element("Folder") - - template_id = ET.SubElement(folder, "wpml:templateId") - template_id.text = "0" - - execute_height_mode = ET.SubElement(folder, "wpml:executeHeightMode") - execute_height_mode.text = "relativeToStartPoint" - - wayline_id = ET.SubElement(folder, "wpml:waylineId") - wayline_id.text = "0" - - distance = ET.SubElement(folder, "wpml:distance") - distance.text = "0" - - duration = ET.SubElement(folder, "wpml:duration") - duration.text = "0" - - auto_flight_speed = ET.SubElement(folder, "wpml:autoFlightSpeed") - auto_flight_speed.text = "2.5" - - for index, placemark_data in enumerate(placemarks): - placemark = create_placemark(index, *placemark_data, generate_each_points) - folder.append(placemark) - - return folder - - -def create_kml(mission_config, folder): - kml = ET.Element("kml") - kml.set("xmlns", "http://www.opengis.net/kml/2.2") - kml.set("xmlns:wpml", "http://www.dji.com/wpmz/1.0.2") - - document = ET.SubElement(kml, "Document") - document.append(mission_config) - document.append(folder) - - return kml - - -def create_xml(placemarks, finish_action, generate_each_points: bool = False): - mission_config = create_mission_config(finish_action) - folder = create_folder(placemarks, generate_each_points) - kml = create_kml(mission_config, folder) - - tree = ET.ElementTree(kml) - - folder_name = str(uuid.uuid4()) - os.makedirs(os.path.join("/tmp/", folder_name), exist_ok=True) - waylines_path = os.path.join("/tmp/", folder_name, "waylines.wpml") - - tree.write(waylines_path, encoding="UTF-8", xml_declaration=True) - output_file_name = create_zip_file(folder_name) - return output_file_name - - -async def generate_waypoints_within_polygon( - aoi, distance_between_lines, generate_each_points -): - # 1 degree = 111 km - # 1 km = 1/111 degree - # 1 metre = 1/111000 degree - - distance_between_lines = 1 / 111000 * distance_between_lines - - polygon = Polygon(aoi["features"][0]["geometry"]["coordinates"][0]) - - minx, miny, maxx, maxy = polygon.bounds - waypoints = [] + Returns: + - True if the point is within the buffer, False otherwise + """ - # Generate waypoints within the polygon - y = miny - row_count = 0 - angle = -90 + # Create a shapely polygon and point using the input coordinates + polygon = shape(polygon_geojson["features"][0]["geometry"]) + from shapely.geometry import Point - # Extend the loop by one iteration so that the point will be outside the polygon - while y <= maxy + distance_between_lines: - x = minx - x_row_waypoints = [] + point = Point(point) - while x <= maxx + distance_between_lines: - x_row_waypoints.append({"coordinates": (x, y), "angle": str(angle)}) - x += distance_between_lines - y += distance_between_lines + # Create a transformer to project from EPSG:4326 to EPSG:3857 (meters) + transformer_to_3857 = Transformer.from_crs("EPSG:4326", "EPSG:3857", always_xy=True) - if generate_each_points: - if row_count % 2 == 0: - waypoints.extend(x_row_waypoints) - else: - waypoints.extend(reversed(x_row_waypoints)) - else: - # Add waypoints ensuring at least two points at each end of the line - if x_row_waypoints: - if row_count % 2 == 0: - waypoints.append(x_row_waypoints[0]) - if len(x_row_waypoints) > 1: - waypoints.append(x_row_waypoints[1]) # Append second point - if len(x_row_waypoints) > 2: - waypoints.append( - x_row_waypoints[-2] - ) # Append second-to-last point - waypoints.append(x_row_waypoints[-1]) # Append last point - else: - if len(x_row_waypoints) > 2: - waypoints.append(x_row_waypoints[-1]) # Append last point - waypoints.append( - x_row_waypoints[-2] - ) # Append second-to-last point - if len(x_row_waypoints) > 1: - waypoints.append(x_row_waypoints[1]) # Append second point - waypoints.append(x_row_waypoints[0]) # Append first point + # Transform the polygon and point to EPSG:3857 (meters) + projected_polygon = transform(transformer_to_3857.transform, polygon) + projected_point = transform(transformer_to_3857.transform, point) - row_count += 1 - angle = angle * -1 + # Create a buffer around the polygon boundary + polygon_buffer = projected_polygon.buffer( + buffer_distance + ) # buffer distance in meters - return waypoints + # Check if the point is within the buffer + is_within_buffer = polygon_buffer.contains(projected_point) + return is_within_buffer diff --git a/src/backend/app/waypoints/waypoint_routes.py b/src/backend/app/waypoints/waypoint_routes.py index ed3dd686..af795fb2 100644 --- a/src/backend/app/waypoints/waypoint_routes.py +++ b/src/backend/app/waypoints/waypoint_routes.py @@ -14,12 +14,16 @@ ) from app.models.enums import HTTPStatus from app.tasks.task_logic import get_task_geojson +from app.waypoints.waypoint_logic import check_point_within_buffer from app.db import database from app.utils import merge_multipolygon from app.s3 import get_file_from_bucket from typing import Annotated from psycopg import Connection from app.projects import project_deps +from geojson_pydantic import Point +from shapely.geometry import shape + # Constant to convert gsd to Altitude above ground level GSD_to_AGL_CONST = 29.7 # For DJI Mini 4 Pro @@ -31,12 +35,13 @@ ) -@router.get("/task/{task_id}/") +@router.post("/task/{task_id}/") async def get_task_waypoint( db: Annotated[Connection, Depends(database.get_db)], project_id: uuid.UUID, task_id: uuid.UUID, download: bool = True, + take_off_point: Point = None, ): """ Retrieve task waypoints and download a flight plan. @@ -52,9 +57,22 @@ async def get_task_waypoint( """ task_geojson = await get_task_geojson(db, task_id) - project = await project_deps.get_project_by_id(project_id, db) + # create a takeoff point in this format ["lon","lat"] + if take_off_point: + take_off_point = [take_off_point.longitude, take_off_point.latitude] + if not check_point_within_buffer(take_off_point, task_geojson, 200): + raise HTTPException( + status_code=400, + detail="Take off point should be within 200m of the boundary", + ) + else: + # take the centroid of the task as the takeoff point + task_polygon = shape(task_geojson["features"][0]["geometry"]) + task_centroid = task_polygon.centroid + take_off_point = [task_centroid.x, task_centroid.y] + forward_overlap = project.front_overlap if project.front_overlap else 70 side_overlap = project.side_overlap if project.side_overlap else 70 generate_each_points = True if project.is_terrain_follow else False @@ -66,16 +84,18 @@ async def get_task_waypoint( altitude = project.altitude_from_ground points = waypoints.create_waypoint( - task_geojson, - altitude, - gsd, - forward_overlap, - side_overlap, - generate_each_points, - generate_3d, + project_area=task_geojson, + agl=altitude, + gsd=gsd, + forward_overlap=forward_overlap, + side_overlap=side_overlap, + rotation_angle=0, + generate_each_points=generate_each_points, + generate_3d=generate_3d, + take_off_point=take_off_point, ) - parameters = calculate_parameters.calculate_parameters( + parameters = calculate_parameters( forward_overlap, side_overlap, altitude, @@ -91,20 +111,14 @@ async def get_task_waypoint( # TODO: Do this with inmemory data outfile_with_elevation = "/tmp/output_file_with_elevation.geojson" - add_elevation_from_dem.add_elevation_from_dem( - dem_path, points, outfile_with_elevation - ) + add_elevation_from_dem(dem_path, points, outfile_with_elevation) inpointsfile = open(outfile_with_elevation, "r") points_with_elevation = inpointsfile.read() - placemarks = create_placemarks.create_placemarks( - geojson.loads(points_with_elevation), parameters - ) + placemarks = create_placemarks(geojson.loads(points_with_elevation), parameters) else: - placemarks = create_placemarks.create_placemarks( - geojson.loads(points), parameters - ) + placemarks = create_placemarks(geojson.loads(points), parameters) if download: outfile = outfile = f"/tmp/{uuid.uuid4()}" kmz_file = wpml.create_wpml(placemarks, outfile) @@ -157,6 +171,7 @@ async def generate_kmz( None, description="The Digital Elevation Model (DEM) file that will be used to generate the terrain follow flight plan. This file should be in GeoTIFF format", ), + take_off_point: Point = None, ): if not (altitude or gsd): raise HTTPException( @@ -182,19 +197,28 @@ async def generate_kmz( boundary = merge_multipolygon(geojson.loads(await project_geojson.read())) + # create a takeoff point in this format ["lon","lat"] + take_off_point = [take_off_point.longitude, take_off_point.latitude] + if not check_point_within_buffer(take_off_point, boundary, 200): + raise HTTPException( + status_code=400, + detail="Take off point should be within 200m of the boundary", + ) + if not download: points = waypoints.create_waypoint( - boundary, - altitude, - gsd, - forward_overlap, - side_overlap, - generate_each_points, - generate_3d, + project_area=boundary, + agl=altitude, + gsd=gsd, + forward_overlap=forward_overlap, + side_overlap=side_overlap, + generate_each_points=generate_each_points, + generate_3d=generate_3d, + take_off_point=take_off_point, ) return geojson.loads(points) else: - output_file = create_flightplan.create_flightplan( + output_file = create_flightplan( aoi=boundary, forward_overlap=forward_overlap, side_overlap=side_overlap, @@ -203,6 +227,7 @@ async def generate_kmz( generate_each_points=generate_each_points, dem=dem_path if dem else None, outfile=f"/tmp/{uuid.uuid4()}", + take_off_point=take_off_point, ) return FileResponse( diff --git a/src/backend/app/waypoints/waypoint_schemas.py b/src/backend/app/waypoints/waypoint_schemas.py new file mode 100644 index 00000000..32e4fee0 --- /dev/null +++ b/src/backend/app/waypoints/waypoint_schemas.py @@ -0,0 +1,14 @@ +import json +from pydantic import BaseModel, model_validator + + +class Point(BaseModel): + longitude: float + latitude: float + + @model_validator(mode="before") + @classmethod + def validate_to_json(cls, value): + if isinstance(value, str): + return cls(**json.loads(value)) + return value diff --git a/src/backend/pdm.lock b/src/backend/pdm.lock index a2e0b338..b031501c 100644 --- a/src/backend/pdm.lock +++ b/src/backend/pdm.lock @@ -102,7 +102,7 @@ summary = "DNS toolkit" [[package]] name = "drone-flightplan" -version = "0.3.1rc3" +version = "0.3.1rc4" requires_python = ">=3.10" summary = "Generates an optimized flight plan for drones to conduct precise and efficient aerial mapping" dependencies = [ @@ -165,7 +165,7 @@ dependencies = [ [[package]] name = "greenlet" -version = "3.0.3" +version = "3.1.0" requires_python = ">=3.7" summary = "Lightweight in-process concurrent programming" @@ -492,7 +492,7 @@ summary = "A small Python utility to set file creation time on Windows" lock_version = "4.2" cross_platform = true groups = ["default"] -content_hash = "sha256:f3d3e89439dfbbd37a9aadd6e96f49b8b065669af1a16ec873ee0b459101f070" +content_hash = "sha256:7c804d9b1dbeb05a25f788f88ea8652b2fee18316cd160c81ed67840aecdf6f2" [metadata.files] "aiosmtplib 3.0.2" = [ @@ -744,9 +744,9 @@ content_hash = "sha256:f3d3e89439dfbbd37a9aadd6e96f49b8b065669af1a16ec873ee0b459 {url = "https://files.pythonhosted.org/packages/37/7d/c871f55054e403fdfd6b8f65fd6d1c4e147ed100d3e9f9ba1fe695403939/dnspython-2.6.1.tar.gz", hash = "sha256:e8f0f9c23a7b7cb99ded64e6c3a6f3e701d78f50c55e002b839dea7225cff7cc"}, {url = "https://files.pythonhosted.org/packages/87/a1/8c5287991ddb8d3e4662f71356d9656d91ab3a36618c3dd11b280df0d255/dnspython-2.6.1-py3-none-any.whl", hash = "sha256:5ef3b9680161f6fa89daf8ad451b5f1a33b18ae8a1c6778cdf4b43f08c0a6e50"}, ] -"drone-flightplan 0.3.1rc3" = [ - {url = "https://files.pythonhosted.org/packages/0f/6c/b7e71fbecb27754831664aa0c12835d1a3c4279ee8eda2190c775d593631/drone_flightplan-0.3.1rc3.tar.gz", hash = "sha256:633d38199ed01fe86733ad94b0c59f3f3b9d48bee66ed0a36efda4d3ba2dfe9b"}, - {url = "https://files.pythonhosted.org/packages/ff/fc/2541eeeabb443c655e57c72b335ab7bbe525a82615f87b331a7de64cdac8/drone_flightplan-0.3.1rc3-py3-none-any.whl", hash = "sha256:0b47b26d02c4922cc53b43fe49809ca56a454cf716be8971ac6922955d2fd524"}, +"drone-flightplan 0.3.1rc4" = [ + {url = "https://files.pythonhosted.org/packages/02/f4/765a09ec0346de3f12b7bbabc378f84bbe7af7cb24b7cdf0bcc84de5b03d/drone_flightplan-0.3.1rc4-py3-none-any.whl", hash = "sha256:d0150655091c187a8db8a3f5170ed4a3d85bc25408ff463f02478a9ed4f29666"}, + {url = "https://files.pythonhosted.org/packages/b6/68/9e800150217c047fbad940ad82feeba737e1e6364a2817eb70da23725104/drone_flightplan-0.3.1rc4.tar.gz", hash = "sha256:badce959ffcf062028e0245e8a00b8f7e9cac52c356f673b0eccdbed5c92a961"}, ] "email-validator 2.2.0" = [ {url = "https://files.pythonhosted.org/packages/48/ce/13508a1ec3f8bb981ae4ca79ea40384becc868bfae97fd1c942bb3a001b1/email_validator-2.2.0.tar.gz", hash = "sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7"}, @@ -771,65 +771,73 @@ content_hash = "sha256:f3d3e89439dfbbd37a9aadd6e96f49b8b065669af1a16ec873ee0b459 {url = "https://files.pythonhosted.org/packages/0d/05/af6c27e0cd2f7629559db36dffc28185afb7192622ca9571d56a548038b0/geojson_pydantic-1.1.0-py3-none-any.whl", hash = "sha256:0de723719d66e585123db10ead99dfa51aff8cec08be512646df25224ac425f4"}, {url = "https://files.pythonhosted.org/packages/b1/28/3606a62de5066c13fb7e9156c2dc371c1b082f2f7e83f510525ba7a17da2/geojson_pydantic-1.1.0.tar.gz", hash = "sha256:b214dc746f1e085641e32f0aaa47e0bfa67eefa2cf60a516326c68b87808e2ae"}, ] -"greenlet 3.0.3" = [ - {url = "https://files.pythonhosted.org/packages/0b/8a/f5140c8713f919af0e98e6aaa40cb20edaaf3739d18c4a077581e2422ac4/greenlet-3.0.3-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:da70d4d51c8b306bb7a031d5cff6cc25ad253affe89b70352af5f1cb68e74b53"}, - {url = "https://files.pythonhosted.org/packages/13/af/8db0d63147c6362447eb49da60573b41aee5cf5864fe1e27bdbaf7060bd2/greenlet-3.0.3-cp38-cp38-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:098d86f528c855ead3479afe84b49242e174ed262456c342d70fc7f972bc13c4"}, - {url = "https://files.pythonhosted.org/packages/14/93/da5e3da0d4f5d7d2613e9a5d5bcb2d9d0a4af1cf71ac8768661a3238dff8/greenlet-3.0.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:daf3cb43b7cf2ba96d614252ce1684c1bccee6b2183a01328c98d36fcd7d5cb0"}, - {url = "https://files.pythonhosted.org/packages/17/14/3bddb1298b9a6786539ac609ba4b7c9c0842e12aa73aaa4d8d73ec8f8185/greenlet-3.0.3.tar.gz", hash = "sha256:43374442353259554ce33599da8b692d5aa96f8976d567d4badf263371fbe491"}, - {url = "https://files.pythonhosted.org/packages/19/76/1f33deb0161a439292a6d25fe9b44712c427ce3ecae74f61e4c003895e49/greenlet-3.0.3-cp37-cp37m-win_amd64.whl", hash = "sha256:01bc7ea167cf943b4c802068e178bbf70ae2e8c080467070d01bfa02f337ee67"}, - {url = "https://files.pythonhosted.org/packages/1c/2f/64628f6ae48e05f585e0eb3fb7399b52e240ef99f602107b445bf6be23ef/greenlet-3.0.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd4f49ae60e10adbc94b45c0b5e6a179acc1736cf7a90160b404076ee283cf83"}, - {url = "https://files.pythonhosted.org/packages/1c/fa/bd5ee0772c7bbcb99bbacdb5608895052349b0ab9f20962c0c81bf6bd41d/greenlet-3.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:419b386f84949bf0e7c73e6032e3457b82a787c1ab4a0e43732898a761cc9dbf"}, - {url = "https://files.pythonhosted.org/packages/20/70/2f99bdcb4e3912d844dee279e077ee670ec43161d96670a9dfad16b89dd1/greenlet-3.0.3-cp39-cp39-win32.whl", hash = "sha256:57e8974f23e47dac22b83436bdcf23080ade568ce77df33159e019d161ce1d1e"}, - {url = "https://files.pythonhosted.org/packages/21/b4/90e06e07c78513ab03855768200bdb35c8e764e805b3f14fb488e56f82dc/greenlet-3.0.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:52f59dd9c96ad2fc0d5724107444f76eb20aaccb675bf825df6435acb7703559"}, - {url = "https://files.pythonhosted.org/packages/24/35/945d5b10648fec9b20bcc6df8952d20bb3bba76413cd71c1fdbee98f5616/greenlet-3.0.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:73a411ef564e0e097dbe7e866bb2dda0f027e072b04da387282b02c308807405"}, - {url = "https://files.pythonhosted.org/packages/25/3b/6d6c5e475aa4d92832cd69c306513f1774f404266c2c9e3e7b225a87d384/greenlet-3.0.3-cp37-cp37m-macosx_11_0_universal2.whl", hash = "sha256:5b51e85cb5ceda94e79d019ed36b35386e8c37d22f07d6a751cb659b180d5274"}, - {url = "https://files.pythonhosted.org/packages/27/94/a4c51f047f9ae9a3a2127985d35100afeca420f53897fdaa7cf01696a8d8/greenlet-3.0.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e3391d1e16e2a5a1507d83e4a8b100f4ee626e8eca43cf2cadb543de69827c4c"}, - {url = "https://files.pythonhosted.org/packages/38/77/efb21ab402651896c74f24a172eb4d7479f9f53898bd5e56b9e20bb24ffd/greenlet-3.0.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:149e94a2dd82d19838fe4b2259f1b6b9957d5ba1b25640d2380bea9c5df37676"}, - {url = "https://files.pythonhosted.org/packages/3a/0d/11f039576f5b4b59b51f9517388a1597f4cc9ec754bde695374044d2288e/greenlet-3.0.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:0448abc479fab28b00cb472d278828b3ccca164531daab4e970a0458786055d6"}, - {url = "https://files.pythonhosted.org/packages/3d/4a/c9590b31bfefe089d8fae72201c77761a63c1685c7f511a692a267d7f25e/greenlet-3.0.3-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:1996cb9306c8595335bb157d133daf5cf9f693ef413e7673cb07e3e5871379ca"}, - {url = "https://files.pythonhosted.org/packages/3d/f6/310a4cd1ffd5484cc922241d928777791113fac19277ee99e7bd6bf2140b/greenlet-3.0.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:1a7191e42732df52cb5f39d3527217e7ab73cae2cb3694d241e18f53d84ea9a7"}, - {url = "https://files.pythonhosted.org/packages/42/11/42ad6b1104c357826bbee7d7b9e4f24dbd9fde94899a03efb004aab62963/greenlet-3.0.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fe754d231288e1e64323cfad462fcee8f0288654c10bdf4f603a39ed923bef33"}, - {url = "https://files.pythonhosted.org/packages/47/79/26d54d7d700ef65b689fc2665a40846d13e834da0486674a8d4f0f371a47/greenlet-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:1ea188d4f49089fc6fb283845ab18a2518d279c7cd9da1065d7a84e991748728"}, - {url = "https://files.pythonhosted.org/packages/53/80/3d94d5999b4179d91bcc93745d1b0815b073d61be79dd546b840d17adb18/greenlet-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:bba5387a6975598857d86de9eac14210a49d554a77eb8261cc68b7d082f78ce2"}, - {url = "https://files.pythonhosted.org/packages/54/4b/965a542baf157f23912e466b50fa9c49dd66132d9495d201e6c607ea16f2/greenlet-3.0.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:77457465d89b8263bca14759d7c1684df840b6811b2499838cc5b040a8b5b113"}, - {url = "https://files.pythonhosted.org/packages/5f/71/db617b97026a5df444d2e953db163339cb9ca046999917e99f2adc3e581a/greenlet-3.0.3-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:99bf650dc5d69546e076f413a87481ee1d2d09aaaaaca058c9251b6d8c14783f"}, - {url = "https://files.pythonhosted.org/packages/63/0f/847ed02cdfce10f0e6e3425cd054296bddb11a17ef1b34681fa01a055187/greenlet-3.0.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b7db1ebff4ba09aaaeae6aa491daeb226c8150fc20e836ad00041bcb11230"}, - {url = "https://files.pythonhosted.org/packages/69/73/7034a57ccc914f6cdc75c55950e4341132a2ed6189f599e2af8e1928285e/greenlet-3.0.3-cp37-cp37m-win32.whl", hash = "sha256:b542be2440edc2d48547b5923c408cbe0fc94afb9f18741faa6ae970dbcb9b6d"}, - {url = "https://files.pythonhosted.org/packages/6c/90/5b14670653f7363fb3e1665f8da6d64bd4c31d53a796d09ef69f48be7273/greenlet-3.0.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dca1e2f3ca00b84a396bc1bce13dd21f680f035314d2379c4160c98153b2059b"}, - {url = "https://files.pythonhosted.org/packages/6e/20/68a278a6f93fa36e21cfc3d7599399a8a831225644eb3b6b18755cd3d6fc/greenlet-3.0.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:b1b5667cced97081bf57b8fa1d6bfca67814b0afd38208d52538316e9422fc61"}, - {url = "https://files.pythonhosted.org/packages/74/00/27e2da76b926e9b5a2c97d3f4c0baf1b7d8181209d3026c0171f621ae6c0/greenlet-3.0.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:7f362975f2d179f9e26928c5b517524e89dd48530a0202570d55ad6ca5d8a56f"}, - {url = "https://files.pythonhosted.org/packages/74/3a/92f188ace0190f0066dca3636cf1b09481d0854c46e92ec5e29c7cefe5b1/greenlet-3.0.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15d79dd26056573940fcb8c7413d84118086f2ec1a8acdfa854631084393efcc"}, - {url = "https://files.pythonhosted.org/packages/74/82/9737e7dee4ccb9e1be2a8f17cf760458be2c36c6ff7bbaef55cbe279e729/greenlet-3.0.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b37eef18ea55f2ffd8f00ff8fe7c8d3818abd3e25fb73fae2ca3b672e333a7a6"}, - {url = "https://files.pythonhosted.org/packages/74/9f/71df0154a13d77e92451891a087a4c5783375964132290fca70c7e80e5d4/greenlet-3.0.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:81bb9c6d52e8321f09c3d165b2a78c680506d9af285bfccbad9fb7ad5a5da3e5"}, - {url = "https://files.pythonhosted.org/packages/7c/68/b5f4084c0a252d7e9c0d95fc1cfc845d08622037adb74e05be3a49831186/greenlet-3.0.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1f672519db1796ca0d8753f9e78ec02355e862d0998193038c7073045899f305"}, - {url = "https://files.pythonhosted.org/packages/8a/74/498377804f8ebfb1efdfbe33e93cf3b29d77e207e9496f0c10912d5055b4/greenlet-3.0.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b66c9c1e7ccabad3a7d037b2bcb740122a7b17a53734b7d72a344ce39882a1b"}, - {url = "https://files.pythonhosted.org/packages/8d/73/9e934f07505ed8e1fed5cfcd99cc7db03fe8eb645dbb24e4ba97af41bc3c/greenlet-3.0.3-cp38-cp38-win32.whl", hash = "sha256:d46677c85c5ba00a9cb6f7a00b2bfa6f812192d2c9f7d9c4f6a55b60216712f3"}, - {url = "https://files.pythonhosted.org/packages/94/ed/1e5f4bca691a81700e5a88e86d6f0e538acb10188cd2cc17140e523255ef/greenlet-3.0.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:894393ce10ceac937e56ec00bb71c4c2f8209ad516e96033e4b3b1de270e200d"}, - {url = "https://files.pythonhosted.org/packages/9d/ea/8bc7ed08ba274bdaff08f2cb546d832b8f44af267e03ca6e449840486915/greenlet-3.0.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1551a8195c0d4a68fac7a4325efac0d541b48def35feb49d803674ac32582f61"}, - {url = "https://files.pythonhosted.org/packages/a2/2f/461615adc53ba81e99471303b15ac6b2a6daa8d2a0f7f77fd15605e16d5b/greenlet-3.0.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:70fb482fdf2c707765ab5f0b6655e9cfcf3780d8d87355a063547b41177599be"}, - {url = "https://files.pythonhosted.org/packages/a2/92/f11dbbcf33809421447b24d2eefee0575c59c8569d6d03f7ca4d2b34d56f/greenlet-3.0.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7dcbe92cc99f08c8dd11f930de4d99ef756c3591a5377d1d9cd7dd5e896da71"}, - {url = "https://files.pythonhosted.org/packages/a4/fa/31e22345518adcd69d1d6ab5087a12c178aa7f3c51103f6d5d702199d243/greenlet-3.0.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2516a9957eed41dd8f1ec0c604f1cdc86758b587d964668b5b196a9db5bfcde6"}, - {url = "https://files.pythonhosted.org/packages/a6/64/bea53c592e3e45799f7c8039a8ee7d6883c518eafef1fcae60beb776070f/greenlet-3.0.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:9da2bd29ed9e4f15955dd1595ad7bc9320308a3b766ef7f837e23ad4b4aac31a"}, - {url = "https://files.pythonhosted.org/packages/a6/76/e1ee9f290bb0d46b09704c2fb0e609cae329eb308ad404c0ee6fa1ecb8a5/greenlet-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:68834da854554926fbedd38c76e60c4a2e3198c6fbed520b106a8986445caaf9"}, - {url = "https://files.pythonhosted.org/packages/a6/d6/408ad9603339db28ce334021b1403dfcfbcb7501a435d49698408d928de7/greenlet-3.0.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d353cadd6083fdb056bb46ed07e4340b0869c305c8ca54ef9da3421acbdf6881"}, - {url = "https://files.pythonhosted.org/packages/af/05/b7e068070a6c143f34dfcd7e9144684271b8067e310f6da68269580db1d8/greenlet-3.0.3-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:64d7675ad83578e3fc149b617a444fab8efdafc9385471f868eb5ff83e446b8b"}, - {url = "https://files.pythonhosted.org/packages/bb/6b/384dee7e0121cbd1757bdc1824a5ee28e43d8d4e3f99aa59521f629442fe/greenlet-3.0.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2797aa5aedac23af156bbb5a6aa2cd3427ada2972c828244eb7d1b9255846379"}, - {url = "https://files.pythonhosted.org/packages/bd/37/56b0da468a85e7704f3b2bc045015301bdf4be2184a44868c71f6dca6fe2/greenlet-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fcd2469d6a2cf298f198f0487e0a5b1a47a42ca0fa4dfd1b6862c999f018ebbf"}, - {url = "https://files.pythonhosted.org/packages/c3/80/01ff837bc7122d049971960123d749ed16adbd43cbc008afdb780a40e3fa/greenlet-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:c5ee858cfe08f34712f548c3c363e807e7186f03ad7a5039ebadb29e8c6be067"}, - {url = "https://files.pythonhosted.org/packages/c6/1f/12d5a6cc26e8b483c2e7975f9c22e088ac735c0d8dcb8a8f72d31a4e5f04/greenlet-3.0.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7f009caad047246ed379e1c4dbcb8b020f0a390667ea74d2387be2998f58a22"}, - {url = "https://files.pythonhosted.org/packages/c7/ec/85b647e59e0f137c7792a809156f413e38379cf7f3f2e1353c37f4be4026/greenlet-3.0.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c5e1536de2aad7bf62e27baf79225d0d64360d4168cf2e6becb91baf1ed074f3"}, - {url = "https://files.pythonhosted.org/packages/cf/5b/2de4a398840d3b4d99c4a3476cda0d82badfa349f3f89846ada2e32e9500/greenlet-3.0.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:086152f8fbc5955df88382e8a75984e2bb1c892ad2e3c80a2508954e52295257"}, - {url = "https://files.pythonhosted.org/packages/d9/84/3d9f0255ae3681010d9eee9f4d1bd4790e41c87dcbdad5cbf893605039b5/greenlet-3.0.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9db1c18f0eaad2f804728c67d6c610778456e3e1cc4ab4bbd5eeb8e6053c6fc"}, - {url = "https://files.pythonhosted.org/packages/dc/c3/06ca5f34b01af6d6e2fd2f97c0ad3673123a442bf4a3add548d374b1cc7c/greenlet-3.0.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d73a9fe764d77f87f8ec26a0c85144d6a951a6c438dfe50487df5595c6373eac"}, - {url = "https://files.pythonhosted.org/packages/e1/65/506e0a80931170b0dac1a03d36b7fc299f3fa3576235b916718602fff2c3/greenlet-3.0.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:649dde7de1a5eceb258f9cb00bdf50e978c9db1b996964cd80703614c86495eb"}, - {url = "https://files.pythonhosted.org/packages/e8/47/0fd13f50da7e43e313cce276c9ec9b5f862a8fedacdc30e7ca2a43ee7fd7/greenlet-3.0.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ddc0f794e6ad661e321caa8d2f0a55ce01213c74722587256fb6566049a8b04"}, - {url = "https://files.pythonhosted.org/packages/e9/55/2c3cfa3cdbb940cf7321fbcf544f0e9c74898eed43bf678abf416812d132/greenlet-3.0.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4d1ac74f5c0c0524e4a24335350edad7e5f03b9532da7ea4d3c54d527784f2e"}, - {url = "https://files.pythonhosted.org/packages/ef/17/e8e72cabfb5a906c0d976d7fbcc88310df292beea0f816efbefdaf694284/greenlet-3.0.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ed7fb269f15dc662787f4119ec300ad0702fa1b19d2135a37c2c4de6fadfd4a"}, - {url = "https://files.pythonhosted.org/packages/f4/b0/03142c8f64a2bc2512aaab6c636f73d30315da40b1e95387557b0ea31805/greenlet-3.0.3-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2dd6e660effd852586b6a8478a1d244b8dc90ab5b1321751d2ea15deb49ed414"}, - {url = "https://files.pythonhosted.org/packages/f6/a2/0ed21078039072f9dc738bbf3af12b103a84106b1385ac4723841f846ce7/greenlet-3.0.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:afaff6cf5200befd5cec055b07d1c0a5a06c040fe5ad148abcd11ba6ab9b114e"}, - {url = "https://files.pythonhosted.org/packages/fb/e6/d6db6e75d8a04eac3d18a8570213851bbd2a859cb4f114b637a9bf542f1b/greenlet-3.0.3-cp37-cp37m-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1f145462f1fa6e4a4ae3c0f782e580ce44d57c8f2c7aae1b6fa88c0b2efdb41"}, - {url = "https://files.pythonhosted.org/packages/fe/1f/b5cd033b55f347008235244626bb1ee2854adf9c3cb97ff406d98d6e1ea3/greenlet-3.0.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7170375bcc99f1a2fbd9c306f5be8764eaf3ac6b5cb968862cad4c7057756506"}, - {url = "https://files.pythonhosted.org/packages/ff/76/0893f4fe7b841660a5d75116c7d755c58652a4e9e12f6a72984eaa396881/greenlet-3.0.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fd096eb7ffef17c456cfa587523c5f92321ae02427ff955bebe9e3c63bc9f0da"}, +"greenlet 3.1.0" = [ + {url = "https://files.pythonhosted.org/packages/05/76/5902a38828f06b2bd964ffca36275439c3be993184b9540341585aadad3d/greenlet-3.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:3e7e6ef1737a819819b1163116ad4b48d06cfdd40352d813bb14436024fcda99"}, + {url = "https://files.pythonhosted.org/packages/0d/20/89674b7d62a19138b3352f6080f2ff3e1ee4a298b29bb793746423d0b908/greenlet-3.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:28fe80a3eb673b2d5cc3b12eea468a5e5f4603c26aa34d88bf61bba82ceb2f9b"}, + {url = "https://files.pythonhosted.org/packages/10/90/53ad671dcdbf973b017a6e98f12a268bd1a00b0a712094be94f916bf8381/greenlet-3.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:9eb4a1d7399b9f3c7ac68ae6baa6be5f9195d1d08c9ddc45ad559aa6b556bce6"}, + {url = "https://files.pythonhosted.org/packages/16/be/4f5fd9ea44eb58e32ecfaf72839f842e2f343eaa0ff5c24cadbcfe22aad5/greenlet-3.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0e49a65d25d7350cca2da15aac31b6f67a43d867448babf997fe83c7505f57bc"}, + {url = "https://files.pythonhosted.org/packages/24/b5/24dc29e920a1f6b4e2f920fdd642a3364a5b082988931b7d5d1229d48340/greenlet-3.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:2651dfb006f391bcb240635079a68a261b227a10a08af6349cba834a2141efa1"}, + {url = "https://files.pythonhosted.org/packages/27/37/b2ee70a8053a295e020e05e4d235d795f3013b55a2ac7513c9cbf0d53133/greenlet-3.1.0-cp37-cp37m-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13ff8c8e54a10472ce3b2a2da007f915175192f18e6495bad50486e87c7f6637"}, + {url = "https://files.pythonhosted.org/packages/27/f9/23fec67219d73ec2fbf24a0dd9bfc51afb6aac9fde29dc7c99a6992d4fe3/greenlet-3.1.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4a3dae7492d16e85ea6045fd11cb8e782b63eac8c8d520c3a92c02ac4573b0a6"}, + {url = "https://files.pythonhosted.org/packages/2c/0b/15c674a64cc35a0ac7808c5c7feb25513feb31300bcc8c50565b798b0f64/greenlet-3.1.0-cp37-cp37m-win32.whl", hash = "sha256:a0409bc18a9f85321399c29baf93545152d74a49d92f2f55302f122007cfda00"}, + {url = "https://files.pythonhosted.org/packages/2d/34/17f5623158ec1fff9326965d61705820aa498cdb5d179f6d42dbc2113c10/greenlet-3.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c3446937be153718250fe421da548f973124189f18fe4575a0510b5c928f0cc"}, + {url = "https://files.pythonhosted.org/packages/31/99/04e9416ee5ad22d5ceaf01efac2e7386e17c3d4c6dd3407a3df4b9c682f6/greenlet-3.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5fd6e94593f6f9714dbad1aaba734b5ec04593374fa6638df61592055868f8b8"}, + {url = "https://files.pythonhosted.org/packages/3b/4e/2d0428b76e39802cfc2ce53afab4b0cbbdc0ba13925180352c7f0cf51b46/greenlet-3.1.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f9482c2ed414781c0af0b35d9d575226da6b728bd1a720668fa05837184965b7"}, + {url = "https://files.pythonhosted.org/packages/3e/e8/5d522a89f890a4ffefd02c21a12be503c03071fb5eb586d216e4f263d9e7/greenlet-3.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44151d7b81b9391ed759a2f2865bbe623ef00d648fed59363be2bbbd5154656f"}, + {url = "https://files.pythonhosted.org/packages/46/b3/cc9cff0bebd128836cf75a200b9e4b319abf4b72e983c4931775a4976ea4/greenlet-3.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:221169d31cada333a0c7fd087b957c8f431c1dba202c3a58cf5a3583ed973e9b"}, + {url = "https://files.pythonhosted.org/packages/47/ff/c8ec3bcf7e23f45ed4085b6673a23b4d4763bb39e9797b787c55ed65dbc1/greenlet-3.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd65695a8df1233309b701dec2539cc4b11e97d4fcc0f4185b4a12ce54db0491"}, + {url = "https://files.pythonhosted.org/packages/4a/dc/3b66219e65dd854037a997a72c84affbdf32fefe3482c43ac26590757b2d/greenlet-3.1.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:184258372ae9e1e9bddce6f187967f2e08ecd16906557c4320e3ba88a93438c3"}, + {url = "https://files.pythonhosted.org/packages/50/15/b3e7de3d7e141a328b8141d85e4b01c27bcff0161e5ca2d9a490b87ae3c5/greenlet-3.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b9505a0c8579899057cbefd4ec34d865ab99852baf1ff33a9481eb3924e2da0b"}, + {url = "https://files.pythonhosted.org/packages/51/a2/09d1306418e81bba6775b41fec2f7c672d77a9175ad6fb1b3beefedaba8f/greenlet-3.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:ac0adfdb3a21dc2a24ed728b61e72440d297d0fd3a577389df566651fcd08f97"}, + {url = "https://files.pythonhosted.org/packages/52/61/8ece7ea36c7ad76f09ddb8c7dce7abf40cd608167564105c8281174d73f5/greenlet-3.1.0-cp38-cp38-win32.whl", hash = "sha256:1544b8dd090b494c55e60c4ff46e238be44fdc472d2589e943c241e0169bcea2"}, + {url = "https://files.pythonhosted.org/packages/56/fe/bc264a26bc7baeb619334385aac76dd19d0ec556429fb0e74443fd7974b6/greenlet-3.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:619935a44f414274a2c08c9e74611965650b730eb4efe4b2270f91df5e4adf9a"}, + {url = "https://files.pythonhosted.org/packages/57/9d/2d618474cdab9f664b2cf0641e7832b1a86e03c6dbd1ff505c7cdf2c4d8e/greenlet-3.1.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:d3c59a06c2c28a81a026ff11fbf012081ea34fb9b7052f2ed0366e14896f0a1d"}, + {url = "https://files.pythonhosted.org/packages/58/a8/a54a8816187e55f42fa135419efe3a88a2749f75ed4169abc6bf300ce0a9/greenlet-3.1.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:24fc216ec7c8be9becba8b64a98a78f9cd057fd2dc75ae952ca94ed8a893bf27"}, + {url = "https://files.pythonhosted.org/packages/60/b5/6e3ecc12dc58c575cde447477db71d6b197f10fcde7eb259ed84e9c9c4de/greenlet-3.1.0-cp39-cp39-win32.whl", hash = "sha256:d0dd943282231480aad5f50f89bdf26690c995e8ff555f26d8a5b9887b559bcc"}, + {url = "https://files.pythonhosted.org/packages/65/1b/3d91623c3eff61c11799e7f3d6c01f6bfa9bd2d1f0181116fd0b9b108a40/greenlet-3.1.0.tar.gz", hash = "sha256:b395121e9bbe8d02a750886f108d540abe66075e61e22f7353d9acb0b81be0f0"}, + {url = "https://files.pythonhosted.org/packages/65/94/eafcd6812ad878e14b92aa0c96a28f84a35a23685d8fad0b7569235ae994/greenlet-3.1.0-cp38-cp38-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:665b21e95bc0fce5cab03b2e1d90ba9c66c510f1bb5fdc864f3a377d0f553f6b"}, + {url = "https://files.pythonhosted.org/packages/66/49/de46b2da577000044e7f5ab514021bbc48a0b0c6dd7af2da9732db36c584/greenlet-3.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a4bf607f690f7987ab3291406e012cd8591a4f77aa54f29b890f9c331e84989"}, + {url = "https://files.pythonhosted.org/packages/6e/3d/c732906902d4cfa75f7432ef28e9acb3461b56e01f69ab485bd8e32458a8/greenlet-3.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:7f346d24d74c00b6730440f5eb8ec3fe5774ca8d1c9574e8e57c8671bb51b910"}, + {url = "https://files.pythonhosted.org/packages/75/4a/c612e5688dbbce6873763642195d9902e04de43914fe415661fe3c435e1e/greenlet-3.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9d86401550b09a55410f32ceb5fe7efcd998bd2dad9e82521713cb148a4a15f"}, + {url = "https://files.pythonhosted.org/packages/77/d5/489ee9a7a9bace162d99c52f347edc14ffa570fdf5684e95d9dc146ba1be/greenlet-3.1.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26811df4dc81271033a7836bc20d12cd30938e6bd2e9437f56fa03da81b0f8fc"}, + {url = "https://files.pythonhosted.org/packages/7b/da/1c095eaf7ade0d67c520ee98ab2f34b9c1279e5be96154a46fb940aa8567/greenlet-3.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:44cd313629ded43bb3b98737bba2f3e2c2c8679b55ea29ed73daea6b755fe8e7"}, + {url = "https://files.pythonhosted.org/packages/7f/19/5824ddd54f91b22420908a099d801e3b3c9e49da55b1805d6c5a6fc163ef/greenlet-3.1.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81eeec4403a7d7684b5812a8aaa626fa23b7d0848edb3a28d2eb3220daddcbd0"}, + {url = "https://files.pythonhosted.org/packages/80/ae/108d1ed1a9e8472ff6a494121fd45ab5666e4c3009b3bfc595e3a0683570/greenlet-3.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a771dc64fa44ebe58d65768d869fcfb9060169d203446c1d446e844b62bdfdca"}, + {url = "https://files.pythonhosted.org/packages/86/01/852b8c516b35ef2b16812655612092e02608ea79de7e79fde841cfcdbae4/greenlet-3.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c6f4c2027689093775fd58ca2388d58789009116844432d920e9147f91acbe64"}, + {url = "https://files.pythonhosted.org/packages/87/b0/ac381b73c9b9e2cb55970b9a5842ff5b6bc83a7f23aedd3dded1589f0039/greenlet-3.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:d46d5069e2eeda111d6f71970e341f4bd9aeeee92074e649ae263b834286ecc0"}, + {url = "https://files.pythonhosted.org/packages/89/dc/d2eaaefca5e295ec9cc09c958f7c3086582a6e1d93de31b780e420cbf6dc/greenlet-3.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d07c28b85b350564bdff9f51c1c5007dfb2f389385d1bc23288de51134ca303"}, + {url = "https://files.pythonhosted.org/packages/96/20/4a7e12ba42c86d511cc6137f15af1b99fad95642134c64a2da7a84f448f3/greenlet-3.1.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:f9671e7282d8c6fcabc32c0fb8d7c0ea8894ae85cee89c9aadc2d7129e1a9954"}, + {url = "https://files.pythonhosted.org/packages/98/bb/208f0b192f6c22e5371d0fd6dfa50d429562af8d79a4045bad0f2d7df4ec/greenlet-3.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:01059afb9b178606b4b6e92c3e710ea1635597c3537e44da69f4531e111dd5e9"}, + {url = "https://files.pythonhosted.org/packages/9b/a4/f2493536dad2539b84f61e60b6071e29bea05e8148cfa67237aeba550898/greenlet-3.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a814dc3100e8a046ff48faeaa909e80cdb358411a3d6dd5293158425c684eda8"}, + {url = "https://files.pythonhosted.org/packages/9d/e7/744b590459b7d06b6b3383036ae0a0540ece9132f5e2c6c3c640de6c36ab/greenlet-3.1.0-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2d004db911ed7b6218ec5c5bfe4cf70ae8aa2223dffbb5b3c69e342bb253cb28"}, + {url = "https://files.pythonhosted.org/packages/a0/ab/194c82e7c81a884057149641a55f6fd1755b396fd19a88ed4ca2472c2724/greenlet-3.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2cd8518eade968bc52262d8c46727cfc0826ff4d552cf0430b8d65aaf50bb91d"}, + {url = "https://files.pythonhosted.org/packages/a2/90/912a1227a841d5df57d6dbe5730e049d5fd38c902c3940e45222360ca336/greenlet-3.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a53dfe8f82b715319e9953330fa5c8708b610d48b5c59f1316337302af5c0811"}, + {url = "https://files.pythonhosted.org/packages/a9/25/c7e0526420b241b5548df8214303463b751ed66b2efff006bfb4b6f9ef3f/greenlet-3.1.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b5ea3664eed571779403858d7cd0a9b0ebf50d57d2cdeafc7748e09ef8cd81a"}, + {url = "https://files.pythonhosted.org/packages/aa/25/5aa6682f68b2c5a4ef1887e7d576cc76f6269f7c46aad71ce5163ae504ee/greenlet-3.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:db1b3ccb93488328c74e97ff888604a8b95ae4f35f4f56677ca57a4fc3a4220b"}, + {url = "https://files.pythonhosted.org/packages/aa/67/12f51aa488d8778e1b8e9fcaeb25678524eda29a7a133a9263d6449fe011/greenlet-3.1.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26d9c1c4f1748ccac0bae1dbb465fb1a795a75aba8af8ca871503019f4285e2a"}, + {url = "https://files.pythonhosted.org/packages/af/c1/abccddcb2ec07538b6ee1fa30999a239a1ec807109a8dc069e55288df636/greenlet-3.1.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:037d9ac99540ace9424cb9ea89f0accfaff4316f149520b4ae293eebc5bded17"}, + {url = "https://files.pythonhosted.org/packages/b2/f5/15440aaf5e0ccb7cb050fe8669b5f625ee6ed2e8ba82315b4bc2c0944b86/greenlet-3.1.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0a5b1c22c82831f56f2f7ad9bbe4948879762fe0d59833a4a71f16e5fa0f682"}, + {url = "https://files.pythonhosted.org/packages/b9/46/d97ad3d8ca6ab8c4f166493164b5461161a295887b6d9ca0bbd4ccdede78/greenlet-3.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76dc19e660baea5c38e949455c1181bc018893f25372d10ffe24b3ed7341fb25"}, + {url = "https://files.pythonhosted.org/packages/c1/7c/6b1f3ced3867a7ca073100aab0d2d200f11b07bc60710eefbb6278cda219/greenlet-3.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fad7a051e07f64e297e6e8399b4d6a3bdcad3d7297409e9a06ef8cbccff4f501"}, + {url = "https://files.pythonhosted.org/packages/c1/e8/30c84a3c639691f6c00b04575abd474d94d404a9ad686e60ba0c17c797d0/greenlet-3.1.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:90b5bbf05fe3d3ef697103850c2ce3374558f6fe40fd57c9fac1bf14903f50a5"}, + {url = "https://files.pythonhosted.org/packages/ca/7d/7c348b13b67930c6d0ee1438ec4be64fc2c8f23f55bd50179db2a5303944/greenlet-3.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:ffb08f2a1e59d38c7b8b9ac8083c9c8b9875f0955b1e9b9b9a965607a51f8e54"}, + {url = "https://files.pythonhosted.org/packages/cc/7a/12e04050093151008ee768580c4fd701c4a4de7ecc01d96af73a130c04ed/greenlet-3.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:713d450cf8e61854de9420fb7eea8ad228df4e27e7d4ed465de98c955d2b3fa6"}, + {url = "https://files.pythonhosted.org/packages/cc/d2/460d00a72720a8798815d29cc4281b72103910017ca2d560a12f801b2138/greenlet-3.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:655b21ffd37a96b1e78cc48bf254f5ea4b5b85efaf9e9e2a526b3c9309d660ca"}, + {url = "https://files.pythonhosted.org/packages/cd/84/9ed78fd909292a9aee9c713c8dc08d2335628ca56a5e675235818ca5f0e0/greenlet-3.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cfcfb73aed40f550a57ea904629bdaf2e562c68fa1164fa4588e752af6efdc3f"}, + {url = "https://files.pythonhosted.org/packages/d3/73/591c60545a81edc62c06325c4948865cca5904eb01388fbd11f9c5a72d5a/greenlet-3.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5415b9494ff6240b09af06b91a375731febe0090218e2898d2b85f9b92abcda0"}, + {url = "https://files.pythonhosted.org/packages/d9/b5/ad4ec97be5cd964932fe4cde80df03d9fca23ed8b5c65d54f16270af639f/greenlet-3.1.0-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:a8870983af660798dc1b529e1fd6f1cefd94e45135a32e58bd70edd694540f33"}, + {url = "https://files.pythonhosted.org/packages/e2/0e/bfca17d8f0e7b7dfc918d504027bb795d1aad9ea459a90c33acb24c29034/greenlet-3.1.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d58ec349e0c2c0bc6669bf2cd4982d2f93bf067860d23a0ea1fe677b0f0b1e09"}, + {url = "https://files.pythonhosted.org/packages/e7/1f/fe4c6f388c9a6736b5afc783979ba6d0fc9ee9c5edb5539184ac88aa8b8c/greenlet-3.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9730929375021ec90f6447bff4f7f5508faef1c02f399a1953870cdb78e0c345"}, + {url = "https://files.pythonhosted.org/packages/e7/80/b1f8b87bcb32f8aa2582e25088dc59e96dff9472d8f6d3e46b19cf9a6e89/greenlet-3.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d45b75b0f3fd8d99f62eb7908cfa6d727b7ed190737dec7fe46d993da550b81a"}, + {url = "https://files.pythonhosted.org/packages/e8/30/22f6c2bc2e21b51ecf0b59f503f00041fe7fc44f5a9923dc701f686a0e47/greenlet-3.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1ddc7bcedeb47187be74208bc652d63d6b20cb24f4e596bd356092d8000da6d6"}, + {url = "https://files.pythonhosted.org/packages/e8/65/577971a48f06ebd2f759466b4c1c59cd4dc901ec43f1a775207430ad80b9/greenlet-3.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:243a223c96a4246f8a30ea470c440fe9db1f5e444941ee3c3cd79df119b8eebf"}, + {url = "https://files.pythonhosted.org/packages/ea/7d/d87885ed60a5bf9dbb4424386b84ab96a50b2f4eb2d00641788b73bdb2cd/greenlet-3.1.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cea1cca3be76c9483282dc7760ea1cc08a6ecec1f0b6ca0a94ea0d17432da19"}, + {url = "https://files.pythonhosted.org/packages/eb/9b/39930fdefa5dab2511ed813a6764458980e04e10c8c3560862fb2f340128/greenlet-3.1.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:76e5064fd8e94c3f74d9fd69b02d99e3cdb8fc286ed49a1f10b256e59d0d3a0b"}, + {url = "https://files.pythonhosted.org/packages/ee/89/88b2b0ef98f942d260109e99b776524309761399f73547b4d0e29275f755/greenlet-3.1.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a22f4e26400f7f48faef2d69c20dc055a1f3043d330923f9abe08ea0aecc44df"}, + {url = "https://files.pythonhosted.org/packages/f1/8c/a9f0d64d8eb142bb6931203a3768099a8016607409674970aeede2a72b53/greenlet-3.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c3967dcc1cd2ea61b08b0b276659242cbce5caca39e7cbc02408222fb9e6ff39"}, + {url = "https://files.pythonhosted.org/packages/f7/ed/f25832e30a54a92fa13ab94a206f2ea296306acdf5f6a48f88bbb41a6e44/greenlet-3.1.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:726377bd60081172685c0ff46afbc600d064f01053190e4450857483c4d44484"}, + {url = "https://files.pythonhosted.org/packages/f9/5f/fb128714bbd96614d570fff1d91bbef7a49345bea183e9ea19bdcda1f235/greenlet-3.1.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:76b3e3976d2a452cba7aa9e453498ac72240d43030fdc6d538a72b87eaff52fd"}, + {url = "https://files.pythonhosted.org/packages/fb/e8/9374e77fc204973d6d901c8bb2d7cb223e81513754874cbee6cc5c5fc0ba/greenlet-3.1.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:cd468ec62257bb4544989402b19d795d2305eccb06cde5da0eb739b63dc04665"}, ] "h11 0.14.0" = [ {url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml index 071fe89d..b3f5e487 100644 --- a/src/backend/pyproject.toml +++ b/src/backend/pyproject.toml @@ -30,7 +30,7 @@ dependencies = [ "GDAL==3.6.2", "aiosmtplib>=3.0.1", "python-slugify>=8.0.4", - "drone-flightplan==0.3.1rc3", + "drone-flightplan==0.3.1rc4", "psycopg2>=2.9.9", ] requires-python = ">=3.11" diff --git a/src/frontend/src/components/CreateProject/FormContents/DefineAOI/MapSection/index.tsx b/src/frontend/src/components/CreateProject/FormContents/DefineAOI/MapSection/index.tsx index d6990a64..3e219a37 100644 --- a/src/frontend/src/components/CreateProject/FormContents/DefineAOI/MapSection/index.tsx +++ b/src/frontend/src/components/CreateProject/FormContents/DefineAOI/MapSection/index.tsx @@ -85,7 +85,7 @@ const MapSection = ({ useEffect(() => { if (!projectArea) return; const bbox = getBbox(projectArea as FeatureCollection); - map?.fitBounds(bbox as LngLatBoundsLike, { padding: 25 }); + map?.fitBounds(bbox as LngLatBoundsLike, { padding: 25, duration: 500 }); }, [map, projectArea]); const drawSaveFromMap = () => { diff --git a/src/frontend/src/components/CreateProject/FormContents/GenerateTask/MapSection/index.tsx b/src/frontend/src/components/CreateProject/FormContents/GenerateTask/MapSection/index.tsx index 70943a54..8751fec9 100644 --- a/src/frontend/src/components/CreateProject/FormContents/GenerateTask/MapSection/index.tsx +++ b/src/frontend/src/components/CreateProject/FormContents/GenerateTask/MapSection/index.tsx @@ -40,7 +40,7 @@ const MapSection = () => { useEffect(() => { if (!projectArea) return; const bbox = getBbox(projectArea as FeatureCollection); - map?.fitBounds(bbox as LngLatBoundsLike, { padding: 25 }); + map?.fitBounds(bbox as LngLatBoundsLike, { padding: 25, duration: 500 }); }, [map, projectArea]); // eslint-disable-next-line no-unused-vars diff --git a/src/frontend/src/components/DroneOperatorTask/DescriptionSection/index.tsx b/src/frontend/src/components/DroneOperatorTask/DescriptionSection/index.tsx index 68345792..0120f319 100644 --- a/src/frontend/src/components/DroneOperatorTask/DescriptionSection/index.tsx +++ b/src/frontend/src/components/DroneOperatorTask/DescriptionSection/index.tsx @@ -96,6 +96,7 @@ const DroneOperatorDescriptionBox = () => { const handleDownloadFlightPlan = () => { fetch( `${BASE_URL}/waypoint/task/${taskId}/?project_id=${projectId}&download=true`, + {"method":'POST'} ) .then(response => { if (!response.ok) { diff --git a/src/frontend/src/components/DroneOperatorTask/MapSection/index.tsx b/src/frontend/src/components/DroneOperatorTask/MapSection/index.tsx index f88259fc..5ddd3954 100644 --- a/src/frontend/src/components/DroneOperatorTask/MapSection/index.tsx +++ b/src/frontend/src/components/DroneOperatorTask/MapSection/index.tsx @@ -61,7 +61,7 @@ const MapSection = () => { if (!taskWayPoints?.geojsonAsLineString) return; const { geojsonAsLineString } = taskWayPoints; const bbox = getBbox(geojsonAsLineString as FeatureCollection); - map?.fitBounds(bbox as LngLatBoundsLike, { padding: 25 }); + map?.fitBounds(bbox as LngLatBoundsLike, { padding: 25, duration: 500 }); }, [map, taskWayPoints]); const getPopupUI = useCallback(() => { @@ -70,11 +70,15 @@ const MapSection = () => {

{popupData?.index}

- {popupData?.coordinates?.lat}, {popupData?.coordinates?.lng}{' '} + {popupData?.coordinates?.lat?.toFixed(8)}, {popupData?.coordinates?.lng?.toFixed(8)}{' '}

-

Angle: {popupData?.angle} degree

+

Speed: {popupData?.speed} m/s

+ {popupData?.elevation && +

Elevation (Sea Level): {popupData?.elevation} meter

+ } +

Take Photo: {popupData?.take_photo ? "True" : "False"}

Gimble angle: {popupData?.gimbal_angle} degree

diff --git a/src/frontend/src/components/IndividualProject/MapSection/index.tsx b/src/frontend/src/components/IndividualProject/MapSection/index.tsx index fa9b4893..f6e4bcdb 100644 --- a/src/frontend/src/components/IndividualProject/MapSection/index.tsx +++ b/src/frontend/src/components/IndividualProject/MapSection/index.tsx @@ -114,7 +114,7 @@ const MapSection = () => { }, ); const bbox = getBbox(tasksCollectiveGeojson as FeatureCollection); - map?.fitBounds(bbox as LngLatBoundsLike, { padding: 25 }); + map?.fitBounds(bbox as LngLatBoundsLike, { padding: 25, duration: 500 }); }, [map, tasksData]); const getPopupUI = useCallback( @@ -248,7 +248,7 @@ const MapSection = () => { paint: { 'fill-color': '#ffffff', 'fill-outline-color': '#484848', - 'fill-opacity': 0.8, + 'fill-opacity': 0.5, }, } } diff --git a/src/frontend/src/components/Projects/MapSection/index.tsx b/src/frontend/src/components/Projects/MapSection/index.tsx index 8ac709dd..81c2b14d 100644 --- a/src/frontend/src/components/Projects/MapSection/index.tsx +++ b/src/frontend/src/components/Projects/MapSection/index.tsx @@ -59,7 +59,7 @@ const ProjectsMapSection = () => { useEffect(() => { if (!projectsList || !projectsList?.features?.length) return; const bbox = getBbox(projectsList as FeatureCollection); - map?.fitBounds(bbox as LngLatBoundsLike, { padding: 100 }); + map?.fitBounds(bbox as LngLatBoundsLike, { padding: 100, duration: 500 }); }, [projectsList, map]); const getPopupUI = useCallback(() => { diff --git a/src/frontend/src/components/Projects/ProjectCard/MapSection/index.tsx b/src/frontend/src/components/Projects/ProjectCard/MapSection/index.tsx index 6fafcc23..3f94d6c4 100644 --- a/src/frontend/src/components/Projects/ProjectCard/MapSection/index.tsx +++ b/src/frontend/src/components/Projects/ProjectCard/MapSection/index.tsx @@ -29,7 +29,7 @@ const MapSection = ({ useEffect(() => { if (!geojson) return; const bbox = getBbox(geojson as FeatureCollection); - map?.fitBounds(bbox as LngLatBoundsLike, { padding: 30 }); + map?.fitBounds(bbox as LngLatBoundsLike, { padding: 30, duration: 500 }); }, [geojson, map]); return ( diff --git a/src/frontend/src/services/tasks.ts b/src/frontend/src/services/tasks.ts index af789959..daa54213 100644 --- a/src/frontend/src/services/tasks.ts +++ b/src/frontend/src/services/tasks.ts @@ -1,7 +1,7 @@ import { api, authenticated } from '.'; export const getTaskWaypoint = (projectId: string, taskId: string) => - authenticated(api).get( + authenticated(api).post( `/waypoint/task/${taskId}/?project_id=${projectId}&download=false`, ); diff --git a/src/frontend/src/views/IndividualProject/index.tsx b/src/frontend/src/views/IndividualProject/index.tsx index 88ecc5d4..e27ceed6 100644 --- a/src/frontend/src/views/IndividualProject/index.tsx +++ b/src/frontend/src/views/IndividualProject/index.tsx @@ -107,7 +107,7 @@ const IndividualProject = () => { )}
-
+
diff --git a/src/frontend/src/views/Projects/index.tsx b/src/frontend/src/views/Projects/index.tsx index 8786db87..b772f938 100644 --- a/src/frontend/src/views/Projects/index.tsx +++ b/src/frontend/src/views/Projects/index.tsx @@ -52,7 +52,7 @@ const Projects = () => { )}
{showMap && ( -
+
)}