treetrail-backend/src/treetrail/api_v1.py

488 lines
17 KiB
Python
Raw Normal View History

2024-10-23 16:19:51 +02:00
import logging
import mimetypes
from pathlib import Path
from datetime import timedelta
import tarfile
from typing import Optional
from base64 import standard_b64decode
import re
from typing import Tuple
from json import loads
from uuid import UUID
from fastapi import (FastAPI, Response, HTTPException,
File, UploadFile, Request, Form, responses,
Depends, status)
from fastapi.staticfiles import StaticFiles
from fastapi.security import OAuth2PasswordRequestForm
from sqlmodel import select
from sqlalchemy import or_
import geopandas as gpd # type: ignore
import pandas as pd
import aiofiles
import aiofiles.os
from PIL import Image
from treetrail.utils import (get_attachment_poi_root, get_attachment_root,
get_attachment_trail_root, get_attachment_tree_root, mkdir)
from treetrail.security import (
Token,
authenticate_user, create_access_token,
get_current_active_user, get_current_user, get_current_roles,
)
from treetrail.database import fastapi_db_session as db_session
from treetrail.models import (BaseMapStyles, User, Role, Bootstrap,
MapStyle, Tree, Trail,
TreeTrail, POI, UserWithRoles, Zone,
VersionedComponent)
from treetrail.config import conf, get_cache_dir, __version__
from treetrail.plantekey import get_local_details
from treetrail.tiles import registry as tilesRegistry
logger = logging.getLogger(__name__)
api_app = FastAPI(
debug=False,
title=conf.app.title,
version=conf.version,
# lifespan=lifespan,
default_response_class=responses.ORJSONResponse,
)
re_findmimetype = re.compile('^data:(\S+);') # type: ignore
attachment_types: dict[str, type[Tree] | type[Trail] | type[POI]] = {
'tree': Tree,
'trail': Trail,
'poi': POI
}
attachment_thumbnailable_fields = {
'photo'
}
thumbnail_size = (200, 200)
@api_app.get('/bootstrap')
async def get_bootstrap(
user: UserWithRoles = Depends(get_current_user)
) -> Bootstrap:
# XXX: hide password - issue zith SQLModel
return Bootstrap(
server=VersionedComponent(version=__version__),
client=VersionedComponent(version=__version__),
app=conf.app,
user=user,
map=conf.map,
baseMapStyles=BaseMapStyles(
embedded=list(tilesRegistry.mbtiles.keys()),
external=conf.mapStyles,
),
)
@api_app.post("/token", response_model=Token)
async def login_for_access_token(
form_data: OAuth2PasswordRequestForm = Depends()
):
user = await authenticate_user(form_data.username, form_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token_expires = timedelta(
minutes=conf.security.access_token_expire_minutes)
access_token = create_access_token(
data={"sub": user.username},
expires_delta=access_token_expires)
return {"access_token": access_token, "token_type": "bearer"}
@api_app.post("/upload/{type}/{field}/{id}")
async def upload(request: Request, type: str, field: str, id: str,
db_session: db_session,
file: UploadFile = File(...),
user: User = Depends(get_current_active_user)
):
if type not in attachment_types:
raise HTTPException(status_code=status.HTTP_417_EXPECTATION_FAILED,
detail=f"No such type: {type}")
model = attachment_types[type]
if field not in model.model_fields:
raise HTTPException(status_code=status.HTTP_417_EXPECTATION_FAILED,
detail=f"No such field for {type}: {field}")
base_dir = get_attachment_root(type) / id
if not base_dir.is_dir():
await aiofiles.os.mkdir(base_dir)
filename = base_dir / file.filename # type: ignore
if field in attachment_thumbnailable_fields:
try:
# TODO: async save
image = Image.open(file.file)
image.thumbnail(thumbnail_size)
image.save(filename)
logger.info(f'Saved thumbnail {filename}')
except Exception as error:
logger.warning('Cannot create thumbnail for ' +
f'{type} {field} {id} ({filename}): {error}')
else:
async with aiofiles.open(filename, 'wb') as f:
await f.write(file.file.read())
logger.info(f'Saved file {filename}')
rec = await db_session.get(model, int(id))
if rec is None:
raise HTTPException(status_code=status.HTTP_417_EXPECTATION_FAILED,
detail=f'No such {type} id {id}')
setattr(rec, field, file.filename)
await db_session.commit()
return {
"message": "Successfully uploaded",
"filename": file.filename,
}
@api_app.get("/makeAttachmentsTarFile")
async def makeAttachmentsTarFile(
db_session: db_session,
current_user: User = Depends(get_current_active_user)
):
"""
Create a tar file with all photos, used to feed clients' caches
for offline use
"""
logger.info('Generating thumbnails and tar file')
tarfile_path = get_cache_dir() / 'attachments.tar'
with tarfile.open(str(tarfile_path), 'w') as tar:
for type, model in attachment_types.items():
data = await db_session.exec(select(model.id, model.photo))
# recs: list[Tree | Trail | POI]
recs = data.all()
for rec in recs:
photo: str = rec.photo # type: ignore
id: str = rec.id # type: ignore
if photo:
file = get_attachment_root(type) / str(id) / photo
if file.is_file():
tar.add(file)
logger.info(f'Generation of thumbnails and tar file ({tarfile_path}) finished')
return {
"message": "Successfully made attachments tar file",
}
@api_app.get("/logout")
def logout(response: Response):
response.delete_cookie(key='token')
return response
@api_app.get('/trail')
async def get_trails(
roles: list[Role] = Depends(get_current_roles),
):
"""
Get all trails
"""
gdf = await Trail.get_gdf(
where=or_(Trail.viewable_role_id.in_([role.name for role in roles]), # type: ignore
Trail.viewable_role_id == None)) # type: ignore # noqa: E711
if len(gdf) == 0:
gdf.set_geometry([], inplace=True)
# Get only file name of the photo URL
else:
photos_path_df = gdf['photo'].str.rpartition('/') # type: ignore
if 2 in photos_path_df.columns:
gdf['photo'] = photos_path_df[2]
gdf['create_date'] = gdf['create_date'].astype(str) # type: ignore
return Response(content=gdf.to_json(),
media_type="application/json") # type: ignore
@api_app.get('/trail/details')
async def get_trail_all_details(
db_session: db_session,
):
"""
Get details of all trails
"""
trails = await db_session.exec(select(
Trail.id,
Trail.name,
Trail.description,
Trail.photo,
))
df = pd.DataFrame(trails.all())
# Get only file name of the photo URL
photos_path_df = df['photo'].str.rpartition('/')
if 2 in photos_path_df.columns:
df['photo'] = photos_path_df[2]
return Response(content=df.to_json(orient='records'),
media_type="application/json")
@api_app.get('/tree-trail')
async def get_tree_trail(
db_session: db_session,
) -> list[TreeTrail]:
"""
Get all relations between trees and trails.
Note that these are not checked for permissions, as there's no really
valuable information.
"""
data = await db_session.exec(select(TreeTrail))
return data.all() # type: ignore
@api_app.get('/tree')
async def get_trees(
roles: list[Role] = Depends(get_current_roles),
):
"""
Get all trees
"""
gdf = await Tree.get_gdf(
where=or_(Tree.viewable_role_id.in_([role.name for role in roles]), # type: ignore
Tree.viewable_role_id == None)) # type: ignore # noqa: E711
if len(gdf) > 0:
gdf['plantekey_id'] = gdf['plantekey_id'].fillna('')
tree_trail_details = await get_local_details()
if len(tree_trail_details) > 0:
gdf = gdf.merge(tree_trail_details, left_on='plantekey_id',
right_index=True, how='left')
gdf['symbol'].fillna('\uE034', inplace=True)
else:
gdf['symbol'] = '\uE034'
else:
gdf.set_geometry([], inplace=True)
# Get only file name of the photo URL
if len(gdf) > 0:
photos_path_df = gdf['photo'].str.rpartition('/') # type: ignore
if 2 in photos_path_df.columns:
gdf['photo'] = photos_path_df[2]
## TODO: format create_date in proper json
gdf['create_date'] = gdf['create_date'].astype(str) # type: ignore
gdf['id'] = gdf.index.astype(str) # type: ignore
return Response(content=gdf.to_json(),
media_type="application/json")
def get_attachment_path(uuid, extension, feature_type, feature_id) -> Tuple[str, Path]:
root_storage_path = Path(conf.storage.root_attachment_path)
full_name = str(uuid) + extension
dir: Path = root_storage_path / feature_type / str(feature_id)
dir.mkdir(parents=True, exist_ok=True)
return full_name, dir / full_name
@api_app.post('/tree')
async def addTree(
request: Request,
db_session: db_session,
user: User = Depends(get_current_active_user),
plantekey_id: str = Form(),
picture: Optional[str] = Form(None),
trail_ids: str | None = Form(None),
lng: str = Form(),
lat: str = Form(),
uuid1: Optional[str] = Form(None),
details: str | None = Form(None)
):
tree = Tree(**Tree.get_tree_insert_params(
plantekey_id,
lng, lat,
user.username,
loads(details) if details else {},
))
if trail_ids is not None:
for trail_id in trail_ids.split(','):
tree_trail = TreeTrail(
tree_id=tree.id,
trail_id=int(trail_id)
)
db_session.add(tree_trail)
## Save files
resp:dict[str, UUID | str | None] = {'id': tree.id}
if picture is not None:
re_mimetype = re_findmimetype.search(picture)
if re_mimetype:
mimetype: str = re_mimetype.group(1)
picture_file, full_path = get_attachment_path(
uuid1, mimetypes.guess_extension(mimetype),
'tree', tree.id)
with open(full_path, 'wb') as file_:
## Feels i'm missing something as it's quite ugly:
# print(full_path)
decoded = standard_b64decode(picture[picture.find(',')+1:])
file_.write(decoded)
resp['picture'] = picture_file
tree.photo = picture_file
else:
logger.warning('Bad picture data: cannot find mimetype')
db_session.add(tree)
await db_session.commit()
return resp
@api_app.get('/poi')
async def get_pois(
db_session: db_session,
roles: list[Role] = Depends(get_current_roles),
) -> list[POI]:
"""
Get all POI
"""
gdf = await POI.get_gdf() # type: ignore
if len(gdf) > 0:
gdf.set_index('id', inplace=True)
gdf.set_geometry(gpd.GeoSeries.from_wkb(gdf.wkb), inplace=True)
gdf.drop('wkb', axis=1, inplace=True)
gdf['symbol'] = '\uE001'
else:
gdf.set_geometry([], inplace=True)
gdf['id'] = gdf.index.astype('str')
# Also remove create_date, not really required and would need to be
# propared to be serialized
gdf.drop(columns='create_date', inplace=True)
return Response(content=gdf.to_json(),
media_type="application/json") # type: ignore
@api_app.get('/zone')
async def get_zones(
db_session: db_session,
roles: list[Role] = Depends(get_current_roles),
) -> list[Zone]:
"""
Get all Zones
"""
gdf = await Zone.get_gdf(
where=or_(Zone.viewable_role_id.in_([role.name for role in roles]), # type: ignore
Zone.viewable_role_id == None)) # type: ignore # noqa: E711
# Sort by area, a simple workaround for selecting smaller areas on the map
gdf['area'] = gdf.area
gdf.sort_values('area', ascending=False, inplace=True)
gdf.drop(columns='area', inplace=True)
# Also remove create_date, not really required and would need to be
# propared to be serialized
gdf.drop(columns='create_date', inplace=True)
return Response(content=gdf.to_json(),
media_type="application/json") # type: ignore
@api_app.get('/style')
async def get_styles(
db_session: db_session,
) -> list[MapStyle]:
"""
Get all Styles
"""
data = await db_session.exec(select(MapStyle))
return data.all() # type: ignore
@api_app.put("/trail/photo/{id}/{file_name}")
async def upload_trail_photo(request: Request,
db_session: db_session,
id: str, file_name: str,
file: UploadFile | None = None):
"""
This was tested with QGis, provided the properties for the trail layer
have been defined correctly.
This includes: in "Attributes Form", field "photo", "Widget Type"
is set as WebDav storage, with store URL set correcly with a URL like:
* 'http://localhost:4200/v1/trail/photo/' || "id" || '/' || file_name(@selected_file_path)
* 'https://treetrail.avcsr.org/v1/trail/' || "id" || '/' || file_name(@selected_file_path)
## XXX: probably broken info as paths have changed
""" # noqa: E501
base_dir = get_attachment_trail_root() / id
if not base_dir.is_dir():
await aiofiles.os.mkdir(base_dir)
if not file:
contents = await request.body()
# WebDAV
if len(contents) > 0:
# Save the file
async with aiofiles.open(base_dir / file_name, 'wb') as f:
await f.write(contents)
# Update the trail record
# With QGis this gets overwritten when it is saved
trail = await db_session.get(Trail, int(id))
if trail is None:
raise HTTPException(status_code=status.HTTP_417_EXPECTATION_FAILED,
detail=f'No such trail id {id}')
trail.photo = file_name
await db_session.commit()
else:
return {"message": "No file found in the request"}
else:
# Multipart form - not tested
try:
contents = file.file.read()
async with aiofiles.open(base_dir, 'wb') as f:
await f.write(contents)
except Exception:
return {"message": "There was an error uploading the file"}
finally:
file.file.close()
return {"message": f"Successfully uploaded {file.filename} for id {id}"}
@api_app.put("/tree/photo/{id}/{file_name}")
async def upload_tree_photo(request: Request,
db_session: db_session,
id: str, file_name: str,
file: UploadFile | None = None):
"""
This was tested with QGis, provided the properties for the tree layer
have been defined correctly.
This includes: in "Attributes Form", field "photo", "Widget Type"
is set as WebDav storage, with store URL set correcly with a URL like:
* 'http://localhost:4200/v1/tree/photo/' || "id" || '/' || file_name(@selected_file_path)
* 'https://treetrail.avcsr.org/v1/tree/' || "id" || '/' || file_name(@selected_file_path)
## XXX: probably broken info as paths have changed
""" # noqa: E501
base_dir = get_attachment_tree_root() / id
if not base_dir.is_dir():
await aiofiles.os.mkdir(base_dir)
if not file:
contents = await request.body()
# WebDAV
if len(contents) > 0:
# Save the file
async with aiofiles.open(base_dir / file_name, 'wb') as f:
await f.write(contents)
# Update the tree record
# With QGis this gets overwritten when it is saved
tree = await db_session.get(Tree, int(id))
if tree is None:
raise HTTPException(status_code=status.HTTP_417_EXPECTATION_FAILED,
detail=f'No such tree id {id}')
tree.photo = file_name
await db_session.commit()
else:
return {'message': 'No file found in the request'}
else:
# Multipart form - not tested
try:
contents = file.file.read()
async with aiofiles.open(base_dir, 'wb') as f:
await f.write(contents)
except Exception:
return {"message": "There was an error uploading the file"}
finally:
file.file.close()
return {"message": f"Successfully uploaded {file.filename} for id {id}"}
# => Below =>
# Serve the images
# The URLs are better served by a reverse proxy front-end, like Nginx
api_app.mount('/tree', StaticFiles(directory=mkdir(get_attachment_tree_root())), name='tree_attachments')
api_app.mount('/trail', StaticFiles(directory=mkdir(get_attachment_trail_root())), name='trail_attachments')
api_app.mount('/poi', StaticFiles(directory=mkdir(get_attachment_poi_root())), name='poi_attachments')