8884e100f9
Overlay 190 upstream items (V8.22→8.31) preserving trade-pro theme, registry tests, and design docs. Re-applied full patch set on identical anchors (discovery/env_for/blocks.css/type-mapping/locale-override/ theme_color/frontend entries/test_theme_pro_v3 mods) plus compose pins. Verified: theme suites 35/35; dual-baseline full run 415 passed vs pristine 386+29 ours, FAILED-set diff EMPTY (same 10 Windows env fails). Live: container 8.31.1-dev.1 healthy, hesupipe 192 entries intact, trade-pro build 203 pages 0 errors. Co-Authored-By: Claude Code <noreply@anthropic.com>
403 lines
22 KiB
Python
403 lines
22 KiB
Python
"""Whole-site header/footer/navigation/responsive drafts and publications.
|
|
|
|
Only site-design `publish` changes the source of static builds. A draft is
|
|
versioned and fenced against edits to the underlying site settings.
|
|
"""
|
|
from __future__ import annotations
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import uuid
|
|
from fastapi import APIRouter, Body, HTTPException, Request
|
|
from . import db, enterprise
|
|
from .core import route_path
|
|
|
|
router=APIRouter(prefix='/api/sites',tags=['V8.7 site design'])
|
|
DEFAULTS={
|
|
'header':{'announcement':'','show_announcement':False,'show_search':True,
|
|
'show_cta':True,'cta_text':'联系我们','cta_href':'/contact/'},
|
|
'footer':{'title':'','description':'','email':'','show_nav':True},
|
|
'nav':[],
|
|
'mobile_tabs':{'enabled':False,'items':[]},
|
|
'tokens':{'font':'system','heading_scale':1.0,'radius':12,'section_spacing':72,
|
|
'container_width':1200,'button_style':'rounded','header_style':'standard'},
|
|
'responsive':{'tablet_gap':64,'mobile_gap':40,'mobile_hide_announcement':True,
|
|
'mobile_hide_footer_links':False,'mobile_container_padding':20},
|
|
}
|
|
|
|
def require_legacy_enabled():
|
|
"""Block historical direct-to-live design writes unless explicitly enabled.
|
|
|
|
Operators migrating an old automation may temporarily enable this escape
|
|
hatch; the V8.7 editor never uses it.
|
|
"""
|
|
if os.environ.get('SITEFORGE_ALLOW_LEGACY_DIRECT_DESIGN_WRITE') != '1':
|
|
raise HTTPException(409,'旧版直接修改正式设计的接口已停用;请使用 site-design 或 studio-draft 草稿发布流程')
|
|
|
|
|
|
def setup():
|
|
with db.connect() as c:
|
|
c.execute('''CREATE TABLE IF NOT EXISTS site_design_drafts (
|
|
site_id TEXT PRIMARY KEY, revision INTEGER NOT NULL, base_hash TEXT NOT NULL,
|
|
config_json TEXT NOT NULL, updated_at TEXT NOT NULL)''')
|
|
c.execute('''CREATE TABLE IF NOT EXISTS site_design_publications (
|
|
id TEXT PRIMARY KEY, site_id TEXT NOT NULL, actor TEXT NOT NULL,
|
|
revision INTEGER NOT NULL, old_config_json TEXT NOT NULL,
|
|
new_config_json TEXT NOT NULL, published_at TEXT NOT NULL)''')
|
|
|
|
def _site(site_id):
|
|
row=db.one('SELECT * FROM sites WHERE id=?',(site_id,))
|
|
if not row:raise HTTPException(404,'网站不存在')
|
|
return row
|
|
|
|
def _settings(row):
|
|
return json.loads(row['settings_json'] or '{}')
|
|
|
|
def _hash(settings):
|
|
return hashlib.sha256(json.dumps(settings,sort_keys=True,ensure_ascii=False).encode()).hexdigest()
|
|
|
|
def _url(value):
|
|
if not isinstance(value,str) or len(value)>350 or any(x in value for x in ('<','>','"',"'",'\\','\n','\r','\t')):
|
|
raise HTTPException(400,'不安全的网址')
|
|
if value.startswith('/') and not value.startswith('//'):
|
|
try:return route_path(value)
|
|
except ValueError:raise HTTPException(400,'不合法的站内链接')
|
|
# External links are intentionally not allowed inside site chrome; the
|
|
# enterprise's external CTA can be built with a vetted form-link block.
|
|
raise HTTPException(400,'全站导航只允许以 / 开头的站内路径')
|
|
|
|
def _nav(rows,depth=0,counter=None):
|
|
if counter is None:counter=[0]
|
|
if not isinstance(rows,list) or len(rows)>18:raise HTTPException(400,'导航项过多')
|
|
result=[]
|
|
for x in rows:
|
|
counter[0]+=1
|
|
if counter[0]>48 or not isinstance(x,dict):raise HTTPException(400,'导航结构不合法')
|
|
if set(x)-{'title','route','children'}:raise HTTPException(400,'导航出现未支持的字段')
|
|
title=x.get('title')
|
|
if not isinstance(title,str) or not title.strip() or len(title)>48:raise HTTPException(400,'导航名称不合法')
|
|
children=x.get('children',[])
|
|
if depth==2 and children:raise HTTPException(400,'最多支持三级导航')
|
|
result.append({'title':title.strip(),'route':_url(x.get('route','/')),
|
|
'children':_nav(children,depth+1,counter) if depth<2 else []})
|
|
return result
|
|
|
|
def _mobile_tab_url(value):
|
|
"""Allow site-internal routes plus safe, explicit user-facing contact links."""
|
|
from urllib.parse import urlsplit
|
|
if not isinstance(value,str) or not value or len(value)>350 or any(c in value for c in ('<','>','"',"'",'\\','\n','\r','\t')):
|
|
raise HTTPException(400,'底部导航网址不安全')
|
|
if value.startswith('/') and not value.startswith('//'):
|
|
return _url(value)
|
|
if re.fullmatch(r'tel:\+?[0-9()\- ]{5,30}',value):
|
|
return value
|
|
if re.fullmatch(r'mailto:[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}',value):
|
|
return value
|
|
parsed=urlsplit(value)
|
|
if parsed.scheme=='https' and parsed.hostname and not parsed.username and not parsed.password and not any(c.isspace() for c in value):
|
|
return value
|
|
raise HTTPException(400,'仅支持站内路径、HTTPS、电话或邮箱链接')
|
|
|
|
def validate(raw):
|
|
if not isinstance(raw,dict) or set(raw)-set(DEFAULTS):raise HTTPException(400,'未知的全站设计设置')
|
|
normalized={}
|
|
for group in ('header','footer','responsive'):
|
|
val=raw.get(group,{})
|
|
if not isinstance(val,dict) or set(val)-set(DEFAULTS[group]):raise HTTPException(400,'无效的全站设置组: '+group)
|
|
normalized[group]={**DEFAULTS[group],**val}
|
|
header=normalized['header'];footer=normalized['footer'];mobile=normalized['responsive']
|
|
for key in ('show_announcement','show_search','show_cta'):
|
|
if type(header[key]) is not bool:raise HTTPException(400,'无效的页眉开关')
|
|
for key in ('show_nav',):
|
|
if type(footer[key]) is not bool:raise HTTPException(400,'无效的页脚开关')
|
|
for key in ('mobile_hide_announcement','mobile_hide_footer_links'):
|
|
if type(mobile[key]) is not bool:raise HTTPException(400,'无效的移动端开关')
|
|
for field,cap in [('announcement',140),('cta_text',40)]:
|
|
if not isinstance(header[field],str) or len(header[field])>cap:raise HTTPException(400,'页眉文字超限')
|
|
for field,cap in [('title',120),('description',650),('email',150)]:
|
|
if not isinstance(footer[field],str) or len(footer[field])>cap:raise HTTPException(400,'页脚文字超限')
|
|
header['cta_href']=_url(header['cta_href'])
|
|
for field,lo,hi in [('tablet_gap',20,120),('mobile_gap',16,100),('mobile_container_padding',8,48)]:
|
|
n=mobile[field]
|
|
if type(n) is not int or not lo<=n<=hi:raise HTTPException(400,'设备间距超出范围: '+field)
|
|
tokens=raw.get('tokens',{})
|
|
if not isinstance(tokens,dict) or set(tokens)-set(DEFAULTS['tokens']):
|
|
raise HTTPException(400,'未知设计规范字段')
|
|
tokens={**DEFAULTS['tokens'],**tokens}
|
|
for field,options in [('font',('system','serif','modern')),
|
|
('button_style',('rounded','square','pill')),('header_style',('standard','compact'))]:
|
|
if tokens[field] not in options:raise HTTPException(400,'不支持的设计规范: '+field)
|
|
for field,lo,hi in [('heading_scale',.75,1.5),('radius',0,30),
|
|
('section_spacing',32,140),('container_width',900,1600)]:
|
|
v=tokens[field]
|
|
if type(v) not in (float,int) or not lo<=v<=hi:raise HTTPException(400,'设计变量超出范围: '+field)
|
|
normalized['tokens']=tokens
|
|
normalized['nav']=_nav(raw.get('nav',[]))
|
|
bar=raw.get('mobile_tabs',{})
|
|
if not isinstance(bar,dict) or set(bar)-{'enabled','items'}:
|
|
raise HTTPException(400,'底部导航配置格式不正确')
|
|
enabled=bar.get('enabled',False)
|
|
items=bar.get('items',[])
|
|
if type(enabled) is not bool or not isinstance(items,list) or len(items)>5:
|
|
raise HTTPException(400,'底部导航仅支持 0 至 5 项')
|
|
allowed_icons={'home','grid','products','news','contact','phone','info','search','menu','star'}
|
|
result=[]
|
|
for item in items:
|
|
if not isinstance(item,dict) or set(item)-{'title','route','icon','action'}:
|
|
raise HTTPException(400,'底部导航项目格式不正确')
|
|
title=item.get('title')
|
|
icon=item.get('icon','grid')
|
|
action=item.get('action','link')
|
|
if not isinstance(title,str) or not 1<=len(title.strip())<=12:
|
|
raise HTTPException(400,'底部导航标题须为 1 至 12 字')
|
|
if icon not in allowed_icons or action not in ('link','menu'):
|
|
raise HTTPException(400,'底部导航图标或操作不受支持')
|
|
if action=='menu':
|
|
route='/'
|
|
else:
|
|
route=_mobile_tab_url(item.get('route','/'))
|
|
result.append({'title':title.strip(),'icon':icon,'route':route,'action':action})
|
|
if enabled and not result:
|
|
raise HTTPException(400,'启用底部导航时至少添加一项;不需要可直接关闭')
|
|
normalized['mobile_tabs']={'enabled':enabled,'items':result}
|
|
if len(json.dumps(normalized,ensure_ascii=False))>35_000:raise HTTPException(400,'全站设计过大')
|
|
return normalized
|
|
|
|
def published(row_or_settings):
|
|
settings=_settings(row_or_settings) if 'settings_json' in row_or_settings else row_or_settings
|
|
merged=dict(settings.get('site_design') or {})
|
|
if 'tokens' not in merged:merged['tokens']=settings.get('design_tokens') or {}
|
|
if 'nav' not in merged:merged['nav']=settings.get('nav') or []
|
|
if 'footer' not in merged:merged['footer']={'description':settings.get('footer_text') or '', 'email':settings.get('email') or ''}
|
|
return validate(merged)
|
|
|
|
def read(site_id):
|
|
site=_site(site_id)
|
|
config=published(site)
|
|
draft=db.one('SELECT * FROM site_design_drafts WHERE site_id=?',(site_id,))
|
|
return {'config':json.loads(draft['config_json']) if draft else config,
|
|
'revision':int(draft['revision']) if draft else 0,
|
|
'state':'draft' if draft else 'published',
|
|
'conflict':bool(draft and draft['base_hash']!=_hash(_settings(site)))}
|
|
|
|
def write(site_id,payload):
|
|
plan=validate(payload.get('config'))
|
|
site=_site(site_id)
|
|
with db.connect() as c:
|
|
old=c.execute('SELECT revision,base_hash FROM site_design_drafts WHERE site_id=?',(site_id,)).fetchone()
|
|
revision=old[0] if old else 0
|
|
if type(payload.get('expected_revision')) is not int or payload['expected_revision']!=revision:
|
|
raise HTTPException(409,'全站草稿已更新,请重新加载')
|
|
if old and old[1]!=_hash(_settings(site)):
|
|
raise HTTPException(409,'网站信息已在其他窗口修改')
|
|
base=old[1] if old else _hash(_settings(site))
|
|
c.execute('''INSERT INTO site_design_drafts VALUES(?,?,?,?,?)
|
|
ON CONFLICT(site_id) DO UPDATE SET revision=excluded.revision,
|
|
config_json=excluded.config_json, updated_at=excluded.updated_at''',
|
|
(site_id,revision+1,base,json.dumps(plan,ensure_ascii=False),db.now()))
|
|
return {'saved':True,'state':'draft','revision':revision+1,'build_unchanged':True}
|
|
|
|
def publish(site_id,expected_revision,actor,expected_plan_token=None):
|
|
site=_site(site_id)
|
|
with db.connect() as c:
|
|
c.execute('BEGIN IMMEDIATE')
|
|
draft=c.execute('SELECT * FROM site_design_drafts WHERE site_id=?',(site_id,)).fetchone()
|
|
if not draft:raise HTTPException(409,'尚无全站设计草稿')
|
|
if type(expected_revision) is not int or expected_revision!=draft['revision']:
|
|
raise HTTPException(409,'草稿版本不匹配')
|
|
live=c.execute('SELECT settings_json FROM sites WHERE id=?',(site_id,)).fetchone()
|
|
settings=json.loads(live['settings_json'])
|
|
if draft['base_hash']!=_hash(settings):raise HTTPException(409,'网站信息已变更,请重新审核')
|
|
before=published(settings)
|
|
config=validate(json.loads(draft['config_json']))
|
|
if expected_plan_token is not None:
|
|
proof=_review_token(site_id,draft['revision'],settings,config)
|
|
if not isinstance(expected_plan_token,str) or not __import__('hmac').compare_digest(expected_plan_token,proof):
|
|
raise HTTPException(409,'审核预览已过期,请重新查看差异后确认')
|
|
settings['site_design']=config
|
|
settings['design_tokens']=config['tokens']
|
|
# Keep old nav/settings for non-professional legacy templates too.
|
|
settings['nav']=config['nav']
|
|
c.execute('UPDATE sites SET settings_json=?,updated_at=? WHERE id=?',
|
|
(json.dumps(settings,ensure_ascii=False),db.now(),site_id))
|
|
publication_id=uuid.uuid4().hex
|
|
c.execute('INSERT INTO site_design_publications VALUES(?,?,?,?,?,?,?)',
|
|
(publication_id,site_id,actor,expected_revision,json.dumps(before,ensure_ascii=False),
|
|
json.dumps(config,ensure_ascii=False),db.now()))
|
|
c.execute('DELETE FROM site_design_drafts WHERE site_id=?',(site_id,))
|
|
return {'published':True,'publication_id':publication_id,'build_required':True}
|
|
|
|
# V8.26 - reviewable, immutable full-site design changes. This workflow uses
|
|
# the existing site_design_drafts/publications tables: no destructive migration.
|
|
REVIEW_SECTIONS = ('header', 'nav', 'footer', 'mobile_tabs', 'tokens', 'responsive')
|
|
|
|
|
|
def _changes(before, after):
|
|
"""Return a bounded, deterministic, safe structured diff for human review."""
|
|
old, new = validate(before), validate(after)
|
|
rows = []
|
|
def inspect(path, a, b):
|
|
if a == b:
|
|
return
|
|
if isinstance(a, dict) and isinstance(b, dict):
|
|
for key in sorted(set(a) | set(b)):
|
|
inspect(path + '.' + key, a.get(key), b.get(key))
|
|
elif isinstance(a, list) and isinstance(b, list):
|
|
# Lists (menu order) are atomic to avoid obscuring a move as deletes.
|
|
rows.append({'field': path, 'before': a, 'after': b})
|
|
else:
|
|
rows.append({'field': path, 'before': a, 'after': b})
|
|
for section in REVIEW_SECTIONS:
|
|
inspect(section, old[section], new[section])
|
|
return rows
|
|
|
|
|
|
def _review_token(site_id, revision, live_settings, draft_config):
|
|
payload={'site_id':site_id,'revision':revision,
|
|
'settings_hash':_hash(live_settings), 'draft':validate(draft_config)}
|
|
return hashlib.sha256(json.dumps(payload,ensure_ascii=False,
|
|
sort_keys=True,separators=(',',':')).encode()).hexdigest()
|
|
|
|
|
|
def review(site_id):
|
|
with db.connect() as c:
|
|
site=c.execute('SELECT settings_json FROM sites WHERE id=?',(site_id,)).fetchone()
|
|
if not site:raise HTTPException(404,'网站不存在')
|
|
draft=c.execute('SELECT * FROM site_design_drafts WHERE site_id=?',(site_id,)).fetchone()
|
|
if not draft:raise HTTPException(409,'没有待审核的全站设计草稿')
|
|
settings=json.loads(site['settings_json'] or '{}')
|
|
if draft['base_hash']!=_hash(settings):raise HTTPException(409,'网站设置已经修改,请先处理草稿冲突')
|
|
proposed=validate(json.loads(draft['config_json']))
|
|
delta=_changes(published(settings),proposed)
|
|
sections=[name for name in REVIEW_SECTIONS if any(
|
|
r['field']==name or r['field'].startswith(name+'.') for r in delta)]
|
|
return {'revision':draft['revision'], 'changed_fields':delta,
|
|
'changed_sections':sections, 'change_count':len(delta),
|
|
'affected_scope':'entire_site_next_build',
|
|
'published_site_unchanged':True,
|
|
'plan_token':_review_token(site_id,draft['revision'],settings,proposed)}
|
|
|
|
|
|
def restore_publication(site_id,publication_id,which,expected_revision,expected_live_hash):
|
|
"""Restore a historical snapshot as a NEW draft, never touch live settings."""
|
|
if which not in ('before','after'):raise HTTPException(400,'恢复来源仅支持 before 或 after')
|
|
if type(expected_revision) is not int:raise HTTPException(400,'需要草稿版本号')
|
|
if not isinstance(expected_live_hash,str) or len(expected_live_hash)!=64:
|
|
raise HTTPException(400,'需要当前全站发布版本指纹')
|
|
with db.connect() as c:
|
|
c.execute('BEGIN IMMEDIATE')
|
|
site=c.execute('SELECT settings_json FROM sites WHERE id=?',(site_id,)).fetchone()
|
|
if not site:raise HTTPException(404,'网站不存在')
|
|
historic=c.execute('SELECT * FROM site_design_publications WHERE site_id=? AND id=?',
|
|
(site_id,publication_id)).fetchone()
|
|
if not historic:raise HTTPException(404,'发布记录不存在')
|
|
settings=json.loads(site['settings_json'] or '{}')
|
|
live_hash=_hash(settings)
|
|
if live_hash!=expected_live_hash:raise HTTPException(409,'全站发布版本已变化,请重新载入')
|
|
raw=historic['old_config_json'] if which=='before' else historic['new_config_json']
|
|
plan=validate(json.loads(raw))
|
|
old=c.execute('SELECT revision FROM site_design_drafts WHERE site_id=?',(site_id,)).fetchone()
|
|
current=old['revision'] if old else 0
|
|
if expected_revision!=current:raise HTTPException(409,'草稿版本已变化,请重新加载')
|
|
new_rev=current+1
|
|
c.execute('INSERT INTO site_design_drafts(site_id,revision,base_hash,config_json,updated_at) '
|
|
'VALUES(?,?,?,?,?) ON CONFLICT(site_id) DO UPDATE SET '
|
|
'revision=excluded.revision,base_hash=excluded.base_hash, '
|
|
'config_json=excluded.config_json,updated_at=excluded.updated_at',
|
|
(site_id,new_rev,live_hash,json.dumps(plan,ensure_ascii=False),db.now()))
|
|
return {'restored_as_draft':True,'revision':new_rev,'published_unchanged':True}
|
|
|
|
|
|
def responsive_css(config):
|
|
r=validate(config)['responsive']
|
|
tablet=r['tablet_gap'];mobile=r['mobile_gap'];padding=r['mobile_container_padding']
|
|
rules=(f'@media(max-width:1023px){{.sf-canvas .sf-section{{padding-block:{tablet}px}}}}'
|
|
f'@media(max-width:767px){{.sf-canvas .sf-section{{padding-block:{mobile}px}}'
|
|
f'.pro-wrap,.sf-container{{padding-left:{padding}px;padding-right:{padding}px}}')
|
|
if r['mobile_hide_announcement']:rules+='body .pro-utility{display:none!important}'
|
|
if r['mobile_hide_footer_links']:rules+='body .pro-footer-links{display:none!important}'
|
|
return 'body .pro-utility[hidden]{display:none!important}'+rules+'}'
|
|
|
|
def token_css(tokens):
|
|
safe=validate({'tokens':tokens})['tokens']
|
|
fonts={'system':'system-ui,-apple-system,BlinkMacSystemFont,sans-serif',
|
|
'serif':'Georgia,Times New Roman,serif',
|
|
'modern':'Arial,Helvetica,sans-serif'}
|
|
radius=safe['radius'];width=safe['container_width'];space=safe['section_spacing']
|
|
scale=safe['heading_scale']
|
|
button={'rounded':f'{radius}px','square':'0px','pill':'999px'}[safe['button_style']]
|
|
return (f':root{{--sf-font:{fonts[safe["font"]]};--sf-radius:{radius}px;'
|
|
f'--sf-space:{space}px;--sf-width:{width}px;--sf-heading-scale:{scale};}}'
|
|
f'body{{font-family:var(--sf-font)}}'
|
|
f'.sf-canvas .sf-section,.sf-canvas .section{{padding-block:var(--sf-space)}}'
|
|
f'.container,.sf-container{{max-width:var(--sf-width)}}'
|
|
f'.btn,.primary,.request,.button,.nav-btn{{border-radius:{button}}}')
|
|
|
|
@router.get('/{site_id}/site-design')
|
|
def api_read(request:Request,site_id:str):
|
|
enterprise.require(request,site_id,'viewer')
|
|
return read(site_id)
|
|
|
|
@router.put('/{site_id}/site-design')
|
|
def api_write(request:Request,site_id:str,payload:dict=Body(...)):
|
|
enterprise.require(request,site_id,'editor')
|
|
return write(site_id,payload)
|
|
|
|
@router.post('/{site_id}/site-design/publish')
|
|
def api_publish(request:Request,site_id:str,payload:dict=Body(...)):
|
|
enterprise.require(request,site_id,'owner')
|
|
return publish(site_id,payload.get('expected_revision'),enterprise.actor(request))
|
|
|
|
@router.delete('/{site_id}/site-design')
|
|
def api_discard(request:Request,site_id:str):
|
|
enterprise.require(request,site_id,'editor')
|
|
_site(site_id)
|
|
db.execute('DELETE FROM site_design_drafts WHERE site_id=?',(site_id,))
|
|
return {'discarded':True}
|
|
|
|
@router.get('/{site_id}/site-design/publications')
|
|
def api_history(request:Request,site_id:str):
|
|
enterprise.require(request,site_id,'viewer')
|
|
_site(site_id)
|
|
return db.rows('''SELECT id,site_id,actor,revision,published_at FROM site_design_publications
|
|
WHERE site_id=? ORDER BY published_at DESC LIMIT 50''',(site_id,))
|
|
|
|
@router.get('/{site_id}/site-design/review')
|
|
def api_review(request:Request,site_id:str):
|
|
enterprise.require(request,site_id,'viewer')
|
|
return review(site_id)
|
|
|
|
|
|
@router.post('/{site_id}/site-design/publish-reviewed')
|
|
def api_publish_reviewed(request:Request,site_id:str,payload:dict=Body(...)):
|
|
enterprise.require(request,site_id,'owner')
|
|
return publish(site_id,payload.get('expected_revision'),enterprise.actor(request),
|
|
payload.get('plan_token',''))
|
|
|
|
|
|
@router.get('/{site_id}/site-design/publications/{publication_id}')
|
|
def api_publication(request:Request,site_id:str,publication_id:str):
|
|
enterprise.require(request,site_id,'viewer')
|
|
_site(site_id)
|
|
row=db.one('SELECT * FROM site_design_publications WHERE site_id=? AND id=?',
|
|
(site_id,publication_id))
|
|
if not row:raise HTTPException(404,'发布版本不存在')
|
|
old=validate(json.loads(row['old_config_json']))
|
|
new=validate(json.loads(row['new_config_json']))
|
|
return {'id':row['id'],'published_at':row['published_at'],'actor':row['actor'],
|
|
'before':old,'after':new,'changes':_changes(old,new)}
|
|
|
|
|
|
@router.get('/{site_id}/site-design/live-hash')
|
|
def api_live_hash(request:Request,site_id:str):
|
|
enterprise.require(request,site_id,'viewer')
|
|
return {'live_hash':_hash(_settings(_site(site_id)))}
|
|
|
|
|
|
@router.post('/{site_id}/site-design/publications/{publication_id}/restore')
|
|
def api_restore_publication(request:Request,site_id:str,publication_id:str,payload:dict=Body(...)):
|
|
enterprise.require(request,site_id,'editor')
|
|
return restore_publication(site_id,publication_id,payload.get('snapshot'),
|
|
payload.get('expected_revision'),payload.get('expected_live_hash'))
|