From ba5e73108fe5a5d8dbdac3dd70affe99d0fb065b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Kv=C3=A1=C4=8Dek?= Date: Mon, 27 May 2024 22:30:44 +0200 Subject: [PATCH] Stahovani pocasi, drobne upravy --- .gitignore | 2 + ...nfo_to_flight_plan_markers_8a67af3909aa.py | 30 +++ src/background_jobs/weather.py | 34 +++- src/config.py | 2 +- src/database/models.py | 4 +- src/endpoints/login.py | 2 + src/external/weather.py | 1 - .../entities/resolvers/flight.py | 10 +- src/graphql_schema/entities/types/types.py | 13 +- src/main.py | 3 +- src/paths.py | 4 + src/scripts/download_weather.py | 22 ++ src/scripts/generate_airport_icons.py | 192 ++++++++++++++++++ 13 files changed, 293 insertions(+), 26 deletions(-) create mode 100644 alembic/versions/20240516-061913_add_weather_info_to_flight_plan_markers_8a67af3909aa.py create mode 100644 src/scripts/download_weather.py create mode 100644 src/scripts/generate_airport_icons.py diff --git a/.gitignore b/.gitignore index 74f0f2c..4f99362 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ uploads/* !uploads/.gitkeep src/emails/templates/html/* .env +/static/airports/ +/static/airports/.gitkeep diff --git a/alembic/versions/20240516-061913_add_weather_info_to_flight_plan_markers_8a67af3909aa.py b/alembic/versions/20240516-061913_add_weather_info_to_flight_plan_markers_8a67af3909aa.py new file mode 100644 index 0000000..45617c8 --- /dev/null +++ b/alembic/versions/20240516-061913_add_weather_info_to_flight_plan_markers_8a67af3909aa.py @@ -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 ### diff --git a/src/background_jobs/weather.py b/src/background_jobs/weather.py index 22a8dcb..02e6126 100644 --- a/src/background_jobs/weather.py +++ b/src/background_jobs/weather.py @@ -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} + ) diff --git a/src/config.py b/src/config.py index 7d995cc..84e4b99 100644 --- a/src/config.py +++ b/src/config.py @@ -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 diff --git a/src/database/models.py b/src/database/models.py index 38b085f..0d90a74 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -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() diff --git a/src/endpoints/login.py b/src/endpoints/login.py index 216c7d3..7185d11 100644 --- a/src/endpoints/login.py +++ b/src/endpoints/login.py @@ -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, diff --git a/src/external/weather.py b/src/external/weather.py index a0fc0dc..52a2258 100644 --- a/src/external/weather.py +++ b/src/external/weather.py @@ -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") diff --git a/src/graphql_schema/entities/resolvers/flight.py b/src/graphql_schema/entities/resolvers/flight.py index 66c350a..90cbae5 100644 --- a/src/graphql_schema/entities/resolvers/flight.py +++ b/src/graphql_schema/entities/resolvers/flight.py @@ -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" ) diff --git a/src/graphql_schema/entities/types/types.py b/src/graphql_schema/entities/types/types.py index 508a5a4..a8f97f0 100644 --- a/src/graphql_schema/entities/types/types.py +++ b/src/graphql_schema/entities/types/types.py @@ -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 diff --git a/src/main.py b/src/main.py index 6293c2f..0b040d6 100644 --- a/src/main.py +++ b/src/main.py @@ -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, diff --git a/src/paths.py b/src/paths.py index 6e9d569..554f3d0 100644 --- a/src/paths.py +++ b/src/paths.py @@ -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}") diff --git a/src/scripts/download_weather.py b/src/scripts/download_weather.py new file mode 100644 index 0000000..bde288d --- /dev/null +++ b/src/scripts/download_weather.py @@ -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)) + )) + diff --git a/src/scripts/generate_airport_icons.py b/src/scripts/generate_airport_icons.py new file mode 100644 index 0000000..ad1d4b3 --- /dev/null +++ b/src/scripts/generate_airport_icons.py @@ -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())