This commit is contained in:
Waylon Walker 2024-05-08 20:45:33 -05:00
commit 0500266b92
No known key found for this signature in database
GPG key ID: 66E2BF2B4190EFE4
21 changed files with 1766 additions and 0 deletions

View file

@ -0,0 +1,4 @@
# SPDX-FileCopyrightText: 2024-present Waylon S. Walker <waylon@waylonwalker.com>
#
# SPDX-License-Identifier: MIT
__version__ = "0.0.28"

3
play_outside/__init__.py Normal file
View file

@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2024-present Waylon S. Walker <waylon@waylonwalker.com>
#
# SPDX-License-Identifier: MIT

270
play_outside/api.py Normal file
View file

@ -0,0 +1,270 @@
from dataclasses import dataclass
from fastapi.responses import PlainTextResponse
from fastapi import FastAPI
import time
from datetime import datetime
from fastapi import Request
from fastapi import Header
import httpx
from play_outside.config import get_config
from play_outside.decorators import cache, no_cache
from fastapi.responses import FileResponse
from fastapi.responses import JSONResponse
from fastapi.responses import RedirectResponse
from fastapi import Depends
import arel
def set_prefers(
request: Request,
):
hx_request_header = request.headers.get("hx-request")
user_agent = request.headers.get("user-agent", "").lower()
if "mozilla" in user_agent or "webkit" in user_agent or hx_request_header:
request.state.prefers_html = True
request.state.prefers_json = False
else:
request.state.prefers_html = False
request.state.prefers_json = True
app = FastAPI(
dependencies=[Depends(set_prefers)],
)
config = get_config()
if config.env == "local":
hot_reload = arel.HotReload(
paths=[arel.Path("fokais"), arel.Path("templates"), arel.Path("static")]
)
app.add_websocket_route("/hot-reload", route=hot_reload, name="hot-reload")
app.add_event_handler("startup", hot_reload.startup)
app.add_event_handler("shutdown", hot_reload.shutdown)
config.templates.env.globals["DEBUG"] = True
config.templates.env.globals["hot_reload"] = hot_reload
async def get_lat_long(ip_address):
if ip_address is None:
ip_address = "140.177.140.75"
async with httpx.AsyncClient() as client:
response = await client.get(f"https://ipwho.is/{ip_address}")
return response.json()
async def get_weather(lat_long=None):
if not lat_long:
lat_long = {"latitude": 40.7128, "longitude": -74.0060}
async with httpx.AsyncClient() as client:
response = await client.get(
f"https://api.openweathermap.org/data/2.5/weather?units=imperial&lat={lat_long['latitude']}&lon={lat_long['longitude']}&appid={config.open_weather_api_key}"
)
return response.json()
async def get_forecast(lat_long=None):
if not lat_long:
lat_long = {"latitude": 40.7128, "longitude": -74.0060}
async with httpx.AsyncClient() as client:
response = await client.get(
f"https://api.openweathermap.org/data/2.5/forecast?units=imperial&lat={lat_long['latitude']}&lon={lat_long['longitude']}&appid={config.open_weather_api_key}"
)
return response.json()["list"]
async def get_air_quality(lat_long):
async with httpx.AsyncClient() as client:
response = await client.get(
f"https://api.openweathermap.org/data/2.5/air_pollution?lat={lat_long['latitude']}&lon={lat_long['longitude']}&appid={config.open_weather_api_key}"
)
return response.json()
@app.get("/htmx.org@1.9.8", include_in_schema=False)
@cache()
async def get_htmx(request: Request):
return FileResponse("static/htmx.org@1.9.8")
@app.get("/favicon.ico", include_in_schema=False)
@cache()
async def get_favicon(request: Request):
return FileResponse("static/favicon.ico")
@app.get("/app3.css", include_in_schema=False)
@cache()
async def get_app_css(request: Request):
"""
return app.css for development updates
"""
return FileResponse("static/app.css")
def make_text_response(request, data, color):
template_response = config.templates.TemplateResponse(
"index.txt", {"request": request, "color": color, **data}
)
return PlainTextResponse(template_response.body, status_code=200)
@app.get("/")
@no_cache
async def get_home(request: Request, color: bool = False, n_forecast: int = None):
data = await get_data(request, n_forecast)
print(n_forecast)
print(len(data["forecast"]))
if request.state.prefers_html:
return config.templates.TemplateResponse(
"index.html", {"request": request, **data}
)
else:
return make_text_response(request, data, color=color)
def hours_till_sunset(weather):
if "sys" not in weather:
return ""
if "sunset" not in weather["sys"]:
return ""
sunset = (
datetime.fromtimestamp(weather["sys"]["sunset"])
- datetime.fromtimestamp(weather["dt"])
).total_seconds() / 60
if sunset < 0:
return "it is after sunset"
elif sunset > 120:
return ""
elif sunset > 60:
return f"Time till sunset is {round(int(sunset)/60)} hours. "
else:
return f"Time till sunset is {round(int(sunset))} minutes. "
ANSI_RED = r"\x1b[1;31m"
ANSI_GREEN = r"\x1b[1;32m"
ANSI_YELLOW = r"\x1b[1;33m"
@dataclass
class PlayCondition:
message: str = ""
color: str = "bg-green-500"
ansi_color: str = ANSI_GREEN
def determine_play_condition(weather, aqi=0):
play_condition = PlayCondition()
feels_like_temperature = weather["main"]["feels_like"]
# visibility = weather["visibility"]
play_condition.message += hours_till_sunset(weather)
if "after" in play_condition.message:
play_condition.color = "bg-red-500"
play_condition.ansi_color = ANSI_RED
# if visibility < 1000:
# play_condition.message += "It's too foggy. Find better activities inside!"
# play_condition.color = "bg-red-500"
# play_condition.ansi_color = ANSI_RED
if aqi > 150:
play_condition.message += "It's too polluted. Find better activities inside!"
play_condition.color = "bg-red-500"
play_condition.ansi_color = ANSI_RED
elif aqi > 100:
play_condition.message += "limit your time outside due to the poor air quality"
play_condition.color = "bg-yellow-500"
play_condition.ansi_color = ANSI_YELLOW
elif aqi > 50:
play_condition.message += "Check the air quality outside at your discression."
play_condition.color = "bg-yellow-500"
play_condition.ansi_color = ANSI_YELLOW
else:
play_condition.message += ""
if feels_like_temperature < 10:
play_condition.message += "It's too cold. Stay indoors and keep warm!"
play_condition.color = "bg-red-500"
play_condition.ansi_color = ANSI_RED
elif feels_like_temperature < 30:
play_condition.message += (
"You can play outside, but limit your time in this cold!"
)
play_condition.color = "bg-yellow-500"
play_condition.ansi_color = ANSI_YELLOW
elif feels_like_temperature < 40:
play_condition.message += (
"Coats and winter gear required for outdoor play. Stay cozy!"
)
elif feels_like_temperature < 50:
play_condition.message += "Grab a warm jacket and enjoy your time outside!"
elif feels_like_temperature < 60:
play_condition.message += "Grab some long sleeves and enjoy your time outside!"
elif feels_like_temperature > 90:
play_condition.message += (
"You can play outside, but limit your time in this heat!"
)
play_condition.color = "bg-yellow-500"
play_condition.ansi_color = ANSI_YELLOW
elif feels_like_temperature > 109:
play_condition.message += (
"It's too hot for outdoor play. Find cooler activities indoors!"
)
play_condition.color = "bg-red-500"
play_condition.ansi_color = ANSI_RED
else:
play_condition.message += "Enjoy your time outside!"
return play_condition
async def get_data(request: Request, n_forecast: int = None):
user_ip = request.headers.get("CF-Connecting-IP")
lat_long = await get_lat_long(user_ip)
weather = await get_weather(lat_long)
forecast = await get_forecast(lat_long)
if n_forecast is not None:
forecast = forecast[: n_forecast + 2]
air_quality = await get_air_quality(lat_long)
weather["play_condition"] = determine_play_condition(
weather,
air_quality["list"][0]["main"]["aqi"],
)
forecast = [
{"play_condition": determine_play_condition(x), **x}
for x in forecast
if datetime.fromtimestamp(x["dt"]).hour >= 6
and datetime.fromtimestamp(x["dt"]).hour <= 21
]
return {
"request.client": request.client,
"request.client.host": request.client.host,
"user_ip": user_ip,
"lat_long": lat_long,
"weather": weather,
"forecast": forecast,
"air_quality": air_quality,
"sunset": weather["sys"]["sunset"],
}
@app.get("/metadata")
async def root(
request: Request,
):
return await get_data(request)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8100)

