feat: finish forecast rendering and account-authenticated API explorer

This commit is contained in:
Pigway
2026-10-02 09:27:44 +08:00
parent e67b4ceb6e
commit d10b07c464
8 changed files with 83 additions and 10 deletions
+19 -1
View File
@@ -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"
+21 -4
View File
@@ -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"],))
+4 -2
View File
@@ -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)
+16 -1
View File
@@ -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):
+2 -1
View File
@@ -9,7 +9,8 @@
"photo_id", "photo_id",
"title", "title",
"weather", "weather",
"weekday" "weekday",
"forecast"
], ],
"defaults": { "defaults": {
"title": "天气 / Weather" "title": "天气 / Weather"
+1 -1
View File
@@ -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);};
+10
View File
@@ -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
+10
View File
@@ -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)