init
This commit is contained in:
commit
0500266b92
21 changed files with 1766 additions and 0 deletions
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue