Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

1"""Sets up the Flask app with all its routes. 

2""" 

3 

4import os 

5from itertools import chain 

6 

7from flask import ( 

8 Flask, 

9 request, 

10 render_template, 

11 send_file, 

12 redirect, 

13 abort, 

14 flash, 

15) 

16 

17from config import Config as C, Names as N 

18from control.utils import pick as G, E, serverprint 

19from control.db import Db 

20from control.workflow.compute import Workflow 

21from control.workflow.apply import execute 

22from control.perm import checkTable 

23from control.auth import Auth 

24from control.context import Context 

25from control.sidebar import Sidebar 

26from control.topbar import Topbar 

27from control.overview import Overview 

28from control.api import Api 

29from control.cust.factory_table import make as mkTable 

30 

31 

32CB = C.base 

33CT = C.tables 

34CW = C.web 

35 

36SECRET_FILE = CB.secretFile 

37 

38STATIC_ROOT = os.path.abspath(CW.staticRoot) 

39"""The url to the directory from which static files are served.""" 

40 

41ALL_TABLES = CT.all 

42USER_TABLES_LIST = CT.userTables 

43USER_TABLES = set(USER_TABLES_LIST) 

44MASTERS = CT.masters 

45DETAILS = CT.details 

46 

47LIMITS = CW.limits 

48 

49URLS = CW.urls 

50"""A dictionary of fixed fall-back urls.""" 

51 

52MESSAGES = CW.messages 

53"""A dictionary of fixed messages for display on the web interface.""" 

54 

55INDEX = CW.indexPage 

56LANDING = CW.landing 

57BODY_METHODS = set(CW.bodyMethods) 

58LIST_ACTIONS = set(CW.listActions) 

59FIELD_ACTIONS = set(CW.fieldActions) 

60 

61START = URLS[N.home][N.url] 

62OVERVIEW = URLS[N.info][N.url] 

63DUMMY = URLS[N.dummy][N.url] 

64LOGIN = URLS[N.login][N.url] 

65LOGOUT = URLS[N.logout][N.url] 

66SLOGOUT = URLS[N.slogout][N.url] 

67REFRESH = URLS[N.refresh][N.url] 

68WORKFLOW = URLS[N.workflow][N.url] 

69SHIB_LOGOUT = URLS[N.shibLogout][N.url] 

70NO_PAGE = MESSAGES[N.noPage] 

71NO_TASK = MESSAGES[N.noTask] 

72NO_TABLE = MESSAGES[N.noTable] 

73NO_RECORD = MESSAGES[N.noRecord] 

74NO_FIELD = MESSAGES[N.noField] 

75NO_ACTION = MESSAGES[N.noAction] 

76 

77 

78def redirectResult(url, good): 

79 """Redirect. 

80 

81 Parameters 

82 ---------- 

83 url: string 

84 The url to redirect to 

85 good: 

86 Whether the redirection corresponds to a normal scenario or is the result of 

87 an error 

88 

89 Returns 

90 ------- 

91 response 

92 A redirect response with either code 302 (good) or 303 (bad) 

93 """ 

94 

95 code = 302 if good else 303 

96 return redirect(url, code=code) 

97 

98 

99def checkBounds(**kwargs): 

100 """Aggressive check on the arguments passed in an url and/or request. 

101 

102 First the total length of the request is counted. 

103 If it is too much, the request will be aborted. 

104 

105 Each argument in request.args and `kwargs` must have a name that is allowed 

106 and its value should have a length under an appropriate limit, 

107 configured in `web.yaml`. There is always a fallback limit. 

108 

109 !!! caution "Security" 

110 Before processing any request arg, whether from a form or from the url, 

111 use this function to check whether the length is within limits. 

112 

113 If the length is exceeded, fail with a bad request, 

114 without giving any useful feedback. 

115 Because in this case we are dealing with a hacker. 

116 

117 Parameters 

118 ---------- 

119 kwargs: dict 

120 The key-values that need to be checked. 

121 

122 Raises 

123 ------ 

124 HTTPException 

125 If the length of any argument is out of bounds, 

126 processing is aborted and a bad request response 

127 is delivered 

128 """ 

129 

130 default = G(LIMITS, N.default, default=100) 

131 maxLen = G(LIMITS, N.request, default=default) 

132 

