Coverage for postrfp/buyer/api/endpoints/sections.py: 97%

171 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-10-22 21:34 +0000

1""" 

2Create, edit and view sections within a project 

3""" 

4 

5from typing import List, Annotated 

6from postrfp.model.composite import update_qmeta_table 

7from cgi import FieldStorage 

8 

9from sqlalchemy.orm import Session 

10 

11from postrfp.shared import fetch, update, serial 

12from postrfp.shared.decorators import http, http_etag 

13from postrfp.model import ( 

14 QuestionInstance, 

15 User, 

16 Section, 

17 AuditEvent, 

18 QuestionDefinition, 

19 ImportType, 

20) 

21 

22from postrfp.buyer.api import authorise 

23from postrfp.model.audit import evt_types 

24from postrfp.model.exc import ( 

25 QuestionnaireStructureException, 

26 DuplicateQuestionDefinition, 

27) 

28from postrfp.model.questionnaire.b36 import from_b36 

29from postrfp.authorisation import perms 

30 

31 

32@http 

33def put_project_section( 

34 session: Session, 

35 user: User, 

36 project_id: int, 

37 section_id: int, 

38 edit_sec_doc: serial.EditableSection, 

39): 

40 """Update title or description for the given Section""" 

41 section = fetch.section_by_id(session, section_id) 

42 project = section.project 

43 authorise.check( 

44 user, perms.PROJECT_EDIT_COSMETIC, project=project, section_id=section.id 

45 ) 

46 

47 evt = AuditEvent.create( 

48 session, 

49 evt_types.SECTION_UPDATED, 

50 project=project, 

51 user_id=user.id, 

52 org_id=user.organisation.id, 

53 object_id=section.id, 

54 private=True, 

55 ) 

56 

57 for attr_name in ("description", "title"): 

58 new_val = getattr(edit_sec_doc, attr_name, None) 

59 if new_val is not None: 

60 old_val = getattr(section, attr_name) 

61 if new_val != old_val: 

62 evt.add_change(attr_name, old_val, new_val) 

63 setattr(section, attr_name, new_val) 

64 

65 session.add(evt) 

66 return serial.Section.model_validate(section) 

67 

68 

69@http 

70def post_project_section( 

71 session: Session, 

72 user: User, 

73 project_id: int, 

74 section_id: int, 

75 edit_sec_doc: serial.EditableSection, 

76) -> serial.Section: 

77 """Create a new Section""" 

78 parent = fetch.section_by_id(session, section_id) 

79 project = fetch.project(session, project_id) 

80 authorise.check( 

81 user, perms.PROJECT_SAVE_QUESTIONNAIRE, project=project, section_id=section_id 

82 ) 

83 

84 new_section = Section( 

85 title=edit_sec_doc.title, description=edit_sec_doc.description 

86 ) 

87 

88 parent.subsections.append(new_section) 

89 parent.renumber() 

90 

91 evt = AuditEvent.create( 

92 session, 

93 evt_types.SECTION_CREATED, 

94 project=project, 

95 user_id=user.id, 

96 org_id=user.organisation.id, 

97 object_id=new_section.id, 

98 private=True, 

99 ) 

100 evt.add_change("title", None, new_section.title) 

101 if edit_sec_doc.description: 

102 evt.add_change("description", None, new_section.description) 

103 session.add(evt) 

104 return serial.Section.model_validate(new_section) 

105 

106 

107@http 

108def delete_project_section( 

109 session: Session, user: User, project_id: int, section_id: int 

110): 

111 """ 

112 Delete the given Section and all questions and subsections contained within that 

113 section. 

114 

115 @permission PROJECT_SAVE_QUESTIONNAIRE 

116 """ 

117 section = fetch.section_by_id(session, section_id) 

118 project = section.project 

119 

120 authorise.check( 

121 user, perms.PROJECT_SAVE_QUESTIONNAIRE, project=project, section_id=section_id 

122 ) 

123 

124 return update.delete_project_section(session, user, project, section) 

125 

126 

127@http 

128def put_project_movesection( 

129 session: Session, user: User, project_id: int, move_section_doc: serial.MoveSection 

130) -> serial.Section: 

131 """ 

132 Move the section given by section_id to a location with the same project given by 

133 new_parent_id - the ID of the new Parent section 

134 """ 

135 project = fetch.project(session, project_id) 

136 new_parent_id = move_section_doc.new_parent_id 

137 section_id = move_section_doc.section_id 

