Coverage for src/lektor_ng/admin/modules/api.py: 100%

233 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-05 16:51 +0000

1import os 

2import posixpath 

3from collections.abc import Callable, Iterator, Mapping 

4from contextvars import ContextVar 

5from dataclasses import dataclass, field 

6from functools import wraps 

7from typing import Any, TypeVar, cast 

8 

9import click 

10import marshmallow 

11import marshmallow_dataclass as mdcls 

12from flask import Blueprint, Response, current_app, jsonify, make_response, request 

13 

14from lektor_ng.admin.context import LektorContext, get_lektor_context 

15from lektor_ng.admin.utils import eventstream 

16from lektor_ng.constants import PRIMARY_ALT 

17from lektor_ng.datamodel import DataModel 

18from lektor_ng.db import Record 

19from lektor_ng.environment.config import Config, ServerInfo 

20from lektor_ng.publisher import PublishError, publish 

21from lektor_ng.utils import cleanup_path, is_valid_id 

22 

23bp = Blueprint("api", __name__, url_prefix="/admin/api") 

24 

25 

26LEKTOR_CONFIG: ContextVar[Config] = ContextVar("lektor_config") 

27 

28 

29@bp.url_value_preprocessor 

30def pass_lektor_context(endpoint: str | None, values: dict[str, Any] | None) -> None: 

31 """Pass LektorContext to each view callable in a `ctx` parameter""" 

32 assert isinstance(values, dict) 

33 values["ctx"] = get_lektor_context() 

34 

35 

36class _ServerInfoField(marshmallow.fields.String): 

37 def _deserialize( 

38 self, 

39 value: str, 

40 attr: str | None, 

41 data: Mapping[str, Any] | None, 

42 **kwargs: Any, 

43 ) -> ServerInfo: 

44 lektor_config = LEKTOR_CONFIG.get() 

45 server_id = super()._deserialize(value, attr, data, **kwargs) 

46 server_info = lektor_config.get_server(server_id) 

47 if server_info is None: 

48 raise marshmallow.ValidationError("Invalid server id.") 

49 return server_info 

50 

51 

52def _is_valid_path(value: str) -> None: 

53 if not cleanup_path(value) == value: 

54 raise marshmallow.ValidationError("Invalid value.") 

55 

56 

57def _is_valid_alt(value: str) -> None: 

58 lektor_config = get_lektor_context().config 

59 if not lektor_config.is_valid_alternative(value): 

60 raise marshmallow.ValidationError("Invalid alternative.") 

61 

62 

63# Mark types for special validation 

64_PathType = mdcls.NewType("_PathType", str, validate=_is_valid_path) 

65_AltType = mdcls.NewType("_AltType", str, validate=_is_valid_alt) 

66_BoolType = mdcls.NewType("_BoolType", bool, truthy={1, "1"}, falsy={0, "0"}) 

67 

68 

69class _SchemaBase(marshmallow.Schema): 

70 TYPE_MAPPING = {ServerInfo: _ServerInfoField} 

71 

72 class Meta: 

73 unknown = marshmallow.EXCLUDE 

74 

75 

76F = TypeVar("F", bound=Callable[..., Any]) 

77 

78 

79def _with_validated(param_type: type) -> Callable[[F], F]: 

80 """Flask view decorator to validate parameters. 

81 

82 The validated parameters are placed into the ``validated`` keyword 

83 arg of the decorated view. 

84 

85 If the request has a JSON body, the parameters are parsed from that. 

86 Otherwise, the parameters are parsed from ``request.values``. 

87 

88 :param param_type: A dataclass which specifies the parameters. 

89 """ 

90 schema_class = mdcls.class_schema(param_type, base_schema=_SchemaBase) 

91 schema = schema_class() 

92 

93 def wrap(f: F) -> F: 

94 @wraps(f) 

95 def wrapper(*args: Any, **kwargs: Any) -> Response: 

96 if request.method in ("POST", "PUT") and request.mimetype == "application/json": 

97 data = request.get_json() or {} 

98 else: 

99 data = request.values 

100 LEKTOR_CONFIG.set(kwargs["ctx"].config) 

