feat: finish forecast rendering and account-authenticated API explorer
This commit is contained in:
@@ -891,7 +891,7 @@ def create_app(settings=None, start_worker=True):
|
|||||||
|
|
||||||
@app.get("/docs", include_in_schema=False)
|
@app.get("/docs", include_in_schema=False)
|
||||||
def api_docs():
|
def api_docs():
|
||||||
return get_swagger_ui_html(
|
response = get_swagger_ui_html(
|
||||||
openapi_url="/openapi.json",
|
openapi_url="/openapi.json",
|
||||||
title="Pigway Frame Flow · API Docs",
|
title="Pigway Frame Flow · API Docs",
|
||||||
swagger_js_url="/ui/swagger-ui-bundle.js",
|
swagger_js_url="/ui/swagger-ui-bundle.js",
|
||||||
@@ -902,6 +902,24 @@ def create_app(settings=None, start_worker=True):
|
|||||||
"displayRequestDuration": True,
|
"displayRequestDuration": True,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
html = (
|
||||||
|
bytes(response.body)
|
||||||
|
.decode()
|
||||||
|
.replace(
|
||||||
|
"SwaggerUIBundle({",
|
||||||
|
"""SwaggerUIBundle({
|
||||||
|
requestInterceptor: async (req) => {
|
||||||
|
if (!['GET','HEAD'].includes(req.method.toUpperCase())) {
|
||||||
|
const session = await fetch('/api/v1/auth/me');
|
||||||
|
if (session.ok) req.headers['X-CSRF-Token'] = (await session.json()).csrf_token;
|
||||||
|
}
|
||||||
|
return req;
|
||||||
|
},""",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
|
||||||
|
return HTMLResponse(html)
|
||||||
|
|
||||||
root = Path(__file__).resolve().parent.parent / "web"
|
root = Path(__file__).resolve().parent.parent / "web"
|
||||||
|
|
||||||
|
|||||||
@@ -7,13 +7,15 @@ import secrets
|
|||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import Request, Response
|
from fastapi import Request, Response, Security
|
||||||
|
from fastapi.security import APIKeyCookie
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from pigway_frame_flow.errors import ErrorCode as E
|
from pigway_frame_flow.errors import ErrorCode as E
|
||||||
from pigway_frame_flow.errors import FlowError
|
from pigway_frame_flow.errors import FlowError
|
||||||
|
|
||||||
COOKIE = "frameflow_session"
|
COOKIE = "frameflow_session"
|
||||||
|
cookie = APIKeyCookie(name=COOKIE, auto_error=False, scheme_name="AccountSession")
|
||||||
|
|
||||||
|
|
||||||
def password_hash(password: str) -> str:
|
def password_hash(password: str) -> str:
|
||||||
@@ -153,18 +155,33 @@ def install_accounts(app, db):
|
|||||||
raise FlowError(E.UNAUTHORIZED, "Account login required", 401)
|
raise FlowError(E.UNAUTHORIZED, "Account login required", 401)
|
||||||
return row
|
return row
|
||||||
|
|
||||||
@app.get("/api/v1/auth/me", response_model=AccountView, tags=["Accounts"])
|
@app.get(
|
||||||
|
"/api/v1/auth/me",
|
||||||
|
response_model=AccountView,
|
||||||
|
tags=["Accounts"],
|
||||||
|
dependencies=[Security(cookie)],
|
||||||
|
)
|
||||||
def me(request: Request):
|
def me(request: Request):
|
||||||
row = current(request)
|
row = current(request)
|
||||||
return AccountView(username=row["username"], csrf_token=row["csrf"])
|
return AccountView(username=row["username"], csrf_token=row["csrf"])
|
||||||
|
|
||||||
@app.post("/api/v1/auth/logout", status_code=204, tags=["Accounts"])
|
@app.post(
|
||||||
|
"/api/v1/auth/logout",
|
||||||
|
status_code=204,
|
||||||
|
tags=["Accounts"],
|
||||||
|
dependencies=[Security(cookie)],
|
||||||
|
)
|
||||||
def logout(request: Request, response: Response):
|
def logout(request: Request, response: Response):
|
||||||
row = current(request)
|
row = current(request)
|
||||||
db.execute("DELETE FROM sessions WHERE token_hash=?", (row["token_hash"],))
|
db.execute("DELETE FROM sessions WHERE token_hash=?", (row["token_hash"],))
|
||||||
response.delete_cookie(COOKIE, path="/")
|
response.delete_cookie(COOKIE, path="/")
|
||||||
|
|
||||||
@app.put("/api/v1/auth/password", status_code=204, tags=["Accounts"])
|
@app.put(
|
||||||
|
"/api/v1/auth/password",
|
||||||
|
status_code=204,
|
||||||
|
tags=["Accounts"],
|
||||||
|
dependencies=[Security(cookie)],
|
||||||
|
)
|
||||||
def change_password(payload: PasswordChange, request: Request, response: Response):
|
def change_password(payload: PasswordChange, request: Request, response: Response):
|
||||||
session = current(request)
|
session = current(request)
|
||||||
user = db.one("SELECT * FROM users WHERE username=?", (session["username"],))
|
user = db.one("SELECT * FROM users WHERE username=?", (session["username"],))
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import json
|
|||||||
from fastapi import Request, Security
|
from fastapi import Request, Security
|
||||||
from fastapi.security import HTTPBearer
|
from fastapi.security import HTTPBearer
|
||||||
|
|
||||||
from pigway_frame_flow.core.accounts import session_user
|
from pigway_frame_flow.core.accounts import cookie, session_user
|
||||||
from pigway_frame_flow.errors import ErrorCode as E
|
from pigway_frame_flow.errors import ErrorCode as E
|
||||||
from pigway_frame_flow.errors import FlowError
|
from pigway_frame_flow.errors import FlowError
|
||||||
from pigway_frame_flow.storage.database import encode, now_iso
|
from pigway_frame_flow.storage.database import encode, now_iso
|
||||||
@@ -38,7 +38,9 @@ def seed_client(db, token):
|
|||||||
|
|
||||||
|
|
||||||
def require(scope):
|
def require(scope):
|
||||||
def authorize(request: Request, credentials=Security(bearer)):
|
def authorize(
|
||||||
|
request: Request, credentials=Security(bearer), session_cookie=Security(cookie)
|
||||||
|
):
|
||||||
db = request.app.state.service.db
|
db = request.app.state.service.db
|
||||||
if not credentials:
|
if not credentials:
|
||||||
account = session_user(request)
|
account = session_user(request)
|
||||||
|
|||||||
@@ -71,6 +71,13 @@ class TemplateRenderer:
|
|||||||
)
|
)
|
||||||
if "weather" not in values:
|
if "weather" not in values:
|
||||||
values["weather"] = self.weather.get_current()
|
values["weather"] = self.weather.get_current()
|
||||||
|
if (
|
||||||
|
any(b.get("field") == "forecast" for b in spec["blocks"])
|
||||||
|
and "forecast" not in values
|
||||||
|
):
|
||||||
|
values["forecast"] = self.weather.get_forecast() or [
|
||||||
|
"尚无天气预报 / No forecast available"
|
||||||
|
]
|
||||||
values["updated"] = current.strftime("%Y-%m-%d %H:%M")
|
values["updated"] = current.strftime("%Y-%m-%d %H:%M")
|
||||||
values["weather_attribution"] = (
|
values["weather_attribution"] = (
|
||||||
values.get("weather", {}).get("attribution", "")
|
values.get("weather", {}).get("attribution", "")
|
||||||
@@ -164,7 +171,15 @@ class TemplateRenderer:
|
|||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
value = "\n".join(
|
value = "\n".join(
|
||||||
"• "
|
"• "
|
||||||
+ (str(v.get("title", "")) if isinstance(v, dict) else str(v))
|
+ (
|
||||||
|
(
|
||||||
|
f"{v.get('date', '')} · {v.get('summary', '')} · {v.get('minimum_c', '--')}–{v.get('maximum_c', '--')}°C · {v.get('rain_probability', '--')}%"
|
||||||
|
if block.get("field") == "forecast"
|
||||||
|
else str(v.get("title", ""))
|
||||||
|
)
|
||||||
|
if isinstance(v, dict)
|
||||||
|
else str(v)
|
||||||
|
)
|
||||||
for v in value[:10]
|
for v in value[:10]
|
||||||
)
|
)
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
"photo_id",
|
"photo_id",
|
||||||
"title",
|
"title",
|
||||||
"weather",
|
"weather",
|
||||||
"weekday"
|
"weekday",
|
||||||
|
"forecast"
|
||||||
],
|
],
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"title": "天气 / Weather"
|
"title": "天气 / Weather"
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ async function templateEditor(selected){
|
|||||||
const labels={title:state.lang==='zh'?'标题':'Title',message:state.lang==='zh'?'正文':'Message',quote:state.lang==='zh'?'一句话':'Quote',author:state.lang==='zh'?'作者':'Author',events:t('eventsHelp'),date:t('date'),weekday:state.lang==='zh'?'星期(留空自动)':'Weekday (blank for automatic)',weather:state.lang==='zh'?'天气数据(JSON,可留空使用 Provider)':'Weather JSON (blank uses provider)'};
|
const labels={title:state.lang==='zh'?'标题':'Title',message:state.lang==='zh'?'正文':'Message',quote:state.lang==='zh'?'一句话':'Quote',author:state.lang==='zh'?'作者':'Author',events:t('eventsHelp'),date:t('date'),weekday:state.lang==='zh'?'星期(留空自动)':'Weekday (blank for automatic)',weather:state.lang==='zh'?'天气数据(JSON,可留空使用 Provider)':'Weather JSON (blank uses provider)'};
|
||||||
const fields=[...new Set(['title',...selected.fields.filter(k=>Object.hasOwn(selected.defaults,k)),...(['daily-dashboard','calendar'].includes(selected.id)?['events','date','weekday']:[]),...(selected.id==='weather'||selected.id==='daily-dashboard'?['weather']:[]),...(['photo-text','daily-dashboard'].includes(selected.id)?['photo_id']:[])])];
|
const fields=[...new Set(['title',...selected.fields.filter(k=>Object.hasOwn(selected.defaults,k)),...(['daily-dashboard','calendar'].includes(selected.id)?['events','date','weekday']:[]),...(selected.id==='weather'||selected.id==='daily-dashboard'?['weather']:[]),...(['photo-text','daily-dashboard'].includes(selected.id)?['photo_id']:[])])];
|
||||||
function invalidate(){state.templatePreview=null;$('#display-template').disabled=true;$('#template-preview').className='empty';$('#template-preview').textContent=t('previewHint');}
|
function invalidate(){state.templatePreview=null;$('#display-template').disabled=true;$('#template-preview').className='empty';$('#template-preview').textContent=t('previewHint');}
|
||||||
function build(){panel.innerHTML=`<h3>${t('formEditor')}</h3>`+fields.map(k=>k==='photo_id'?`<div class="field"><label for="field-photo_id">${t('photo')}</label><select id="field-photo_id" data-field="photo_id"><option value="">${t('noPhoto')}</option>${assets.map(a=>`<option value="${esc(a.asset_id)}" ${a.asset_id===values.photo_id?'selected':''}>${esc(a.name)}</option>`).join('')}</select></div>`:`<div class="field"><label for="field-${k}">${esc(labels[k]||k)}</label><textarea id="field-${k}" data-field="${k}" rows="${['events','message','weather','quote'].includes(k)?3:1}">${esc(k==='events'?(values[k]||[]).map(v=>typeof v==='object'?v.title:v).join('\n'):k==='weather'?(values[k]?JSON.stringify(values[k],null,2):''):values[k]||'')}</textarea></div>`).join('');panel.querySelectorAll('[data-field]').forEach(el=>el.oninput=()=>{try{const k=el.dataset.field;let v=el.value;if(k==='events')v=v.split('\n').filter(x=>x.trim());if(k==='weather')v=v.trim()?JSON.parse(v):null;if(v===null||v==='')delete values[k];else values[k]=v;raw.value=JSON.stringify(values,null,2);el.setCustomValidity('');invalidate();}catch{el.setCustomValidity(t('INVALID_REQUEST'));el.reportValidity();}});}
|
function build(){panel.innerHTML=`<h3>${t('formEditor')}</h3>`+fields.map(k=>k==='photo_id'?`<div class="field"><label for="field-photo_id">${t('photo')}</label><select id="field-photo_id" data-field="photo_id"><option value="">${t('noPhoto')}</option>${assets.map(a=>`<option value="${esc(a.asset_id)}" ${a.asset_id===values.photo_id?'selected':''}>${esc(a.name)}</option>`).join('')}</select></div>`:`<div class="field"><label for="field-${k}">${esc(labels[k]||k)}</label><textarea id="field-${k}" data-field="${k}" rows="${['events','message','weather','quote'].includes(k)?3:1}">${esc(k==='events'?(values[k]||[]).map(v=>typeof v==='object'?v.title:v).join('\n'):['weather','forecast'].includes(k)?(values[k]?JSON.stringify(values[k],null,2):''):values[k]||'')}</textarea></div>`).join('');panel.querySelectorAll('[data-field]').forEach(el=>el.oninput=()=>{try{const k=el.dataset.field;let v=el.value;if(k==='events')v=v.split('\n').filter(x=>x.trim());if(['weather','forecast'].includes(k))v=v.trim()?JSON.parse(v):null;if(v===null||v==='')delete values[k];else values[k]=v;raw.value=JSON.stringify(values,null,2);el.setCustomValidity('');invalidate();}catch{el.setCustomValidity(t('INVALID_REQUEST'));el.reportValidity();}});}
|
||||||
build();const advanced=document.createElement('details');advanced.innerHTML=`<summary>${t('advanced')}</summary>`;raw.before(advanced);advanced.append(raw);raw.oninput=()=>{invalidate();try{values=JSON.parse(raw.value);if(!values||typeof values!=='object'||Array.isArray(values))throw Error();raw.setCustomValidity('');build();}catch{raw.setCustomValidity(t('INVALID_REQUEST'));}};
|
build();const advanced=document.createElement('details');advanced.innerHTML=`<summary>${t('advanced')}</summary>`;raw.before(advanced);advanced.append(raw);raw.oninput=()=>{invalidate();try{values=JSON.parse(raw.value);if(!values||typeof values!=='object'||Array.isArray(values))throw Error();raw.setCustomValidity('');build();}catch{raw.setCustomValidity(t('INVALID_REQUEST'));}};
|
||||||
const reset=document.createElement('button');reset.id='reset-template';reset.textContent=t('resetTemplate');$('#save-template').after(reset);bind('reset-template',()=>confirmDialog(t('resetConfirm'),async()=>{await api('/api/v1/templates/'+selected.id+'/config',{method:'DELETE'});await navigate();}));
|
const reset=document.createElement('button');reset.id='reset-template';reset.textContent=t('resetTemplate');$('#save-template').after(reset);bind('reset-template',()=>confirmDialog(t('resetConfirm'),async()=>{await api('/api/v1/templates/'+selected.id+'/config',{method:'DELETE'});await navigate();}));
|
||||||
const save=$('#save-template');const oldSave=save.onclick;save.onclick=async e=>{if([...panel.querySelectorAll('textarea'),raw].some(x=>!x.checkValidity())){toast(t('INVALID_REQUEST'));return;}await oldSave(e);};
|
const save=$('#save-template');const oldSave=save.onclick;save.onclick=async e=>{if([...panel.querySelectorAll('textarea'),raw].some(x=>!x.checkValidity())){toast(t('INVALID_REQUEST'));return;}await oldSave(e);};
|
||||||
|
|||||||
@@ -115,3 +115,13 @@ def test_reset_template(client):
|
|||||||
client.get("/api/v1/templates/quote").json()["defaults"]["title"]
|
client.get("/api/v1/templates/quote").json()["defaults"]["title"]
|
||||||
!= "Saved title"
|
!= "Saved title"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_openapi_describes_cookie_auth_and_docs_csrf(client):
|
||||||
|
schema = client.get("/openapi.json").json()
|
||||||
|
cookie = schema["components"]["securitySchemes"]["AccountSession"]
|
||||||
|
assert cookie["in"] == "cookie" and cookie["name"] == "frameflow_session"
|
||||||
|
assert {"AccountSession": []} in schema["paths"]["/api/v1/devices"]["get"][
|
||||||
|
"security"
|
||||||
|
]
|
||||||
|
assert "X-CSRF-Token" in client.get("/docs").text
|
||||||
|
|||||||
@@ -125,3 +125,13 @@ def test_image_url_allowlist_is_persistent_and_restrictive(client, service):
|
|||||||
).json()["allowed_hosts"] == ["example.com"]
|
).json()["allowed_hosts"] == ["example.com"]
|
||||||
assert service.settings.image_url_hosts == ["example.com"]
|
assert service.settings.image_url_hosts == ["example.com"]
|
||||||
assert service.db.one("SELECT payload FROM app_config WHERE key='image_url_hosts'")
|
assert service.db.one("SELECT payload FROM app_config WHERE key='image_url_hosts'")
|
||||||
|
|
||||||
|
|
||||||
|
def test_weather_template_resolves_forecast(service):
|
||||||
|
provider = ConfigurableWeatherProvider(service.db, transport())
|
||||||
|
provider.configure(WeatherConfig(provider="open-meteo"))
|
||||||
|
service.renderer.weather = provider
|
||||||
|
snapshot = service.renderer.resolve("weather", {})
|
||||||
|
assert snapshot["forecast"][0]["maximum_c"] == 26
|
||||||
|
assert snapshot["weather_attribution"] == "Open-Meteo / CC BY 4.0"
|
||||||
|
assert service.renderer.render("weather", snapshot)
|
||||||
|
|||||||
Reference in New Issue
Block a user