138 

139 authorise.check( 

140 user, 

141 perms.PROJECT_SAVE_QUESTIONNAIRE, 

142 project=project, 

143 section_id=section_id, 

144 deny_restricted=True, 

145 ) 

146 authorise.check( 

147 user, 

148 perms.PROJECT_SAVE_QUESTIONNAIRE, 

149 project=project, 

150 section_id=new_parent_id, 

151 deny_restricted=True, 

152 ) 

153 

154 section = project.sections.filter_by(id=section_id).one() 

155 parent_section = project.sections.filter_by(id=new_parent_id).one() 

156 

157 if section is parent_section: 

158 m = "section_id and new_parent_id must be different values" 

159 raise QuestionnaireStructureException(m) 

160 

161 if section.parent_id == new_parent_id: 

162 raise ValueError( 

163 "parent_section_id provided cannot be the same as the current parent_id" 

164 ) 

165 

166 section.parent_id = new_parent_id 

167 session.flush() 

168 if parent_section.parent: 

169 parent_section.parent.renumber() 

170 else: 

171 parent_section.renumber() 

172 session.refresh(section) 

173 return serial.Section.model_validate(section) 

174 

175 

176@http 

177def put_project_section_children( 

178 session: Session, 

179 user: User, 

180 project_id: int, 

181 section_id: int, 

182 child_nodes_doc: serial.SectionChildNodes, 

183) -> serial.SummarySection: 

184 """ 

185 Set the contents of the Section by providing a list of section IDs *or* question IDs. 

186 

187 This method can be used to move *existing* questions or sections to a new parent section or 

188 to re-order existing section contents. It cannot be used to move questions or sections from a 

189 different project. 

190 

191 Child objects (Section or Question) which exist within the current section but whose ID is not 

192 included in the JSON body parameter are termed 'orphans'. If 'delete_orphans' is true, then 

193 such objects will be deleted, i.e. the contents of the current section will be exactly the same 

194 as that provided by the question_ids or section_ids parameter. 

195 

196 The default value for 'delete_orphans' is false - this value should only be overridden with 

197 care. 

198 

199 N.B. The JSON body object should contain either question_ids or section_ids arrays, but 

200 not both. 

201 """ 

202 from postrfp.shared.movenodes import ( 

203 SectionsMover, 

204 QuestionsMover, 

205 AbstractNodeMover, 

206 ) 

207 

208 project = fetch.project(session, project_id) 

209 parent: Section = project.sections.filter_by(id=section_id).one() 

210 authorise.check(user, perms.PROJECT_EDIT, project=project, section_id=parent.id) 

211 

212 section_ids = child_nodes_doc.section_ids 

213 question_ids = child_nodes_doc.question_ids 

214 

215 mover: AbstractNodeMover 

216 

217 if section_ids: 

218 if parent.id in set(section_ids): 

219 raise ValueError( 

220 f"Parent section ID {parent.id} (from URL param) cannot be assigned under section_ids" 

221 ) 

222 mover = SectionsMover( 

223 session=session, 

224 user=user, 

225 project=project, 

226 parent=parent, 

227 provided_ids=section_ids, 

228 delete_orphans=child_nodes_doc.delete_orphans, 

229 ) 

230 elif question_ids: 

231 mover = QuestionsMover( 

232 session=session, 

233 user=user, 

234 project=project, 

235 parent=parent, 

236 provided_ids=question_ids, 

237 delete_orphans=child_nodes_doc.delete_orphans, 

238 ) 

239 else: 

240 raise ValueError("Either section_ids or question_ids must be provided") 

241 

242 mover.execute() 

243 

244 return serial.SummarySection.model_validate(parent) 

245 

246 

247@http 

248def get_project_section( 

249 session: Session, user: User, project_id: int, section_id: int 

250) -> serial.SummarySection: 

251 """ 

252 Get the Section with the given ID together with questions and subsections contained 

253 within the Section. 

254 """ 

255 project = fetch.project(session, project_id) 

256 section = fetch.section_of_project(project, section_id) 

257 authorise.check( 

258 user, 

259 perms.PROJECT_VIEW_QUESTIONNAIRE, 

260 project=project, 

261 section_id=section.id, 

262 deny_restricted=False, 

263 ) 

264 

265 sec_doc = serial.SummarySection( 

266 id=section.id, 

267 parent_id=section.parent_id, 

268 number=section.number, 

269 title=section.title, 

270 description=section.description, 

271 ) 