133
play_outside/cli/api.py Normal file
View file

@ -0,0 +1,133 @@
from pathlib import Path
import typer
import uvicorn
from rich.console import Console
from play_outside.config import get_config
api_app = typer.Typer()
@api_app.callback()
def api():
"model cli"
@api_app.command()
def config(
env: str = typer.Option(
help="the environment to use",
),
):
play_outside_config = get_config(env)
Console().print(play_outside_config)
@api_app.command()
def upgrade(
env: str = typer.Option(
help="the environment to use",
),
alembic_revision: str = typer.Option(
"head",
help="the alembic revision to use",
),
):
play_outside_config = get_config(env)
Console().print(play_outside_config)
alembic_cfg = Config("alembic.ini")
alembic_cfg.set_main_option("sqlalchemy.url", play_outside_config.database_url)
alembic.command.upgrade(config=alembic_cfg, revision=alembic_revision)
@api_app.command()
def revision(
env: str = typer.Option(
help="the environment to use",
),
alembic_revision: str = typer.Option(
"head",
help="the alembic revision to upgrade to before creating a new revision",
),
message: str = typer.Option(
None,
"--message",
"-m",
help="the message to use for the new revision",
),
):
play_outside_config = get_config(env)
Console().print(play_outside_config)
alembic_cfg = Config("alembic.ini")
alembic_cfg.set_main_option("sqlalchemy.url", play_outside_config.database_url)
alembic.command.upgrade(config=alembic_cfg, revision=alembic_revision)
alembic.command.revision(
config=alembic_cfg,
message=message,
autogenerate=True,
)
alembic.command.upgrade(config=alembic_cfg, revision="head")
@api_app.command()
def datasette(
env: str = typer.Option(
help="the environment to use",
),
alembic_revision: str = typer.Option(
"head",
help="the alembic revision to use",
),
):
from datasette.app import Datasette
play_outside_config = get_config(env)
Console().print(play_outside_config)
alembic_cfg = Config("alembic.ini")
alembic_cfg.set_main_option("sqlalchemy.url", play_outside_config.database_url)
alembic.command.upgrade(config=alembic_cfg, revision=alembic_revision)
ds = Datasette(
files=[Path(play_outside_config.database_url.replace("sqlite:///", ""))],
)
uvicorn.run(
ds.app(), **play_outside_config.datasette_server.dict(exclude={"app", "reload"})
)
@api_app.command()
def run(
env: str = typer.Option(
help="the environment to use",
),
alembic_revision: str = typer.Option(
"head",
help="the alembic revision to use",
),
):
play_outside_config = get_config(env)
Console().print(play_outside_config)
# alembic_cfg = Config("alembic.ini")
# alembic_cfg.set_main_option("sqlalchemy.url", play_outside_config.database_url)
# alembic.command.upgrade(config=alembic_cfg, revision=alembic_revision)
uvicorn.run(**play_outside_config.api_server.dict())
@api_app.command()
def justrun(
env: str = typer.Option(
"local",
help="the environment to use",
),
alembic_revision: str = typer.Option(
"head",
help="the alembic revision to use",
),
):
play_outside_config = get_config(env)
Console().print(play_outside_config)
uvicorn.run(**play_outside_config.api_server.dict())
if __name__ == "__main__":
api_app()

