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)
def api_docs():
return get_swagger_ui_html(
response = get_swagger_ui_html(
openapi_url="/openapi.json",
title="Pigway Frame Flow · API Docs",
swagger_js_url="/ui/swagger-ui-bundle.js",
@@ -902,6 +902,24 @@ def create_app(settings=None, start_worker=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"
+21 -4
View File
@@ -7,13 +7,15 @@ import secrets
import time
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 pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
COOKIE = "frameflow_session"
cookie = APIKeyCookie(name=COOKIE, auto_error=False, scheme_name="AccountSession")
def password_hash(password: str) -> str:
@@ -153,18 +155,33 @@ def install_accounts(app, db):
raise FlowError(E.UNAUTHORIZED, "Account login required", 401)
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):
row = current(request)
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):
row = current(request)
db.execute("DELETE FROM sessions WHERE token_hash=?", (row["token_hash"],))
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):
session = current(request)
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.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 FlowError
from pigway_frame_flow.storage.database import encode, now_iso
@@ -38,7 +38,9 @@ def seed_client(db, token):
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
if not credentials:
account = session_user(request)
+16 -1
View File
@@ -71,6 +71,13 @@ class TemplateRenderer:
)
if "weather" not in values:
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["weather_attribution"] = (
values.get("weather", {}).get("attribution", "")
@@ -164,7 +171,15 @@ class TemplateRenderer:
if isinstance(value, list):
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]
)
if isinstance(value, dict):
+2 -1
View File
@@ -9,7 +9,8 @@
"photo_id",
"title",
"weather",
"weekday"
"weekday",
"forecast"
],
"defaults": {
"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 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 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'));}};
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);};
+10
View File
@@ -115,3 +115,13 @@ def test_reset_template(client):
client.get("/api/v1/templates/quote").json()["defaults"]["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"]
assert service.settings.image_url_hosts == ["example.com"]
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)