133 if request.content_length and request.content_length > maxLen: 133 ↛ 134line 133 didn't jump to line 134, because the condition on line 133 was never true

134 abort(400) 

135 

136 n = len(request.args) 

137 boundN = G(LIMITS, N.keys, default=default) 

138 if len(kwargs) > boundN: 138 ↛ 139line 138 didn't jump to line 139, because the condition on line 138 was never true

139 serverprint(f"""OUT-OF-BOUNDS: {n} > {boundN} KEYS IN {kwargs}""") 

140 abort(400) 

141 for (k, v) in chain.from_iterable((kwargs.items(), request.args.items())): 

142 if k not in LIMITS: 

143 abort(400) 

144 valN = G(LIMITS, k, default=default) 

145 if v is not None and len(v) > valN: 

146 serverprint(f"""OUT-OF-BOUNDS: LENGTH ARG "{k}" > {valN} ({v})""") 

147 abort(400) 

148 

149 

150def appFactory(regime, test, debug, **kwargs): 

151 """Creates the flask app that serves the website. 

152 

153 We read a secret key from the system which is stored in a file outside the app. 

154 This information is needed to encrypt sessions. 

155 

156 !!! caution 

157 We read and cache substantial information from MongoDb before 

158 forking into workers. 

159 Before we fork, we close the MongoDb connection, because PyMongo is not 

160 [fork-safe](https://api.mongodb.com/python/current/faq.html#is-pymongo-fork-safe). 

161 

162 Parameters 

163 ---------- 

164 regime: {development, production} 

165 test: boolean 

166 Whether the app is in test mode. 

167 debug: boolean 

168 Whether to generate debug messages for certain actions. 

169 kwargs: dict 

170 Additional parameters to tweak the behaviour of the Flask application. 

171 They will be passed to the object initializer `Flask()`. 

172 

173 Returns 

174 ------- 

175 object 

176 The flask app. 

177 """ 

178 

179 kwargs["static_url_path"] = DUMMY 

180 

181 app = Flask(__name__, **kwargs) 

182 if test: 182 ↛ 185line 182 didn't jump to line 185, because the condition on line 182 was never false

183 app.config.from_mapping(dict(TESTING=True)) 

184 

185 with open(SECRET_FILE) as fh: 

186 app.secret_key = fh.read() 

187 

188 GP = dict(methods=[N.GET, N.POST]) 

189 

190 DB = Db(regime, test=test) 

191 """*object* The `control.db.Db` singleton.""" 

192 

193 WF = Workflow(DB) 

194 """*object* The `control.workflow.compute.Workflow` singleton.""" 

195 

196 WF.initWorkflow(drop=True) 

197 

198 auth = Auth(DB, regime) 

199 

200 DB.mongoClose() 

201 

202 def getContext(): 

203 return Context(DB, WF, auth) 

204 

205 def tablePerm(table, action=None): 

206 return checkTable(auth, table) and (action is None or auth.authenticated()) 

207 

208 if debug and auth.isDevel: 208 ↛ 209line 208 didn't jump to line 209, because the condition on line 208 was never true

209 CT.showReferences() 

210 N.showNames() 

211 

212 @app.route("""/whoami""") 

213 def serveWhoami(): 

214 checkBounds() 

215 return G(auth.user, N.eppn) if auth.authenticated() else N.public 

216 

217 @app.route(f"""/{N.static}/<path:filepath>""") 

218 def serveStatic(filepath): 

219 checkBounds(filepath=filepath) 

220 

221 path = f"""{STATIC_ROOT}/{filepath}""" 

222 if os.path.isfile(path): 

223 return send_file(path) 

224 flash(f"file not found: {filepath}", "error") 

225 return redirectResult(START, False) 

226 

227 @app.route(f"""/{N.favicons}/<path:filepath>""") 

228 def serveFavicons(filepath): 

229 checkBounds(filepath=filepath) 

230 

231 path = f"""{STATIC_ROOT}/{N.favicons}/{filepath}""" 

232 if os.path.isfile(path): 

233 return send_file(path) 

234 flash(f"icon not found: {filepath}", "error") 

235 return redirectResult(START, False) 

236 

237 @app.route(START) 

238 @app.route(f"""/{N.index}""") 

239 @app.route(f"""/{INDEX}""") 

240 def serveIndex(): 

241 checkBounds() 

