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)
|
||||
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"
|
||||
|
||||
|
||||
@@ -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"],))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"photo_id",
|
||||
"title",
|
||||
"weather",
|
||||
"weekday"
|
||||
"weekday",
|
||||
"forecast"
|
||||
],
|
||||
"defaults": {
|
||||
"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 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);};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user