98
play_outside/config.py Normal file
View file

@ -0,0 +1,98 @@
import pydantic
from dotenv import load_dotenv
from pydantic_settings import BaseSettings
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import BaseModel
from pydantic import Field
from typing import Any
from fastapi.templating import Jinja2Templates
from urllib.parse import quote_plus
from typing import Optional
from datetime import datetime
from datetime import timezone
from functools import lru_cache
import os
import jinja2
from rich.console import Console
console = Console()
class ApiServer(BaseModel):
app: str = "play_outside.api:app"
port: int = 8200
reload: bool = True
log_level: str = "info"
host: str = "0.0.0.0"
workers: int = 1
forwarded_allow_ips: str = "*"
proxy_headers: bool = True
if hasattr(jinja2, "pass_context"):
pass_context = jinja2.pass_context
else:
pass_context = jinja2.contextfunction
@pass_context
def https_url_for(context: dict, name: str, **path_params: Any) -> str:
request = context["request"]
http_url = request.url_for(name, **path_params)
return str(http_url).replace("http", "https", 1)
def get_templates(config: BaseSettings) -> Jinja2Templates:
templates = Jinja2Templates(directory="templates")
templates.env.filters["quote_plus"] = lambda u: quote_plus(str(u))
templates.env.filters["timestamp"] = lambda u: datetime.fromtimestamp(
u, tz=timezone.utc
).strftime("%B %d, %Y")
templates.env.globals["https_url_for"] = https_url_for
templates.env.globals["config"] = config
templates.env.globals["datetime"] = datetime
templates.env.globals["len"] = len
templates.env.globals["int"] = int
console.print(f'Using environment: {os.environ.get("ENV")}')
if os.environ.get("ENV") in ["dev", "qa", "prod"]:
templates.env.globals["url_for"] = https_url_for
console.print("Using HTTPS")
else:
console.print("Using HTTP")
return templates
class Config(BaseSettings):
env: str = "prod"
open_weather_api_key: Optional[str] = None
api_server: ApiServer = ApiServer()
the_templates: Optional[Jinja2Templates] = Field(None, exclude=True)
model_config = SettingsConfigDict(
env_prefix="PLAY_OUTSIDE_",
env_nested_delimiter="__",
case_sensitive=False,
)
@property
def templates(self) -> Jinja2Templates:
if self.the_templates is None:
self.the_templates = get_templates(self)
return self.the_templates
@pydantic.validator("open_weather_api_key", pre=True, always=True)
def validate_open_weather_api_key(cls, v):
if v is None:
v = os.getenv("OPEN_WEATHER_API_KEY")
return v
@lru_cache
def get_config(env: Optional[str] = None):
if env is None:
env = os.environ.get("ENV", "prod")
load_dotenv(dotenv_path=f".env.{env}")
config = Config(env=env)
return config