272 

273 sq = section.questions_query.join( 

274 QuestionDefinition, 

275 QuestionInstance.question_def_id == QuestionDefinition.id, 

276 ).with_entities( 

277 QuestionInstance.id, QuestionDefinition.title, QuestionInstance.b36_number 

278 ) 

279 sec_doc.questions = [ 

280 serial.Node(id=r.id, title=r.title, number=from_b36(r.b36_number)) for r in sq 

281 ] 

282 

283 for subsec in fetch.visible_subsections_query(section, user): 

284 sec_doc.subsections.append( 

285 serial.Node(id=subsec.id, title=subsec.title, number=subsec.number) 

286 ) 

287 

288 return sec_doc 

289 

290 

291@http_etag 

292def get_project_treenodes( 

293 session: Session, user: User, project_id: int 

294) -> List[serial.ProjectNode]: 

295 """ 

296 N.B. - this method is deprecated. Use get_project_nodes instead. 

297 

298 Get an array of all sections, subsections and questions for the given project id. 

299 

300 Each section or question is a "node" in the questionnaire tree structure. The array 

301 is sorted in document order: 1.1.1, 1.1.2, 1.2.1, 1.2.2 etc. 

302 """ 

303 project = fetch.project(session, project_id) 

304 authorise.check( 

305 user, perms.PROJECT_VIEW_QUESTIONNAIRE, project=project, deny_restricted=True 

306 ) 

307 nodes = [] 

308 

309 for node in fetch.light_nodes(session, project_id): 

310 nodes.append( 

311 serial.ProjectNode( 

312 id=node.id, 

313 title=node.title, 

314 type=node.type, 

315 parent_id=node.parent_id, 

316 number=from_b36(node.b36_number), 

317 position=node.position, 

318 depth=int(node.depth), 

319 ) 

320 ) 

321 return nodes 

322 

323 

324@http 

325def get_project_nodes( 

326 session: Session, 

327 user: User, 

328 project_id: int, 

329 q_section_id: int, 

330 with_ancestors: bool, 

331) -> List[serial.ProjectNode]: 

332 """ 

333 Get an array of subsections and questions for the given project id and section id. 

334 If section ID is not provided then results for the root section of the project are returned. 

335 

336 If ancestors parameter is true or 1, then nodes of all direct ancestors and siblings of those 

337 ancestors are returned, facilitating the respresentation of a tree structure. 

338 

339 Each section or question is a "node" in the questionnaire. The array 

340 is sorted in document order: 1.1.1, 1.1.2, 1.2.1, 1.2.2 etc. 

341 """ 

342 project = fetch.project(session, project_id) 

343 authorise.check( 

344 user, perms.PROJECT_VIEW_QUESTIONNAIRE, project=project, deny_restricted=True 

345 ) 

346 

347 if q_section_id is None: 

348 q_section_id = project.section_id 

349 sec = ( 

350 session.query(Section) 

351 .filter(Section.id == q_section_id) 

352 .filter(Section.project_id == project_id) 

353 .one() 

354 ) 

355 

356 nodes = [] 

357 for node in fetch.visible_nodes(session, sec, with_ancestors=with_ancestors): 

358 nodes.append( 

359 serial.ProjectNode( 

360 id=node.id, 

361 title=node.title, 

362 type=node.type, 

363 parent_id=node.parent_id, 

364 number=from_b36(node.b36_number), 

365 position=node.position, 

366 depth=int(node.depth), 

367 ) 

368 ) 

369 return nodes 

370 

371 

372@http 

373def post_project_section_excel( 

374 session: Session, 

375 user: User, 

376 project_id: int, 

377 section_id: int, 

378 data_upload: FieldStorage, 

379) -> serial.ExcelImportResult: 

380 """ 

381 Create questions and subsections within the given section by uploading an Excel spreadsheet 

382 with a specific format of 5 columns: 

383 

384 - A: Section Title. Add the newly created question to this section, creating the Section if 

385 necessary 

386 - B: Question Title. Short - for reference and navigation 

387 - C: Question Text. The body of the question 

388 - D: Multiple Choices. Multiple choice options seperated by semi colons. A numeric value 

389 in angle brackets, e.g. <5> after the option text is interpreted as the Autoscore value 

390 for that option 

391 - E: Comments Field header. If provided in conjunction with Multiple choice options the 

392 value of this column is used to create a subheader below the multiple choice field 

393 and above a text field for comments or qualifications. 

394 

395 

396 At most 500 rows are processed. Additional rows are disregarded. If column D is empty the 

397 question will consist of a single text input field. 

398 """ 

