Stahovani pocasi, drobne upravy

This commit is contained in:
Michal Kváček
2024-05-27 22:30:44 +02:00
parent 94613a5b1b
commit ba5e73108f
13 changed files with 293 additions and 26 deletions
+2
View File
@@ -10,3 +10,5 @@ uploads/*
!uploads/.gitkeep
src/emails/templates/html/*
.env
/static/airports/
/static/airports/.gitkeep
@@ -0,0 +1,30 @@
"""add weather info to flight plan markers
Revision ID: 8a67af3909aa
Revises: 790ba00fc021
Create Date: 2024-05-16 06:19:13.112911
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '8a67af3909aa'
down_revision = '790ba00fc021'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('flight_plan_marker', sa.Column('weather_info_id', sa.Integer(), nullable=True))
op.create_foreign_key(None, 'flight_plan_marker', 'weather_info', ['weather_info_id'], ['id'])
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'flight_plan_marker', type_='foreignkey')
op.drop_column('flight_plan_marker', 'weather_info_id')
# ### end Alembic commands ###
+27 -7
View File
@@ -6,19 +6,14 @@ from external.weather import weather_api
from logger import log
async def download_weather(date_time: datetime, flight_id: int, airport_id: int, type_: Literal['landing', 'takeoff']):
async with get_session() as db:
airport = await models.Airport.get_one(db, id=airport_id)
gps = (airport.gps_latitude, airport.gps_longitude)
async def get_weather(date_time: datetime, gps: tuple[float, float]):
try:
weather = await weather_api.get_weather_for_hour(date_time.astimezone(), gps=gps)
log.warning(weather)
except Exception as e:
log.error(f"Error in downloading weather: {e}")
return None
data = {
return {
"datetime": weather['datetime'],
"qnh": weather['pressure_msl'],
"temperature_surface": weather['temperature_2m'],
@@ -30,6 +25,14 @@ async def download_weather(date_time: datetime, flight_id: int, airport_id: int,
"wind_direction_surface": weather['winddirection_10m'],
}
async def download_weather_for_flight(date_time: datetime, flight_id: int, airport_id: int, type_: Literal['landing', 'takeoff']):
async with get_session() as db:
airport = await models.Airport.get_one(db, id=airport_id)
gps = (airport.gps_latitude, airport.gps_longitude)
data = await get_weather(date_time, gps)
async with get_session() as db:
flight = await models.Flight.get_one(db, id=flight_id)
existing_weather_id = getattr(flight, f'{type_}_weather_info_id')
@@ -39,3 +42,20 @@ async def download_weather(date_time: datetime, flight_id: int, airport_id: int,
else:
weather_model = await models.WeatherInfo.create(db, data=data)
await models.Flight.update(db, obj=flight, data={f"{type_}_weather_info_id": weather_model.id})
async def download_weather_for_plan(date_time: datetime, flight_plan_id: int, gps: tuple[float, float]):
data = await get_weather(date_time, gps)
async with get_session() as db:
plan = await models.FlightPlan.get_one(db, id=flight_plan_id)
if plan.weather_info_id:
await models.WeatherInfo.update(db, id=plan.weather_info.id, data=data)
else:
weather_model = await models.WeatherInfo.create(db, data=data)
await models.FlightPlan.update(
db,
obj=plan,
data={"weather_info_id": weather_model.id}
)
+1 -1
View File
@@ -1,6 +1,6 @@
import os
APP_DEBUG = True
APP_DEBUG = os.environ.get("APP_ENV", 'production') == 'development'
GRAPHIQL = True
REFRESH_TOKEN_VALIDITY_DAYS = 30
+2 -2
View File
@@ -128,7 +128,7 @@ class FlightPlanMarker(BaseModel):
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
gps_latitude: Mapped[float] = mapped_column(Float, nullable=False)
gps_longitude: Mapped[float] = mapped_column(Float, nullable=False)
weather_info_id: Mapped[int] = mapped_column(Integer, ForeignKey('weather_info.id'), nullable=True)
class Airport(BaseModel):
__tablename__ = "airport"
@@ -145,7 +145,7 @@ class Airport(BaseModel):
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
created_by_id: Mapped[int] = mapped_column(Integer, ForeignKey('user.id'), nullable=True) # automaticky import nebude mit ID # noqa
runways: Mapped[list['Runway']] = relationship()
runways: Mapped[list['Runway']] = relationship('Runway')
frequencies: Mapped[list['Frequency']] = relationship()
created_by: Mapped['User'] = relationship()
+2
View File
@@ -33,6 +33,8 @@ class LoginEndpoint(AuthEndpoint):
access_token = self.access_security.create_access_token(subject=subject)
refresh_token = self.refresh_security.create_refresh_token(subject=subject)
# TODO: SameSite na refresh_token_cookie!
# https://medium.com/swlh/how-the-new-chrome-80-cookie-rule-samesite-none-secure-affects-web-development-c06380220ced
# self.access_security.set_access_cookie(resp, access_token)
self.refresh_security.set_refresh_cookie(
resp, refresh_token,
-1
View File
@@ -52,7 +52,6 @@ class Weather:
data = await self.download_weather_for_day(date_time.date(), gps)
# TODO: kontrola timezone!
# TODO: interpolace - udelat vazenyprumer z dvou po sobe jdoucich hodin
idx = date_time.hour
result_data = {metric: data['hourly'][metric][idx] for metric in self.METRICS}
result_data['datetime'] = datetime.datetime.strptime(data['hourly']['time'][idx], "%Y-%m-%dT%H:%M")
@@ -4,7 +4,7 @@ from sqlalchemy import delete, insert, select, func, text
from sqlalchemy.ext.asyncio import AsyncSession
from strawberry.file_uploads import Upload
from background_jobs.elevation import add_terrain_elevation_to_flight
from background_jobs.weather import download_weather
from background_jobs.weather import download_weather_for_flight
from database import models
from database.models import flight_has_copilot
from database.transaction import get_session
@@ -155,12 +155,12 @@ class FlightMutationResolver(BaseMutationResolver):
flight = await self._do_create(db, data)
context.background_tasks.add_task(
download_weather,
download_weather_for_flight,
flight_id=flight.id, airport_id=flight.takeoff_airport_id, date_time=flight.takeoff_datetime,
type_="takeoff"
)
context.background_tasks.add_task(
download_weather,
download_weather_for_flight,
flight_id=flight.id, airport_id=flight.landing_airport_id, date_time=flight.landing_datetime,
type_="landing"
)
@@ -193,7 +193,7 @@ class FlightMutationResolver(BaseMutationResolver):
data['takeoff_datetime'] = input.takeoff_datetime or flight_data['takeoff_datetime']
context.background_tasks.add_task(
download_weather, flight_id=id, airport_id=takeoff_airport_id, date_time=data['takeoff_datetime'],
download_weather_for_flight, flight_id=id, airport_id=takeoff_airport_id, date_time=data['takeoff_datetime'],
type_="takeoff"
)
@@ -207,7 +207,7 @@ class FlightMutationResolver(BaseMutationResolver):
data['landing_datetime'] = input.landing_datetime or flight_data['landing_datetime']
context.background_tasks.add_task(
download_weather, flight_id=id, airport_id=landing_airport_id, date_time=data['landing_datetime'],
download_weather_for_flight, flight_id=id, airport_id=landing_airport_id, date_time=data['landing_datetime'],
type_="landing"
)
+4 -9
View File
@@ -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
from paths import get_avatar_url, get_title_image_url, get_photo_thumbnail_url, get_photo_url, FLIGHT_GPX_TRACK_PATH, airport_icon_url
@strawberry.type
@@ -67,14 +67,9 @@ class Airport:
reporting_points: List[ReportingPoint] = strawberry.field(
resolver=lambda root: reporting_points_dataloader.load(root.id)
)
runways: List[Runway] = strawberry.field(
resolver=lambda root: runways_dataloader.load(root.id)
)
frequencies: List[Frequency] = strawberry.field(
resolver=lambda root: frequencies_dataloader.load(root.id)
)
# TODO: loader na ikonu
runways: List[Runway] = strawberry.field(resolver=lambda root: runways_dataloader.load(root.id))
frequencies: List[Frequency] = strawberry.field(resolver=lambda root: frequencies_dataloader.load(root.id))
icon_url: str = strawberry.field(resolver=lambda root: airport_icon_url(root.icao_code))
@strawberry.type
+2 -1
View File
@@ -9,7 +9,7 @@ from starlette.background import BackgroundTasks
from starlette.middleware.cors import CORSMiddleware
from starlette.responses import Response, JSONResponse
from starlette.staticfiles import StaticFiles
from config import APP_SECRET_KEY, ALLOW_CORS_ORIGINS, SENTRY_DSN, REFRESH_TOKEN_VALIDITY_DAYS
from config import APP_SECRET_KEY, ALLOW_CORS_ORIGINS, SENTRY_DSN, REFRESH_TOKEN_VALIDITY_DAYS, APP_DEBUG
from endpoints.contact import ContactEndpoint, ContactInput
from endpoints.forgotten_password import ForgottenPasswordRequest, ForgottenPasswordEndpoint, ChangeForgottenPassword
from endpoints.graphql import setup_graphql_endpoint
@@ -35,6 +35,7 @@ class App:
access_security = JwtAccessBearerCookie(
secret_key=APP_SECRET_KEY,
auto_error=False,
access_expires_delta=timedelta(days=1) if APP_DEBUG else timedelta(minutes=20)
)
refresh_security = JwtRefreshBearerCookie(
secret_key=APP_SECRET_KEY,
+4
View File
@@ -18,6 +18,10 @@ def get_public_url(filename: Optional[str]) -> str:
return f"{API_URL}/uploads/{filename}" if filename else None
def airport_icon_url(icao_code: str) -> str:
return f"{API_URL}/static/airports/{icao_code}.png"
def get_photo_url(root) -> str:
filename = root.filename if not root.filename_extension else f"{root.filename}.{root.filename_extension}"
return get_public_url(f"photos/{root.flight_id}/{filename}?cache={root.cache_key}")
+22
View File
@@ -0,0 +1,22 @@
from sqlalchemy import select
from database import models
from database.transaction import get_session
class FlighPlanWeather:
@staticmethod
async def get_plans() -> list:
async with get_session() as db:
markers_without_weather = (await db.execute(
select(models.FlightPlanMarker).filter(models.FlightPlanMarker.weather_info_id.is_(None))
))
flight_plan_ids = {m.flight_plan_id for m in markers_without_weather}
if not flight_plan_ids:
return []
return (await db.execute(
select(models.FlightPlan, models.FlightPlan.markers)
.filter(models.FlightPlan.id.in_(flight_plan_ids))
))
+192
View File
@@ -0,0 +1,192 @@
import asyncio
import dataclasses
import sys
from collections import defaultdict
from PIL import Image, ImageDraw, ImageFont, ImageFilter
from sqlalchemy import select
sys.path.insert(0, "/app/src")
from database import models
from database.transaction import get_session
@dataclasses.dataclass(frozen=True)
class Runway:
heading: int
surface: str
designator: str
@dataclasses.dataclass()
class Airport:
name: str
runways: list[Runway]
async def get_airports():
async with get_session() as db:
airports_data = (await db.scalars(
select(models.Airport)
# .filter(models.Airport.icao_code.in_(["LKJH", "LKLT", "LKBE"]))
)).all()
runways_data = (await db.execute(
select(models.Runway.designator, models.Runway.heading, models.Runway.surface, models.Runway.airport_id)
.distinct()
.order_by(models.Runway.designator)
))
runways = defaultdict(set)
for rwy in runways_data:
runways[rwy.airport_id].add(rwy)
airports = []
for airport in airports_data:
airport_rwy = runways[airport.id]
if not airport_rwy:
continue
airports.append(
Airport(
name=airport.icao_code,
runways=[Runway(heading=rwy.heading, surface=rwy.surface, designator=rwy.designator) for rwy in airport_rwy]
)
)
return airports
def draw_airport(draw, icon_size):
diameter = icon_size // 2
draw.ellipse(
[(diameter // 2, diameter // 2), (icon_size - diameter // 2, icon_size - diameter // 2)],
fill="#cccccc",
outline="black"
)
return draw
# def make_rectangle(l, w, theta, offset=(0, 0)):
# c, s = math.cos(theta), math.sin(theta)
# rect_coords = [(l / 2.0, w / 2.0), (l / 2.0, -w / 2.0), (-l / 2.0, -w / 2.0), (-l / 2.0, w / 2.0)]
# return [(c * x - s * y + offset[0], s * x + c * y + offset[1]) for (x, y) in rect_coords]
#
# def draw_runway(airport_img, runway: Runway, size):
# if runway.heading > 180:
# return
#
# rwy = Image.new("RGBA", (size, size), (0, 0, 0, 0))
#
# draw = ImageDraw.Draw(rwy)
#
# width = 60
# center = size // 2 - width // 2
#
# colors = {
# "asphalt": "#333333",
# "concrete": "#dddddd",
# "grass": "#00ff00"
# }
#
# draw.rectangle((center, 60, center + width, size-60), fill=colors[runway.surface])
#
# font = ImageFont.truetype('/app/static/Calibri.ttf', 60)
# draw.text((center, size - width), runway.designator, font=font, fill="black")
# rwy = rwy.rotate(-1 * runway.heading, expand=False)
#
# airport_img.paste(rwy, mask=rwy)
# print(f"Kreslim RWY {runway}")
# length = size * 0.9
# width = 40
# vertices = make_rectangle(length, width, theta=runway.heading, offset=(250, 250))
# draw.polygon(vertices, fill="#dddddd", outline="black")
# return draw
def add_edge_border(image, border_size=5, border_color=(255, 0, 0, 255)):
mask = image.split()[3]
edge_mask = mask.filter(ImageFilter.FIND_EDGES)
border_image = Image.new("RGBA", image.size, (0, 0, 0, 0))
border_image.paste(border_color, mask=edge_mask)
for _ in range(border_size - 1):
edge_mask = edge_mask.filter(ImageFilter.MaxFilter(3))
border_image.paste(border_color, mask=edge_mask)
bordered_image = Image.alpha_composite(border_image, image)
return bordered_image
def draw_runways(airport_img, runways: list[Runway], size: int):
rwy_width = 60
rwy_margin = 10
center = size // 2
all_runways_width = len(runways) * rwy_width + (len(runways) - 1) * rwy_margin
start = center - all_runways_width // 2
colors = {
"asphalt": "#555555",
"concrete": "#dddddd",
"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)
for runway in runways:
top_left = (start, 80)
bottom_right = (start + rwy_width, size - 80)
draw.rectangle((*top_left, *bottom_right), fill=colors[runway.surface])
# draw.text((start, size - 60), runway.designator, font=font, fill="black")
start += rwy_width + rwy_margin
heading_sum += runway.heading
rwy = rwy.rotate(-1 * heading_sum // len(runways), expand=False)
airport_img.paste(rwy, mask=rwy)
async def generate_icon(airport: Airport):
icon_size = 500
# creating new Image object
img = Image.new("RGBA", (icon_size, icon_size), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
if len(airport.name) == 4:
draw = draw_airport(draw, icon_size)
parallel_runways = defaultdict(list)
for runway in airport.runways:
key = runway.designator[:2]
parallel_runways[key].append(runway)
for designator, runways in parallel_runways.items():
draw_runways(img, runways, icon_size)
img = add_edge_border(img, border_size=10, border_color=(0, 0, 255))
img = img.resize((40, 40), resample=Image.LANCZOS)
img.save(f'/app/static/airports/{airport.name}.png', 'PNG')
# img.save(f'/app/static/airports/{airport.name}.webp', 'WEBP')
print(airport.name, [(rwy.designator, rwy.surface) for rwy in airport.runways])
async def generate_icons():
airports = await get_airports()
for airport in airports:
await generate_icon(airport)
# return
if __name__ == "__main__":
loop = asyncio.get_event_loop()
loop.run_until_complete(generate_icons())