101 try: 

102 kwargs["validated"] = schema.load(data) 

103 except marshmallow.ValidationError as exc: 

104 error = { 

105 "title": "Invalid parameters", 

106 "messages": exc.messages, 

107 } 

108 return make_response(jsonify(error=error), 400) 

109 return f(*args, **kwargs) 

110 

111 # This cast seems necessary, atm. 

112 # https://github.com/python/mypy/issues/1927 

113 return cast(F, wrapper) 

114 

115 return wrap 

116 

117 

118@dataclass 

119class _PathAndAlt: 

120 path: _PathType 

121 alt: _AltType = PRIMARY_ALT 

122 

123 

124@bp.route("/pathinfo") 

125@_with_validated(_PathAndAlt) 

126def get_path_info(validated: _PathAndAlt, ctx: LektorContext) -> Response: 

127 """Returns the path segment information for a record.""" 

128 alt = validated.alt 

129 tree_item = ctx.tree.get(validated.path) 

130 segments = [] 

131 

132 while tree_item is not None: 

133 segments.append( 

134 { 

135 "id": tree_item.id, 

136 "path": tree_item.path, 

137 "label_i18n": tree_item.get_record_label_i18n(alt), 

138 "exists": tree_item.exists, 

139 "can_have_children": tree_item.can_have_children, 

140 } 

141 ) 

142 tree_item = tree_item.get_parent() 

143 

144 segments.reverse() 

145 return jsonify(segments=segments) 

146 

147 

148@bp.route("/recordinfo") 

149@_with_validated(_PathAndAlt) 

150def get_record_info(validated: _PathAndAlt, ctx: LektorContext) -> Response: 

151 alt = validated.alt 

152 tree_item = ctx.tree.get(validated.path) 

153 

154 return jsonify( 

155 id=tree_item.id, 

156 path=tree_item.path, 

157 label_i18n=tree_item.get_record_label_i18n(alt), 

158 exists=tree_item.exists, 

159 is_attachment=tree_item.is_attachment, 

160 attachments=[ 

161 { 

162 "id": x.id, 

163 "path": x.path, 

164 "type": x.attachment_type, 

165 } 

166 for x in tree_item.iter_attachments() 

167 ], 

168 children=[ 

169 { 

170 "id": x.id, 

171 "path": x.path, 

172 "label": x.id, 

173 "label_i18n": x.get_record_label_i18n(alt), 

174 "visible": x.is_visible, 

175 } 

176 for x in tree_item.iter_subpages() 

177 ], 

178 alts=[ 

179 { 

180 "alt": _.id, 

181 "is_primary": _.id == PRIMARY_ALT, 

182 "primary_overlay": _.is_primary_overlay, 

183 "name_i18n": _.name_i18n, 

184 "exists": _.exists, 

185 } 

186 for _ in tree_item.alts.values() 

187 ], 

188 can_have_children=tree_item.can_have_children, 

189 can_have_attachments=tree_item.can_have_attachments, 

190 can_be_deleted=tree_item.can_be_deleted, 

191 ) 

192 

193 

194@bp.route("/previewinfo") 

195@_with_validated(_PathAndAlt) 

196def get_preview_info(validated: _PathAndAlt, ctx: LektorContext) -> Response: 

197 record = ctx.pad.get(validated.path, alt=validated.alt) 

198 if record is None: 

199 return jsonify(exists=False, url=None, is_hidden=True) 

200 return jsonify(exists=True, url=record.url_path, is_hidden=record.is_hidden) 

201 

202 

203@dataclass 

204class _FindParams: 

205 q: str 

206 alt: _AltType = PRIMARY_ALT 

207 lang: str | None = None 

208 

209 

210@bp.route("/find", methods=["POST"]) 

211@_with_validated(_FindParams) 

212def find(validated: _FindParams, ctx: LektorContext) -> Response: 

213 lang = validated.lang or current_app.config.get("lektor.ui_lang", "en") 

214 return jsonify(results=ctx.builder.find_files(validated.q, alt=validated.alt, lang=lang)) 

215 

216 

217@bp.route("/browsefs", methods=["POST"]) 

