554 lines
21 KiB
Python
554 lines
21 KiB
Python
from fastapi import APIRouter, HTTPException, Query
|
|
from fastapi.responses import HTMLResponse, JSONResponse
|
|
import requests
|
|
import yaml
|
|
import time
|
|
import pytz
|
|
import traceback
|
|
from datetime import datetime, timezone
|
|
from typing import Union, List
|
|
import asyncio
|
|
import pytz
|
|
import aiohttp
|
|
import folium
|
|
import time as timer
|
|
from dateutil.parser import parse as dateutil_parse
|
|
from pathlib import Path
|
|
from pydantic import BaseModel
|
|
from typing import Optional, Any, Dict, List, Union
|
|
from datetime import datetime, timedelta, time
|
|
from sijapi import NAMED_LOCATIONS, TZ, DynamicTZ
|
|
from sijapi import DEBUG, INFO, WARN, ERR, CRITICAL, DB
|
|
from sijapi.classes import Location
|
|
from sijapi.utilities import haversine
|
|
# from osgeo import gdal
|
|
# import elevation
|
|
|
|
|
|
locate = APIRouter()
|
|
|
|
async def reverse_geocode(latitude: float, longitude: float) -> Optional[Location]:
|
|
url = f"https://nominatim.openstreetmap.org/reverse?format=json&lat={latitude}&lon={longitude}"
|
|
INFO(f"Calling Nominatim API at {url}")
|
|
headers = {
|
|
'User-Agent': 'sij.law/1.0 (sij@sij.law)', # replace with your app name and email
|
|
}
|
|
try:
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.get(url, headers=headers) as response:
|
|
response.raise_for_status()
|
|
data = await response.json()
|
|
|
|
address = data.get("address", {})
|
|
location = Location(
|
|
latitude=float(data.get("lat", latitude)),
|
|
longitude=float(data.get("lon", longitude)),
|
|
datetime=datetime.now(timezone.utc),
|
|
zip=address.get("postcode"),
|
|
street=address.get("road"),
|
|
city=address.get("city"),
|
|
state=address.get("state"),
|
|
country=address.get("country"),
|
|
context={}, # Initialize with an empty dict, to be filled as needed
|
|
class_=data.get("class"),
|
|
type=data.get("type"),
|
|
name=data.get("name"),
|
|
display_name=data.get("display_name"),
|
|
boundingbox=data.get("boundingbox"),
|
|
amenity=address.get("amenity"),
|
|
house_number=address.get("house_number"),
|
|
road=address.get("road"),
|
|
quarter=address.get("quarter"),
|
|
neighbourhood=address.get("neighbourhood"),
|
|
suburb=address.get("suburb"),
|
|
county=address.get("county"),
|
|
country_code=address.get("country_code")
|
|
)
|
|
INFO(f"Created Location object: {location}")
|
|
return location
|
|
except aiohttp.ClientError as e:
|
|
ERR(f"Error: {e}")
|
|
return None
|
|
|
|
|
|
|
|
## NOT YET IMPLEMENTED
|
|
async def geocode(zip_code: Optional[str] = None, latitude: Optional[float] = None, longitude: Optional[float] = None, city: Optional[str] = None, state: Optional[str] = None, country_code: str = 'US') -> Location:
|
|
if (latitude is None or longitude is None) and (zip_code is None) and (city is None or state is None):
|
|
ERR(f"Must provide sufficient information for geocoding!")
|
|
return None
|
|
|
|
try:
|
|
# Establish the database connection
|
|
async with DB.get_connection() as conn:
|
|
|
|
# Build the SQL query based on the provided parameters
|
|
query = "SELECT id, street, city, state, country, latitude, longitude, zip, elevation, datetime, date, ST_Distance(geom, ST_SetSRID(ST_MakePoint($1, $2), 4326)) AS distance FROM Locations"
|
|
|
|
conditions = []
|
|
params = []
|
|
|
|
if latitude is not None and longitude is not None:
|
|
conditions.append("ST_DWithin(geom, ST_SetSRID(ST_MakePoint($1, $2), 4326), 50000)") # 50 km radius
|
|
params.extend([longitude, latitude])
|
|
|
|
if zip_code:
|
|
conditions.append("zip = $3 AND country = $4")
|
|
params.extend([zip_code, country_code])
|
|
|
|
if city and state:
|
|
conditions.append("city ILIKE $5 AND state ILIKE $6 AND country = $7")
|
|
params.extend([city, state, country_code])
|
|
|
|
if conditions:
|
|
query += " WHERE " + " OR ".join(conditions)
|
|
|
|
query += " ORDER BY distance LIMIT 1;"
|
|
|
|
DEBUG(f"Executing query: {query} with params: {params}")
|
|
|
|
# Execute the query with the provided parameters
|
|
result = await conn.fetchrow(query, *params)
|
|
|
|
# Close the connection
|
|
await conn.close()
|
|
|
|
if result:
|
|
location_info = Location(
|
|
latitude=result['latitude'],
|
|
longitude=result['longitude'],
|
|
datetime=result.get['datetime'],
|
|
zip=result['zip'],
|
|
street=result.get('street', ''),
|
|
city=result['city'],
|
|
state=result['state'],
|
|
country=result['country'],
|
|
elevation=result.get('elevation', 0),
|
|
distance=result.get('distance')
|
|
)
|
|
DEBUG(f"Found location: {location_info}")
|
|
return location_info
|
|
else:
|
|
DEBUG("No location found with provided parameters.")
|
|
return Location()
|
|
|
|
except Exception as e:
|
|
ERR(f"Error occurred: {e}")
|
|
raise Exception("An error occurred while processing your request")
|
|
|
|
|
|
async def localize_datetime(dt, fetch_loc: bool = False):
|
|
initial_dt = dt
|
|
|
|
if fetch_loc:
|
|
loc = await get_last_location()
|
|
tz = await DynamicTZ.get_current(loc)
|
|
else:
|
|
tz = await DynamicTZ.get_last()
|
|
|
|
try:
|
|
if isinstance(dt, str):
|
|
dt = dateutil_parse(dt)
|
|
DEBUG(f"{initial_dt} was a string so we attempted converting to datetime. Result: {dt}")
|
|
|
|
if isinstance(dt, datetime):
|
|
DEBUG(f"{dt} is a datetime object, so we will ensure it is tz-aware.")
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=TZ)
|
|
# DEBUG(f"{dt} should now be tz-aware. Returning it now.")
|
|
return dt
|
|
else:
|
|
# DEBUG(f"{dt} already was tz-aware. Returning it now.")
|
|
return dt
|
|
else:
|
|
ERR(f"Conversion failed")
|
|
raise TypeError("Conversion failed")
|
|
except Exception as e:
|
|
ERR(f"Error parsing datetime: {e}")
|
|
raise TypeError("Input must be a string or datetime object")
|
|
|
|
|
|
|
|
def find_override_locations(lat: float, lon: float) -> Optional[str]:
|
|
# Load the JSON file
|
|
with open(NAMED_LOCATIONS, 'r') as file:
|
|
locations = yaml.safe_load(file)
|
|
|
|
closest_location = None
|
|
closest_distance = float('inf')
|
|
|
|
# Iterate through each location entry in the JSON
|
|
for location in locations:
|
|
loc_name = location.get("name")
|
|
loc_lat = location.get("latitude")
|
|
loc_lon = location.get("longitude")
|
|
loc_radius = location.get("radius")
|
|
|
|
# Calculate distance using haversine
|
|
distance = haversine(lat, lon, loc_lat, loc_lon)
|
|
|
|
# Check if the distance is within the specified radius
|
|
if distance <= loc_radius:
|
|
if distance < closest_distance:
|
|
closest_distance = distance
|
|
closest_location = loc_name
|
|
|
|
return closest_location
|
|
|
|
def get_elevation(latitude, longitude):
|
|
url = "https://api.open-elevation.com/api/v1/lookup"
|
|
|
|
payload = {
|
|
"locations": [
|
|
{
|
|
"latitude": latitude,
|
|
"longitude": longitude
|
|
}
|
|
]
|
|
}
|
|
|
|
try:
|
|
response = requests.post(url, json=payload)
|
|
response.raise_for_status() # Raise an exception for unsuccessful requests
|
|
|
|
data = response.json()
|
|
|
|
if "results" in data:
|
|
elevation = data["results"][0]["elevation"]
|
|
return elevation
|
|
else:
|
|
return None
|
|
|
|
except requests.exceptions.RequestException as e:
|
|
ERR(f"Error: {e}")
|
|
return None
|
|
|
|
|
|
|
|
async def fetch_locations(start: datetime, end: datetime = None) -> List[Location]:
|
|
start_datetime = await localize_datetime(start)
|
|
if end is None:
|
|
end_datetime = await localize_datetime(start_datetime.replace(hour=23, minute=59, second=59))
|
|
else:
|
|
end_datetime = await localize_datetime(end)
|
|
|
|
if start_datetime.time() == datetime.min.time() and end_datetime.time() == datetime.min.time():
|
|
end_datetime = end_datetime.replace(hour=23, minute=59, second=59)
|
|
|
|
DEBUG(f"Fetching locations between {start_datetime} and {end_datetime}")
|
|
|
|
async with DB.get_connection() as conn:
|
|
locations = []
|
|
# Check for records within the specified datetime range
|
|
range_locations = await conn.fetch('''
|
|
SELECT id, datetime,
|
|
ST_X(ST_AsText(location)::geometry) AS longitude,
|
|
ST_Y(ST_AsText(location)::geometry) AS latitude,
|
|
ST_Z(ST_AsText(location)::geometry) AS elevation,
|
|
city, state, zip, street,
|
|
action, device_type, device_model, device_name, device_os
|
|
FROM locations
|
|
WHERE datetime >= $1 AND datetime <= $2
|
|
ORDER BY datetime DESC
|
|
''', start_datetime.replace(tzinfo=None), end_datetime.replace(tzinfo=None))
|
|
|
|
DEBUG(f"Range locations query returned: {range_locations}")
|
|
locations.extend(range_locations)
|
|
|
|
if not locations and (end is None or start_datetime.date() == end_datetime.date()):
|
|
location_data = await conn.fetchrow('''
|
|
SELECT id, datetime,
|
|
ST_X(ST_AsText(location)::geometry) AS longitude,
|
|
ST_Y(ST_AsText(location)::geometry) AS latitude,
|
|
ST_Z(ST_AsText(location)::geometry) AS elevation,
|
|
city, state, zip, street,
|
|
action, device_type, device_model, device_name, device_os
|
|
FROM locations
|
|
WHERE datetime < $1
|
|
ORDER BY datetime DESC
|
|
LIMIT 1
|
|
''', start_datetime.replace(tzinfo=None))
|
|
|
|
DEBUG(f"Fallback query returned: {location_data}")
|
|
if location_data:
|
|
locations.append(location_data)
|
|
|
|
DEBUG(f"Locations found: {locations}")
|
|
|
|
# Sort location_data based on the datetime field in descending order
|
|
sorted_locations = sorted(locations, key=lambda x: x['datetime'], reverse=True)
|
|
|
|
# Create Location objects directly from the location data
|
|
location_objects = [
|
|
Location(
|
|
latitude=loc['latitude'],
|
|
longitude=loc['longitude'],
|
|
datetime=loc['datetime'],
|
|
elevation=loc.get('elevation'),
|
|
city=loc.get('city'),
|
|
state=loc.get('state'),
|
|
zip=loc.get('zip'),
|
|
street=loc.get('street'),
|
|
context={
|
|
'action': loc.get('action'),
|
|
'device_type': loc.get('device_type'),
|
|
'device_model': loc.get('device_model'),
|
|
'device_name': loc.get('device_name'),
|
|
'device_os': loc.get('device_os')
|
|
}
|
|
) for loc in sorted_locations if loc['latitude'] is not None and loc['longitude'] is not None
|
|
]
|
|
|
|
return location_objects if location_objects else []
|
|
|
|
# Function to fetch the last location before the specified datetime
|
|
async def fetch_last_location_before(datetime: datetime) -> Optional[Location]:
|
|
datetime = await localize_datetime(datetime)
|
|
|
|
DEBUG(f"Fetching last location before {datetime}")
|
|
|
|
async with DB.get_connection() as conn:
|
|
|
|
location_data = await conn.fetchrow('''
|
|
SELECT id, datetime,
|
|
ST_X(ST_AsText(location)::geometry) AS longitude,
|
|
ST_Y(ST_AsText(location)::geometry) AS latitude,
|
|
ST_Z(ST_AsText(location)::geometry) AS elevation,
|
|
city, state, zip, street, country,
|
|
action
|
|
FROM locations
|
|
WHERE datetime < $1
|
|
ORDER BY datetime DESC
|
|
LIMIT 1
|
|
''', datetime.replace(tzinfo=None))
|
|
|
|
await conn.close()
|
|
|
|
if location_data:
|
|
DEBUG(f"Last location found: {location_data}")
|
|
return Location(**location_data)
|
|
else:
|
|
DEBUG("No location found before the specified datetime")
|
|
return None
|
|
|
|
|
|
|
|
@locate.get("/map/start_date={start_date_str}&end_date={end_date_str}", response_class=HTMLResponse)
|
|
async def generate_map_endpoint(start_date_str: str, end_date_str: str):
|
|
try:
|
|
start_date = await localize_datetime(start_date_str)
|
|
end_date = await localize_datetime(end_date_str)
|
|
except ValueError:
|
|
raise HTTPException(status_code=400, detail="Invalid date format")
|
|
|
|
html_content = await generate_map(start_date, end_date)
|
|
return HTMLResponse(content=html_content)
|
|
|
|
|
|
@locate.get("/map", response_class=HTMLResponse)
|
|
async def generate_alltime_map_endpoint():
|
|
try:
|
|
start_date = await localize_datetime(datetime.fromisoformat("2022-01-01"))
|
|
end_date = localize_datetime(datetime.now())
|
|
except ValueError:
|
|
raise HTTPException(status_code=400, detail="Invalid date format")
|
|
|
|
html_content = await generate_map(start_date, end_date)
|
|
return HTMLResponse(content=html_content)
|
|
|
|
|
|
async def generate_map(start_date: datetime, end_date: datetime):
|
|
locations = await fetch_locations(start_date, end_date)
|
|
if not locations:
|
|
raise HTTPException(status_code=404, detail="No locations found for the given date range")
|
|
|
|
# Create a folium map centered around the first location
|
|
map_center = [locations[0].latitude, locations[0].longitude]
|
|
m = folium.Map(location=map_center, zoom_start=5)
|
|
|
|
# Add markers for each location
|
|
for loc in locations:
|
|
folium.Marker(
|
|
location=[loc.latitude, loc.longitude],
|
|
popup=f"{loc.city}, {loc.state}<br>Elevation: {loc.elevation}m<br>Date: {loc.datetime}",
|
|
tooltip=f"{loc.city}, {loc.state}"
|
|
).add_to(m)
|
|
|
|
# Save the map to an HTML file and return the HTML content
|
|
map_html = "map.html"
|
|
m.save(map_html)
|
|
|
|
with open(map_html, 'r') as file:
|
|
html_content = file.read()
|
|
|
|
return html_content
|
|
|
|
|
|
async def post_location(location: Location):
|
|
DEBUG(f"post_location called with {location.datetime}")
|
|
|
|
async with DB.get_connection() as conn:
|
|
try:
|
|
context = location.context or {}
|
|
action = context.get('action', 'manual')
|
|
device_type = context.get('device_type', 'Unknown')
|
|
device_model = context.get('device_model', 'Unknown')
|
|
device_name = context.get('device_name', 'Unknown')
|
|
device_os = context.get('device_os', 'Unknown')
|
|
|
|
# Parse and localize the datetime
|
|
localized_datetime = await localize_datetime(location.datetime)
|
|
|
|
await conn.execute('''
|
|
INSERT INTO locations (datetime, location, city, state, zip, street, action, device_type, device_model, device_name, device_os)
|
|
VALUES ($1, ST_SetSRID(ST_MakePoint($2, $3, $4), 4326), $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
|
''', localized_datetime, location.longitude, location.latitude, location.elevation, location.city, location.state, location.zip, location.street, action, device_type, device_model, device_name, device_os)
|
|
await conn.close()
|
|
INFO(f"Successfully posted location: {location.latitude}, {location.longitude} on {localized_datetime}")
|
|
return {
|
|
'datetime': localized_datetime,
|
|
'latitude': location.latitude,
|
|
'longitude': location.longitude,
|
|
'city': location.city,
|
|
'state': location.state,
|
|
'zip': location.zip,
|
|
'street': location.street,
|
|
'elevation': location.elevation,
|
|
'action': action,
|
|
'device_type': device_type,
|
|
'device_model': device_model,
|
|
'device_name': device_name,
|
|
'device_os': device_os
|
|
}
|
|
except Exception as e:
|
|
ERR(f"Error posting location {e}")
|
|
ERR(traceback.format_exc())
|
|
return None
|
|
|
|
|
|
@locate.post("/locate")
|
|
async def post_locate_endpoint(locations: Union[Location, List[Location]]):
|
|
responses = []
|
|
if isinstance(locations, Location):
|
|
locations = [locations]
|
|
|
|
for location in locations:
|
|
if not location.datetime:
|
|
location.datetime = datetime.now(timezone.utc).isoformat()
|
|
|
|
if not location.elevation:
|
|
location.elevation = location.altitude if location.altitude else await get_elevation(location.latitude, location.longitude)
|
|
|
|
# Ensure context is a dictionary with default values if not provided
|
|
if not location.context:
|
|
location.context = {
|
|
"action": "manual",
|
|
"device_type": "Pythonista",
|
|
"device_model": "Unknown",
|
|
"device_name": "Unknown",
|
|
"device_os": "Unknown"
|
|
}
|
|
|
|
DEBUG(f"datetime before localization: {location.datetime}")
|
|
# Convert datetime string to timezone-aware datetime object
|
|
location.datetime = await localize_datetime(location.datetime)
|
|
DEBUG(f"datetime after localization: {location.datetime}")
|
|
|
|
# Perform reverse geocoding
|
|
geocoded_location = await reverse_geocode(location.latitude, location.longitude)
|
|
if geocoded_location:
|
|
# Update location with geocoded information
|
|
for field in location.__fields__:
|
|
if getattr(location, field) is None:
|
|
setattr(location, field, getattr(geocoded_location, field))
|
|
|
|
location_entry = await post_location(location)
|
|
if location_entry:
|
|
responses.append({"location_data": location_entry}) # Add weather data if necessary
|
|
|
|
return {"message": "Locations and weather updated", "results": responses}
|
|
|
|
# Assuming post_location and get_elevation are async functions. If not, they should be modified to be async as well.
|
|
|
|
|
|
|
|
async def get_last_location() -> Optional[Location]:
|
|
query_datetime = datetime.now(TZ)
|
|
DEBUG(f"Query_datetime: {query_datetime}")
|
|
|
|
location = await fetch_last_location_before(query_datetime)
|
|
|
|
if location:
|
|
DEBUG(f"location: {location}")
|
|
return location
|
|
|
|
return None
|
|
|
|
@locate.get("/locate", response_model=Location)
|
|
async def get_last_location_endpoint() -> JSONResponse:
|
|
location = await get_last_location()
|
|
|
|
if location:
|
|
location_dict = location.model_dump()
|
|
location_dict["datetime"] = location.datetime.isoformat()
|
|
return JSONResponse(content=location_dict)
|
|
else:
|
|
raise HTTPException(status_code=404, detail="No location found before the specified datetime")
|
|
|
|
@locate.get("/locate/{datetime_str}", response_model=List[Location])
|
|
async def get_locate(datetime_str: str, all: bool = False):
|
|
try:
|
|
date_time = await localize_datetime(datetime_str)
|
|
except ValueError as e:
|
|
ERR(f"Invalid datetime string provided: {datetime_str}")
|
|
return ["ERROR: INVALID DATETIME PROVIDED. USE YYYYMMDDHHmmss or YYYYMMDD format."]
|
|
|
|
locations = await fetch_locations(date_time)
|
|
if not locations:
|
|
raise HTTPException(status_code=404, detail="No nearby data found for this date and time")
|
|
|
|
return locations if all else [locations[0]]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
future_elevation = """
|
|
def get_elevation_srtm(latitude, longitude, srtm_file):
|
|
try:
|
|
# Open the SRTM dataset
|
|
dataset = gdal.Open(srtm_file)
|
|
|
|
# Get the geotransform and band information
|
|
geotransform = dataset.GetGeoTransform()
|
|
band = dataset.GetRasterBand(1)
|
|
|
|
# Calculate the pixel coordinates from the latitude and longitude
|
|
x = int((longitude - geotransform[0]) / geotransform[1])
|
|
y = int((latitude - geotransform[3]) / geotransform[5])
|
|
|
|
# Read the elevation value from the SRTM dataset
|
|
elevation = band.ReadAsArray(x, y, 1, 1)[0][0]
|
|
|
|
# Close the dataset
|
|
dataset = None
|
|
|
|
return elevation
|
|
|
|
except Exception as e:
|
|
ERR(f"Error: {e}")
|
|
return None
|
|
"""
|
|
|
|
def get_elevation2(latitude: float, longitude: float) -> float:
|
|
url = f"https://nationalmap.gov/epqs/pqs.php?x={longitude}&y={latitude}&units=Meters&output=json"
|
|
|
|
try:
|
|
response = requests.get(url)
|
|
data = response.json()
|
|
elevation = data["USGS_Elevation_Point_Query_Service"]["Elevation_Query"]["Elevation"]
|
|
return float(elevation)
|
|
except Exception as e:
|
|
# Handle exceptions (e.g., network errors, API changes) appropriately
|
|
raise RuntimeError(f"Error getting elevation data: {str(e)}")
|