init
This commit is contained in:
commit
0500266b92
21 changed files with 1766 additions and 0 deletions
46
justfile
Normal file
46
justfile
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
default: build tag push convert set-image deploy
|
||||
fresh: create-ns cred convert deploy viz
|
||||
update: convert patch
|
||||
|
||||
regcred:
|
||||
kubectl get secret -n default regcred --output=yaml -o yaml | sed 's/namespace: default/namespace: play-outside/' | kubectl apply -n play-outside -f - && echo deployed secret || echo secret exists
|
||||
build:
|
||||
podman build -t docker.io/waylonwalker/play-outside -f Dockerfile .
|
||||
tag:
|
||||
podman tag docker.io/waylonwalker/play-outside docker.io/waylonwalker/play-outside:$(hatch version)
|
||||
push:
|
||||
podman push docker.io/waylonwalker/play-outside docker.io/waylonwalker/play-outside:$(hatch version)
|
||||
podman push docker.io/waylonwalker/play-outside docker.io/waylonwalker/play-outside:latest
|
||||
set-image:
|
||||
kubectl set image deployment/play-outside-wayl-one --namespace play-outside play-outside-wayl-one=docker.io/waylonwalker/play-outside:$(hatch version)
|
||||
|
||||
create-ns:
|
||||
kubectl create ns play-outside && echo created ns play-outside || echo namespace play-outside already exists
|
||||
cred:
|
||||
kubectl get secret regcred --output=yaml -o yaml | sed 's/namespace: default/namespace: play-outside/' | kubectl apply -n play-outside -f - && echo deployed secret || echo secret exists
|
||||
convert:
|
||||
kompose convert -o deployment.yaml -n play-outside --replicas 3
|
||||
deploy:
|
||||
kubectl apply -f deployment.yaml
|
||||
delete:
|
||||
kubectl delete all --all -n play-outside --timeout=0s
|
||||
viz:
|
||||
k8sviz -n play-outside --kubeconfig $KUBECONFIG -t png -o play-outside-k8s.png
|
||||
restart:
|
||||
kubectl rollout restart -n play-outside deployment/play-outside-wayl-one
|
||||
patch:
|
||||
kubectl patch -f deployment.yaml
|
||||
|
||||
describe:
|
||||
kubectl get deployment -n play-outside
|
||||
kubectl get rs -n play-outside
|
||||
kubectl get pod -n play-outside
|
||||
kubectl get svc -n play-outside
|
||||
kubectl get ing -n play-outside
|
||||
|
||||
|
||||
describe-pod:
|
||||
kubectl describe pod -n play-outside
|
||||
|
||||
logs:
|
||||
kubectl logs --all-containers -l io.kompose.service=play-outside-wayl-one -n play-outside -f
|
||||
4
play_outside/__about__.py
Normal file
4
play_outside/__about__.py
Normal 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
3
play_outside/__init__.py
Normal 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
270
play_outside/api.py
Normal 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
133
play_outside/cli/api.py
Normal 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
98
play_outside/config.py
Normal 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
104
play_outside/decorators.py
Normal 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
4
play_outside/queries.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import httpx
|
||||
|
||||
|
||||
# get weather from open weather api
|
||||
79
pyproject.toml
Normal file
79
pyproject.toml
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["play_outside"]
|
||||
|
||||
[project]
|
||||
name = "play-outside"
|
||||
dynamic = ["version"]
|
||||
description = 'Use Open Weather Api to determine if Kids can play outside.'
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.8"
|
||||
license = "MIT"
|
||||
keywords = []
|
||||
authors = [{ name = "Waylon S. Walker", email = "waylon@waylonwalker.com" }]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Programming Language :: Python",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: Implementation :: CPython",
|
||||
"Programming Language :: Python :: Implementation :: PyPy",
|
||||
]
|
||||
dependencies = [
|
||||
'httpx',
|
||||
'fastapi',
|
||||
'uvicorn[standard]',
|
||||
'typer',
|
||||
'python-dotenv',
|
||||
'rich',
|
||||
'pydantic-settings',
|
||||
'jinja2',
|
||||
'arel',
|
||||
]
|
||||
|
||||
|
||||
[project.urls]
|
||||
Documentation = "https://github.com/unknown/play-outside#readme"
|
||||
Issues = "https://github.com/unknown/play-outside/issues"
|
||||
Source = "https://github.com/unknown/play-outside"
|
||||
|
||||
[project.scripts]
|
||||
play-outside = "play_outside.cli.api:api_app"
|
||||
|
||||
[tool.hatch.version]
|
||||
path = "play_outside/__about__.py"
|
||||
|
||||
[tool.hatch.envs.default]
|
||||
dependencies = ["coverage[toml]>=6.5", "pytest"]
|
||||
[tool.hatch.envs.default.scripts]
|
||||
test = "pytest {args:tests}"
|
||||
test-cov = "coverage run -m pytest {args:tests}"
|
||||
cov-report = ["- coverage combine", "coverage report"]
|
||||
cov = ["test-cov", "cov-report"]
|
||||
|
||||
[[tool.hatch.envs.all.matrix]]
|
||||
python = ["3.8", "3.9", "3.10", "3.11", "3.12"]
|
||||
|
||||
[tool.hatch.envs.types]
|
||||
dependencies = ["mypy>=1.0.0"]
|
||||
[tool.hatch.envs.types.scripts]
|
||||
check = "mypy --install-types --non-interactive {args:play_outside tests}"
|
||||
|
||||
[tool.coverage.run]
|
||||
source_pkgs = ["play_outside", "tests"]
|
||||
branch = true
|
||||
parallel = true
|
||||
omit = ["play_outside/__about__.py"]
|
||||
|
||||
[tool.coverage.paths]
|
||||
play_outside = ["play_outside", "*/play-outside/play_outside"]
|
||||
tests = ["tests", "*/play-outside/tests"]
|
||||
|
||||
[tool.coverage.report]
|
||||
exclude_lines = ["no cov", "if __name__ == .__main__.:", "if TYPE_CHECKING:"]
|
||||
822
static/app.css
Normal file
822
static/app.css
Normal file
|
|
@ -0,0 +1,822 @@
|
|||
/*
|
||||
! tailwindcss v3.4.1 | MIT License | https://tailwindcss.com
|
||||
*/
|
||||
|
||||
/*
|
||||
1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
|
||||
2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
|
||||
*/
|
||||
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
box-sizing: border-box;
|
||||
/* 1 */
|
||||
border-width: 0;
|
||||
/* 2 */
|
||||
border-style: solid;
|
||||
/* 2 */
|
||||
border-color: #e5e7eb;
|
||||
/* 2 */
|
||||
}
|
||||
|
||||
::before,
|
||||
::after {
|
||||
--tw-content: '';
|
||||
}
|
||||
|
||||
/*
|
||||
1. Use a consistent sensible line-height in all browsers.
|
||||
2. Prevent adjustments of font size after orientation changes in iOS.
|
||||
3. Use a more readable tab size.
|
||||
4. Use the user's configured `sans` font-family by default.
|
||||
5. Use the user's configured `sans` font-feature-settings by default.
|
||||
6. Use the user's configured `sans` font-variation-settings by default.
|
||||
7. Disable tap highlights on iOS
|
||||
*/
|
||||
|
||||
html,
|
||||
:host {
|
||||
line-height: 1.5;
|
||||
/* 1 */
|
||||
-webkit-text-size-adjust: 100%;
|
||||
/* 2 */
|
||||
-moz-tab-size: 4;
|
||||
/* 3 */
|
||||
-o-tab-size: 4;
|
||||
tab-size: 4;
|
||||
/* 3 */
|
||||
font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
/* 4 */
|
||||
font-feature-settings: normal;
|
||||
/* 5 */
|
||||
font-variation-settings: normal;
|
||||
/* 6 */
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
/* 7 */
|
||||
}
|
||||
|
||||
/*
|
||||
1. Remove the margin in all browsers.
|
||||
2. Inherit line-height from `html` so users can set them as a class directly on the `html` element.
|
||||
*/
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
/* 1 */
|
||||
line-height: inherit;
|
||||
/* 2 */
|
||||
}
|
||||
|
||||
/*
|
||||
1. Add the correct height in Firefox.
|
||||
2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
|
||||
3. Ensure horizontal rules are visible by default.
|
||||
*/
|
||||
|
||||
hr {
|
||||
height: 0;
|
||||
/* 1 */
|
||||
color: inherit;
|
||||
/* 2 */
|
||||
border-top-width: 1px;
|
||||
/* 3 */
|
||||
}
|
||||
|
||||
/*
|
||||
Add the correct text decoration in Chrome, Edge, and Safari.
|
||||
*/
|
||||
|
||||
abbr:where([title]) {
|
||||
-webkit-text-decoration: underline dotted;
|
||||
text-decoration: underline dotted;
|
||||
}
|
||||
|
||||
/*
|
||||
Remove the default font size and weight for headings.
|
||||
*/
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
/*
|
||||
Reset links to optimize for opt-in styling instead of opt-out.
|
||||
*/
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
|
||||
/*
|
||||
Add the correct font weight in Edge and Safari.
|
||||
*/
|
||||
|
||||
b,
|
||||
strong {
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
/*
|
||||
1. Use the user's configured `mono` font-family by default.
|
||||
2. Use the user's configured `mono` font-feature-settings by default.
|
||||
3. Use the user's configured `mono` font-variation-settings by default.
|
||||
4. Correct the odd `em` font sizing in all browsers.
|
||||
*/
|
||||
|
||||
code,
|
||||
kbd,
|
||||
samp,
|
||||
pre {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
/* 1 */
|
||||
font-feature-settings: normal;
|
||||
/* 2 */
|
||||
font-variation-settings: normal;
|
||||
/* 3 */
|
||||
font-size: 1em;
|
||||
/* 4 */
|
||||
}
|
||||
|
||||
/*
|
||||
Add the correct font size in all browsers.
|
||||
*/
|
||||
|
||||
small {
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
/*
|
||||
Prevent `sub` and `sup` elements from affecting the line height in all browsers.
|
||||
*/
|
||||
|
||||
sub,
|
||||
sup {
|
||||
font-size: 75%;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
|
||||
sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
|
||||
/*
|
||||
1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
|
||||
2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
|
||||
3. Remove gaps between table borders by default.
|
||||
*/
|
||||
|
||||
table {
|
||||
text-indent: 0;
|
||||
/* 1 */
|
||||
border-color: inherit;
|
||||
/* 2 */
|
||||
border-collapse: collapse;
|
||||
/* 3 */
|
||||
}
|
||||
|
||||
/*
|
||||
1. Change the font styles in all browsers.
|
||||
2. Remove the margin in Firefox and Safari.
|
||||
3. Remove default padding in all browsers.
|
||||
*/
|
||||
|
||||
button,
|
||||
input,
|
||||
optgroup,
|
||||
select,
|
||||
textarea {
|
||||
font-family: inherit;
|
||||
/* 1 */
|
||||
font-feature-settings: inherit;
|
||||
/* 1 */
|
||||
font-variation-settings: inherit;
|
||||
/* 1 */
|
||||
font-size: 100%;
|
||||
/* 1 */
|
||||
font-weight: inherit;
|
||||
/* 1 */
|
||||
line-height: inherit;
|
||||
/* 1 */
|
||||
color: inherit;
|
||||
/* 1 */
|
||||
margin: 0;
|
||||
/* 2 */
|
||||
padding: 0;
|
||||
/* 3 */
|
||||
}
|
||||
|
||||
/*
|
||||
Remove the inheritance of text transform in Edge and Firefox.
|
||||
*/
|
||||
|
||||
button,
|
||||
select {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
/*
|
||||
1. Correct the inability to style clickable types in iOS and Safari.
|
||||
2. Remove default button styles.
|
||||
*/
|
||||
|
||||
button,
|
||||
[type='button'],
|
||||
[type='reset'],
|
||||
[type='submit'] {
|
||||
-webkit-appearance: button;
|
||||
/* 1 */
|
||||
background-color: transparent;
|
||||
/* 2 */
|
||||
background-image: none;
|
||||
/* 2 */
|
||||
}
|
||||
|
||||
/*
|
||||
Use the modern Firefox focus style for all focusable elements.
|
||||
*/
|
||||
|
||||
:-moz-focusring {
|
||||
outline: auto;
|
||||
}
|
||||
|
||||
/*
|
||||
Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
|
||||
*/
|
||||
|
||||
:-moz-ui-invalid {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/*
|
||||
Add the correct vertical alignment in Chrome and Firefox.
|
||||
*/
|
||||
|
||||
progress {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
/*
|
||||
Correct the cursor style of increment and decrement buttons in Safari.
|
||||
*/
|
||||
|
||||
::-webkit-inner-spin-button,
|
||||
::-webkit-outer-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/*
|
||||
1. Correct the odd appearance in Chrome and Safari.
|
||||
2. Correct the outline style in Safari.
|
||||
*/
|
||||
|
||||
[type='search'] {
|
||||
-webkit-appearance: textfield;
|
||||
/* 1 */
|
||||
outline-offset: -2px;
|
||||
/* 2 */
|
||||
}
|
||||
|
||||
/*
|
||||
Remove the inner padding in Chrome and Safari on macOS.
|
||||
*/
|
||||
|
||||
::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
/*
|
||||
1. Correct the inability to style clickable types in iOS and Safari.
|
||||
2. Change font properties to `inherit` in Safari.
|
||||
*/
|
||||
|
||||
::-webkit-file-upload-button {
|
||||
-webkit-appearance: button;
|
||||
/* 1 */
|
||||
font: inherit;
|
||||
/* 2 */
|
||||
}
|
||||
|
||||
/*
|
||||
Add the correct display in Chrome and Safari.
|
||||
*/
|
||||
|
||||
summary {
|
||||
display: list-item;
|
||||
}
|
||||
|
||||
/*
|
||||
Removes the default spacing and border for appropriate elements.
|
||||
*/
|
||||
|
||||
blockquote,
|
||||
dl,
|
||||
dd,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
hr,
|
||||
figure,
|
||||
p,
|
||||
pre {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
legend {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
ol,
|
||||
ul,
|
||||
menu {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
Reset default styling for dialogs.
|
||||
*/
|
||||
|
||||
dialog {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
Prevent resizing textareas horizontally by default.
|
||||
*/
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
/*
|
||||
1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
|
||||
2. Set the default placeholder color to the user's configured gray 400 color.
|
||||
*/
|
||||
|
||||
input::-moz-placeholder, textarea::-moz-placeholder {
|
||||
opacity: 1;
|
||||
/* 1 */
|
||||
color: #9ca3af;
|
||||
/* 2 */
|
||||
}
|
||||
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
opacity: 1;
|
||||
/* 1 */
|
||||
color: #9ca3af;
|
||||
/* 2 */
|
||||
}
|
||||
|
||||
/*
|
||||
Set the default cursor for buttons.
|
||||
*/
|
||||
|
||||
button,
|
||||
[role="button"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/*
|
||||
Make sure disabled buttons don't get the pointer cursor.
|
||||
*/
|
||||
|
||||
:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/*
|
||||
1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)
|
||||
2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
|
||||
This can trigger a poorly considered lint error in some tools but is included by design.
|
||||
*/
|
||||
|
||||
img,
|
||||
svg,
|
||||
video,
|
||||
canvas,
|
||||
audio,
|
||||
iframe,
|
||||
embed,
|
||||
object {
|
||||
display: block;
|
||||
/* 1 */
|
||||
vertical-align: middle;
|
||||
/* 2 */
|
||||
}
|
||||
|
||||
/*
|
||||
Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
|
||||
*/
|
||||
|
||||
img,
|
||||
video {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* Make elements with the HTML hidden attribute stay hidden by default */
|
||||
|
||||
[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
*, ::before, ::after {
|
||||
--tw-border-spacing-x: 0;
|
||||
--tw-border-spacing-y: 0;
|
||||
--tw-translate-x: 0;
|
||||
--tw-translate-y: 0;
|
||||
--tw-rotate: 0;
|
||||
--tw-skew-x: 0;
|
||||
--tw-skew-y: 0;
|
||||
--tw-scale-x: 1;
|
||||
--tw-scale-y: 1;
|
||||
--tw-pan-x: ;
|
||||
--tw-pan-y: ;
|
||||
--tw-pinch-zoom: ;
|
||||
--tw-scroll-snap-strictness: proximity;
|
||||
--tw-gradient-from-position: ;
|
||||
--tw-gradient-via-position: ;
|
||||
--tw-gradient-to-position: ;
|
||||
--tw-ordinal: ;
|
||||
--tw-slashed-zero: ;
|
||||
--tw-numeric-figure: ;
|
||||
--tw-numeric-spacing: ;
|
||||
--tw-numeric-fraction: ;
|
||||
--tw-ring-inset: ;
|
||||
--tw-ring-offset-width: 0px;
|
||||
--tw-ring-offset-color: #fff;
|
||||
--tw-ring-color: rgb(59 130 246 / 0.5);
|
||||
--tw-ring-offset-shadow: 0 0 #0000;
|
||||
--tw-ring-shadow: 0 0 #0000;
|
||||
--tw-shadow: 0 0 #0000;
|
||||
--tw-shadow-colored: 0 0 #0000;
|
||||
--tw-blur: ;
|
||||
--tw-brightness: ;
|
||||
--tw-contrast: ;
|
||||
--tw-grayscale: ;
|
||||
--tw-hue-rotate: ;
|
||||
--tw-invert: ;
|
||||
--tw-saturate: ;
|
||||
--tw-sepia: ;
|
||||
--tw-drop-shadow: ;
|
||||
--tw-backdrop-blur: ;
|
||||
--tw-backdrop-brightness: ;
|
||||
--tw-backdrop-contrast: ;
|
||||
--tw-backdrop-grayscale: ;
|
||||
--tw-backdrop-hue-rotate: ;
|
||||
--tw-backdrop-invert: ;
|
||||
--tw-backdrop-opacity: ;
|
||||
--tw-backdrop-saturate: ;
|
||||
--tw-backdrop-sepia: ;
|
||||
}
|
||||
|
||||
::backdrop {
|
||||
--tw-border-spacing-x: 0;
|
||||
--tw-border-spacing-y: 0;
|
||||
--tw-translate-x: 0;
|
||||
--tw-translate-y: 0;
|
||||
--tw-rotate: 0;
|
||||
--tw-skew-x: 0;
|
||||
--tw-skew-y: 0;
|
||||
--tw-scale-x: 1;
|
||||
--tw-scale-y: 1;
|
||||
--tw-pan-x: ;
|
||||
--tw-pan-y: ;
|
||||
--tw-pinch-zoom: ;
|
||||
--tw-scroll-snap-strictness: proximity;
|
||||
--tw-gradient-from-position: ;
|
||||
--tw-gradient-via-position: ;
|
||||
--tw-gradient-to-position: ;
|
||||
--tw-ordinal: ;
|
||||
--tw-slashed-zero: ;
|
||||
--tw-numeric-figure: ;
|
||||
--tw-numeric-spacing: ;
|
||||
--tw-numeric-fraction: ;
|
||||
--tw-ring-inset: ;
|
||||
--tw-ring-offset-width: 0px;
|
||||
--tw-ring-offset-color: #fff;
|
||||
--tw-ring-color: rgb(59 130 246 / 0.5);
|
||||
--tw-ring-offset-shadow: 0 0 #0000;
|
||||
--tw-ring-shadow: 0 0 #0000;
|
||||
--tw-shadow: 0 0 #0000;
|
||||
--tw-shadow-colored: 0 0 #0000;
|
||||
--tw-blur: ;
|
||||
--tw-brightness: ;
|
||||
--tw-contrast: ;
|
||||
--tw-grayscale: ;
|
||||
--tw-hue-rotate: ;
|
||||
--tw-invert: ;
|
||||
--tw-saturate: ;
|
||||
--tw-sepia: ;
|
||||
--tw-drop-shadow: ;
|
||||
--tw-backdrop-blur: ;
|
||||
--tw-backdrop-brightness: ;
|
||||
--tw-backdrop-contrast: ;
|
||||
--tw-backdrop-grayscale: ;
|
||||
--tw-backdrop-hue-rotate: ;
|
||||
--tw-backdrop-invert: ;
|
||||
--tw-backdrop-opacity: ;
|
||||
--tw-backdrop-saturate: ;
|
||||
--tw-backdrop-sepia: ;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.container {
|
||||
max-width: 640px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.container {
|
||||
max-width: 768px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.container {
|
||||
max-width: 1024px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.container {
|
||||
max-width: 1280px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1536px) {
|
||||
.container {
|
||||
max-width: 1536px;
|
||||
}
|
||||
}
|
||||
|
||||
.mx-auto {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.my-4 {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.my-\[-1rem\] {
|
||||
margin-top: -1rem;
|
||||
margin-bottom: -1rem;
|
||||
}
|
||||
|
||||
.my-\[-4rem\] {
|
||||
margin-top: -4rem;
|
||||
margin-bottom: -4rem;
|
||||
}
|
||||
|
||||
.mb-0 {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.mt-24 {
|
||||
margin-top: 6rem;
|
||||
}
|
||||
|
||||
.ml-\[10rem\] {
|
||||
margin-left: 10rem;
|
||||
}
|
||||
|
||||
.block {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.inline-block {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.max-w-3xl {
|
||||
max-width: 48rem;
|
||||
}
|
||||
|
||||
.max-w-4xl {
|
||||
max-width: 56rem;
|
||||
}
|
||||
|
||||
.list-none {
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
.rounded-3xl {
|
||||
border-radius: 1.5rem;
|
||||
}
|
||||
|
||||
.border-2 {
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.border-green-500 {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(34 197 94 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.border-red-500 {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(239 68 68 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.border-yellow-500 {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(234 179 8 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.bg-green-500 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(34 197 94 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-red-500 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(239 68 68 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-yellow-500 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(234 179 8 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-zinc-900 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(24 24 27 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-opacity-10 {
|
||||
--tw-bg-opacity: 0.1;
|
||||
}
|
||||
|
||||
.bg-gradient-to-r {
|
||||
background-image: linear-gradient(to right, var(--tw-gradient-stops));
|
||||
}
|
||||
|
||||
.from-red-600 {
|
||||
--tw-gradient-from: #dc2626 var(--tw-gradient-from-position);
|
||||
--tw-gradient-to: rgb(220 38 38 / 0) var(--tw-gradient-to-position);
|
||||
--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
|
||||
}
|
||||
|
||||
.via-pink-500 {
|
||||
--tw-gradient-to: rgb(236 72 153 / 0) var(--tw-gradient-to-position);
|
||||
--tw-gradient-stops: var(--tw-gradient-from), #ec4899 var(--tw-gradient-via-position), var(--tw-gradient-to);
|
||||
}
|
||||
|
||||
.to-yellow-400 {
|
||||
--tw-gradient-to: #facc15 var(--tw-gradient-to-position);
|
||||
}
|
||||
|
||||
.bg-clip-text {
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.p-4 {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.pb-0 {
|
||||
padding-bottom: 0px;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-3xl {
|
||||
font-size: 1.875rem;
|
||||
line-height: 2.25rem;
|
||||
}
|
||||
|
||||
.text-8xl {
|
||||
font-size: 6rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.text-xl {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.75rem;
|
||||
}
|
||||
|
||||
.font-black {
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.leading-loose {
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
.leading-tight {
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.text-transparent {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.text-zinc-100 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(244 244 245 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.ring-red-500 {
|
||||
--tw-ring-opacity: 1;
|
||||
--tw-ring-color: rgb(239 68 68 / var(--tw-ring-opacity));
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(39 39 42 / var(--tw-bg-opacity));
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(255 255 255 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
html:-webkit-autofill {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(234 179 8 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
html:autofill {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(234 179 8 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
border-radius: 9999px;
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(24 24 27 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
body::-webkit-scrollbar-track {
|
||||
border-radius: 9999px;
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(219 39 119 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
border-radius: 9999px;
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(82 82 91 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(113 113 122 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
body::-webkit-scrollbar-thumb {
|
||||
border-radius: 9999px;
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(6 182 212 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
body::-webkit-scrollbar-thumb:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(34 211 238 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.autofill\:bg-yellow-500:-webkit-autofill {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(234 179 8 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.autofill\:bg-yellow-500:autofill {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(234 179 8 / var(--tw-bg-opacity));
|
||||
}
|
||||
BIN
static/favicon.ico
Normal file
BIN
static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
1
static/htmx.org@1.9.8
Normal file
1
static/htmx.org@1.9.8
Normal file
File diff suppressed because one or more lines are too long
17
tailwind.config.js
Normal file
17
tailwind.config.js
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ["templates/**/*.html"],
|
||||
plugins: [require("@tailwindcss/typography")],
|
||||
theme: {
|
||||
extend: {
|
||||
width: {
|
||||
128: "32rem",
|
||||
},
|
||||
boxShadow: {
|
||||
xlc: "0 0 60px 15px rgba(0, 0, 0, 0.3)",
|
||||
lgc: "0 0 20px 0px rgba(0, 0, 0, 0.3)",
|
||||
},
|
||||
|
||||
},
|
||||
},
|
||||
};
|
||||
28
tailwind/app.css
Normal file
28
tailwind/app.css
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
@apply bg-zinc-800 text-white autofill:bg-yellow-500;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
@apply h-4 w-4;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@apply rounded-full bg-zinc-900;
|
||||
}
|
||||
|
||||
body::-webkit-scrollbar-track {
|
||||
@apply rounded-full bg-pink-600;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply rounded-full bg-zinc-600 hover:bg-zinc-500;
|
||||
}
|
||||
|
||||
body::-webkit-scrollbar-thumb {
|
||||
@apply rounded-full bg-cyan-500 hover:bg-cyan-400;
|
||||
}
|
||||
49
templates/base.html
Normal file
49
templates/base.html
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
{% block head %}
|
||||
<title>
|
||||
{% block title %}Play Outside{% endblock %}
|
||||
</title>
|
||||
<link rel="icon" type="image/x-icon" href="{{ url_for('get_favicon') }}" />
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="og:title" name="og:title" content="Can I Play Outside" />
|
||||
<meta name="twitter:title" name="twitter:title" content="Can I Play Outside" />
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="og:image" name="og:image"
|
||||
content="https://shots.wayl.one/shot/?url=https://play-outside.wayl.one&height=1080&width=1920&scaled_width=1200&scaled_height=600&selectors=" />
|
||||
<meta name="twitter:image" name="twitter:image"
|
||||
content="https://shots.wayl.one/shot/?url=https://play-outside.wayl.one&height=1080&width=1920&scaled_width=1280&scaled_height=640&selectors=" />
|
||||
<meta name="og:image:height" content="640" />
|
||||
<meta name="og:image:width" content="1280" />
|
||||
<meta name="og:url" name="og:url" content="https://waylonwalker.com" />
|
||||
<meta name="description" name="description"
|
||||
content="Check if my kids can play outside" />
|
||||
<meta name="og:description" name="Check if my kids can play outside"
|
||||
content="Check if my kids can play outside" />
|
||||
<meta name="twitter:description" name="twitter:description"
|
||||
content="Check if my kids can play outside" />
|
||||
|
||||
<link href="{{ url_for('get_app_css') }}" rel="stylesheet" />
|
||||
<!-- <script src="{{ url_for('get_htmx') }}"></script> -->
|
||||
|
||||
{% if config.env == 'local' %}
|
||||
{{ hot_reload.script(url_for('hot-reload') ) | safe }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
</head>
|
||||
<body >
|
||||
<div class='p-4 bg-zinc-900 text-zinc-100 autofill:bg-yellow-500'>
|
||||
<h2 class='mx-auto max-w-4xl'>
|
||||
Disclaimer
|
||||
</h2>
|
||||
<p class='mx-auto max-w-4xl'>
|
||||
I use this site to determine if my kids can play outside, each kid is different, use your own discretion before allowing kids outside.
|
||||
</p>
|
||||
</div>
|
||||
{% block body %}
|
||||
{{ content | safe }}
|
||||
{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
70
templates/card.html
Normal file
70
templates/card.html
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
{% if datetime.fromtimestamp(weather.dt).hour == 6 %}
|
||||
</ul>
|
||||
<hr>
|
||||
<h2 class='block mt-24 text-3xl font-black text-center'>
|
||||
{{ datetime.fromtimestamp(weather.dt).strftime(format = '%A') }}
|
||||
<br>
|
||||
<span class='text-xl'>
|
||||
{{ datetime.fromtimestamp(weather.dt).strftime(format = '%Y-%m-%d') }}
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
|
||||
<ul class='list-none'>
|
||||
{% endif %}
|
||||
<li <p class='text-center'>
|
||||
{{ datetime.fromtimestamp(weather.dt).strftime(format = '%I:%M %p') }}
|
||||
</p>
|
||||
<div
|
||||
class='container p-4 my-4 mx-auto max-w-3xl list-none rounded-3xl border-2 {{ weather.play_condition.color.replace("bg-", "border-") }} {{ weather.play_condition.color }} bg-opacity-10'>
|
||||
<h1 id="title"
|
||||
class="inline-block pb-0 mx-auto mb-0 text-xl text-8xl font-black leading-tight leading-loose text-transparent bg-clip-text bg-gradient-to-r from-red-600 via-pink-500 to-yellow-400 ring-red-500 text-shadow-xl text-shadow-zinc-950 ring-5">
|
||||
|
||||
{{ weather.play_condition.message.replace('.', '<br />') | safe }}
|
||||
</h1>
|
||||
|
||||
<p>
|
||||
{{ weather.name }}
|
||||
</p>
|
||||
<p>
|
||||
Feels Like: {{ weather.main.feels_like }}
|
||||
</p>
|
||||
<ul class='list-none'>
|
||||
{% for w in weather.weather %}
|
||||
<li>
|
||||
<p>
|
||||
{{ w.description }}
|
||||
<img src='https://openweathermap.org/img/wn/{{ w.icon }}.png' class='inline-block my-[-1rem]' />
|
||||
</p>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<p>
|
||||
Air Quality: {{ air_quality.list.0.main.aqi }}
|
||||
</p>
|
||||
<p>
|
||||
Sunset: {{ datetime.fromtimestamp(sunset) }}
|
||||
</p>
|
||||
{% if weather.sys.sunset %}
|
||||
{% if (datetime.fromtimestamp(weather.sys.sunset) - datetime.fromtimestamp(weather.dt)).total_seconds() > 0
|
||||
%}
|
||||
|
||||
<p>
|
||||
Time till sunset: {{ datetime.fromtimestamp(weather.sys.sunset) - datetime.fromtimestamp(weather.dt) }}
|
||||
</p>
|
||||
{% else %}
|
||||
<p>
|
||||
Its after sunset
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<p>
|
||||
Visibility: {{ weather.visibility }}
|
||||
</p>
|
||||
{% if weather.wind.speed > 5 %}
|
||||
<p>
|
||||
Wind Speed: {{ weather.wind.speed }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</li>
|
||||
12
templates/card.txt
Normal file
12
templates/card.txt
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{% set w = int((len(weather.play_condition.message) - len(datetime.fromtimestamp(weather.dt).strftime(format = '%A %I:%M %p')))-1) %}
|
||||
╭─ {{ datetime.fromtimestamp(weather.dt).strftime(format = '%A %I:%M %p')}} {{ '─'*w }}╮
|
||||
│ {%if color %}{{ weather.play_condition.ansi_color }}{%endif%}{{ weather.play_condition.message | safe }}{%if color%}\033[0m{%endif%} │
|
||||
╰─{{ '─'* len(weather.play_condition.message) }}─╯
|
||||
{{ weather.name }}
|
||||
Feels Like: {{ weather.main.feels_like }}
|
||||
Air Quality: {{ air_quality.list.0.main.aqi }}
|
||||
Sunset: {{ datetime.fromtimestamp(sunset).strftime(format = '%I:%M %p') }}
|
||||
Visibility: {{ weather.visibility }}
|
||||
{% if weather.wind.speed > 5 -%}
|
||||
Wind Speed: {{ weather.wind.speed }}
|
||||
{% endif -%}
|
||||
6
templates/includestyles.html
Normal file
6
templates/includestyles.html
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<div class='bg-green-500 border-green-500'>
|
||||
</div>
|
||||
<div class='bg-red-500 border-red-500'>
|
||||
</div>
|
||||
<div class='bg-yellow-500 border-yellow-500'>
|
||||
</div>
|
||||
13
templates/index.html
Normal file
13
templates/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{% extends "base.html" %}
|
||||
{% block body %}
|
||||
<h2 class='block mt-24 text-3xl font-black text-center'>Currently</h2>
|
||||
<ul class='list-none'>
|
||||
{% include "card.html" %}
|
||||
</ul>
|
||||
<h2 class='block mt-24 text-3xl font-black text-center'>5 Day Forecast</h2>
|
||||
<ul class='list-none'>
|
||||
{% for weather in forecast %}
|
||||
{% include "card.html" %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endblock %}
|
||||
4
templates/index.txt
Normal file
4
templates/index.txt
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{% include "card.txt" %}
|
||||
{% for weather in forecast %}
|
||||
{% include "card.txt" %}
|
||||
{% endfor %}
|
||||
3
tests/__init__.py
Normal file
3
tests/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# SPDX-FileCopyrightText: 2024-present Waylon S. Walker <waylon@waylonwalker.com>
|
||||
#
|
||||
# SPDX-License-Identifier: MIT
|
||||
Loading…
Add table
Add a link
Reference in a new issue