218@_with_validated(_PathAndAlt) 

219def browsefs(validated: _PathAndAlt, ctx: LektorContext) -> Response: 

220 record = ctx.pad.get(validated.path, alt=validated.alt) 

221 okay = False 

222 if record is not None: 

223 if record.is_attachment: 

224 fn = record.attachment_filename 

225 else: 

226 fn = record.source_filename 

227 if os.path.exists(fn): 

228 click.launch(fn, locate=True) 

229 okay = True 

230 return jsonify(okay=okay) 

231 

232 

233@dataclass 

234class _UrlPath: 

235 url_path: str 

236 

237 

238@bp.route("/matchurl") 

239@_with_validated(_UrlPath) 

240def match_url(validated: _UrlPath, ctx: LektorContext) -> Response: 

241 """Find the Record that corresponds to a URL. 

242 

243 This is used by the admin UI to find the db record that corresponds 

244 to a page when the preview iframe is navigated. 

245 """ 

246 record = ctx.pad.resolve_url_path(validated.url_path, alt_fallback=False) 

247 if not isinstance(record, Record): 

248 return jsonify(exists=False, path=None, alt=None) 

249 return jsonify(exists=True, path=record["_path"], alt=record["_alt"]) 

250 

251 

252@bp.route("/rawrecord") 

253@_with_validated(_PathAndAlt) 

254def get_raw_record(validated: _PathAndAlt, ctx: LektorContext) -> Response: 

255 ts = ctx.tree.edit(validated.path, alt=validated.alt) 

256 return jsonify(ts.to_json()) 

257 

258 

259@bp.route("/newrecord") 

260@_with_validated(_PathAndAlt) 

261def get_new_record_info(validated: _PathAndAlt, ctx: LektorContext) -> Response: 

262 pad = ctx.pad 

263 alt = validated.alt 

264 tree_item = ctx.tree.get(validated.path) 

265 

266 def describe_model(model: DataModel) -> dict[str, Any]: 

267 primary_field = None 

268 if model.primary_field is not None: 

269 f = model.field_map.get(model.primary_field) 

270 if f is not None: 

271 primary_field = f.to_json(pad) 

272 return { 

273 "id": model.id, 

274 "name": model.name, 

275 "name_i18n": model.name_i18n, 

276 "primary_field": primary_field, 

277 } 

278 

279 implied_model = tree_item.implied_child_datamodel 

280 label_i18n = tree_item.get_record_label_i18n(alt) 

281 return jsonify( 

282 { 

283 "label_i18n": label_i18n, 

284 "label": label_i18n["en"], 

285 "can_have_children": tree_item.can_have_children, 

286 "implied_model": implied_model, 

287 "available_models": { 

288 k: describe_model(v) for k, v in pad.db.datamodels.items() if not v.hidden or k == implied_model 

289 }, 

290 } 

291 ) 

292 

293 

294@bp.route("/newattachment") 

295@_with_validated(_PathAndAlt) 

296def get_new_attachment_info(validated: _PathAndAlt, ctx: LektorContext) -> Response: 

297 tree_item = ctx.tree.get(validated.path) 

298 label_i18n = tree_item.get_record_label_i18n(validated.alt) 

299 return jsonify( 

300 { 

301 "can_upload": tree_item.can_have_attachments, 

302 "label_i18n": label_i18n, 

303 "label": label_i18n["en"], 

304 } 

305 ) 

306 

307 

308@bp.route("/newattachment", methods=["POST"]) 

309@_with_validated(_PathAndAlt) 

310def upload_new_attachments(validated: _PathAndAlt, ctx: LektorContext) -> Response: 

311 ts = ctx.tree.edit(validated.path, alt=validated.alt) 

312 if not ts.exists or ts.is_attachment: 

313 return jsonify({"bad_upload": True}) 

314 

315 buckets = [] 

316 for file in request.files.getlist("file"): 

317 stored_filename = ts.add_attachment(file.filename, file) 

318 buckets.append({"original_filename": file.filename, "stored_filename": stored_filename}) 

319 

320 return jsonify( 

321 { 

322 "bad_upload": False, 

323 "path": validated.path, 

324 "buckets": buckets, 

325 } 

326 ) 