399 

400 from ..io import excel_import 

401 

402 project = fetch.project(session, project_id) 

403 section = fetch.section_of_project(project, section_id) 

404 authorise.check( 

405 user, 

406 perms.PROJECT_SAVE_QUESTIONNAIRE, 

407 project=project, 

408 section_id=section_id, 

409 deny_restricted=True, 

410 ) 

411 eqi = excel_import.ExcelQImporter(section) 

412 eqi.read_questions_excel(data_upload.file) 

413 if section.is_top_level: 

414 section.renumber() 

415 elif section.parent is not None: 

416 section.parent.renumber() 

417 

418 descendant_qids = project.questions.filter( 

419 QuestionInstance.b36_number.startswith(section.b36_number) 

420 ).with_entities(QuestionInstance.question_def_id) 

421 update_qmeta_table(session, {r[0] for r in descendant_qids}) 

422 

423 return serial.ExcelImportResult(imported_count=eqi.created_count) 

424 

425 

426@http 

427def get_project_qstats( 

428 session: Session, user: User, project_id: int 

429) -> Annotated[dict[str, int | dict], serial.QuestionnaireStats]: 

430 """ 

431 Retrieve statistics for counts of sections, questions and question elements (by type) 

432 for the given project ID. 

433 """ 

434 project = fetch.project(session, project_id) 

435 authorise.check(user, perms.PROJECT_VIEW_QUESTIONNAIRE, project=project) 

436 return fetch.questionnaire_stats(session, project_id) 

437 

438 

439@http 

440def post_project_section_import( 

441 session: Session, 

442 user: User, 

443 project_id: int, 

444 section_id: int, 

445 import_section_doc: serial.SectionImportDoc, 

446) -> serial.SectionImportResult: 

447 """ 

448 Import questionnaire from previous project 

449 """ 

450 

451 des_project = fetch.project(session, project_id) 

452 des_section = fetch.section_of_project(des_project, section_id) 

453 

454 src_project = fetch.project(session, import_section_doc.project_id) 

455 

456 authorise.check( 

457 user, 

458 perms.PROJECT_SAVE_QUESTIONNAIRE, 

459 project=des_project, 

460 section_id=des_section.id, 

461 ) 

462 authorise.check(user, action=perms.PROJECT_ACCESS, project=src_project) 

463 

464 src_sections = [] 

465 for section_id in import_section_doc.section_ids: 

466 src_sections.append(fetch.section_of_project(src_project, section_id)) 

467 

468 src_questions = [] 

469 for question_id in import_section_doc.question_ids: 

470 src_questions.append(fetch.question_of_project(src_project, question_id)) 

471 

472 import_type = ImportType.COPY if import_section_doc.clone else ImportType.SHARE 

473 if import_type == ImportType.SHARE: 

474 duplicated_qis: list[QuestionInstance] = fetch.duplicated_qdefs( 

475 session, 

476 project_id, 

477 import_section_doc.project_id, 

478 src_sections, 

479 src_questions, 

480 ) 

481 if len(duplicated_qis) > 0: 

482 duplicated_titles = [f"{q.number}. {q.title}" for q in duplicated_qis] 

483 raise DuplicateQuestionDefinition( 

484 f"Duplicate questions definition(s) {duplicated_titles}" 

485 ) 

486 

487 imported_sections: list[Section] = [] 

488 imported_questions: list[QuestionInstance] = [] 

489 for src_sec in src_sections: 

490 sections, questions = update.import_section( 

491 session, src_sec, des_section, import_type 

492 ) 

493 imported_sections += sections 

494 imported_questions += questions 

495 for src_que in src_questions: 

496 imported_qi: QuestionInstance = update.import_q_instance( 

497 src_que, des_section, import_type 

498 ) 

499 imported_questions.append(imported_qi) 

500 

501 session.flush() 

502 if import_type == ImportType.COPY: 

503 update_qmeta_table(session, {qi.question_def_id for qi in imported_questions}) 

504 

505 if des_section.is_top_level: 

506 des_section.renumber() 

507 elif des_section.parent is not None: 

508 des_section.parent.renumber() 

509 

510 return serial.SectionImportResult( 

511 section_count=len(imported_sections), question_count=len(imported_questions) 

512 )