242 path = START 

243 context = getContext() 

244 auth.authenticate() 

245 topbar = Topbar(context).wrap() 

246 sidebar = Sidebar(context, path).wrap() 

247 return render_template(INDEX, topbar=topbar, sidebar=sidebar, material=LANDING) 

248 

249 # OVERVIEW PAGE 

250 

251 @app.route(f"""{OVERVIEW}""") 

252 def serveOverview(): 

253 checkBounds() 

254 path = START 

255 context = getContext() 

256 auth.authenticate() 

257 topbar = Topbar(context).wrap() 

258 sidebar = Sidebar(context, path).wrap() 

259 overview = Overview(context).wrap() 

260 return render_template(INDEX, topbar=topbar, sidebar=sidebar, material=overview) 

261 

262 @app.route(f"""{OVERVIEW}.tsv""") 

263 def serveOverviewTsv(): 

264 checkBounds() 

265 context = getContext() 

266 auth.authenticate() 

267 return Overview(context).wrap(asTsv=True) 

268 

269 # LOGIN / LOGOUT 

270 

271 @app.route(f"""{SLOGOUT}""") 

272 def serveSlogout(): 

273 checkBounds() 

274 if auth.authenticated(): 

275 auth.deauthenticate() 

276 flash("logged out from DARIAH") 

277 return redirectResult(SHIB_LOGOUT, True) 

278 flash("you were not logged in", "error") 

279 return redirectResult(START, False) 

280 

281 @app.route(f"""{LOGIN}""") 

282 def serveLogin(): 

283 checkBounds() 

284 if auth.authenticated(): 

285 flash("you are already logged in") 

286 good = True 

287 if auth.authenticate(login=True): 

288 flash("log in successful") 

289 else: 

290 good = False 

291 flash("log in unsuccessful", "error") 

292 return redirectResult(START, good) 

293 

294 @app.route(f"""{LOGOUT}""") 

295 def serveLogout(): 

296 checkBounds() 

297 if auth.authenticated(): 

298 auth.deauthenticate() 

299 flash("logged out") 

300 return redirectResult(START, True) 

301 flash("you were not logged in", "error") 

302 return redirectResult(START, False) 

303 

304 # SYSADMIN 

305 

306 @app.route(f"""{REFRESH}""") 

307 def serveRefresh(): 

308 checkBounds() 

309 context = getContext() 

310 auth.authenticate() 

311 done = context.refreshCache() 

312 if done: 312 ↛ 315line 312 didn't jump to line 315, because the condition on line 312 was never false

313 flash("Cache refreshed") 

314 else: 

315 flash("Cache not refreshed", "error") 

316 return redirectResult(START, done) 

317 

318 @app.route(f"""{WORKFLOW}""") 

319 def serveWorkflow(): 

320 checkBounds() 

321 context = getContext() 

322 auth.authenticate() 

323 nWf = context.resetWorkflow() 

324 if nWf >= 0: 

325 flash(f"{nWf} workflow records recomputed and stored") 

326 else: 

327 flash("workflow not recomputed", "error") 

328 return redirectResult(START, nWf >= 0) 

329 

330 # API CALLS 

331 

332 @app.route('/api/db/<string:table>/<string:eid>', methods=['GET', 'POST']) 

333 def serveApiDbView(table, eid): 

334 checkBounds(table=table, eid=eid) 

335 context = getContext() 

336 auth.authenticate() 

337 return Api(context).view(table, eid) 

338 

339 @app.route('/api/db/<string:table>', methods=['GET', 'POST']) 

340 def serveApiDbList(table): 

341 checkBounds(table=table) 

342 context = getContext() 

343 auth.authenticate() 

344 return Api(context).list(table) 

345 

346 @app.route('/api/db/<path:verb>', methods=['GET', 'POST']) 

347 def serveApiDb(verb): 

348 checkBounds() 

349 context = getContext() 

350 auth.authenticate() 

351 return Api(context).notimplemented(verb) 

352 

353 # WORKFLOW TASKS 

354 

355 @app.route("""/api/task/<string:task>/<string:eid>""") 

356 def serveTask(task, eid): 

357 checkBounds(task=task, eid=eid) 

358 

359 context = getContext() 

360 auth.authenticate() 

361 (good, newPath) = execute(context, task, eid) 