327 

328 

329@dataclass 

330class _NewRecordParams: 

331 id: str 

332 model: str | None 

333 data: dict[str, str | None] 

334 path: _PathType 

335 alt: _AltType = PRIMARY_ALT 

336 

337 

338@bp.route("/newrecord", methods=["POST"]) 

339@_with_validated(_NewRecordParams) 

340def add_new_record(validated: _NewRecordParams, ctx: LektorContext) -> Response: 

341 exists = False 

342 

343 if not is_valid_id(validated.id): 

344 return jsonify(valid_id=False, exists=False, path=None) 

345 

346 path = posixpath.join(validated.path, validated.id) 

347 

348 ts = ctx.tree.edit(path, datamodel=validated.model, alt=validated.alt) 

349 with ts: 

350 if ts.exists: 

351 exists = True 

352 else: 

353 ts.data.update(validated.data) 

354 

355 return jsonify({"valid_id": True, "exists": exists, "path": path}) 

356 

357 

358@dataclass 

359class _DeleteRecordParams: 

360 delete_master: _BoolType 

361 path: _PathType 

362 alt: _AltType = PRIMARY_ALT 

363 

364 

365@bp.route("/deleterecord", methods=["POST"]) 

366@_with_validated(_DeleteRecordParams) 

367def delete_record(validated: _DeleteRecordParams, ctx: LektorContext) -> Response: 

368 if validated.path != "/": 

369 ts = ctx.tree.edit(validated.path, alt=validated.alt) 

370 with ts: 

371 ts.delete(delete_master=validated.delete_master) 

372 return jsonify(okay=True) 

373 

374 

375@dataclass 

376class _UpdateRawRecordParams: 

377 data: dict[str, str | None] 

378 path: _PathType 

379 alt: _AltType = PRIMARY_ALT 

380 

381 

382@bp.route("/rawrecord", methods=["PUT"]) 

383@_with_validated(_UpdateRawRecordParams) 

384def update_raw_record(validated: _UpdateRawRecordParams, ctx: LektorContext) -> Response: 

385 ts = ctx.tree.edit(validated.path, alt=validated.alt) 

386 with ts: 

387 ts.data.update(validated.data) 

388 return jsonify(path=ts.path) 

389 

390 

391@bp.route("/servers") 

392def get_servers(ctx: LektorContext) -> Response: 

393 servers = ctx.config.get_servers(public=True) 

394 return jsonify(servers=sorted([x.to_json() for x in servers.values()], key=lambda x: x["name"].lower())) 

395 

396 

397@bp.route("/build", methods=["POST"]) 

398def trigger_build(ctx: LektorContext) -> Response: 

399 builder = ctx.builder 

400 builder.build_all() 

401 builder.prune() 

402 return jsonify(okay=True) 

403 

404 

405@bp.route("/clean", methods=["POST"]) 

406def trigger_clean(ctx: LektorContext) -> Response: 

407 builder = ctx.builder 

408 builder.prune(all=True) 

409 builder.touch_site_config() 

410 return jsonify(okay=True) 

411 

412 

413@dataclass 

414class _PublishBuildParams: 

415 server_info: ServerInfo = field(metadata={"data_key": "server"}) 

416 

417 

418@bp.route("/publish") 

419@_with_validated(_PublishBuildParams) 

420def publish_build(validated: _PublishBuildParams, ctx: LektorContext) -> Response: 

421 @eventstream 

422 def generator() -> Iterator[dict[str, str]]: 

423 try: 

424 event_iter = ( 

425 publish( 

426 ctx.env, 

427 validated.server_info.target, 

428 ctx.output_path, 

429 server_info=validated.server_info, 

430 ) 

431 or () 

432 ) 

433 for event in event_iter: 

434 yield {"msg": event} 

435 except PublishError as exc: 

436 yield {"msg": f"Error: {exc}"} 

437 

438 return generator() 

439 

440 

441@bp.route("/ping") 

442def ping(ctx: LektorContext) -> Response: 

443 return jsonify(project_id=ctx.project_id, okay=True)