104
play_outside/decorators.py Normal file
View file

@ -0,0 +1,104 @@
import inspect
import time
from functools import wraps
from inspect import signature
from fastapi import Request
from fastapi.responses import JSONResponse
from play_outside.config import get_config
config = get_config()
admin_routes = []
not_cached_routes = []
cached_routes = []
def not_found(request):
hx_request_header = request.headers.get("hx-request")
user_agent = request.headers.get("user-agent", "").lower()
if "mozilla" in user_agent or "webkit" in user_agent or hx_request_header:
return config.templates.TemplateResponse(
"error.html",
{"status_code": 404, "detail": "Not Found", "request": request},
status_code=404,
)
else:
return JSONResponse(
content={
"status_code": 404,
"detail": "Not Found",
},
status_code=404,
)
def no_cache(func):
not_cached_routes.append(f"{func.__module__}.{func.__name__}")
@wraps(func)
async def wrapper(*args, request: Request, **kwargs):
# my_header will be now available in decorator
if "request" in signature(func).parameters:
kwargs["request"] = request
if inspect.iscoroutinefunction(func):
response = await func(*args, **kwargs)
else:
response = func(*args, **kwargs)
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response
return wrapper
def cache(max_age=86400):
def inner_wrapper(func):
cached_routes.append(f"{func.__module__}.{func.__name__}")
@wraps(func)
async def wrapper(*args, request: Request, **kwargs):
if "request" in signature(func).parameters:
kwargs["request"] = request
if inspect.iscoroutinefunction(func):
response = await func(*args, **kwargs)
else:
response = func(*args, **kwargs)
response.headers[
"Cache-Control"
] = f"public, max-age={max_age}, stale-while-revalidate=31536000, stale-if-error=31536000"
response.headers["Expires"] = f"{int(time.time()) + max_age}"
return response
return wrapper
return inner_wrapper
default_data = {}
def defaults(data=default_data):
def inner_wrapper(func):
default_data[f"{func.__module__}.{func.__name__}"] = data
@wraps(func)
async def wrapper(*args, request: Request, **kwargs):
if "request" in signature(func).parameters:
kwargs["request"] = request
if inspect.iscoroutinefunction(func):
response = await func(*args, **kwargs)
else:
response = func(*args, **kwargs)
return response
return wrapper
return inner_wrapper

4
play_outside/queries.py Normal file
View file

@ -0,0 +1,4 @@
import httpx
# get weather from open weather api