362 if not good and newPath is None: 362 ↛ 363line 362 didn't jump to line 363, because the condition on line 362 was never true

363 newPath = START 

364 return redirectResult(newPath, good) 

365 

366 # INSERT RECORD IN TABLE 

367 

368 @app.route(f"""/api/<string:table>/{N.insert}""") 

369 def serveTableInsert(table): 

370 checkBounds(table=table) 

371 

372 newPath = f"""/{table}/{N.list}""" 

373 if table in ALL_TABLES and table not in MASTERS: 373 ↛ 383line 373 didn't jump to line 383, because the condition on line 373 was never false

374 context = getContext() 

375 auth.authenticate() 

376 eid = None 

377 if tablePerm(table): 377 ↛ 379line 377 didn't jump to line 379, because the condition on line 377 was never false

378 eid = mkTable(context, table).insert() 

379 if eid: 

380 newPath = f"""/{table}/{N.item}/{eid}""" 

381 flash("item added") 

382 else: 

383 flash(f"Cannot add items to {table}", "error") 

384 return redirectResult(newPath, eid is not None) 

385 

386 # INSERT RECORD IN DETAIL TABLE 

387 

388 @app.route(f"""/api/<string:table>/<string:eid>/<string:dtable>/{N.insert}""") 

389 def serveTableInsertDetail(table, eid, dtable): 

390 checkBounds(table=table, eid=eid, dtable=dtable) 

391 

392 newPath = f"""/{table}/{N.item}/{eid}""" 

393 dEid = None 

394 if ( 394 ↛ 399line 394 didn't jump to line 399

395 table in USER_TABLES_LIST[0:2] 

396 and table in DETAILS 

397 and dtable in DETAILS[table] 

398 ): 

399 context = getContext() 

400 auth.authenticate() 

401 if tablePerm(table): 

402 dEid = mkTable(context, dtable).insert(masterTable=table, masterId=eid) 

403 if dEid: 

404 newPath = f"""/{table}/{N.item}/{eid}/{N.open}/{dtable}/{dEid}""" 

405 if dEid: 405 ↛ 406line 405 didn't jump to line 406, because the condition on line 405 was never true

406 flash(f"{dtable} item added") 

407 else: 

408 flash(f"Cannot add a {dtable} here", "error") 

409 return redirectResult(newPath, dEid is not None) 

410 

411 # LIST VIEWS ON TABLE 

412 

413 @app.route(f"""/<string:table>/{N.list}/<string:eid>""") 

414 def serveTableListOpen(table, eid): 

415 checkBounds(table=table, eid=eid) 

416 

417 return serveTable(table, eid) 

418 

419 @app.route(f"""/<string:table>/{N.list}""") 

420 def serveTableList(table): 

421 checkBounds(table=table) 

422 

423 return serveTable(table, None) 

424 

425 def serveTable(table, eid): 

426 checkBounds() 

427 action = G(request.args, N.action) 

428 actionRep = f"?action={action}" if action else E 

429 eidRep = f"""/{eid}""" if eid else E 

430 path = f"""/{table}/{N.list}{eidRep}{actionRep}""" 

431 if not action or action in LIST_ACTIONS: 

432 if table in ALL_TABLES: 432 ↛ 446line 432 didn't jump to line 446, because the condition on line 432 was never false

433 context = getContext() 

434 auth.authenticate() 

435 topbar = Topbar(context).wrap() 

436 sidebar = Sidebar(context, path).wrap() 

437 tableList = None 

438 if tablePerm(table, action=action): 438 ↛ 440line 438 didn't jump to line 440, because the condition on line 438 was never false

439 tableList = mkTable(context, table).wrap(eid, action=action) 

440 if tableList is None: 440 ↛ 441line 440 didn't jump to line 441, because the condition on line 440 was never true

441 flash(f"{action or E} view on {table} not allowed", "error") 

442 return redirectResult(START, False) 

443 return render_template( 

444 INDEX, topbar=topbar, sidebar=sidebar, material=tableList, 

445 ) 

446 flash(f"Unknown table {table}", "error") 

447 if action: 447 ↛ 450line 447 didn't jump to line 450, because the condition on line 447 was never false

448 flash(f"Unknown view {action}", "error") 

449 else: 

450 flash("Missing view", "error") 

451 return redirectResult(START, False) 

