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 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 && \ RUN apk update && \
apk add --no-cache tzdata mariadb-client mariadb-dev && \ apk add --no-cache tzdata mariadb-client mariadb-dev && \
pip install --upgrade pip 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 requirements.txt /app/requirements.txt
COPY --from=email_builder /app/templates/html/ /app/src/email/templates/html/ 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" SENTRY_DSN: "https://184d1d6bd39f4fbb94804ae84e9afdd5@o472821.ingest.sentry.io/5506983"
volumes: volumes:
- ./uploads:/app/uploads - ./uploads:/app/uploads
- ./static:/app/static
+4 -1
View File
@@ -1,11 +1,13 @@
from aiohttp import ClientResponseError from aiohttp import ClientResponseError
from database import models from database import models
from database.transaction import get_session from database.transaction import get_session
from decorators.retryable import retryable
from external.elevation import elevation_api from external.elevation import elevation_api
from external.gpx_parser import GPXParser from external.gpx_parser import GPXParser
from paths import FLIGHT_GPX_TRACK_PATH from paths import FLIGHT_GPX_TRACK_PATH
@retryable
async def add_terrain_elevation_to_flight(flight_id: int, gpx_filename: str): async def add_terrain_elevation_to_flight(flight_id: int, gpx_filename: str):
gpx_parser = GPXParser(f"{FLIGHT_GPX_TRACK_PATH}/{gpx_filename}") gpx_parser = GPXParser(f"{FLIGHT_GPX_TRACK_PATH}/{gpx_filename}")
coordinates = await gpx_parser.get_coordinates_all() 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) print(e)
@retryable
async def add_terrain_elevation_to_photo(photo): async def add_terrain_elevation_to_photo(photo):
try: try:
elevation = await elevation_api.get_elevation_for_points([ elevation = await elevation_api.get_elevation_for_points([
@@ -34,7 +37,7 @@ async def add_terrain_elevation_to_photo(photo):
print("Cannot get elevation") print("Cannot get elevation")
return return
terrain_elevation = elevation[0]['elevation'] terrain_elevation = elevation[0].elevation
async with get_session() as db: async with get_session() as db:
await models.Photo.update(db_session=db, obj=photo, data={"terrain_elevation": terrain_elevation}) await models.Photo.update(db_session=db, obj=photo, data={"terrain_elevation": terrain_elevation})
except Exception as e: 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): def setup_graphql_endpoint(app: FastAPI, access_security: JwtAccessBearerCookie):
if not APP_DEBUG: if APP_DEBUG:
return debug_router = APIRouter()
debug_router = APIRouter()
@debug_router.get("/graphql/autologin") @debug_router.get("/graphql/autologin")
async def autologin(): async def autologin():
access_token = access_security.create_access_token(subject={"id": 6, "name": "Franta Vomacka"}) access_token = access_security.create_access_token(subject={"id": 6, "name": "Franta Vomacka"})
response = RedirectResponse(url="/graphql") response = RedirectResponse(url="/graphql")
access_security.set_access_cookie(response, access_token, expires_delta=timedelta(days=14)) 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)): async def setup_graphql_context(credentials: JwtAuthorizationCredentials = Security(access_security)):
user_id = credentials['id'] if credentials else None 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 from typing import List, Dict
import aiohttp 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: class ElevationAPI:
ELEVATION_ENDPOINT = "https://api.open-elevation.com/api/v1/lookup" ELEVATION_ENDPOINT: str
def get_request(self, points: List[Dict[str, float]]): def get_request(self, session: aiohttp.ClientSession, points: List[Dict[str, float]]):
return {"locations": [{"latitude": point['lat'], "longitude": point['lng']} for point in points]} ...
@retryable
async def call_api(self, points: List[Dict[str, float]]): async def call_api(self, points: List[Dict[str, float]]):
async with aiohttp.ClientSession() as session: 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() resp.raise_for_status()
return await resp.json() 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) response = await self.call_api(points)
return [{ return [
"lat": loc['latitude'], ElevationInfo(lat=loc['latitude'], lng=loc['longitude'], elevation=loc['elevation'])
"lng": loc['longitude'], for loc in response['results']
"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 sqlalchemy import delete
from database import models from database import models
from database.transaction import get_session 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 # https://pyopenair.readthedocs.io/en/latest/openair.html
+2
View File
@@ -3,6 +3,7 @@ import urllib.parse
from typing import Tuple, Dict from typing import Tuple, Dict
import aiohttp import aiohttp
from aiocache import cached from aiocache import cached
from decorators.retryable import retryable
class Weather: class Weather:
@@ -38,6 +39,7 @@ class Weather:
return f"{url}{query_string}" return f"{url}{query_string}"
@cached(ttl=6 * 3600) @cached(ttl=6 * 3600)
@retryable
async def download_weather_for_day(self, date: datetime.date, gps: Tuple[float, float]): 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) 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 from typing import Optional
import strawberry import strawberry
from graphql import GraphQLError
from decorators.endpoints import authenticated_user_only, allow_public from decorators.endpoints import authenticated_user_only, allow_public
from decorators.error_logging import error_logging from decorators.error_logging import error_logging
from graphql_schema.entities.resolvers.flight import FlightMutationResolver, FlightQueryResolver 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 paths import FLIGHT_GPX_TRACK_PATH
from utils.file import delete_file from utils.file import delete_file
from utils.str_utils import random_str from utils.str_utils import random_str
from utils.upload import handle_file_upload from utils.file import handle_file_upload
class FlightQueryResolver(BaseQueryResolver): class FlightQueryResolver(BaseQueryResolver):
@@ -16,7 +16,7 @@ from graphql_schema.entities.types.types import Photo
from paths import get_photo_basepath from paths import get_photo_basepath
from utils.file import delete_file from utils.file import delete_file
from utils.image import PhotoEditor, parse_exif_info 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): class PhotoQueryResolver(BaseQueryResolver):
+8 -3
View File
@@ -6,7 +6,7 @@ from typing import Optional, List
import strawberry import strawberry
from database import models from database import models
from decorators.endpoints import authenticated_user_only 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 external.gpx_parser import GPXParser
from graphql_schema.dataloaders.flight_duration import flight_duration_dataloader from graphql_schema.dataloaders.flight_duration import flight_duration_dataloader
from graphql_schema.dataloaders.multi_models import ( 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.permissions import IsAuthenticated
from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_type 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 @strawberry.type
@@ -194,7 +194,9 @@ class Flight:
duration_min_calculated: int = strawberry.field( duration_min_calculated: int = strawberry.field(
resolver=lambda root: flight_duration_dataloader.load(root.id) 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( debriefing_notes: Optional[str] = strawberry.field(
permission_classes=[IsAuthenticated], permission_classes=[IsAuthenticated],
resolver=lambda root: root.debriefing_notes resolver=lambda root: root.debriefing_notes
@@ -295,6 +297,9 @@ class Organization:
class User: class User:
avatar_image_url: Optional[str] = strawberry.field(resolver=lambda root: get_avatar_url(root)) 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)) 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( organizations: List[Organization] = strawberry.field(
resolver=lambda root: user_organizations_dataloader.load(root.id) 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 database.transaction import get_session
from graphql_schema.entities.types.types import User from graphql_schema.entities.types.types import User
from utils.file import delete_file from utils.file import delete_file
from utils.upload import handle_file_upload from utils.file import handle_file_upload
@strawberry.type @strawberry.type
+3 -3
View File
@@ -18,15 +18,15 @@ async def add_elevation_to_photos():
coordinates = [ coordinates = [
{"lat": p.gps_latitude, "lng": p.gps_longitude} for p in photos if p.gps_latitude or p.gps_longitude {"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: if not coordinates:
print("all done") print("all done")
return return
points = await elevation_api.get_elevation_for_points(coordinates) points = await elevation_api.get_elevation_for_points(coordinates)
for point in points: for point in points:
photo = photos_by_corrdinates[point['lat'], point['lng']] photo = photos_by_coordinates[point.lat, point.lng]
await models.Photo.update(db_session=session, obj=photo, data={"terrain_elevation": point['elevation']}) await models.Photo.update(db_session=session, obj=photo, data={"terrain_elevation": point.elevation})
await session.flush() await session.flush()
await session.commit() await session.commit()
+5 -7
View File
@@ -2,13 +2,13 @@ import asyncio
import dataclasses import dataclasses
import sys import sys
from collections import defaultdict from collections import defaultdict
from PIL import Image, ImageDraw, ImageFont, ImageFilter from PIL import Image, ImageDraw, ImageFilter
from sqlalchemy import select from sqlalchemy import select
sys.path.insert(0, "/app/src") sys.path.insert(0, "/app/src")
from database import models from database import models # noqa
from database.transaction import get_session from database.transaction import get_session # noqa
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
@@ -127,13 +127,11 @@ def draw_runways(airport_img, runways: list[Runway], size: int):
start = center - all_runways_width // 2 start = center - all_runways_width // 2
colors = { colors = {
"asphalt": "#555555", "asphalt": "#777777",
"concrete": "#dddddd", "concrete": "#cccccc",
"grass": "#00aa00" "grass": "#00aa00"
} }
font = ImageFont.truetype('/app/static/Calibri.ttf', 50)
rwy = Image.new("RGBA", (size, size), (0, 0, 0, 0)) rwy = Image.new("RGBA", (size, size), (0, 0, 0, 0))
heading_sum = 0 heading_sum = 0
draw = ImageDraw.Draw(rwy) 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 os
import re
import uuid
from strawberry.file_uploads import Upload
def delete_file(path: str, silent: bool = False): def delete_file(path: str, silent: bool = False):
@@ -14,3 +17,31 @@ def check_directories(path: str):
os.makedirs(path, exist_ok=True) os.makedirs(path, exist_ok=True)
except OSError: except OSError:
pass 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 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: 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