Initial commit
This commit is contained in:
commit
f4cf78603a
25 changed files with 2895 additions and 0 deletions
15
.forgejo/workflows/install.yaml
Normal file
15
.forgejo/workflows/install.yaml
Normal file
|
@ -0,0 +1,15 @@
|
|||
on: [push]
|
||||
jobs:
|
||||
install:
|
||||
runs-on: container
|
||||
container:
|
||||
image: tiptop:5000/treetrail-backend-ci-base
|
||||
services:
|
||||
treetrail-database:
|
||||
image: treetrail-database
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install dependencies
|
||||
run: uv install
|
||||
- name: Run basic test (bootstrap)
|
||||
run: .venv/bin/pytest -s tests/basic.py
|
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
|
@ -0,0 +1,13 @@
|
|||
# Python-generated files
|
||||
__pycache__/
|
||||
*.py[oc]
|
||||
build/
|
||||
dist/
|
||||
wheels/
|
||||
*.egg-info
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
|
||||
# Custom
|
||||
.python-version
|
12
Containerfile
Normal file
12
Containerfile
Normal file
|
@ -0,0 +1,12 @@
|
|||
FROM localhost/trixie_python
|
||||
WORKDIR /usr/src/treetrail
|
||||
ENV PATH="/usr/src/treetrail/.venv/bin:$PATH"
|
||||
ENV PYTHONPATH="/usr/src"
|
||||
COPY --from=localhost/treetrail_backend_deps /usr/src/treetrail/.venv/ /usr/src/treetrail/.venv
|
||||
COPY --from=localhost/treetrail_backend_deps /usr/local/treetrail/ /usr/local/treetrail
|
||||
COPY ./treetrail ./pyproject.toml ./README.md .
|
||||
|
||||
# Instances should override the prod.yaml file
|
||||
COPY ./prod.yaml /etc/treetrail/prod.yaml
|
||||
|
||||
CMD ["uvicorn", "treetrail.application:app", "--port", "8081", "--log-config", "logging.yaml", "--host", "0.0.0.0"]
|
11
Containerfile.backend_ci_base
Normal file
11
Containerfile.backend_ci_base
Normal file
|
@ -0,0 +1,11 @@
|
|||
FROM debian:trixie-slim
|
||||
MAINTAINER philo email phil.dev@philome.mooo.com
|
||||
|
||||
RUN apt update
|
||||
RUN apt install --no-install-recommends -y python-is-python3 python3-pip python3-venv nodejs git
|
||||
RUN pip install --break-system-packages pdm
|
||||
|
||||
RUN apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN rm -rf /root/.cache
|
36
Containerfile.backend_deps
Normal file
36
Containerfile.backend_deps
Normal file
|
@ -0,0 +1,36 @@
|
|||
FROM localhost/trixie_python
|
||||
MAINTAINER philo email phil.dev@philome.mooo.com
|
||||
|
||||
#ENV PROJ_DIR=/usr
|
||||
ENV PYTHONDONTWRITEBYTECODE 1
|
||||
ENV PDM_CHECK_UPDATE=false
|
||||
#RUN apk add --no-cache make cmake clang gdal-dev geos-dev proj-dev proj-util gcc musl-dev bash
|
||||
#RUN apk add --no-cache gdal-dev geos-dev proj-dev proj-util gcc musl-dev bash
|
||||
|
||||
WORKDIR /usr/src/treetrail
|
||||
COPY ./pyproject.toml ./README.md ./pdm.lock .
|
||||
# Cheating pdm with the app version to allow install of dependencies
|
||||
RUN PDM_BUILD_SCM_VERSION=1.0 pdm install --check --prod --no-editable
|
||||
|
||||
## Instances should populate these dirs below
|
||||
RUN mkdir -p /usr/local/treetrail/osm \
|
||||
/usr/local/treetrail/sprite \
|
||||
/usr/local/treetrail/cache/plantekey/img \
|
||||
/usr/local/treetrail/cache/plantekey/thumbnails \
|
||||
/usr/local/treetrail/cache/plantekey/type \
|
||||
/usr/local/treetrail/map/sprite \
|
||||
/usr/local/treetrail/map/osm \
|
||||
/usr/local/treetrail/attachments/tree \
|
||||
/usr/local/treetrail/attachments/trail \
|
||||
/usr/local/treetrail/attachments/poi
|
||||
#COPY ./sprite /usr/local/treetrail
|
||||
#COPY ./osm /usr/local/treetrail
|
||||
|
||||
#RUN python -c 'import _version as v;print(v.__version__)' > version.txt
|
||||
|
||||
#RUN PDM_BUILD_SCM_VERSION=$(cat version.txt) pdm install --check --prod --no-editable
|
||||
#
|
||||
# Clear some space (caches)
|
||||
#RUN pdm cache clear
|
||||
#RUN rm -rf .mypy_cache
|
||||
#RUN rm -rf __pycache__
|
11
Containerfile.trixie_python
Normal file
11
Containerfile.trixie_python
Normal file
|
@ -0,0 +1,11 @@
|
|||
FROM debian:trixie-slim
|
||||
MAINTAINER philo email phil.dev@philome.mooo.com
|
||||
|
||||
RUN apt update
|
||||
RUN apt install --no-install-recommends -y python-is-python3 python3-pip python3-venv
|
||||
RUN pip install --break-system-packages pdm
|
||||
|
||||
RUN apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN rm -rf /root/.cache
|
3
README.md
Normal file
3
README.md
Normal file
|
@ -0,0 +1,3 @@
|
|||
*Tree Trail* is a fun and pedagogic tool to discover the trails and trees around.
|
||||
|
||||
This is the server (back-end), written in Python.
|
79
pyproject.toml
Normal file
79
pyproject.toml
Normal file
|
@ -0,0 +1,79 @@
|
|||
[project]
|
||||
name = "treetrail-srv"
|
||||
version = "0.1.0"
|
||||
#dynamic = ["version"]
|
||||
dynamic = ["version"]
|
||||
description = "A fun and pedagogic tool to discover the trails and trees around"
|
||||
authors = [
|
||||
{ name = "Philippe May", email = "phil.treetrail@philome.mooo.com" }
|
||||
]
|
||||
dependencies = [
|
||||
"aiofiles",
|
||||
"aiohttp-client-cache",
|
||||
"aiosqlite",
|
||||
"asyncpg",
|
||||
"fastapi",
|
||||
"geoalchemy2",
|
||||
"geopandas",
|
||||
"httptools>=0.6.1",
|
||||
"orjson",
|
||||
"pandas",
|
||||
"passlib[bcrypt]",
|
||||
"pillow",
|
||||
"psycopg2-binary",
|
||||
"pyarrow",
|
||||
"pydantic-settings",
|
||||
"python-jose[cryptography]",
|
||||
"python-multipart",
|
||||
"requests",
|
||||
"sqlalchemy[asyncio]",
|
||||
"sqlmodel",
|
||||
"uvicorn[standard]",
|
||||
"uvloop",
|
||||
]
|
||||
requires-python = ">=3.11"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Framework :: FastAPI",
|
||||
"Environment :: Web Environment",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: GNU General Public License (GPL)",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Operating System :: MacOS :: MacOS X",
|
||||
"Operating System :: POSIX",
|
||||
"Programming Language :: Python",
|
||||
]
|
||||
|
||||
#[project.scripts]
|
||||
#treetrail-srv = "treetrail_srv:main"
|
||||
|
||||
|
||||
#[tool.pdm.build]
|
||||
#includes = ["src/"]
|
||||
#
|
||||
#[tool.pdm.version]
|
||||
#source = "scm"
|
||||
#write_to = "treetrail/_version.py"
|
||||
#write_template = "__version__ = '{}'"
|
||||
#
|
||||
#[tool.pdm.dev-dependencies]
|
||||
#dev = [
|
||||
# "ipdb",
|
||||
# "pandas-stubs",
|
||||
# "types-Pillow",
|
||||
# "types-PyYAML",
|
||||
# "types-aiofiles",
|
||||
# "types-passlib",
|
||||
# "types-python-jose",
|
||||
# "types-requests",
|
||||
#]
|
||||
#test = [
|
||||
# "pytest>=8.3.3",
|
||||
# "httpx>=0.27.2",
|
||||
#]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
1
src/treetrail/_version.py
Normal file
1
src/treetrail/_version.py
Normal file
|
@ -0,0 +1 @@
|
|||
__version__ = '2024.4.dev3+g0527f08.d20241021'
|
487
src/treetrail/api_v1.py
Normal file
487
src/treetrail/api_v1.py
Normal file
|
@ -0,0 +1,487 @@
|
|||
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')
|
249
src/treetrail/application.py
Executable file
249
src/treetrail/application.py
Executable file
|
@ -0,0 +1,249 @@
|
|||
#!/usr/bin/env python
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
try:
|
||||
import coloredlogs # type: ignore
|
||||
except ImportError:
|
||||
pass
|
||||
else:
|
||||
coloredlogs.install()
|
||||
|
||||
from fastapi import FastAPI, responses
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from treetrail.config import conf, get_cache_dir, create_dirs
|
||||
from treetrail.plantekey import setup as setup_plantekey, pek_app
|
||||
from treetrail.api_v1 import api_app
|
||||
from treetrail.tiles import tiles_app, registry as tiles_registry
|
||||
from treetrail.attachments import attachment_app
|
||||
from treetrail.database import create_db
|
||||
from treetrail.utils import mkdir
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
create_dirs()
|
||||
setup_plantekey(app)
|
||||
await create_db()
|
||||
await tiles_registry.setup(app)
|
||||
yield
|
||||
await tiles_registry.shutdown(app)
|
||||
|
||||
app = FastAPI(
|
||||
title=conf.app.title,
|
||||
lifespan=lifespan,
|
||||
version=conf.version,
|
||||
default_response_class=responses.ORJSONResponse,
|
||||
)
|
||||
|
||||
api_app.mount('/plantekey', pek_app)
|
||||
app.mount(f'{conf.base_href}/v1', api_app)
|
||||
app.mount(f'{conf.base_href}/tiles', tiles_app)
|
||||
app.mount(f'{conf.base_href}/attachment', attachment_app)
|
||||
app.mount(
|
||||
f'{conf.base_href}/static/cache',
|
||||
StaticFiles(directory=mkdir(get_cache_dir())),
|
||||
name='static_generated'
|
||||
)
|
||||
|
||||
def _main(argv=None):
|
||||
from argparse import ArgumentParser
|
||||
arg_parser = ArgumentParser(
|
||||
description="fastapi Application server",
|
||||
prog="fastapi"
|
||||
)
|
||||
arg_parser.add_argument(
|
||||
'--path',
|
||||
help='Path of socket file',
|
||||
)
|
||||
arg_parser.add_argument(
|
||||
"-H", "--hostname",
|
||||
help="TCP/IP hostname to serve on (default: %(default)r)",
|
||||
default="localhost"
|
||||
)
|
||||
arg_parser.add_argument(
|
||||
"-P", "--port",
|
||||
help="TCP/IP port to serve on",
|
||||
type=int,
|
||||
)
|
||||
arg_parser.add_argument(
|
||||
"-c", "--create-db",
|
||||
help="Create tables in database",
|
||||
action="store_true"
|
||||
)
|
||||
arg_parser.add_argument(
|
||||
"--username",
|
||||
help="Create or update a user in database",
|
||||
type=str,
|
||||
)
|
||||
arg_parser.add_argument(
|
||||
"--add-role",
|
||||
help="Add the role",
|
||||
type=str,
|
||||
)
|
||||
arg_parser.add_argument(
|
||||
"--add-user-role",
|
||||
help="Add the role to the user",
|
||||
type=str,
|
||||
)
|
||||
arg_parser.add_argument(
|
||||
"--password",
|
||||
help="Set the password for a user in database",
|
||||
type=str,
|
||||
)
|
||||
arg_parser.add_argument(
|
||||
"--full-name",
|
||||
help="Set the full name for a user in database",
|
||||
type=str,
|
||||
)
|
||||
arg_parser.add_argument(
|
||||
"--email",
|
||||
help="Set the email for a user in database",
|
||||
type=str,
|
||||
)
|
||||
arg_parser.add_argument(
|
||||
"--enable",
|
||||
help="Enable user",
|
||||
action="store_true"
|
||||
)
|
||||
arg_parser.add_argument(
|
||||
"--disable",
|
||||
help="Disable user",
|
||||
action="store_true"
|
||||
)
|
||||
arg_parser.add_argument(
|
||||
"--delete-user",
|
||||
help="Delete user",
|
||||
action="store_true"
|
||||
)
|
||||
arg_parser.add_argument(
|
||||
"--import-trees",
|
||||
help="Import trees (eg. gpkg file). Images can be imported.",
|
||||
type=str,
|
||||
)
|
||||
arg_parser.add_argument(
|
||||
"--layers",
|
||||
help="Layers to import.",
|
||||
nargs='*',
|
||||
type=str,
|
||||
)
|
||||
arg_parser.add_argument(
|
||||
"--import-zones",
|
||||
help="Import zones (eg. gpkg file).",
|
||||
type=str,
|
||||
)
|
||||
arg_parser.add_argument(
|
||||
"--import-plantekey-trees-to-trail",
|
||||
help="Import trees from plantekey web site. Provide the trail id.",
|
||||
type=str,
|
||||
)
|
||||
arg_parser.add_argument(
|
||||
"--import-plantekey-plants",
|
||||
help="Import plants from plantekey web site",
|
||||
action="store_true"
|
||||
)
|
||||
arg_parser.add_argument(
|
||||
"--list-layers",
|
||||
help="List layers in the geodata file",
|
||||
type=str,
|
||||
)
|
||||
arg_parser.add_argument(
|
||||
"-d", "--debug", '-d',
|
||||
help="Set debug logging",
|
||||
action="store_true"
|
||||
)
|
||||
args = arg_parser.parse_args()
|
||||
|
||||
if args.debug:
|
||||
logging.root.setLevel(logging.DEBUG)
|
||||
## For ipdb:
|
||||
logging.getLogger('parso').setLevel(logging.WARNING)
|
||||
|
||||
if args.create_db:
|
||||
from treetrail.database import create_db
|
||||
import asyncio
|
||||
asyncio.run(create_db())
|
||||
sys.exit(0)
|
||||
|
||||
if args.enable:
|
||||
from treetrail.security import enable_user
|
||||
import asyncio
|
||||
asyncio.run(enable_user(args.username))
|
||||
sys.exit(0)
|
||||
|
||||
if args.disable:
|
||||
from treetrail.security import enable_user
|
||||
import asyncio
|
||||
asyncio.run(enable_user(args.username, False))
|
||||
sys.exit(0)
|
||||
|
||||
if args.add_role:
|
||||
from treetrail.security import add_role
|
||||
import asyncio
|
||||
asyncio.run(add_role(args.add_role))
|
||||
sys.exit(0)
|
||||
|
||||
if args.add_user_role:
|
||||
from treetrail.security import add_user_role
|
||||
import asyncio
|
||||
if not args.username:
|
||||
print('Please provide username')
|
||||
sys.exit(1)
|
||||
asyncio.run(add_user_role(args.username, args.add_user_role))
|
||||
sys.exit(0)
|
||||
|
||||
if args.delete_user:
|
||||
from treetrail.security import delete_user
|
||||
import asyncio
|
||||
asyncio.run(delete_user(args.username))
|
||||
sys.exit(0)
|
||||
|
||||
if args.list_layers:
|
||||
from treetrail.import_cli import list_layers
|
||||
list_layers(args.list_layers)
|
||||
sys.exit(0)
|
||||
|
||||
if args.import_trees:
|
||||
from treetrail.import_cli import import_trees
|
||||
import_trees(args)
|
||||
sys.exit(0)
|
||||
|
||||
if args.import_zones:
|
||||
from treetrail.import_cli import import_zones
|
||||
import_zones(args)
|
||||
sys.exit(0)
|
||||
|
||||
if args.import_plantekey_plants:
|
||||
from treetrail.import_cli import import_plantekey_plants
|
||||
import asyncio
|
||||
asyncio.run(import_plantekey_plants(args))
|
||||
sys.exit(0)
|
||||
|
||||
if args.import_plantekey_trees_to_trail:
|
||||
from treetrail.import_cli import import_plantekey_trees
|
||||
import_plantekey_trees(args)
|
||||
sys.exit(0)
|
||||
|
||||
if args.username:
|
||||
from treetrail.security import create_user
|
||||
import asyncio
|
||||
asyncio.run(create_user(**vars(args)))
|
||||
sys.exit(0)
|
||||
|
||||
print(
|
||||
'This application needs to be run with an asgi server like uvicorn.',
|
||||
'For example:',
|
||||
'uvicorn application:app',
|
||||
'or:',
|
||||
'uvicorn application:app --port 5002',
|
||||
'or (for development):',
|
||||
'uvicorn --reload application:app --uds /var/run/treetrail.socket',
|
||||
'or (for production):',
|
||||
'uvicorn --loop uvloop application:app --port 5002',
|
||||
sep='\n'
|
||||
)
|
||||
|
||||
if __name__ == '__main__':
|
||||
_main()
|
23
src/treetrail/attachments.py
Normal file
23
src/treetrail/attachments.py
Normal file
|
@ -0,0 +1,23 @@
|
|||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from treetrail.api_v1 import (get_attachment_tree_root,
|
||||
get_attachment_trail_root, get_attachment_poi_root)
|
||||
from treetrail.plantekey import get_thumbnail_root, get_img_root, get_img_type_root
|
||||
from treetrail.utils import mkdir
|
||||
|
||||
attachment_app = FastAPI()
|
||||
|
||||
attachment_app.mount("/plantekey/img", StaticFiles(directory=mkdir(get_img_root())),
|
||||
name="plantekey_img")
|
||||
attachment_app.mount("/plantekey/thumb", StaticFiles(directory=mkdir(get_thumbnail_root())),
|
||||
name="plantekey_thumb")
|
||||
attachment_app.mount("/plantekey/type", StaticFiles(directory=mkdir(get_img_type_root())),
|
||||
name="plantekey_type")
|
||||
|
||||
attachment_app.mount("/trail", StaticFiles(directory=mkdir(get_attachment_trail_root())),
|
||||
name="trail")
|
||||
attachment_app.mount("/tree", StaticFiles(directory=mkdir(get_attachment_tree_root())),
|
||||
name="tree")
|
||||
attachment_app.mount("/poi", StaticFiles(directory=mkdir(get_attachment_poi_root())),
|
||||
name="poi")
|
167
src/treetrail/config.py
Normal file
167
src/treetrail/config.py
Normal file
|
@ -0,0 +1,167 @@
|
|||
from os import environ
|
||||
from pathlib import Path
|
||||
from secrets import token_hex
|
||||
from typing import Any, Type, Tuple
|
||||
from yaml import safe_load
|
||||
import logging
|
||||
|
||||
from pydantic_settings import (
|
||||
BaseSettings,
|
||||
PydanticBaseSettingsSource,
|
||||
SettingsConfigDict,
|
||||
)
|
||||
from pydantic.v1.utils import deep_update
|
||||
|
||||
from treetrail._version import __version__
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
ENV = environ.get("env", "prod")
|
||||
|
||||
config_files = [
|
||||
Path(Path.cwd().root) / "etc" / "treetrail" / ENV,
|
||||
Path.home() / ".local" / "treetrail" / ENV,
|
||||
]
|
||||
|
||||
|
||||
def config_file_settings() -> dict[str, Any]:
|
||||
config: dict[str, Any] = {}
|
||||
for p in config_files:
|
||||
for suffix in {".yaml", ".yml"}:
|
||||
path = p.with_suffix(suffix)
|
||||
if not path.is_file():
|
||||
logger.debug(f"No file found at `{path.resolve()}`")
|
||||
continue
|
||||
logger.info(f"Reading config file `{path.resolve()}`")
|
||||
if path.suffix in {".yaml", ".yml"}:
|
||||
config = deep_update(config, load_yaml(path))
|
||||
else:
|
||||
logger.info(f"Unknown config file extension `{path.suffix}`")
|
||||
return config
|
||||
|
||||
|
||||
def load_yaml(path: Path) -> dict[str, Any]:
|
||||
with Path(path).open("r") as f:
|
||||
config = safe_load(f)
|
||||
if not isinstance(config, dict):
|
||||
raise TypeError(f"Config file has no top-level mapping: {path}")
|
||||
return config
|
||||
|
||||
|
||||
def create_dirs():
|
||||
"""
|
||||
Create the directories needed for a proper functioning of the app
|
||||
"""
|
||||
## Avoid circular imports
|
||||
from treetrail.api_v1 import attachment_types
|
||||
|
||||
for type in attachment_types:
|
||||
base_dir = Path(conf.storage.root_attachment_path) / type
|
||||
base_dir.mkdir(parents=True, exist_ok=True)
|
||||
logger.info(f"Cache dir: {get_cache_dir()}")
|
||||
get_cache_dir().mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def get_cache_dir() -> Path:
|
||||
return Path(conf.storage.root_cache_path)
|
||||
|
||||
class MyBaseSettings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix='treetrail_',
|
||||
env_nested_delimiter="_",
|
||||
)
|
||||
|
||||
|
||||
class DB(MyBaseSettings):
|
||||
# uri: str
|
||||
host: str = "treetrail-database"
|
||||
port: int = 5432
|
||||
user: str = "treetrail"
|
||||
db: str = "treetrail"
|
||||
password: str = "treetrail"
|
||||
debug: bool = False
|
||||
info: bool = False
|
||||
pool_size: int = 10
|
||||
max_overflow: int = 10
|
||||
echo: bool = False
|
||||
|
||||
def get_sqla_url(self):
|
||||
return f"postgresql+asyncpg://{self.user}:{self.password}@{self.host}:{self.port}/{self.db}"
|
||||
|
||||
def get_pg_url(self):
|
||||
return f"postgresql://{self.user}:{self.password}@{self.host}:{self.port}/{self.db}"
|
||||
|
||||
|
||||
class App(MyBaseSettings):
|
||||
title: str = "Tree Trail"
|
||||
|
||||
|
||||
class Storage(MyBaseSettings):
|
||||
root_attachment_path: str = "/var/lib/treetrail/attachments"
|
||||
root_cache_path: str = "/var/lib/treetrail/cache"
|
||||
|
||||
|
||||
class Tiles(MyBaseSettings):
|
||||
baseDir: str = "/var/lib/treetrail/mbtiles_files"
|
||||
useRequestUrl: bool = True
|
||||
spriteBaseDir: str = "/var/lib/treetrail/mbtiles_sprites"
|
||||
spriteUrl: str = "/tiles/sprite/sprite"
|
||||
spriteBaseUrl: str = "https://treetrail.example.org"
|
||||
osmBaseDir: str = "/var/lib/treetrail/osm"
|
||||
|
||||
|
||||
class Map(MyBaseSettings):
|
||||
zoom: float = 14.0
|
||||
pitch: float = 0.0
|
||||
lat: float = 12.0000
|
||||
lng: float = 79.8106
|
||||
bearing: float = 0
|
||||
background: str = "OpenFreeMap"
|
||||
|
||||
|
||||
class Geo(MyBaseSettings):
|
||||
simplify_geom_factor: int = 10000000
|
||||
simplify_preserve_topology: bool = False
|
||||
|
||||
|
||||
class Security(MyBaseSettings):
|
||||
"""
|
||||
JWT security configuration
|
||||
"""
|
||||
secret_key: str = token_hex(32)
|
||||
'''Generate with eg.: "openssl rand -hex 32"'''
|
||||
access_token_expire_minutes: float = 30
|
||||
|
||||
|
||||
class ExternalMapStyle(MyBaseSettings):
|
||||
name: str
|
||||
url: str
|
||||
|
||||
|
||||
class Config(MyBaseSettings):
|
||||
|
||||
@classmethod
|
||||
def settings_customise_sources(
|
||||
cls,
|
||||
settings_cls: Type[BaseSettings],
|
||||
init_settings: PydanticBaseSettingsSource,
|
||||
env_settings: PydanticBaseSettingsSource,
|
||||
dotenv_settings: PydanticBaseSettingsSource,
|
||||
file_secret_settings: PydanticBaseSettingsSource,
|
||||
) -> Tuple[PydanticBaseSettingsSource, ...]:
|
||||
return (env_settings, init_settings, file_secret_settings, config_file_settings) # type: ignore
|
||||
|
||||
app: App = App()
|
||||
# postgres: dict
|
||||
storage: Storage = Storage()
|
||||
map: Map = Map()
|
||||
mapStyles: dict[str, str] = {}
|
||||
tiles: Tiles = Tiles()
|
||||
security: Security = Security()
|
||||
geo: Geo = Geo()
|
||||
version: str
|
||||
db: DB = DB()
|
||||
base_href: str = '/treetrail'
|
||||
|
||||
|
||||
conf = Config(version=__version__) # type: ignore
|
103
src/treetrail/database.py
Normal file
103
src/treetrail/database.py
Normal file
|
@ -0,0 +1,103 @@
|
|||
from contextlib import asynccontextmanager
|
||||
import sys
|
||||
from typing import Annotated
|
||||
from collections.abc import AsyncGenerator
|
||||
from asyncio import sleep
|
||||
import logging
|
||||
|
||||
from fastapi import Depends
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
from sqlmodel import SQLModel, select, func, col
|
||||
|
||||
from treetrail.config import conf
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CREATE_DB_TIMEOUT = 30
|
||||
|
||||
engine = create_async_engine(
|
||||
conf.db.get_sqla_url(),
|
||||
echo=conf.db.echo,
|
||||
pool_size=conf.db.pool_size,
|
||||
max_overflow=conf.db.max_overflow,
|
||||
)
|
||||
|
||||
|
||||
async def create_db(drop=False):
|
||||
attempts = CREATE_DB_TIMEOUT
|
||||
|
||||
async def try_once():
|
||||
async with engine.begin() as conn:
|
||||
if drop:
|
||||
await conn.run_sync(SQLModel.metadata.drop_all)
|
||||
await conn.run_sync(SQLModel.metadata.create_all)
|
||||
|
||||
while attempts > 0:
|
||||
try:
|
||||
await try_once()
|
||||
except ConnectionRefusedError:
|
||||
logger.debug(
|
||||
f"Cannot connect to database during init (create_db), "
|
||||
f"waiting {attempts} more seconds"
|
||||
)
|
||||
attempts -= 1
|
||||
await sleep(1)
|
||||
else:
|
||||
if await is_fresh_install():
|
||||
await populate_init_db()
|
||||
return
|
||||
else:
|
||||
logger.warning(
|
||||
f"Cannot connect to database after {CREATE_DB_TIMEOUT}, giving up."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
async def is_fresh_install() -> bool:
|
||||
"""Detect is the database is newly created, without data"""
|
||||
from treetrail.models import User
|
||||
|
||||
async with db_session() as session:
|
||||
nb_users = (await session.exec(select(func.count(col(User.username))))).one()
|
||||
return nb_users == 0
|
||||
|
||||
|
||||
async def populate_init_db():
|
||||
"""Populate the database for a fresh install"""
|
||||
from sqlalchemy import text
|
||||
from treetrail.security import create_user, add_role, add_user_role
|
||||
logger.info("Populating initial database")
|
||||
|
||||
user = await create_user(username="admin", password="admin")
|
||||
role = await add_role(role_id="admin")
|
||||
await add_user_role(user.username, role.name)
|
||||
async with db_session() as session:
|
||||
for initial in initials:
|
||||
await session.execute(text(initial))
|
||||
logger.debug(f'Added map style {initial}')
|
||||
await session.commit()
|
||||
|
||||
|
||||
## Default styles, to be inserted in the DB
|
||||
initials: list[str] = [
|
||||
"""INSERT INTO map_style (layer, paint, layout) values ('trail', '{"line-color": "#cd861a", "line-width": 6, "line-blur": 2, "line-opacity": 0.9 }', '{"line-join": "bevel"}');""",
|
||||
"""INSERT INTO map_style (layer, layout) values ('tree', '{"icon-image":"tree", "icon-size": 0.4}');""",
|
||||
"""INSERT INTO map_style (layer, layout) values ('tree-hl', '{"icon-image":"tree", "icon-size": 0.4}');""",
|
||||
"""INSERT INTO map_style (layer, layout) values ('poi', '{"icon-image":"poi", "icon-size": 0.4}');""",
|
||||
"""INSERT INTO map_style (layer, paint) VALUES ('zone', '{"fill-color": ["match", ["string", ["get", "type"]], "Forest", "#00FF00", "Master Plan", "#EE4455", "#000000"], "fill-opacity": 0.5}');""",
|
||||
] # noqa: E501
|
||||
|
||||
|
||||
async def get_db_session() -> AsyncGenerator[AsyncSession]:
|
||||
async with AsyncSession(engine) as session:
|
||||
yield session
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def db_session() -> AsyncGenerator[AsyncSession]:
|
||||
async with AsyncSession(engine) as session:
|
||||
yield session
|
||||
|
||||
|
||||
fastapi_db_session = Annotated[AsyncSession, Depends(get_db_session)]
|
37
src/treetrail/defaults.yml
Normal file
37
src/treetrail/defaults.yml
Normal file
|
@ -0,0 +1,37 @@
|
|||
app:
|
||||
title: Tree Trail
|
||||
|
||||
db:
|
||||
database: treetrail
|
||||
user: treetrail
|
||||
password: treetrail!secret
|
||||
host: localhost
|
||||
port: 5432
|
||||
minsize: 1
|
||||
maxsize: 5
|
||||
|
||||
storage:
|
||||
root_attachment_path: /var/lib/treetrail/attachments
|
||||
root_cache_path: /var/lib/treetrail/cache
|
||||
|
||||
map:
|
||||
tiles:
|
||||
baseDir: /var/lib/treetrail/mbtiles_files
|
||||
useRequestUrl: true
|
||||
spriteBaseDir: /var/lib/treetrail/mbtiles_sprites
|
||||
spriteUrl: /tiles/sprite/sprite
|
||||
spriteBaseUrl: https://treetrail.example.org
|
||||
osmBaseDir: /var/lib/treetrail/osm
|
||||
zoom: 14
|
||||
pitch: 45
|
||||
lat: 45.8822
|
||||
lng: 6.1781
|
||||
bearing: 0
|
||||
background: OpenFreeMap
|
||||
|
||||
mapStyles:
|
||||
OpenFreeMap: https://tiles.openfreemap.org/styles/liberty
|
||||
|
||||
security:
|
||||
secret_key: '993e39ce154ca95d0908384a4eedc9bd26147b34995be96cc722b654616d0c28'
|
||||
access_token_expire_minutes: 30
|
19
src/treetrail/gisaf.py
Normal file
19
src/treetrail/gisaf.py
Normal file
|
@ -0,0 +1,19 @@
|
|||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
|
||||
from sqlalchemy import String
|
||||
from geoalchemy2 import Geometry, WKBElement
|
||||
from sqlmodel import Field
|
||||
|
||||
from treetrail.models import BaseModel
|
||||
|
||||
class GisafTree(BaseModel, table=True):
|
||||
__tablename__: str = "gisaf_tree" # type: ignore
|
||||
|
||||
plantekey_id: int = Field(foreign_key='plantekey.id', primary_key=True)
|
||||
data: int
|
||||
create_date: datetime = Field(default_factory=datetime.now)
|
||||
geom: Annotated[str, WKBElement] = Field(
|
||||
sa_type=Geometry('POINTZ', srid=4326, dimension=3))
|
||||
|
||||
photo: str = Field(sa_type=String(250)) # type: ignore
|
276
src/treetrail/import_cli.py
Normal file
276
src/treetrail/import_cli.py
Normal file
|
@ -0,0 +1,276 @@
|
|||
import sys
|
||||
from pathlib import Path
|
||||
from json import dumps
|
||||
import logging
|
||||
from shutil import copy
|
||||
from datetime import datetime
|
||||
import requests
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlmodel import select, Session, delete, create_engine as sqlmodel_create_engine
|
||||
import geopandas as gpd # type: ignore
|
||||
import pandas as pd
|
||||
|
||||
from treetrail.config import conf
|
||||
from treetrail.utils import get_attachment_tree_root
|
||||
from treetrail.models import Tree, Trail, TreeTrail, User, Zone
|
||||
from treetrail.plantekey import Plant, fetch_browse, update_details
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
column_mapper = {
|
||||
'pic_full': 'photo',
|
||||
'Comment': 'comment',
|
||||
}
|
||||
|
||||
base_tree_attachment_dir = get_attachment_tree_root()
|
||||
|
||||
def list_layers(file):
|
||||
from fiona import listlayers
|
||||
print(' '.join(f"'{x}'" for x in listlayers(file)))
|
||||
|
||||
def copy_image(record, base_dir):
|
||||
'''Copy the file to the proper location for attachments'''
|
||||
if pd.isna(record.photo):
|
||||
return
|
||||
file = base_dir / record.photo
|
||||
dest_dir = base_tree_attachment_dir / record.id
|
||||
dest_dir.mkdir(exist_ok=True)
|
||||
copy(file, dest_dir)
|
||||
|
||||
def import_trees(args) -> None:
|
||||
""" Import trees from a file containing geo data.
|
||||
The geopackage file name is expected to be strict.
|
||||
A description sould be given in the documentation"""
|
||||
|
||||
contributor_id = args.username
|
||||
if contributor_id is None:
|
||||
raise Exception('A user name is required to identify the contributor')
|
||||
file_to_import = Path(args.import_trees).expanduser()
|
||||
|
||||
sync_engine = create_engine(conf.db.get_pg_url())
|
||||
session = Session(sync_engine)
|
||||
|
||||
# Read and format the data in the file
|
||||
gdf_trees = gpd.read_file(file_to_import, layer=args.layers or None)
|
||||
gdf_trees.rename_geometry('geom', inplace=True)
|
||||
gdf_trees.to_crs(4326, inplace=True)
|
||||
gdf_trees.rename(columns=column_mapper, inplace=True)
|
||||
# Photos: take only the file name
|
||||
# gdf_trees['photo'] = gdf_trees['photo'].str.split('/', expand=True)[1]
|
||||
gdf_trees['pic_stem'] = gdf_trees['pic_stem'].str.split('/', expand=True)[1]
|
||||
gdf_trees['contributor_id'] = contributor_id
|
||||
gdf_trees['create_date'] = pd.to_datetime(gdf_trees['Date Edited'])
|
||||
gdf_trees['id'] = gdf_trees['UUID'].str.strip('{').str.strip('}')
|
||||
gdf_trees.drop(columns='UUID', inplace=True)
|
||||
|
||||
## Determine which columns are in the database
|
||||
# ... and store the remaining in a dict datastructure to store in JSON "data" column
|
||||
gdf_existing_trees: gpd.GeoDataFrame
|
||||
gdf_existing_trees = gpd.read_postgis('select * from tree', sync_engine) # type: ignore
|
||||
unknown_columns = {col for col in gdf_trees if col not in gdf_existing_trees.columns}
|
||||
known_columns = {col for col in gdf_trees if col in gdf_existing_trees.columns}
|
||||
left_columns = {col for col in gdf_existing_trees.columns if col not in gdf_trees}
|
||||
logger.debug(f'Known columns: {known_columns}')
|
||||
logger.debug(f'Unknown left: {unknown_columns}')
|
||||
logger.debug(f'Columns left: {left_columns}')
|
||||
|
||||
# Remove empty extra fields
|
||||
new_trees_data_raw = gdf_trees[list(unknown_columns)].to_dict(orient='records')
|
||||
new_trees_data = []
|
||||
for data in new_trees_data_raw:
|
||||
new_trees_data.append(
|
||||
{k: v for k, v in data.items()
|
||||
if not pd.isna(v)
|
||||
}
|
||||
)
|
||||
|
||||
gdf_trees['data'] = [dumps(d) for d in new_trees_data]
|
||||
gdf_trees.drop(columns=unknown_columns, inplace=True)
|
||||
gdf_trees.reset_index(inplace=True)
|
||||
gdf_trees.drop(columns='index', inplace=True)
|
||||
|
||||
# Find the trails
|
||||
gdf_trails: gpd.GeoDataFrame
|
||||
gdf_trails = gpd.read_postgis(select(Trail), sync_engine, index_col='id') # type: ignore
|
||||
|
||||
# Assign trails to the new trees
|
||||
gdf_trails['zone'] = gdf_trails.to_crs(3857).buffer(150).to_crs(4326) # type: ignore
|
||||
gdf_trails.set_geometry('zone', inplace=True)
|
||||
|
||||
gdf_trees[['trail', 'viewable_role_id']] = gdf_trees.sjoin(gdf_trails, how='left')[['index_right', 'viewable_role_id']]
|
||||
|
||||
# Save trees to the database
|
||||
|
||||
## Remove the trees already in the DB from the datafreame to insert
|
||||
gdf_trees.set_index('id', inplace=True)
|
||||
gdf_new_trees = gdf_trees.loc[~gdf_trees.index.isin(gdf_existing_trees['id'].astype(str))].reset_index() # type:ignore
|
||||
gdf_new_trees.drop(columns='trail').to_postgis(Tree.__tablename__, sync_engine, if_exists='append')
|
||||
# Copy the images to the treetail storage dir
|
||||
gdf_new_trees.apply(copy_image, axis=1, base_dir=file_to_import.parent)
|
||||
# for file in import_image_dir.iterdir():
|
||||
# id = file.stem.split('_')[-1]
|
||||
# gdf_trees.photo.str.split('/', expand=True)1]
|
||||
df_tt_existing = pd.read_sql(select(TreeTrail), sync_engine)
|
||||
df_tt_existing.rename(columns={'tree_id': 'id', 'trail_id': 'trail'}, inplace=True)
|
||||
df_tt_existing['id'] = df_tt_existing['id'].astype(str)
|
||||
|
||||
df_tt_new = gdf_trees['trail'].reset_index()
|
||||
df_tt_to_insert = pd.concat([df_tt_new, df_tt_existing]).drop_duplicates(keep=False) # type: ignore
|
||||
def get_tt_rel(tree):
|
||||
return TreeTrail(tree_id=tree.id, trail_id=tree.trail)
|
||||
tree_trails = df_tt_to_insert.reset_index().apply(get_tt_rel, axis=1) # type: ignore
|
||||
with Session(sync_engine) as session:
|
||||
for tt in tree_trails:
|
||||
session.add(tt)
|
||||
session.commit()
|
||||
logger.info(f'Imported on behalf of {args.username} {len(gdf_new_trees)} trees')
|
||||
|
||||
def import_zones(args) -> None:
|
||||
"""Import a geopackage with zones.
|
||||
The format of the input file is strict.
|
||||
"""
|
||||
if args.layers is None:
|
||||
print('Provide layer names from:')
|
||||
list_layers(args.import_zones)
|
||||
sys.exit(1)
|
||||
file_to_import = Path(args.import_zones).expanduser()
|
||||
fields_map = {
|
||||
'Area': 'name',
|
||||
}
|
||||
fields_ignored = [
|
||||
'fid',
|
||||
'Area Area',
|
||||
'Area type',
|
||||
]
|
||||
sync_engine = create_engine(conf.db.get_pg_url())
|
||||
gdf_existing_zones: gpd.GeoDataFrame
|
||||
gdf_existing_zones = gpd.read_postgis('select * from zone', sync_engine) # type: ignore
|
||||
now = datetime.now()
|
||||
for layer in args.layers:
|
||||
print(layer)
|
||||
gdf = gpd.read_file(file_to_import, layer=layer)
|
||||
gdf.rename(columns=fields_map, inplace=True)
|
||||
gdf.drop(columns=fields_ignored, inplace=True, errors='ignore')
|
||||
unknown_columns = {col for col in gdf
|
||||
if col not in gdf_existing_zones.columns}
|
||||
unknown_columns = unknown_columns - {'geometry'}
|
||||
all_data_raw = gdf[list(unknown_columns)].to_dict(orient='records')
|
||||
all_data = []
|
||||
for data in all_data_raw:
|
||||
all_data.append(
|
||||
{k: v for k, v in data.items()
|
||||
if not pd.isna(v)
|
||||
}
|
||||
)
|
||||
gdf['data'] = [dumps(d) for d in all_data]
|
||||
gdf.drop(columns=unknown_columns, inplace=True)
|
||||
gdf.reset_index(inplace=True)
|
||||
gdf.drop(columns='index', inplace=True)
|
||||
gdf['type'] = layer
|
||||
gdf['create_date'] = now
|
||||
gdf.to_crs("EPSG:4326", inplace=True)
|
||||
gdf.rename_geometry('geom', inplace=True)
|
||||
if 'name' not in gdf.columns:
|
||||
gdf['name'] = '?'
|
||||
else:
|
||||
gdf['name'].fillna('?', inplace=True)
|
||||
gdf.to_postgis(Zone.__tablename__, sync_engine,
|
||||
if_exists='append', index=False)
|
||||
|
||||
def import_plantekey_trees(args, contributor_id='plantekey'):
|
||||
"""Import all trees from plantekey web site
|
||||
"""
|
||||
now = datetime.now()
|
||||
sync_engine = sqlmodel_create_engine(conf.db.get_pg_url())
|
||||
trail_id = int(args.import_plantekey_trees_to_trail)
|
||||
|
||||
## Harmless check that the 'plantekey' contributor exists
|
||||
with Session(sync_engine) as session:
|
||||
contributor = session.get(User, contributor_id)
|
||||
if contributor is None:
|
||||
raise UserWarning('User plantekey not found')
|
||||
|
||||
## Get the raw data from the plantekey web site
|
||||
plantekey_trees_raw = requests.get('https://plantekey.com/api.php?action=markers').json()['markers']
|
||||
|
||||
## Convert that raw data into a nice dataframe
|
||||
df_tree_plantekey = pd.DataFrame(plantekey_trees_raw).drop(columns=['0', '1', '2', '3'])
|
||||
df_tree_plantekey['MasterID'] = df_tree_plantekey['MasterID'].astype(int)
|
||||
df_tree_plantekey.sort_values('MasterID', inplace=True)
|
||||
df_tree_plantekey.reset_index(drop=True, inplace=True)
|
||||
print(f'Found {len(df_tree_plantekey)} trees in Plantekey web site')
|
||||
|
||||
## The MasterID is probably the plant id, so just dropping it
|
||||
df_tree_plantekey.drop(columns=['MasterID'], inplace=True)
|
||||
|
||||
## Get the existing plants in the database
|
||||
df_plants = pd.read_sql(select(Plant), sync_engine)
|
||||
|
||||
## Merge those trees with the plants
|
||||
df_tree = df_tree_plantekey.merge(df_plants[['name', 'id']], left_on='name', right_on='name', how='left')
|
||||
df_tree.rename(columns={'id': 'plantekey_id'}, inplace=True)
|
||||
|
||||
## Generate a primary key (custom UUID), which is predictable: it marks the source as plantekey, and tracks the plantkey tree id
|
||||
base_fields = (0x10000001, 0x0001, 0x0001, 0x00, 0x01)
|
||||
def id_to_uuid(tree) -> UUID:
|
||||
return UUID(fields=base_fields + (tree['index'], ))
|
||||
df_tree['id'] = df_tree.reset_index().apply(lambda _: id_to_uuid(_), axis=1) # type: ignore
|
||||
#gdf_tree.drop(columns=['plantekey_tree_id'], inplace=True)
|
||||
df_tree.set_index('id', inplace=True)
|
||||
|
||||
## Detect missing plants
|
||||
missing_plants = df_tree.loc[df_tree.plantekey_id.isna()]['name'].unique()
|
||||
if len(missing_plants) > 0:
|
||||
print(f'* Warning: {len(missing_plants)} plants are missing in Treetrail, please update it!')
|
||||
print('* Missing plants:')
|
||||
for mp in missing_plants:
|
||||
print(f' {mp}')
|
||||
df_tree = df_tree.loc[~df_tree.plantekey_id.isna()]
|
||||
print(f'* Importing only {len(df_tree)} trees.')
|
||||
|
||||
## Build the geodataframe
|
||||
gdf_tree = gpd.GeoDataFrame(
|
||||
df_tree,
|
||||
geometry=gpd.points_from_xy(df_tree["Longitude"], df_tree["Latitude"]),
|
||||
crs="EPSG:4326",
|
||||
) # type: ignore
|
||||
gdf_tree.drop(columns=['Latitude', 'Longitude', 'name'], inplace=True)
|
||||
#gdf_tree['data'] = gdf_tree.plantekey_tree_id.apply(lambda _: dumps({'Plantekey tree id': int(_)}))
|
||||
#gdf_tree['data'] = dumps({'Source': 'Plantekey (Botanical Graden)'})
|
||||
gdf_tree.rename(columns={'geometry': 'geom'}, inplace=True)
|
||||
gdf_tree.set_geometry('geom', inplace=True)
|
||||
|
||||
# Save to the database
|
||||
|
||||
## Prepare the geodataframe for saving
|
||||
gdf_tree['create_date'] = now
|
||||
gdf_tree['contributor_id'] = contributor.username
|
||||
gdf_tree['data'] = dumps({})
|
||||
|
||||
## Remove all trees with the contributor "plantekey" before adding the new ones
|
||||
with Session(sync_engine) as session:
|
||||
to_delete_trees = session.exec(select(Tree).where(Tree.contributor_id==contributor_id)).all()
|
||||
to_delete_tree_ids = [tree.id for tree in to_delete_trees]
|
||||
print(f'{len(to_delete_trees)} trees existing in the database from plantekey, deleting all of them')
|
||||
## Also delete their relationships to that trail
|
||||
session.exec(delete(TreeTrail).where(TreeTrail.tree_id.in_(to_delete_tree_ids))) # type: ignore
|
||||
session.exec(delete(Tree).where(Tree.id.in_(to_delete_tree_ids))) # type: ignore
|
||||
session.commit()
|
||||
|
||||
## Finally insert to the database
|
||||
gdf_tree.to_postgis(Tree.__tablename__, sync_engine, if_exists='append', index=True)
|
||||
|
||||
## And add those to the trail
|
||||
with Session(sync_engine) as session:
|
||||
for tree_id in gdf_tree.index:
|
||||
session.add(TreeTrail(tree_id=tree_id, trail_id=trail_id))
|
||||
session.commit()
|
||||
print(f'Added {len(gdf_tree)} trees.')
|
||||
print('Import done.')
|
||||
|
||||
async def import_plantekey_plants(args):
|
||||
df = await fetch_browse()
|
||||
await update_details(df)
|
22
src/treetrail/logging.yaml
Normal file
22
src/treetrail/logging.yaml
Normal file
|
@ -0,0 +1,22 @@
|
|||
version: 1
|
||||
disable_existing_loggers: false
|
||||
|
||||
formatters:
|
||||
standard:
|
||||
format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
|
||||
handlers:
|
||||
console:
|
||||
class: logging.StreamHandler
|
||||
formatter: standard
|
||||
stream: ext://sys.stdout
|
||||
|
||||
loggers:
|
||||
uvicorn:
|
||||
error:
|
||||
propagate: true
|
||||
|
||||
root:
|
||||
level: INFO
|
||||
handlers: [console]
|
||||
propagate: no
|
271
src/treetrail/models.py
Normal file
271
src/treetrail/models.py
Normal file
|
@ -0,0 +1,271 @@
|
|||
|
||||
from typing import Annotated, Any, Literal
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
from sqlalchemy.ext.mutable import MutableDict
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import joinedload, QueryableAttribute
|
||||
from geoalchemy2 import Geometry, WKBElement # type: ignore
|
||||
from sqlmodel import (SQLModel, Field, String, Relationship, JSON,
|
||||
select)
|
||||
import pandas as pd
|
||||
import geopandas as gpd # type: ignore
|
||||
|
||||
from treetrail.utils import pandas_query, geopandas_query
|
||||
from treetrail.config import Map, conf, App
|
||||
from treetrail.database import db_session
|
||||
|
||||
class BaseModel(SQLModel):
|
||||
@classmethod
|
||||
def selectinload(cls) -> list[Literal['*'] | QueryableAttribute[Any]]:
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
async def get_df(cls, **kwargs) -> pd.DataFrame:
|
||||
return await cls._get_df(pandas_query, **kwargs)
|
||||
|
||||
@classmethod
|
||||
async def get_gdf(cls, **kwargs) -> gpd.GeoDataFrame:
|
||||
return await cls._get_df(geopandas_query, model=cls, **kwargs) # type: ignore
|
||||
|
||||
@classmethod
|
||||
async def _get_df(cls, method, *,
|
||||
where=None, with_related=True, with_only_columns=[],
|
||||
simplify_tolerance: float | None=None,
|
||||
preserve_topology: bool | None=None,
|
||||
**kwargs) -> pd.DataFrame | gpd.GeoDataFrame:
|
||||
async with db_session() as session:
|
||||
if len(with_only_columns) == 0:
|
||||
query = select(cls)
|
||||
else:
|
||||
columns = set(with_only_columns)
|
||||
# TODO: user SQLModel model_fields instead of __table__
|
||||
columns.add(*(col.name for col in cls.__table__.primary_key.columns)) # type: ignore
|
||||
query = select(*(getattr(cls, col) for col in columns))
|
||||
if where is not None:
|
||||
query = query.where(where)
|
||||
## Get the joined tables
|
||||
joined_tables = cls.selectinload()
|
||||
if with_related and len(joined_tables) > 0:
|
||||
query = query.options(*(joinedload(jt) for jt in joined_tables))
|
||||
df = await session.run_sync(method, query, **kwargs)
|
||||
if method is geopandas_query and simplify_tolerance is not None:
|
||||
df['geom'] = df['geom'].simplify(
|
||||
simplify_tolerance / conf.geo.simplify_geom_factor,
|
||||
preserve_topology=(conf.geo.simplify_preserve_topology
|
||||
if preserve_topology is None
|
||||
else preserve_topology)
|
||||
)
|
||||
## Chamge column names to reflect the joined tables
|
||||
## Leave the first columns unchanged, as their names come straight
|
||||
## from the model's fields
|
||||
joined_columns = list(df.columns[len(cls.model_fields):])
|
||||
renames: dict[str, str] = {}
|
||||
## Match colum names with the joined tables
|
||||
## Important: this assumes that orders of the joined tables
|
||||
## and their columns is preserved by pandas' read_sql
|
||||
for joined_table in joined_tables:
|
||||
target = joined_table.property.target # type: ignore
|
||||
target_name = target.name
|
||||
for col in target.columns:
|
||||
## Pop the column from the colujmn list and make a new name
|
||||
renames[joined_columns.pop(0)] = f'{target.schema}_{target_name}_{col.name}'
|
||||
df.rename(columns=renames, inplace=True)
|
||||
## Finally, set the index of the df as the index of cls
|
||||
df.set_index([c.name for c in cls.__table__.primary_key.columns], # type: ignore
|
||||
inplace=True)
|
||||
return df
|
||||
|
||||
class TreeTrail(BaseModel, table=True):
|
||||
__tablename__: str = 'tree_trail' # type: ignore
|
||||
|
||||
tree_id: uuid.UUID | None = Field(
|
||||
default=None,
|
||||
foreign_key='tree.id',
|
||||
primary_key=True
|
||||
)
|
||||
trail_id: int | None = Field(
|
||||
default=None,
|
||||
foreign_key='trail.id',
|
||||
primary_key=True
|
||||
)
|
||||
|
||||
|
||||
class Trail(BaseModel, table=True):
|
||||
__tablename__: str = "trail" # type: ignore
|
||||
|
||||
id: int = Field(primary_key=True)
|
||||
name: str
|
||||
description: str
|
||||
create_date: datetime = Field(default_factory=datetime.now)
|
||||
geom: Annotated[str, WKBElement] = Field(
|
||||
sa_type=Geometry('LINESTRING', srid=4326, dimension=2),
|
||||
)
|
||||
photo: str = Field(sa_type=String(250)) # type: ignore
|
||||
trees: list['Tree'] = Relationship(
|
||||
link_model=TreeTrail,
|
||||
back_populates="trails")
|
||||
viewable_role_id: str | None = Field(foreign_key='role.name', index=True)
|
||||
viewable_role: 'Role' = Relationship(back_populates='viewable_trails')
|
||||
|
||||
# __mapper_args__ = {"eager_defaults": True}
|
||||
|
||||
life_stages = ('Y', 'MA', 'M', 'OM', 'A')
|
||||
|
||||
|
||||
class Tree(BaseModel, table=True):
|
||||
__tablename__: str = "tree" # type: ignore
|
||||
|
||||
id: uuid.UUID | None = Field(
|
||||
default_factory=uuid.uuid1,
|
||||
primary_key=True,
|
||||
index=True,
|
||||
nullable=False,
|
||||
)
|
||||
create_date: datetime = Field(default_factory=datetime.now)
|
||||
# ALTER TABLE tree ADD CONSTRAINT tree_plant_id_fkey FOREIGN KEY (plantekey_id) REFERENCES plant(id); # noqa: E501
|
||||
plantekey_id: str = Field(foreign_key='plant.id')
|
||||
geom: Annotated[str, WKBElement] = Field(
|
||||
sa_type=Geometry('POINT', srid=4326, dimension=2))
|
||||
photo: str | None = Field(sa_type=String(250)) # type: ignore
|
||||
height: float | None
|
||||
comments: str | None
|
||||
# ALTER TABLE public.tree ADD contributor_id varchar(50) NULL;
|
||||
# ALTER TABLE public.tree ADD CONSTRAINT contributor_fk FOREIGN KEY (contributor_id) REFERENCES public."user"(username);
|
||||
contributor_id: str = Field(foreign_key='user.username', index=True)
|
||||
contributor: 'User' = Relationship()
|
||||
viewable_role_id: str | None = Field(foreign_key='role.name', index=True)
|
||||
viewable_role: 'Role' = Relationship(back_populates='viewable_trees')
|
||||
|
||||
# CREATE EXTENSION hstore;
|
||||
# ALTER TABLE tree ADD COLUMN data JSONB;
|
||||
data: dict = Field(sa_type=MutableDict.as_mutable(JSONB), # type: ignore
|
||||
default_factory=dict) # type: ignore
|
||||
trails: list[Trail] = Relationship(
|
||||
link_model=TreeTrail,
|
||||
back_populates="trees")
|
||||
|
||||
__mapper_args__ = {"eager_defaults": True}
|
||||
|
||||
@classmethod
|
||||
def get_tree_insert_params(
|
||||
cls,
|
||||
plantekey_id: str,
|
||||
lng, lat,
|
||||
username,
|
||||
details: dict,
|
||||
) -> dict:
|
||||
params = {
|
||||
'plantekey_id': plantekey_id,
|
||||
'geom': f'POINT({lng} {lat})',
|
||||
'contributor_id': username
|
||||
}
|
||||
## Consume some details in their respective field...
|
||||
if p:=details.pop('comments', None):
|
||||
params['comments'] = p
|
||||
if p:=details.pop('height', None):
|
||||
params['height'] = p
|
||||
# ... and store the rest in data
|
||||
params['data'] = {k: v for k, v in details.items() if v}
|
||||
return params
|
||||
|
||||
|
||||
class UserRoleLink(SQLModel, table=True):
|
||||
__tablename__: str = 'roles_users' # type: ignore
|
||||
user_id: str | None = Field(
|
||||
default=None,
|
||||
foreign_key='user.username',
|
||||
primary_key=True
|
||||
)
|
||||
role_id: str | None = Field(
|
||||
default=None,
|
||||
foreign_key='role.name',
|
||||
primary_key=True
|
||||
)
|
||||
|
||||
|
||||
class UserBase(BaseModel):
|
||||
username: str = Field(sa_type=String(50), primary_key=True) # type: ignore
|
||||
full_name: str | None = None
|
||||
email: str | None = None
|
||||
|
||||
|
||||
class User(UserBase, table=True):
|
||||
__tablename__: str = "user" # type: ignore
|
||||
roles: list["Role"] = Relationship(back_populates="users",
|
||||
link_model=UserRoleLink)
|
||||
password: str
|
||||
disabled: bool = False
|
||||
|
||||
|
||||
class UserWithRoles(UserBase):
|
||||
roles: list['Role']
|
||||
|
||||
|
||||
class Role(BaseModel, table=True):
|
||||
__tablename__: str = "role" # type: ignore
|
||||
name: str = Field(sa_type=String(50), primary_key=True) # type: ignore
|
||||
users: list[User] = Relationship(back_populates="roles",
|
||||
link_model=UserRoleLink)
|
||||
viewable_trees: list[Tree] = Relationship(back_populates='viewable_role')
|
||||
viewable_zones: list['Zone'] = Relationship(back_populates='viewable_role')
|
||||
viewable_trails: list[Trail] = Relationship(back_populates='viewable_role')
|
||||
|
||||
|
||||
class POI(BaseModel, table=True):
|
||||
__tablename__: str = "poi" # type: ignore
|
||||
|
||||
id: int = Field(primary_key=True)
|
||||
name: str = Field(sa_column=String(200)) # type: ignore
|
||||
description: str | None = None
|
||||
create_date: datetime = Field(default_factory=datetime.now)
|
||||
geom: Annotated[str, WKBElement] = Field(
|
||||
sa_type=Geometry('POINTZ', srid=4326, dimension=3))
|
||||
photo: str = Field(sa_column=String(250)) # type: ignore
|
||||
type: str = Field(sa_column=String(25)) # type: ignore
|
||||
data: dict = Field(sa_type=MutableDict.as_mutable(JSONB), # type: ignore
|
||||
default_factory=dict) # type: ignore
|
||||
|
||||
|
||||
class Zone(BaseModel, table=True):
|
||||
__tablename__: str = "zone" # type: ignore
|
||||
id: int = Field(primary_key=True)
|
||||
name: str = Field(sa_type=String(200)) # type:ignore
|
||||
description: str
|
||||
create_date: datetime = Field(default_factory=datetime.now)
|
||||
geom: Annotated[str, WKBElement] = Field(
|
||||
sa_type=Geometry('MULTIPOLYGON', srid=4326))
|
||||
photo: str | None = Field(sa_type=String(250)) # type:ignore
|
||||
type: str = Field(sa_type=String(30)) # type:ignore
|
||||
data: dict | None = Field(sa_type=MutableDict.as_mutable(JSONB), # type:ignore
|
||||
default_factory=dict) # type:ignore
|
||||
viewable_role_id: str | None = Field(foreign_key='role.name', index=True)
|
||||
viewable_role: 'Role' = Relationship(back_populates='viewable_zones')
|
||||
|
||||
|
||||
class MapStyle(BaseModel, table=True):
|
||||
__tablename__: str = "map_style" # type: ignore
|
||||
|
||||
id: int = Field(primary_key=True)
|
||||
layer: str = Field(sa_type=String(100), nullable=False) # type:ignore
|
||||
paint: dict[str, Any] | None = Field(sa_type=JSON(none_as_null=True)) # type:ignore
|
||||
layout: dict[str, Any] | None = Field(sa_type=JSON(none_as_null=True)) # type:ignore
|
||||
|
||||
|
||||
class VersionedComponent(BaseModel):
|
||||
version: str
|
||||
|
||||
|
||||
class BaseMapStyles(BaseModel):
|
||||
embedded: list[str]
|
||||
external: dict[str, str]
|
||||
|
||||
|
||||
class Bootstrap(BaseModel):
|
||||
client: VersionedComponent
|
||||
server: VersionedComponent
|
||||
app: App
|
||||
user: UserWithRoles | None # type:ignore
|
||||
map: Map
|
||||
baseMapStyles: BaseMapStyles
|
488
src/treetrail/plantekey.py
Normal file
488
src/treetrail/plantekey.py
Normal file
|
@ -0,0 +1,488 @@
|
|||
import tarfile
|
||||
from io import BytesIO
|
||||
from logging import getLogger
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from aiohttp_client_cache import FileBackend
|
||||
from aiohttp_client_cache.session import CachedSession
|
||||
from fastapi import Depends, FastAPI, HTTPException, Response, status
|
||||
from fastapi.responses import ORJSONResponse, PlainTextResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from PIL import Image
|
||||
from sqlalchemy import String
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.ext.mutable import MutableDict
|
||||
from sqlmodel import Field, Relationship, select
|
||||
|
||||
from treetrail.config import get_cache_dir
|
||||
from treetrail.database import db_session, fastapi_db_session
|
||||
from treetrail.models import BaseModel, User
|
||||
from treetrail.security import get_current_active_user
|
||||
from treetrail.utils import read_sql, mkdir
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
cache_expiry = 3600 * 24
|
||||
thumbnail_size = (200, 200)
|
||||
|
||||
# cache = SQLiteBackend(get_cache_dir() / 'plantekey')
|
||||
cache = FileBackend(get_cache_dir() / "plantekey" / "http")
|
||||
|
||||
pek_app = FastAPI(
|
||||
default_response_class=ORJSONResponse,
|
||||
)
|
||||
|
||||
|
||||
def get_plantekey_api_url(id):
|
||||
return f"https://plantekey.com/api.php?action=plant&name={id}"
|
||||
|
||||
|
||||
def get_storage_root():
|
||||
return get_cache_dir() / "plantekey"
|
||||
|
||||
|
||||
def get_storage(f):
|
||||
return get_storage_root() / f
|
||||
|
||||
|
||||
def get_img_root() -> Path:
|
||||
return get_storage_root() / "img"
|
||||
|
||||
|
||||
def get_img_path(img: str):
|
||||
return get_img_root() / img
|
||||
|
||||
|
||||
def get_thumbnail_root() -> Path:
|
||||
return get_storage_root() / "thumbnails"
|
||||
|
||||
|
||||
def get_thumbnail_tar_path():
|
||||
return get_storage_root() / "thumbnails.tar"
|
||||
|
||||
|
||||
def get_img_type_root():
|
||||
return get_storage_root() / "type"
|
||||
|
||||
|
||||
def get_img_type_path(type: str):
|
||||
return get_img_type_root() / (type + ".png")
|
||||
|
||||
|
||||
def setup(ap):
|
||||
"""
|
||||
Create empty directories if needed.
|
||||
Intended to be used at startup
|
||||
"""
|
||||
get_img_root().mkdir(parents=True, exist_ok=True)
|
||||
get_thumbnail_root().mkdir(parents=True, exist_ok=True)
|
||||
get_img_type_root().mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
class Plant(BaseModel, table=True):
|
||||
"""
|
||||
Record of a Plantekey plant
|
||||
"""
|
||||
|
||||
__tablename__: str = "plant" # type: ignore
|
||||
|
||||
def __str__(self):
|
||||
return str(self.id)
|
||||
|
||||
def __repr__(self):
|
||||
return f"treetrail.database.Plant: {self.id}"
|
||||
|
||||
id: str | None = Field(primary_key=True, default=None)
|
||||
ID: int
|
||||
family: str = Field(sa_type=String(100)) # type: ignore
|
||||
name: str = Field(sa_type=String(200)) # type: ignore
|
||||
description: str | None
|
||||
habit: str | None
|
||||
landscape: str | None
|
||||
uses: str | None
|
||||
planting: str | None
|
||||
propagation: str | None
|
||||
type: str = Field(sa_type=String(30)) # type: ignore
|
||||
img: str = Field(sa_type=String(100)) # type: ignore
|
||||
element: str = Field(sa_type=String(30)) # type: ignore
|
||||
isOnMap: str | None = Field(sa_type=String(10)) # type: ignore
|
||||
english: str | None = Field(sa_type=String(100)) # type: ignore
|
||||
hindi: str | None = Field(sa_type=String(100)) # type: ignore
|
||||
tamil: str | None = Field(sa_type=String(100)) # type: ignore
|
||||
spiritual: str | None = Field(sa_type=String(150)) # type: ignore
|
||||
woody: bool
|
||||
latex: str | None = Field(sa_type=String(20)) # type: ignore
|
||||
leaf_style: str | None = Field(sa_type=String(20)) # type: ignore
|
||||
leaf_type: str | None = Field(sa_type=String(20)) # type: ignore
|
||||
leaf_arrangement: str | None = Field(sa_type=String(20)) # type: ignore
|
||||
leaf_aroma: bool | None
|
||||
leaf_length: float | None
|
||||
leaf_width: float | None
|
||||
flower_color: str | None = Field(sa_type=String(20)) # type: ignore
|
||||
flower_size: float | None
|
||||
flower_aroma: bool | None
|
||||
fruit_color: str | None = Field(sa_type=String(20)) # type: ignore
|
||||
fruit_size: float | None
|
||||
fruit_type: str | None = Field(sa_type=String(20)) # type: ignore
|
||||
thorny: str | None = Field(sa_type=String(20)) # type: ignore
|
||||
images: dict = Field(
|
||||
sa_type=MutableDict.as_mutable(JSONB), # type: ignore
|
||||
default_factory=dict,
|
||||
) # type: ignore
|
||||
|
||||
# CREATE EXTENSION hstore;
|
||||
# ALTER TABLE tree ADD COLUMN data JSONB;
|
||||
data: dict = Field(
|
||||
sa_type=MutableDict.as_mutable(JSONB), # type: ignore
|
||||
default_factory=dict,
|
||||
) # type: ignore
|
||||
|
||||
# __mapper_args__ = {"eager_defaults": True}
|
||||
|
||||
|
||||
class PlantImage(BaseModel, table=True):
|
||||
__tablename__: str = "plant_image" # type: ignore
|
||||
|
||||
id: int | None = Field(primary_key=True, default=None)
|
||||
plant_id: str = Field(foreign_key="plant.id")
|
||||
plant: Plant = Relationship()
|
||||
caption: str = Field(sa_type=String(50)) # type: ignore
|
||||
IsDefault: bool
|
||||
src: str = Field(sa_type=String(100)) # type: ignore
|
||||
|
||||
def get_thumbnail_path(self) -> Path:
|
||||
return get_thumbnail_root() / self.src # type: ignore
|
||||
|
||||
|
||||
class Plantekey(BaseModel, table=True):
|
||||
"""
|
||||
Details for the plantekey data, like symbols for the map
|
||||
"""
|
||||
|
||||
## CREATE TABLE plantekey (id VARCHAR(100) PRIMARY KEY NOT NULL, symbol CHAR(1));
|
||||
## GRANT ALL on TABLE plantekey to treetrail ;
|
||||
__tablename__: str = "plantekey" # type: ignore
|
||||
|
||||
id: str | None = Field(primary_key=True, default=None)
|
||||
symbol: str = Field(sa_type=String(1)) # type: ignore
|
||||
iso: str = Field(sa_type=String(100)) # type: ignore
|
||||
|
||||
|
||||
async def fetch_browse():
|
||||
logger.info("Fetching list of plants (browse) from plantekey.com...")
|
||||
plantekey_url = "https://www.plantekey.com/api.php?action=browse"
|
||||
async with CachedSession(cache=cache) as session:
|
||||
async with session.get(plantekey_url) as response:
|
||||
try:
|
||||
content = await response.json(content_type=None)
|
||||
except Exception as err:
|
||||
logger.warning("Error browsing plantekey")
|
||||
logger.exception(err)
|
||||
content = {}
|
||||
df = pd.DataFrame(
|
||||
data=content,
|
||||
columns=[
|
||||
"ID",
|
||||
"english",
|
||||
"family",
|
||||
"hindi",
|
||||
"img",
|
||||
"name",
|
||||
"spiritual",
|
||||
"tamil",
|
||||
"type",
|
||||
],
|
||||
)
|
||||
df["id"] = df["name"].str.replace(" ", "-").str.lower()
|
||||
df["ID"] = df["ID"].astype(int)
|
||||
# async with db_session() as session:
|
||||
# array = df.apply(lambda item: Plant(**item), axis=1)
|
||||
# await session.exec(delete(Plant))
|
||||
# session.add_all(array)
|
||||
# await session.commit()
|
||||
return df
|
||||
|
||||
|
||||
async def get_all():
|
||||
"""
|
||||
Return the list of plants, with a local cache
|
||||
"""
|
||||
## TODO: implement cache mechanism
|
||||
## Store in db?
|
||||
# path = get_storage('all')
|
||||
# if path.stat().st_ctime - time() > cache_expiry:
|
||||
# return fetch_browse()
|
||||
|
||||
# return pd.read_feather(path)
|
||||
async with db_session() as session:
|
||||
all = await session.exec(select(Plant))
|
||||
df = pd.DataFrame(all.all())
|
||||
return df
|
||||
|
||||
|
||||
async def get_local_details() -> pd.DataFrame:
|
||||
"""
|
||||
Return a dataframe of the plantekey table, containing extra information
|
||||
like symbols for the map
|
||||
"""
|
||||
async with db_session() as session:
|
||||
data = await session.exec(select(Plantekey.id, Plantekey.symbol))
|
||||
df = pd.DataFrame(data.all())
|
||||
if len(df) > 0:
|
||||
df.set_index("id", inplace=True)
|
||||
return df
|
||||
|
||||
|
||||
async def get_details():
|
||||
"""
|
||||
Return the details of plants, as stored on the local server
|
||||
"""
|
||||
local_details = await get_local_details()
|
||||
async with db_session() as session:
|
||||
con = await session.connection()
|
||||
df = await con.run_sync(read_sql, select(Plant))
|
||||
# df.set_index('id', inplace=True)
|
||||
if len(local_details) > 0:
|
||||
ndf = df.merge(local_details, left_on="id", right_index=True, how="left")
|
||||
ndf["symbol"].fillna("\ue034", inplace=True)
|
||||
return ndf
|
||||
else:
|
||||
return df
|
||||
|
||||
|
||||
async def fetch_image(img: PlantImage):
|
||||
url = f"https://www.plantekey.com/admin/images/plants/{img.src}"
|
||||
logger.info(f"Fetching image at {url}")
|
||||
async with CachedSession(cache=cache) as session:
|
||||
try:
|
||||
resp = await session.get(url)
|
||||
except Exception as err:
|
||||
logger.warn(f"Cannot get image for {img.plant_id} ({url}): {err}")
|
||||
return
|
||||
img_data = await resp.content.read()
|
||||
if img_data[0] != 255:
|
||||
logger.warn(f"Image for {img.plant_id} at {url} is not an image")
|
||||
return
|
||||
with open(get_img_path(img.src), "bw") as f: # type: ignore
|
||||
f.write(img_data)
|
||||
update_thumbnail(BytesIO(img_data), img)
|
||||
|
||||
|
||||
async def fetch_images():
|
||||
"""
|
||||
Fetch all the images from the plantekey server
|
||||
"""
|
||||
logger.info("Fetching all images from plantekey server")
|
||||
async with db_session() as session:
|
||||
images = await session.exec(select(PlantImage))
|
||||
for img_rec in images:
|
||||
await fetch_image(img_rec[0])
|
||||
|
||||
|
||||
def update_thumbnail(data, img: PlantImage):
|
||||
try:
|
||||
tn_image = Image.open(data)
|
||||
tn_image.thumbnail(thumbnail_size)
|
||||
tn_image.save(img.get_thumbnail_path())
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
f"Cannot create thumbnail for {img.plant_id} ({img.src}): {error}"
|
||||
)
|
||||
|
||||
|
||||
async def fetch_types():
|
||||
df = await get_all()
|
||||
for type in df.type.unique():
|
||||
await fetch_type(type)
|
||||
|
||||
|
||||
async def fetch_type(type):
|
||||
plantekey_url = f"https://plantekey.com/img/icons/plant_types/{type}.png"
|
||||
async with CachedSession(cache=cache) as session:
|
||||
async with session.get(plantekey_url) as data:
|
||||
img_data = await data.content.read()
|
||||
with open(get_img_type_path(type), "bw") as f:
|
||||
f.write(img_data)
|
||||
|
||||
|
||||
async def update_details(df: pd.DataFrame):
|
||||
"""
|
||||
Update the server database from plantekey details
|
||||
"""
|
||||
all = {}
|
||||
images = {}
|
||||
for id in df["id"]:
|
||||
try:
|
||||
all[id], images[id] = await fetch_detail(id)
|
||||
except Exception as err:
|
||||
logger.warning(f"Error fetching details for {id}: {err}")
|
||||
df_details = pd.DataFrame.from_dict(all, orient="index")
|
||||
df_details.index.name = "id"
|
||||
df_details["ID"] = df_details["ID"].astype(int)
|
||||
## Cleanup according to DB data types
|
||||
for float_col_name in ("leaf_width", "leaf_length", "flower_size", "fruit_size"):
|
||||
df_details[float_col_name] = pd.to_numeric(
|
||||
df_details[float_col_name], errors="coerce", downcast="float"
|
||||
)
|
||||
for bool_col_name in ("woody", "leaf_aroma", "flower_aroma"):
|
||||
df_details[bool_col_name] = (
|
||||
df_details[bool_col_name].replace({"Yes": True, "No": False}).astype(bool)
|
||||
)
|
||||
# TODO: migrate __table__ to SQLModel, use model_fields
|
||||
for str_col_name in [
|
||||
c.name
|
||||
for c in Plant.__table__.columns # type: ignore
|
||||
if isinstance(c.type, String) and not c.primary_key
|
||||
]: # type: ignore
|
||||
df_details[str_col_name] = df_details[str_col_name].replace([np.nan], [None])
|
||||
|
||||
async with db_session() as session:
|
||||
plants_array = df_details.reset_index().apply(
|
||||
lambda item: Plant(**item), axis=1
|
||||
) # type: ignore
|
||||
existing_plants = await session.exec(select(Plant))
|
||||
for existing_plant in existing_plants.all():
|
||||
await session.delete(existing_plant)
|
||||
session.add_all(plants_array)
|
||||
await session.flush()
|
||||
## Images
|
||||
existing_plant_images = await session.exec(select(PlantImage))
|
||||
for existing_plant_image in existing_plant_images.all():
|
||||
await session.delete(existing_plant_image)
|
||||
images_array: list[PlantImage] = []
|
||||
for plant_id, plant_images in images.items():
|
||||
images_array.extend(
|
||||
[PlantImage(plant_id=plant_id, **image) for image in plant_images]
|
||||
)
|
||||
for image in images_array:
|
||||
image.IsDefault = False if image.IsDefault == "0" else True # type: ignore
|
||||
session.add_all(images_array)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def fetch_detail(id):
|
||||
logger.info(f"Fetching details from plantekey.com for {id}...")
|
||||
async with CachedSession(cache=cache) as session:
|
||||
async with session.get(get_plantekey_api_url(id)) as response:
|
||||
result = await response.json(content_type=None)
|
||||
## Sanitize
|
||||
result["plant"] = {
|
||||
k: v for k, v in result["plant"].items() if not k.isdecimal()
|
||||
}
|
||||
result["images"] = [
|
||||
{k: v for k, v in image.items() if not k.isdecimal()}
|
||||
for image in result["images"]
|
||||
]
|
||||
## Merge dicts, Python 3.9
|
||||
detail = result["plant"] | (result["characteristics"] or {})
|
||||
return detail, result["images"]
|
||||
|
||||
|
||||
async def create_thumbnail_archive():
|
||||
"""
|
||||
Create a tar file with all thumbnails of plants
|
||||
"""
|
||||
logger.info("Generating thumbnails and tar file")
|
||||
async with db_session() as session:
|
||||
images = await session.exec(select(PlantImage))
|
||||
with tarfile.open(str(get_thumbnail_tar_path()), "w") as tar:
|
||||
for img_rec in images:
|
||||
img: PlantImage = img_rec[0]
|
||||
path = img.get_thumbnail_path()
|
||||
if img.IsDefault:
|
||||
if path.is_file():
|
||||
tar.add(path)
|
||||
logger.info(
|
||||
"Generation of thumbnails and tar file "
|
||||
+ f"({get_thumbnail_tar_path()}) finished"
|
||||
)
|
||||
|
||||
|
||||
@pek_app.get("/updateData")
|
||||
async def updateData(user: User = Depends(get_current_active_user)):
|
||||
"""
|
||||
Get list and details of all plants from plantekey
|
||||
"""
|
||||
try:
|
||||
df = await fetch_browse()
|
||||
await update_details(df)
|
||||
except Exception as error:
|
||||
logger.exception(error)
|
||||
logger.error(error)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Update failed: {error.code}", # type: ignore
|
||||
)
|
||||
else:
|
||||
logger.info("updateData finished")
|
||||
return {"status": 0, "message": "Server updated"}
|
||||
|
||||
|
||||
@pek_app.get("/updateImages")
|
||||
async def updateImages(user: User = Depends(get_current_active_user)):
|
||||
"""
|
||||
Get images from plantekey, using the list of plants
|
||||
fetched with updateData
|
||||
"""
|
||||
try:
|
||||
await fetch_images()
|
||||
except Exception as error:
|
||||
logger.exception(error)
|
||||
logger.error(error)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Update of images failed: {error}",
|
||||
)
|
||||
try:
|
||||
await create_thumbnail_archive()
|
||||
except Exception as error:
|
||||
logger.exception(error)
|
||||
logger.error(error)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Update of images failed while creating thumbnails: {error}",
|
||||
)
|
||||
return {"status": 0, "message": "Server updated"}
|
||||
|
||||
|
||||
@pek_app.get("/plant/info/{id}")
|
||||
async def get_plantekey(
|
||||
id: str,
|
||||
db_session: fastapi_db_session,
|
||||
) -> Plant | None: # type: ignore
|
||||
"""
|
||||
Get details of a specific plant
|
||||
"""
|
||||
plant = await db_session.get(Plant, id)
|
||||
if plant is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
return plant
|
||||
|
||||
|
||||
@pek_app.get("/details")
|
||||
async def get_plantekey_all_details():
|
||||
"""
|
||||
Get all plants
|
||||
"""
|
||||
df = await get_details()
|
||||
content = df.to_json(orient="records")
|
||||
return Response(content=content, media_type="application/json")
|
||||
|
||||
|
||||
@pek_app.get("/details/csv", response_class=PlainTextResponse)
|
||||
async def get_plantekey_all_details_csv():
|
||||
"""
|
||||
Get all plants, return CSV
|
||||
"""
|
||||
df = await get_details()
|
||||
content = df.to_csv()
|
||||
return Response(content=content, media_type="text/csv")
|
||||
|
||||
|
||||
pek_app.mount("/img", StaticFiles(directory=mkdir(get_img_root())), name="plantekey_img")
|
||||
pek_app.mount("/thumb", StaticFiles(directory=mkdir(get_thumbnail_root())), name="plantekey_thumbnail")
|
||||
pek_app.mount("/type", StaticFiles(directory=mkdir(get_img_type_root())), name="plantekey_type")
|
163
src/treetrail/security.py
Normal file
163
src/treetrail/security.py
Normal file
|
@ -0,0 +1,163 @@
|
|||
from datetime import datetime, timedelta
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from sqlalchemy.orm import joinedload
|
||||
from pydantic import BaseModel
|
||||
from jose import JWTError, jwt
|
||||
from sqlmodel import select
|
||||
|
||||
from treetrail.config import conf
|
||||
from treetrail.models import User, Role, UserRoleLink
|
||||
from treetrail.database import db_session
|
||||
|
||||
|
||||
# openssl rand -hex 32
|
||||
# import secrets
|
||||
# SECRET_KEY = secrets.token_hex(32)
|
||||
ALGORITHM: str = "HS256"
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str
|
||||
|
||||
|
||||
class TokenData(BaseModel):
|
||||
username: str | None = None
|
||||
|
||||
|
||||
# class User(BaseModel):
|
||||
# username: str
|
||||
# email: str | None = None
|
||||
# full_name: str | None = None
|
||||
# disabled: bool = False
|
||||
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token", auto_error=False)
|
||||
|
||||
pwd_context = CryptContext(schemes=["sha256_crypt", "bcrypt"], deprecated="auto")
|
||||
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
def get_password_hash(password: str):
|
||||
return pwd_context.hash(password)
|
||||
|
||||
async def delete_user(username) -> None:
|
||||
async with db_session() as session:
|
||||
user_in_db = await get_user(username)
|
||||
if user_in_db is None:
|
||||
raise SystemExit(f'User {username} does not exist in the database')
|
||||
await session.delete(user_in_db)
|
||||
|
||||
async def enable_user(username, enable=True) -> None:
|
||||
async with db_session() as session:
|
||||
user_in_db = await get_user(username)
|
||||
if user_in_db is None:
|
||||
raise SystemExit(f'User {username} does not exist in the database')
|
||||
user_in_db.disabled = not enable # type: ignore
|
||||
session.add(user_in_db)
|
||||
await session.commit()
|
||||
|
||||
async def create_user(username: str, password: str,
|
||||
full_name: str | None = None,
|
||||
email: str | None = None) -> User:
|
||||
async with db_session() as session:
|
||||
user = await get_user(username)
|
||||
if user is None:
|
||||
user = User(
|
||||
username=username,
|
||||
password=get_password_hash(password),
|
||||
full_name=full_name,
|
||||
email=email,
|
||||
disabled=False
|
||||
)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
else:
|
||||
user.full_name = full_name # type: ignore
|
||||
user.email = email # type: ignore
|
||||
user.password = get_password_hash(password) # type: ignore
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return user
|
||||
|
||||
async def get_user(username: str) -> User | None: # type: ignore
|
||||
async with db_session() as session:
|
||||
query = select(User)\
|
||||
.where(User.username==username)\
|
||||
.options(joinedload(User.roles)) # type: ignore
|
||||
data = await session.exec(query)
|
||||
return data.first()
|
||||
|
||||
def verify_password(plain_password, hashed_password):
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
async def get_current_user(token: str = Depends(oauth2_scheme)) -> User | None: # type: ignore
|
||||
if token is None or token == 'null':
|
||||
return None
|
||||
try:
|
||||
payload = jwt.decode(token, conf.security.secret_key, algorithms=[ALGORITHM])
|
||||
username: str = payload.get("sub", '')
|
||||
if username == '':
|
||||
raise credentials_exception
|
||||
token_data = TokenData(username=username)
|
||||
except JWTError:
|
||||
return None
|
||||
user = await get_user(username=token_data.username) # type: ignore
|
||||
return user
|
||||
|
||||
async def authenticate_user(username: str, password: str):
|
||||
user = await get_user(username)
|
||||
if not user:
|
||||
return False
|
||||
if not verify_password(password, user.password):
|
||||
return False
|
||||
return user
|
||||
|
||||
async def get_current_active_user(
|
||||
current_user: User | None = Depends(get_current_user)) -> User: # type: ignore
|
||||
if current_user is None:
|
||||
raise HTTPException(status_code=400, detail="Not authenticated")
|
||||
if current_user.disabled:
|
||||
raise HTTPException(status_code=400, detail="Inactive user")
|
||||
return current_user
|
||||
|
||||
async def get_current_roles(user: User | None = Depends(get_current_user)) -> list[Role]: # type: ignore
|
||||
roles: list[Role]
|
||||
if user is None:
|
||||
roles = []
|
||||
else:
|
||||
roles = user.roles
|
||||
return roles
|
||||
|
||||
def create_access_token(data: dict, expires_delta: timedelta):
|
||||
to_encode = data.copy()
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode,
|
||||
conf.security.secret_key,
|
||||
algorithm=ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
async def add_user_role(username: str, role_id: str):
|
||||
async with db_session() as session:
|
||||
user_in_db = await get_user(username)
|
||||
if user_in_db is None:
|
||||
raise SystemExit(f'User {username} does not exist in the database')
|
||||
user_role = UserRoleLink(user_id=user_in_db.username, role_id=role_id)
|
||||
session.add(user_role)
|
||||
await session.commit()
|
||||
|
||||
async def add_role(role_id: str) -> Role:
|
||||
async with db_session() as session:
|
||||
role = Role(name=role_id)
|
||||
session.add(role)
|
||||
await session.commit()
|
||||
await session.refresh(role)
|
||||
return role
|
307
src/treetrail/tiles.py
Normal file
307
src/treetrail/tiles.py
Normal file
|
@ -0,0 +1,307 @@
|
|||
"""
|
||||
mbtile server
|
||||
|
||||
Instructions (example):
|
||||
|
||||
cd map ## Matches tilesBaseDir in config
|
||||
|
||||
curl http://download.geofabrik.de/asia/india/southern-zone-latest.osm.pbf -o osm.pbf
|
||||
|
||||
TILEMAKER_SRC=/home/phil/gisaf_misc/tilemaker
|
||||
# Or, for fish
|
||||
set TILEMAKER_SRC /home/phil/gisaf_misc/tilemaker
|
||||
cp $TILEMAKER_SRC/resources/config-openmaptiles.json .
|
||||
cp $TILEMAKER_SRC/resources/process-openmaptiles.lua .
|
||||
|
||||
## Edit config-openmaptiles.json, eg add in "settings":
|
||||
# "bounding_box":[79.76777,11.96541,79.86909,12.04497]
|
||||
vi config-openmaptiles.json
|
||||
|
||||
## Generate mbtile database:
|
||||
tilemaker \
|
||||
--config config-openmaptiles.json \
|
||||
--process process-openmaptiles.lua \
|
||||
--input osm.pbf \
|
||||
--output osm.mbtiles
|
||||
|
||||
|
||||
## Generate static tiles files
|
||||
mkdir osm
|
||||
tilemaker \
|
||||
--config config-openmaptiles.json \
|
||||
--process process-openmaptiles.lua \
|
||||
--input osm.pbf \
|
||||
--output osm
|
||||
|
||||
----
|
||||
|
||||
Get the style from https://github.com/openmaptiles, eg.
|
||||
curl -o osm-bright-full.json https://raw.githubusercontent.com/openmaptiles/osm-bright-gl-style/master/style.json
|
||||
## Minify json:
|
||||
python -c 'import json, sys;json.dump(json.load(sys.stdin), sys.stdout)' < osm-bright-full.json > osm-bright.json
|
||||
|
||||
----
|
||||
|
||||
Get the sprites from openmaptiles:
|
||||
|
||||
cd tiles ## Matches tilesSpriteBaseDir in config
|
||||
|
||||
curl -O 'https://openmaptiles.github.io/osm-bright-gl-style/sprite.png'
|
||||
curl -O 'https://openmaptiles.github.io/osm-bright-gl-style/sprite.json'
|
||||
curl -O 'https://openmaptiles.github.io/osm-bright-gl-style/sprite@2x.png'
|
||||
curl -O 'https://openmaptiles.github.io/osm-bright-gl-style/sprite@2x.json'
|
||||
|
||||
""" # noqa: E501
|
||||
|
||||
import logging
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
from json import loads, dumps
|
||||
from io import BytesIO
|
||||
|
||||
from fastapi import FastAPI, Response, HTTPException, Request
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
import aiosqlite
|
||||
|
||||
from treetrail.config import conf
|
||||
from treetrail.models import BaseMapStyles
|
||||
from treetrail.utils import mkdir
|
||||
|
||||
logger = logging.getLogger('treetrail tile server')
|
||||
|
||||
tiles_app = FastAPI()
|
||||
|
||||
def get_tiles_tar_path(style):
|
||||
## FIXME: use conf
|
||||
return Path(__file__).parent.parent/f'treetrail-app/src/data/tiles/{style}.tar'
|
||||
|
||||
|
||||
OSM_ATTRIBUTION = '<a href=\"http://www.openstreetmap.org/about/" target="_blank">' \
|
||||
'© OpenStreetMap contributors</a>'
|
||||
|
||||
|
||||
class MBTiles:
|
||||
def __init__(self, file_path, style_name):
|
||||
self.file_path = file_path
|
||||
self.name = style_name
|
||||
self.scheme = 'tms'
|
||||
self.etag = f'W/"{hex(int(file_path.stat().st_mtime))[2:]}"'
|
||||
self.style_layers: list[dict]
|
||||
## FIXME: use conf
|
||||
try:
|
||||
with open(Path(__file__).parent.parent / 'treetrail-app' / 'src' /
|
||||
'assets' / 'map' / 'style.json') as f:
|
||||
style = loads(f.read())
|
||||
self.style_layers = style['layers']
|
||||
except FileNotFoundError:
|
||||
self.style_layers = []
|
||||
for layer in self.style_layers:
|
||||
if 'source' in layer:
|
||||
layer['source'] = 'treeTrailTiles'
|
||||
|
||||
async def connect(self):
|
||||
self.db = await aiosqlite.connect(self.file_path)
|
||||
self.metadata = {}
|
||||
try:
|
||||
async with self.db.execute('select name, value from metadata') as cursor:
|
||||
async for row in cursor:
|
||||
self.metadata[row[0]] = row[1]
|
||||
except aiosqlite.DatabaseError as err:
|
||||
logger.warning(f'Cannot read {self.file_path}, will not be able'
|
||||
f' to serve tiles (error: {err.args[0]})')
|
||||
|
||||
## Fix types
|
||||
if 'bounds' in self.metadata:
|
||||
self.metadata['bounds'] = [float(v)
|
||||
for v in self.metadata['bounds'].split(',')]
|
||||
self.metadata['maxzoom'] = int(self.metadata['maxzoom'])
|
||||
self.metadata['minzoom'] = int(self.metadata['minzoom'])
|
||||
logger.info(f'Serving tiles in {self.file_path}')
|
||||
|
||||
async def get_style(self, request: Request):
|
||||
"""
|
||||
Generate on the fly the style
|
||||
"""
|
||||
if conf.tiles.useRequestUrl:
|
||||
base_url = str(request.base_url).removesuffix("/")
|
||||
else:
|
||||
base_url = conf.tiles.spriteBaseUrl
|
||||
base_tiles_url = f"{base_url}/tiles/{self.name}"
|
||||
scheme = self.scheme
|
||||
resp = {
|
||||
'basename': self.file_path.stem,
|
||||
#'center': self.center,
|
||||
'description': f'Extract of {self.file_path.stem} from OSM by Gisaf',
|
||||
'format': self.metadata['format'],
|
||||
'id': f'gisaftiles_{self.name}',
|
||||
'maskLevel': 5,
|
||||
'name': self.name,
|
||||
#'pixel_scale': 256,
|
||||
#'planettime': '1499040000000',
|
||||
'tilejson': '2.0.0',
|
||||
'version': 8,
|
||||
'glyphs': "/assets/fonts/glyphs/{fontstack}/{range}.pbf",
|
||||
'sprite': f"{base_url}{conf.tiles.spriteUrl}",
|
||||
'sources': {
|
||||
'treeTrailTiles': {
|
||||
'type': 'vector',
|
||||
'tiles': [
|
||||
f'{base_tiles_url}/{{z}}/{{x}}/{{y}}.pbf',
|
||||
],
|
||||
'maxzoom': self.metadata['maxzoom'],
|
||||
'minzoom': self.metadata['minzoom'],
|
||||
'bounds': self.metadata['bounds'],
|
||||
'scheme': scheme,
|
||||
'attribution': OSM_ATTRIBUTION,
|
||||
'version': self.metadata['version'],
|
||||
}
|
||||
},
|
||||
'layers': self.style_layers,
|
||||
}
|
||||
return resp
|
||||
|
||||
async def get_tile(self, z, x, y):
|
||||
async with self.db.execute(
|
||||
'select tile_data from tiles where zoom_level=? ' \
|
||||
'and tile_column=? and tile_row=?', (z, x, y)) as cursor:
|
||||
async for row in cursor:
|
||||
return row[0]
|
||||
|
||||
async def get_all_tiles_tar(self, style, request):
|
||||
s = 0
|
||||
n = 0
|
||||
buf = BytesIO()
|
||||
with tarfile.open(fileobj=buf, mode='w') as tar:
|
||||
## Add tiles
|
||||
async with self.db.execute('select zoom_level, tile_column, ' \
|
||||
'tile_row, tile_data from tiles') as cursor:
|
||||
async for row in cursor:
|
||||
z, x, y, tile = row
|
||||
tar_info = tarfile.TarInfo()
|
||||
tar_info.path = f'{style}/{z}/{x}/{y}.pbf'
|
||||
tar_info.size = len(tile)
|
||||
tar.addfile(tar_info, BytesIO(tile))
|
||||
logger.debug(f'Added {style}/{z}/{x}/{y} ({len(tile)})')
|
||||
n += 1
|
||||
s += len(tile)
|
||||
logger.info(f'Added {n} files ({s} bytes)')
|
||||
|
||||
## Add style
|
||||
tar_info = tarfile.TarInfo()
|
||||
tar_info.path = f'style/{style}'
|
||||
style_definition = await self.get_style(request)
|
||||
style_data = dumps(style_definition, check_circular=False).encode('utf-8')
|
||||
tar_info.size = len(style_data)
|
||||
tar.addfile(tar_info, BytesIO(style_data))
|
||||
|
||||
## Add sprites ex. /tiles/sprite/sprite.json and /tiles/sprite/sprite.png
|
||||
tar.add(conf.tiles.spriteBaseDir, 'sprite')
|
||||
|
||||
## Extract
|
||||
buf.seek(0)
|
||||
## XXX: Could write to file:
|
||||
#file_path = get_tiles_tar_path(style)
|
||||
return buf.read()
|
||||
|
||||
class MBTilesRegistry:
|
||||
mbtiles: dict[str, MBTiles]
|
||||
async def setup(self, app):
|
||||
"""
|
||||
Read all mbtiles, construct styles
|
||||
"""
|
||||
self.mbtiles = {}
|
||||
for file_path in Path(conf.tiles.baseDir).glob('*.mbtiles'):
|
||||
mbtiles = MBTiles(file_path, file_path.stem)
|
||||
self.mbtiles[file_path.stem] = mbtiles
|
||||
await mbtiles.connect()
|
||||
|
||||
async def shutdown(self, app):
|
||||
"""
|
||||
Tear down the connection to the mbtiles files
|
||||
"""
|
||||
for mbtiles in self.mbtiles.values():
|
||||
await mbtiles.db.close()
|
||||
|
||||
|
||||
gzip_headers = {
|
||||
'Content-Encoding': 'gzip',
|
||||
'Content-Type': 'application/octet-stream',
|
||||
}
|
||||
|
||||
tar_headers = {
|
||||
'Content-Type': 'application/x-tar',
|
||||
}
|
||||
|
||||
|
||||
@tiles_app.get('/styles')
|
||||
async def get_styles() -> BaseMapStyles:
|
||||
"""Styles for the map background. There are 2 types:
|
||||
- found on the embedded tiles server, that can be used offline
|
||||
- external providers, defined in the config with a simple url
|
||||
"""
|
||||
return BaseMapStyles(
|
||||
external=conf.mapStyles,
|
||||
embedded=list(registry.mbtiles.keys())
|
||||
)
|
||||
|
||||
|
||||
@tiles_app.get('/{style_name}/{z}/{x}/{y}.pbf')
|
||||
async def get_tile(style_name:str, z:int, x:int, y:int):
|
||||
"""
|
||||
Return the specific tile
|
||||
"""
|
||||
## TODO: implement etag
|
||||
#if request.headers.get('If-None-Match') == mbtiles.etag:
|
||||
# request.not_modified = True
|
||||
# return web.Response(body=None)
|
||||
#request.response_etag = mbtiles.etag
|
||||
|
||||
if style_name not in registry.mbtiles:
|
||||
raise HTTPException(status_code=404)
|
||||
mbtiles = registry.mbtiles[style_name]
|
||||
try:
|
||||
tile = await mbtiles.get_tile(z, x, y)
|
||||
except Exception as err:
|
||||
logger.info(f'Cannot get tile {z}, {x}, {y}')
|
||||
logger.exception(err)
|
||||
raise HTTPException(status_code=404)
|
||||
else:
|
||||
return Response(content=tile,
|
||||
media_type="application/json",
|
||||
headers=gzip_headers)
|
||||
|
||||
|
||||
@tiles_app.get('/{style_name}/all.tar')
|
||||
async def get_tiles_tar(style_name, request: Request):
|
||||
"""
|
||||
Get a tar file with all the tiles. Typically, used to feed into
|
||||
the browser's cache for offline use.
|
||||
"""
|
||||
mbtiles: MBTiles = registry.mbtiles[style_name]
|
||||
tar = await mbtiles.get_all_tiles_tar(style_name, request)
|
||||
return Response(content=tar, media_type="application/x-tar", headers=tar_headers)
|
||||
|
||||
#@tiles_app.get('/sprite/{name:\S+}')
|
||||
#async def get_sprite(request):
|
||||
|
||||
|
||||
@tiles_app.get('/style/{style_name}')
|
||||
async def get_style(style_name: str, request: Request):
|
||||
"""
|
||||
Return the base style.
|
||||
"""
|
||||
if style_name not in registry.mbtiles:
|
||||
raise HTTPException(status_code=404)
|
||||
mbtiles = registry.mbtiles[style_name]
|
||||
|
||||
return await mbtiles.get_style(request)
|
||||
|
||||
|
||||
registry = MBTilesRegistry()
|
||||
|
||||
tiles_app.mount("/sprite",
|
||||
StaticFiles(directory=mkdir(conf.tiles.spriteBaseDir)),
|
||||
name="tiles_sprites")
|
||||
tiles_app.mount('/osm',
|
||||
StaticFiles(directory=mkdir(conf.tiles.osmBaseDir)),
|
||||
name='tiles_osm')
|
86
src/treetrail/utils.py
Normal file
86
src/treetrail/utils.py
Normal file
|
@ -0,0 +1,86 @@
|
|||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
import logging
|
||||
|
||||
import pandas as pd
|
||||
from sqlalchemy.ext.declarative import DeclarativeMeta
|
||||
from sqlalchemy.engine.row import Row
|
||||
from sqlalchemy.sql.selectable import Select
|
||||
import geopandas as gpd # type: ignore
|
||||
|
||||
from treetrail.config import conf
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class AlchemyEncoder(json.JSONEncoder):
|
||||
def default(self, obj):
|
||||
if isinstance(obj.__class__, DeclarativeMeta):
|
||||
# an SQLAlchemy class
|
||||
fields = {}
|
||||
for field in [x for x in dir(obj)
|
||||
if not x.startswith('_') and x != 'metadata']:
|
||||
data = obj.__getattribute__(field)
|
||||
try:
|
||||
# this will fail on non-encodable values, like other classes
|
||||
json.dumps(data)
|
||||
fields[field] = data
|
||||
except TypeError:
|
||||
fields[field] = None
|
||||
# a json-encodable dict
|
||||
return fields
|
||||
|
||||
if isinstance(obj, Row):
|
||||
return dict(obj)
|
||||
return json.JSONEncoder.default(self, obj)
|
||||
|
||||
|
||||
async def read_sql_async(stmt, con):
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, pd.read_sql, stmt, con)
|
||||
|
||||
|
||||
def read_sql(con, stmt):
|
||||
## See https://stackoverflow.com/questions/70848256/how-can-i-use-pandas-read-sql-on-an-async-connection
|
||||
return pd.read_sql_query(stmt, con)
|
||||
|
||||
|
||||
def get_attachment_root(type: str):
|
||||
return Path(conf.storage.root_attachment_path) / type
|
||||
|
||||
|
||||
def get_attachment_tree_root():
|
||||
return get_attachment_root('tree')
|
||||
|
||||
|
||||
def get_attachment_trail_root():
|
||||
return get_attachment_root('trail')
|
||||
|
||||
|
||||
def get_attachment_poi_root():
|
||||
return get_attachment_root('poi')
|
||||
|
||||
|
||||
def pandas_query(session, query):
|
||||
return pd.read_sql_query(query, session.connection())
|
||||
|
||||
def geopandas_query(session, query: Select, model, *,
|
||||
# simplify_tolerance: float|None=None,
|
||||
crs=None, cast=True,
|
||||
):
|
||||
## XXX: I could not get the add_columns work without creating a subquery,
|
||||
## so moving the simplification to geopandas - see in _get_df
|
||||
# if simplify_tolerance is not None:
|
||||
# query = query.with_only_columns(*(col for col in query.columns
|
||||
# if col.name != 'geom'))
|
||||
# new_column = model.__table__.columns['geom'].ST_SimplifyPreserveTopology(
|
||||
# simplify_tolerance).label('geom')
|
||||
# query = query.add_columns(new_column)
|
||||
return gpd.GeoDataFrame.from_postgis(query, session.connection(), crs=crs)
|
||||
|
||||
def mkdir(dir: Path | str) -> Path:
|
||||
path = Path(dir)
|
||||
if not path.is_dir():
|
||||
logger.info(f'Create directory {path}')
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
16
tests/basic.py
Normal file
16
tests/basic.py
Normal file
|
@ -0,0 +1,16 @@
|
|||
from fastapi.testclient import TestClient
|
||||
|
||||
from treetrail.application import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_read_main():
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/treetrail/v1/bootstrap")
|
||||
assert response.status_code == 200
|
||||
json = response.json()
|
||||
assert set(json) == {'client', 'server', 'user', 'map', 'baseMapStyles', 'app'}
|
||||
assert json['user'] is None
|
||||
assert set(json['map']) == {'bearing', 'lat', 'background', 'lng', 'pitch', 'zoom'}
|
||||
assert set(json['baseMapStyles']) == {'external', 'embedded'}
|
Loading…
Add table
Add a link
Reference in a new issue