452 

453 # RECORD DELETE 

454 

455 @app.route(f"""/api/<string:table>/{N.delete}/<string:eid>""") 

456 def serveRecordDelete(table, eid): 

457 checkBounds(table=table, eid=eid) 

458 

459 if table in ALL_TABLES: 459 ↛ 472line 459 didn't jump to line 472, because the condition on line 459 was never false

460 context = getContext() 

461 auth.authenticate() 

462 good = False 

463 if tablePerm(table): 463 ↛ 467line 463 didn't jump to line 467, because the condition on line 463 was never false

464 good = mkTable(context, table).record(eid=eid).delete() 

465 newUrlPart = f"?{N.action}={N.my}" if table in USER_TABLES else E 

466 newPath = f"""/{table}/{N.list}{newUrlPart}""" 

467 if good: 

468 flash("item deleted") 

469 else: 

470 flash("item not deleted", "error") 

471 return redirectResult(newPath, good) 

472 flash(f"Unknown table {table}", "error") 

473 return redirectResult(START, False) 

474 

475 # RECORD DELETE DETAIL 

476 

477 @app.route( 

478 f"""/api/<string:table>/<string:masterId>/""" 

479 f"""<string:dtable>/{N.delete}/<string:eid>""" 

480 ) 

481 def serveRecordDeleteDetail(table, masterId, dtable, eid): 

482 checkBounds(table=table, masterId=masterId, dtable=dtable, eid=eid) 

483 

484 newPath = f"""/{table}/{N.item}/{masterId}""" 

485 good = False 

486 if ( 486 ↛ 491line 486 didn't jump to line 491

487 table in USER_TABLES_LIST[0:2] 

488 and table in DETAILS 

489 and dtable in DETAILS[table] 

490 ): 

491 context = getContext() 

492 auth.authenticate() 

493 if tablePerm(table): 

494 recordObj = mkTable(context, dtable).record(eid=eid) 

495 

496 wfitem = recordObj.wfitem 

497 if wfitem: 

498 good = recordObj.delete() 

499 

500 if good: 500 ↛ 501line 500 didn't jump to line 501, because the condition on line 500 was never true

501 flash(f"{dtable} detail deleted") 

502 else: 

503 flash(f"{dtable} detail not deleted", "error") 

504 return redirectResult(newPath, good) 

505 

506 # RECORD VIEW 

507 

508 @app.route(f"""/api/<string:table>/{N.item}/<string:eid>""") 

509 def serveRecord(table, eid): 

510 checkBounds(table=table, eid=eid) 

511 

512 if table in ALL_TABLES: 512 ↛ 521line 512 didn't jump to line 521, because the condition on line 512 was never false

513 context = getContext() 

514 auth.authenticate() 

515 if tablePerm(table): 515 ↛ 521line 515 didn't jump to line 521, because the condition on line 515 was never false

516 recordObj = mkTable(context, table).record( 

517 eid=eid, withDetails=True, **method() 

518 ) 

519 if recordObj.mayRead is not False: 519 ↛ 521line 519 didn't jump to line 521, because the condition on line 519 was never false

520 return recordObj.wrap() 

521 return noRecord(table) 

522 

523 @app.route(f"""/api/<string:table>/{N.item}/<string:eid>/{N.title}""") 

524 def serveRecordTitle(table, eid): 

525 checkBounds(table=table, eid=eid) 

526 

527 if table in ALL_TABLES: 527 ↛ 536line 527 didn't jump to line 536, because the condition on line 527 was never false

528 context = getContext() 

529 auth.authenticate() 

530 if tablePerm(table): 530 ↛ 536line 530 didn't jump to line 536, because the condition on line 530 was never false

531 recordObj = mkTable(context, table).record( 

532 eid=eid, withDetails=False, **method() 

533 ) 

534 if recordObj.mayRead is not False: 534 ↛ 536line 534 didn't jump to line 536, because the condition on line 534 was never false

535 return recordObj.wrap(expanded=-1) 

536 return noRecord(table) 

537 

538 # with specific detail opened 

539 

540 @app.route( 

541 f"""/<string:table>/{N.item}/<string:eid>/""" 

542 f"""{N.open}/<string:dtable>/<string:deid>""" 

543 ) 

544 def serveRecordPageDetail(table, eid, dtable, deid): 

