Uprava stahovani elevace, slouceni gps utils do jednoho filu, retryable, pridani social image k pilotovi a letu

This commit is contained in:
Michal Kváček
2024-06-03 23:07:23 +02:00
parent ba5e73108f
commit 2bf737b9d6
23 changed files with 315 additions and 6982 deletions
+7 -5
View File
@@ -9,15 +9,17 @@ RUN mjml /app/templates/* -o /app/templates/html/
FROM python:3.11-alpine AS app
# protoze xfs uzivatel ma id=33
# RUN deluser xfs
# uzivatel www-data v alpine neexistuje
RUN delgroup www-data && addgroup -g 33 -S www-data && adduser -u 33 -D -S -G www-data www-data
RUN apk update && \
apk add --no-cache tzdata mariadb-client mariadb-dev && \
pip install --upgrade pip
# protoze xfs uzivatel ma id=33
RUN deluser xfs
# uzivatel www-data v alpine neexistuje
RUN delgroup www-data && addgroup -g 33 -S www-data && adduser -u 33 -D -S -G www-data www-data
COPY requirements.txt /app/requirements.txt
COPY --from=email_builder /app/templates/html/ /app/src/email/templates/html/
+1
View File
@@ -12,3 +12,4 @@ services:
SENTRY_DSN: "https://184d1d6bd39f4fbb94804ae84e9afdd5@o472821.ingest.sentry.io/5506983"
volumes:
- ./uploads:/app/uploads
- ./static:/app/static
+4 -1
View File
@@ -1,11 +1,13 @@
from aiohttp import ClientResponseError
from database import models
from database.transaction import get_session
from decorators.retryable import retryable
from external.elevation import elevation_api
from external.gpx_parser import GPXParser
from paths import FLIGHT_GPX_TRACK_PATH
@retryable
async def add_terrain_elevation_to_flight(flight_id: int, gpx_filename: str):
gpx_parser = GPXParser(f"{FLIGHT_GPX_TRACK_PATH}/{gpx_filename}")
coordinates = await gpx_parser.get_coordinates_all()
@@ -25,6 +27,7 @@ async def add_terrain_elevation_to_flight(flight_id: int, gpx_filename: str):
print(e)
@retryable
async def add_terrain_elevation_to_photo(photo):
try:
elevation = await elevation_api.get_elevation_for_points([
@@ -34,7 +37,7 @@ async def add_terrain_elevation_to_photo(photo):
print("Cannot get elevation")
return
terrain_elevation = elevation[0]['elevation']
terrain_elevation = elevation[0].elevation
async with get_session() as db:
await models.Photo.update(db_session=db, obj=photo, data={"terrain_elevation": terrain_elevation})
except Exception as e:
+58
View File
@@ -0,0 +1,58 @@
from PIL import Image, ImageDraw, ImageOps
async def add_circular_avatar(base_image_path, avatar_image_path, output_image_path):
# Load the base image
base_image = Image.open(base_image_path)
base_width, base_height = base_image.size
# Calculate the new dimensions for cropping to a 1.9:1 aspect ratio
new_width = base_width
new_height = int(new_width / 1.9)
if new_height > base_height:
new_height = base_height
new_width = int(new_height * 1.9)
# Center crop the base image to the new dimensions
left = (base_width - new_width) // 2
top = (base_height - new_height) // 2
right = (base_width + new_width) // 2
bottom = (base_height + new_height) // 2
base_image = base_image.crop((left, top, right, bottom))
base_width, base_height = base_image.size
# Load the avatar image
avatar_image = Image.open(avatar_image_path)
avatar_size = int(base_height * 0.3) # Set the size of the avatar
avatar_image = avatar_image.resize((avatar_size, avatar_size), Image.LANCZOS)
# Create a mask to make the avatar circular
mask = Image.new('L', (avatar_size, avatar_size), 0)
draw = ImageDraw.Draw(mask)
draw.ellipse((0, 0, avatar_size, avatar_size), fill=255)
# Apply the mask to the avatar image
avatar_image = ImageOps.fit(avatar_image, mask.size, centering=(0.5, 0.5))
avatar_image.putalpha(mask)
# Create a border around the avatar
border_size = int(avatar_size * 0.03)
border_image = Image.new('RGBA', (avatar_size + 2 * border_size, avatar_size + 2 * border_size), (255, 255, 255, 0))
border_mask = Image.new('L', (avatar_size + 2 * border_size, avatar_size + 2 * border_size), 0)
border_draw = ImageDraw.Draw(border_mask)
border_draw.ellipse((0, 0, avatar_size + 2 * border_size, avatar_size + 2 * border_size), fill=255)
border_draw.ellipse((border_size, border_size, avatar_size + border_size, avatar_size + border_size), fill=0)
avatar_margin = int(avatar_size * 0.1)
border_image.putalpha(border_mask)
border_image.paste(avatar_image, (border_size, border_size), avatar_image)
# Calculate the position to paste the avatar (bottom right corner)
position = (base_width - avatar_size - 2 * border_size - avatar_margin, base_height - avatar_size - 2 * border_size - avatar_margin)
# Paste the avatar with border onto the base image
base_image.paste(border_image, position, border_image)
# Save the result
base_image.save(output_image_path)
+24
View File
@@ -0,0 +1,24 @@
import asyncio
def retryable(func):
max_retries = 5
async def func_wrapper(*args, **kwargs):
for i in range(1, max_retries):
try:
return await func(*args, **kwargs)
except Exception as ex:
if i > max_retries:
raise ex
sleep_time = i * 2
print(
"Error while getting the data from source:"
" decorator is going to sleep for %s seconds"
" and retry it again (%s/%s)" % (
sleep_time, i, max_retries
)
)
await asyncio.sleep(sleep_time)
return func_wrapper
+9 -10
View File
@@ -11,19 +11,18 @@ from graphql_schema.schema import GraphQLContext, schema
def setup_graphql_endpoint(app: FastAPI, access_security: JwtAccessBearerCookie):
if not APP_DEBUG:
return
debug_router = APIRouter()
if APP_DEBUG:
debug_router = APIRouter()
@debug_router.get("/graphql/autologin")
async def autologin():
access_token = access_security.create_access_token(subject={"id": 6, "name": "Franta Vomacka"})
response = RedirectResponse(url="/graphql")
access_security.set_access_cookie(response, access_token, expires_delta=timedelta(days=14))
@debug_router.get("/graphql/autologin")
async def autologin():
access_token = access_security.create_access_token(subject={"id": 6, "name": "Franta Vomacka"})
response = RedirectResponse(url="/graphql")
access_security.set_access_cookie(response, access_token, expires_delta=timedelta(days=14))
return response
return response
app.include_router(debug_router)
app.include_router(debug_router)
async def setup_graphql_context(credentials: JwtAuthorizationCredentials = Security(access_security)):
user_id = credentials['id'] if credentials else None
+62 -11
View File
@@ -1,27 +1,78 @@
import asyncio
import dataclasses
from typing import List, Dict
import aiohttp
from decorators.retryable import retryable
from utils.list_utils import chunks
@dataclasses.dataclass
class ElevationInfo:
lat: float
lng: float
elevation: float
class ElevationAPI:
ELEVATION_ENDPOINT = "https://api.open-elevation.com/api/v1/lookup"
ELEVATION_ENDPOINT: str
def get_request(self, points: List[Dict[str, float]]):
return {"locations": [{"latitude": point['lat'], "longitude": point['lng']} for point in points]}
def get_request(self, session: aiohttp.ClientSession, points: List[Dict[str, float]]):
...
@retryable
async def call_api(self, points: List[Dict[str, float]]):
async with aiohttp.ClientSession() as session:
async with session.post(self.ELEVATION_ENDPOINT, json=self.get_request(points)) as resp:
async with self.get_request(session, points) as resp:
resp.raise_for_status()
return await resp.json()
async def get_elevation_for_points(self, points: List[Dict[str, float]]) -> List[dict]:
class OpenElevationAPI(ElevationAPI):
ELEVATION_ENDPOINT = "https://api.open-elevation.com/api/v1/lookup"
def get_request(self, session: aiohttp.ClientSession, points: List[Dict[str, float]]):
data = {"locations": [{"latitude": point['lat'], "longitude": point['lng']} for point in points]}
return session.post(self.ELEVATION_ENDPOINT, json=data)
async def get_elevation_for_points(self, points: List[Dict[str, float]]) -> List[ElevationInfo]:
response = await self.call_api(points)
return [{
"lat": loc['latitude'],
"lng": loc['longitude'],
"elevation": loc['elevation']
} for loc in response['results']]
return [
ElevationInfo(lat=loc['latitude'], lng=loc['longitude'], elevation=loc['elevation'])
for loc in response['results']
]
elevation_api = ElevationAPI()
class OpenMeteoElevationAPI(ElevationAPI):
ELEVATION_ENDPOINT = "https://api.open-meteo.com/v1/elevation"
def get_request(self, session: aiohttp.ClientSession, points: List[Dict[str, float]]):
latitudes = ",".join(str(point['lat']) for point in points)
longitudes = ",".join(str(point['lng']) for point in points)
url = f"{self.ELEVATION_ENDPOINT}?latitude={latitudes}&longitude={longitudes}"
return session.get(url)
async def get_elevation_for_points(self, points: List[Dict[str, float]]) -> List[ElevationInfo]:
elevation = []
for chunk in chunks(points, n=100):
response = await self.call_api(chunk)
chunk_elevation = [
ElevationInfo(**point, elevation=response['elevation'][i]) for i, point in enumerate(points)
]
elevation += chunk_elevation
return elevation
class CombinedElevationAPI:
async def get_elevation_for_points(self, points: List[Dict[str, float]]) -> List[ElevationInfo]:
for api in (OpenMeteoElevationAPI(), OpenElevationAPI()):
elevation = await api.get_elevation_for_points(points)
if elevation:
return elevation
elevation_api = CombinedElevationAPI()
-34
View File
@@ -1,34 +0,0 @@
import math
EARTH_RADIUS = 6378.1 # Radius of the Earth
def get_bearing(center_point: tuple[float, float], end_point: tuple[float, float]):
lat3 = math.radians(end_point[0])
long3 = math.radians(end_point[1])
lat1 = math.radians(center_point[0])
long1 = math.radians(center_point[1])
dLon = long3 - long1
X = math.cos(lat3) * math.sin(dLon)
Y = math.cos(lat1) * math.sin(lat3) - math.sin(lat1) * math.cos(lat3) * math.cos(dLon)
end_brng = math.atan2(X, Y)
return end_brng
def get_distance(a: tuple[float, float], b: tuple[float, float]):
lat1 = math.radians(a[0])
lon1 = math.radians(a[1])
lat2 = math.radians(b[0])
lon2 = math.radians(b[1])
dlon = lon2 - lon1
dlat = lat2 - lat1
a = math.sin(dlat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
return EARTH_RADIUS * c
+1 -1
View File
@@ -8,7 +8,7 @@ import aiohttp
from sqlalchemy import delete
from database import models
from database.transaction import get_session
from external.gps_utils import get_distance, get_bearing, EARTH_RADIUS
from utils.gps import get_distance, get_bearing, EARTH_RADIUS
# https://pyopenair.readthedocs.io/en/latest/openair.html
+2
View File
@@ -3,6 +3,7 @@ import urllib.parse
from typing import Tuple, Dict
import aiohttp
from aiocache import cached
from decorators.retryable import retryable
class Weather:
@@ -38,6 +39,7 @@ class Weather:
return f"{url}{query_string}"
@cached(ttl=6 * 3600)
@retryable
async def download_weather_for_day(self, date: datetime.date, gps: Tuple[float, float]):
url = self.get_weather_info_url(start_date=date, end_date=date, gps=gps)
-2
View File
@@ -1,7 +1,5 @@
from typing import Optional
import strawberry
from graphql import GraphQLError
from decorators.endpoints import authenticated_user_only, allow_public
from decorators.error_logging import error_logging
from graphql_schema.entities.resolvers.flight import FlightMutationResolver, FlightQueryResolver
@@ -18,7 +18,7 @@ from graphql_schema.entities.types.types import Flight
from paths import FLIGHT_GPX_TRACK_PATH
from utils.file import delete_file
from utils.str_utils import random_str
from utils.upload import handle_file_upload
from utils.file import handle_file_upload
class FlightQueryResolver(BaseQueryResolver):
@@ -16,7 +16,7 @@ from graphql_schema.entities.types.types import Photo
from paths import get_photo_basepath
from utils.file import delete_file
from utils.image import PhotoEditor, parse_exif_info
from utils.upload import handle_file_upload
from utils.file import handle_file_upload
class PhotoQueryResolver(BaseQueryResolver):
+8 -3
View File
@@ -6,7 +6,7 @@ from typing import Optional, List
import strawberry
from database import models
from decorators.endpoints import authenticated_user_only
from external.gps_utils import get_bearing, get_distance
from utils.gps import get_bearing, get_distance
from external.gpx_parser import GPXParser
from graphql_schema.dataloaders.flight_duration import flight_duration_dataloader
from graphql_schema.dataloaders.multi_models import (
@@ -24,7 +24,7 @@ from graphql_schema.dataloaders.single_model import (
)
from graphql_schema.permissions import IsAuthenticated
from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_type
from paths import get_avatar_url, get_title_image_url, get_photo_thumbnail_url, get_photo_url, FLIGHT_GPX_TRACK_PATH, airport_icon_url
from paths import get_avatar_url, get_title_image_url, get_photo_thumbnail_url, get_photo_url, FLIGHT_GPX_TRACK_PATH, airport_icon_url, get_public_url
@strawberry.type
@@ -194,7 +194,9 @@ class Flight:
duration_min_calculated: int = strawberry.field(
resolver=lambda root: flight_duration_dataloader.load(root.id)
)
social_image_url: Optional[str] = strawberry.field(
resolver=lambda root: get_public_url(f'photos/{root.id}/title_photo.jpg')
)
debriefing_notes: Optional[str] = strawberry.field(
permission_classes=[IsAuthenticated],
resolver=lambda root: root.debriefing_notes
@@ -295,6 +297,9 @@ class Organization:
class User:
avatar_image_url: Optional[str] = strawberry.field(resolver=lambda root: get_avatar_url(root))
title_image_url: str = strawberry.field(resolver=lambda root: get_title_image_url(root))
social_image_url: Optional[str] = strawberry.field(
resolver=lambda root: get_public_url(f'profile/{root.id}/title_photo.jpg')
)
organizations: List[Organization] = strawberry.field(
resolver=lambda root: user_organizations_dataloader.load(root.id)
)
+1 -1
View File
@@ -11,7 +11,7 @@ from decorators.error_logging import error_logging
from database.transaction import get_session
from graphql_schema.entities.types.types import User
from utils.file import delete_file
from utils.upload import handle_file_upload
from utils.file import handle_file_upload
@strawberry.type
+3 -3
View File
@@ -18,15 +18,15 @@ async def add_elevation_to_photos():
coordinates = [
{"lat": p.gps_latitude, "lng": p.gps_longitude} for p in photos if p.gps_latitude or p.gps_longitude
]
photos_by_corrdinates = {(p.gps_latitude, p.gps_longitude): p for p in photos}
photos_by_coordinates = {(p.gps_latitude, p.gps_longitude): p for p in photos}
if not coordinates:
print("all done")
return
points = await elevation_api.get_elevation_for_points(coordinates)
for point in points:
photo = photos_by_corrdinates[point['lat'], point['lng']]
await models.Photo.update(db_session=session, obj=photo, data={"terrain_elevation": point['elevation']})
photo = photos_by_coordinates[point.lat, point.lng]
await models.Photo.update(db_session=session, obj=photo, data={"terrain_elevation": point.elevation})
await session.flush()
await session.commit()
+5 -7
View File
@@ -2,13 +2,13 @@ import asyncio
import dataclasses
import sys
from collections import defaultdict
from PIL import Image, ImageDraw, ImageFont, ImageFilter
from PIL import Image, ImageDraw, ImageFilter
from sqlalchemy import select
sys.path.insert(0, "/app/src")
from database import models
from database.transaction import get_session
from database import models # noqa
from database.transaction import get_session # noqa
@dataclasses.dataclass(frozen=True)
@@ -127,13 +127,11 @@ def draw_runways(airport_img, runways: list[Runway], size: int):
start = center - all_runways_width // 2
colors = {
"asphalt": "#555555",
"concrete": "#dddddd",
"asphalt": "#777777",
"concrete": "#cccccc",
"grass": "#00aa00"
}
font = ImageFont.truetype('/app/static/Calibri.ttf', 50)
rwy = Image.new("RGBA", (size, size), (0, 0, 0, 0))
heading_sum = 0
draw = ImageDraw.Draw(rwy)
+49
View File
@@ -0,0 +1,49 @@
import asyncio
import sys
from sqlalchemy import select
sys.path.insert(0, '/app/src')
from background_jobs.flight_title_photo import add_circular_avatar # noqa
from database import models # noqa
from database.transaction import get_session # noqa
async def flight_title_photos():
async with get_session() as db:
flights = (await db.execute(
select(models.Photo, models.Flight, models.User)
.join(models.Flight.title_photo)
.join(models.Flight.created_by)
)).all()
for photo, flight, pilot in flights:
await add_circular_avatar(
base_image_path=f'/app/uploads/photos/{flight.id}/{photo.filename}.{photo.filename_extension}',
avatar_image_path=f'/app/uploads/profile/{pilot.id}/{pilot.avatar_image_filename}',
output_image_path=f'/app/uploads/photos/{flight.id}/title_photo.jpg',
)
async def user_title_photos():
async with get_session() as db:
users = (await db.execute(
select(models.User.id, models.User.title_image_filename, models.User.avatar_image_filename)
.filter(models.User.title_image_filename != '')
)).all()
for user_id, title_image, avatar_image in users:
await add_circular_avatar(
base_image_path=f"/app/uploads/profile/{user_id}/{title_image}",
avatar_image_path=f"/app/uploads/profile/{user_id}/{avatar_image}",
output_image_path=f'/app/uploads/profile/{user_id}/title_photo.jpg'
)
async def generate_images():
await user_title_photos()
await flight_title_photos()
if __name__ == "__main__":
loop = asyncio.get_event_loop()
loop.run_until_complete(generate_images())
+31
View File
@@ -1,4 +1,7 @@
import os
import re
import uuid
from strawberry.file_uploads import Upload
def delete_file(path: str, silent: bool = False):
@@ -14,3 +17,31 @@ def check_directories(path: str):
os.makedirs(path, exist_ok=True)
except OSError:
pass
async def handle_file_upload(
file: Upload,
path: str,
filename_maxlength: int = 64,
uid_prefix: bool = True,
overwrite: bool = True
) -> str:
check_directories(path)
prefix = f"{uuid.uuid4()}-" if uid_prefix else ""
filename = f"{prefix}{file.filename}"[-1 * filename_maxlength:]
# sanitize filename
filename = re.sub(r'[^\w_. -]', '', filename).replace(" ", "-")
target_path = f"{path}/{filename}"
if os.path.exists(target_path) and not overwrite:
raise FileExistsError(f"File {filename} already exists")
content = await file.read()
image = open(target_path, "wb")
image.write(content)
image.close()
return filename
+34
View File
@@ -1,4 +1,38 @@
from typing import Tuple
import math
EARTH_RADIUS = 6378.1 # Radius of the Earth
def get_bearing(center_point: tuple[float, float], end_point: tuple[float, float]):
lat3 = math.radians(end_point[0])
long3 = math.radians(end_point[1])
lat1 = math.radians(center_point[0])
long1 = math.radians(center_point[1])
dLon = long3 - long1
X = math.cos(lat3) * math.sin(dLon)
Y = math.cos(lat1) * math.sin(lat3) - math.sin(lat1) * math.cos(lat3) * math.cos(dLon)
end_brng = math.atan2(X, Y)
return end_brng
def get_distance(a: tuple[float, float], b: tuple[float, float]):
lat1 = math.radians(a[0])
lon1 = math.radians(a[1])
lat2 = math.radians(b[0])
lon2 = math.radians(b[1])
dlon = lon2 - lon1
dlat = lat2 - lat1
a = math.sin(dlat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
return EARTH_RADIUS * c
def gps_to_decimal(input: Tuple[float, float, float]) -> float:
+14
View File
@@ -0,0 +1,14 @@
import typing
from itertools import islice
def chunks(items: list | dict, n: int) -> typing.Generator:
"""Yield successive n-sized chunks from lst."""
if isinstance(items, list):
for i in range(0, len(items), n):
yield items[i:i + n]
elif isinstance(items, dict):
it = iter(items)
for i in range(0, len(items), n):
yield {k: items[k] for k in islice(it, n)}
-33
View File
@@ -1,33 +0,0 @@
import os
import re
import uuid
from strawberry.file_uploads import Upload
from utils.file import check_directories
async def handle_file_upload(
file: Upload,
path: str,
filename_maxlength: int = 64,
uid_prefix: bool = True,
overwrite: bool = True
) -> str:
check_directories(path)
prefix = f"{uuid.uuid4()}-" if uid_prefix else ""
filename = f"{prefix}{file.filename}"[-1 * filename_maxlength:]
# sanitize filename
filename = re.sub(r'[^\w_. -]', '', filename).replace(" ", "-")
target_path = f"{path}/{filename}"
if os.path.exists(target_path) and not overwrite:
raise FileExistsError(f"File {filename} already exists")
content = await file.read()
image = open(target_path, "wb")
image.write(content)
image.close()
return filename
File diff suppressed because it is too large Load Diff