545 checkBounds(table=table, eid=eid, dtable=dtable, deid=deid) 

546 

547 path = f"""/{table}/{N.item}/{eid}/{N.open}/{dtable}/{deid}""" 

548 if table in ALL_TABLES: 548 ↛ 564line 548 didn't jump to line 564, because the condition on line 548 was never false

549 context = getContext() 

550 auth.authenticate() 

551 topbar = Topbar(context).wrap() 

552 sidebar = Sidebar(context, path).wrap() 

553 if tablePerm(table): 553 ↛ 564line 553 didn't jump to line 564, because the condition on line 553 was never false

554 recordObj = mkTable(context, table).record( 

555 eid=eid, withDetails=True, **method() 

556 ) 

557 if recordObj.mayRead is not False: 557 ↛ 562line 557 didn't jump to line 562, because the condition on line 557 was never false

558 record = recordObj.wrap(showTable=dtable, showEid=deid) 

559 return render_template( 

560 INDEX, topbar=topbar, sidebar=sidebar, material=record, 

561 ) 

562 flash(f"Unknown record in table {table}", "error") 

563 return redirectResult(f"""/{table}/{N.list}""", False) 

564 flash(f"Unknown table {table}", "error") 

565 return redirectResult(START, False) 

566 

567 @app.route(f"""/<string:table>/{N.item}/<string:eid>""") 

568 def serveRecordPageDet(table, eid): 

569 checkBounds(table=table, eid=eid) 

570 

571 path = f"""/{table}/{N.item}/{eid}""" 

572 if table in ALL_TABLES: 572 ↛ 588line 572 didn't jump to line 588, because the condition on line 572 was never false

573 context = getContext() 

574 auth.authenticate() 

575 topbar = Topbar(context).wrap() 

576 sidebar = Sidebar(context, path).wrap() 

577 if tablePerm(table): 577 ↛ 588line 577 didn't jump to line 588, because the condition on line 577 was never false

578 recordObj = mkTable(context, table).record( 

579 eid=eid, withDetails=True, **method() 

580 ) 

581 if recordObj.mayRead is not False: 

582 record = recordObj.wrap() 

583 return render_template( 

584 INDEX, topbar=topbar, sidebar=sidebar, material=record, 

585 ) 

586 flash(f"Unknown record in table {table}", "error") 

587 return redirectResult(f"""/{table}/{N.list}""", False) 

588 flash(f"Unknown table {table}", "error") 

589 return redirectResult(START, False) 

590 

591 def method(): 

592 method = G(request.args, N.method) 

593 if method not in BODY_METHODS: 593 ↛ 595line 593 didn't jump to line 595, because the condition on line 593 was never false

594 return {} 

595 return dict(bodyMethod=method) 

596 

597 # FIELD VIEWS AND EDITS 

598 

599 @app.route( 

600 f"""/api/<string:table>/{N.item}/<string:eid>/{N.field}/<string:field>""", **GP 

601 ) 

602 def serveField(table, eid, field): 

603 checkBounds(table=table, eid=eid, field=field) 

604 

605 action = G(request.args, N.action) 

606 if action in FIELD_ACTIONS: 

607 context = getContext() 

608 auth.authenticate() 

609 if table in ALL_TABLES and tablePerm(table): 

610 recordObj = mkTable(context, table).record(eid=eid) 

611 if recordObj.mayRead is not False: 

612 fieldObj = mkTable(context, table).record(eid=eid).field(field) 

613 if fieldObj: 

614 return fieldObj.wrap(action=action) 

615 return noField(table, field) 

616 return noRecord(table) 

617 return noTable(table) 

618 return noAction(action) 

619 

620 # FALL-BACK 

621 

622 @app.route("""/<path:anything>""") 

623 def serveNotFound(anything=None): 

624 checkBounds(anything=anything) 

625 

626 flash(f"Cannot find {anything}", "error") 

627 return redirectResult(START, False) 

628 

629 def noTable(table): 

630 return f"""{NO_TABLE} {table}""" 

631 

632 def noRecord(table): 

633 return f"""{NO_RECORD} {table}""" 

634 

635 def noField(table, field): 

636 return f"""{NO_FIELD} {table}:{field}""" 

637 

638 def noAction(action): 

639 return f"""{NO_ACTION} {action}""" 

640 

641 return app