Coverage for rfpy/api/endpoints/questions.py: 100%

125 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-04-24 10:52 +0000

1""" 

2Create, edit and fetch questions within a project 

3""" 

4 

5from rfpy.model.composite import update_qmeta_table 

6from typing import List 

7 

8from sqlalchemy.orm import Session, subqueryload 

9 

10from rfpy.suxint import http 

11from rfpy.model import ( 

12 QuestionInstance, 

13 User, 

14 AuditEvent, 

15 QuestionDefinition, 

16 Project, 

17 Participant, 

18 QElement, 

19) 

20from rfpy.api import fetch, validate, update 

21from rfpy.web import serial 

22from rfpy.auth import perms 

23from rfpy.model.audit import evt_types 

24 

25 

26@http 

27def get_project_section_question( 

28 session, user, project_id: int, section_id: int, question_id 

29) -> serial.Question: 

30 """Get the question with the given ID""" 

31 qi = ( 

32 session.query(QuestionInstance).filter(QuestionInstance.id == question_id).one() 

33 ) 

34 project = qi.project 

35 validate.check( 

36 user, 

37 perms.PROJECT_VIEW_QUESTIONNAIRE, 

38 project=project, 

39 section_id=qi.section_id, 

40 ) 

41 return serial.Question.model_validate(qi) 

42 

43 

44@http 

45def put_project_section_question( 

46 session: Session, 

47 user: User, 

48 project_id: int, 

49 section_id: int, 

50 question_id: int, 

51 qdef_doc: serial.QuestionDef, 

52) -> serial.Question: 

53 """ 

54 Update the title and question elements for the given question 

55 

56 The provided JSON document must provide elements with ID values for all elements that are to 

57 be retained. 

58 Elements with ID values provided are updated. 

59 Elements without an ID are added as new. 

60 Any existing elements not provided (with matching IDs) in the JSON document are deleted. 

61 

62 If items to be deleted have already been answered (in any project) this update is rejected 

63 with an HTTP Status code of 409 (Conflict). 

64 """ 

65 

66 qinstance: QuestionInstance = ( 

67 session.query(QuestionInstance).filter(QuestionInstance.id == question_id).one() 

68 ) 

69 

70 project = qinstance.project 

71 

72 validate.check( 

73 user, 

74 perms.PROJECT_SAVE_QUESTIONNAIRE, 

75 project=project, 

76 section_id=qinstance.section_id, 

77 deny_restricted=True, 

78 ) 

79 

80 qdef: QuestionDefinition = qinstance.question_def 

81 

82 evt: AuditEvent = AuditEvent.create( 

83 session, 

84 "QUESTION_EDITED", 

85 project=project, 

86 user_id=user.id, 

87 org_id=user.org_id, 

88 object_id=qinstance.id, 

89 question_id=qinstance.id, 

90 private=True, 

91 ) 

92 new_title = qdef_doc.title 

93 if qdef.title != new_title: 

94 evt.add_change("Title", qdef.title, new_title) 

95 qdef.title = new_title 

96 

97 el_map = {el.id: el for el in qdef.elements} 

98 

99 for row_number, row in enumerate(qdef_doc.elements.root, start=1): 

100 for col_number, el in enumerate(row.root, start=1): 

101 el_dict = el.model_dump() 

102 el_dict["row"] = row_number 

103 el_dict["col"] = col_number 

104 update.update_create_qdef(qdef, evt, el_map, el_dict) 

105 

106 update.check_for_saved_answers(session, qdef, el_map) 

107 

108 for _id, unwanted_element in el_map.items(): 

109 qdef.elements.remove(unwanted_element) 

110 evt.add_change("Element Removed", unwanted_element.el_type, None) 

111 

112 session.flush() 

113 update_qmeta_table(session, {qdef.id}) 

114 session.add(evt) 

115 qdef.elements.sort(key=lambda el: (el.row, el.col)) 

116 return serial.Question.model_validate(qinstance) 

117 

118 

119@http 

120def post_project_section_question( 

121 session, user, project_id, section_id, qdef_doc: serial.QuestionDef 

122) -> serial.Question: 

123 """Create a new Question in the given section""" 

124 section = fetch.section_by_id(session, section_id) 

125 project = section.project 

126 validate.check( 

127 user, 

128 perms.PROJECT_SAVE_QUESTIONNAIRE, 

129 project=project, 

130 section_id=section.id, 

131 deny_restricted=True, 

132 ) 

133 

134 qi = QuestionInstance(project=project) 

135 qi.question_def = QuestionDefinition.build(qdef_doc, strip_ids=True) 

136 section.questions.append(qi) 

137 session.flush() 

138 evt = AuditEvent.create( 

139 session, 

140 "QUESTION_CREATED", 

141 project=project, 

142 user_id=user.id, 

143 org_id=user.organisation.id, 

144 object_id=qi.id, 

145 private=True, 

146 question_id=qi.id, 

147 ) 

148 evt.add_change("Title", None, qi.title) 

149 

150 update_qmeta_table(session, {qi.question_def_id}) 

151 return serial.Question.model_validate(qi) 

152 

153 

154@http 

155def delete_project_section_question( 

156 session, user, project_id, section_id, question_id 

157) -> List[serial.QI]: 

158 """ 

159 Delete the Question with the given ID 

160 

161 The return value is an array of remaining instances of the same question that may exist 

162 in other projects 

163 

164 @permission PROJECT_SAVE_QUESTIONNAIRE 

165 """ 

166 

167 qi: QuestionInstance = ( 

168 session.query(QuestionInstance).filter(QuestionInstance.id == question_id).one() 

169 ) 

170 if qi.section_id != section_id: 

171 raise ValueError( 

172 f"Question #{question_id} does not belong to section #{section_id}" 

173 ) 

174 project = qi.project 

175 section = qi.section 

176 

177 validate.check( 

178 user, 

179 perms.PROJECT_SAVE_QUESTIONNAIRE, 

180 project=project, 

181 section_id=section_id, 

182 deny_restricted=True, 

183 ) 

184 

185 return update.delete_project_section_question(session, user, project, section, qi) 

186 

187 

188@http 

189def post_project_section_question_copy( 

190 session: Session, user: User, project_id: int, section_id: int, question_id: int 

191) -> serial.Node: 

192 """Create a copy of the question (instance & definition) given by question_id""" 

193 

194 section = fetch.section_by_id(session, section_id) 

195 project = section.project 

196 

197 validate.check( 

198 user, perms.PROJECT_SAVE_QUESTIONNAIRE, project=project, section_id=section.id 

199 ) 

200 

201 qi = fetch.question_of_section(session, section_id, question_id) 

202 

203 original_qdef = qi.question_def 

204 copied_qdef = update.copy_q_definition(original_qdef, session) 

205 

206 new_qi: QuestionInstance = QuestionInstance(question_def=copied_qdef) 

207 copied_qdef.title += f" (copied from {qi.number.dotted})" 

208 section.questions.append(new_qi) 

209 

210 session.flush() 

211 

212 evt = AuditEvent.create( 

213 session, 

214 "QUESTION_COPIED", 

215 project=project, 

216 user_id=user.id, 

217 org_id=user.organisation.id, 

218 object_id=qi.id, 

219 private=True, 

220 question_id=new_qi.id, 

221 ) 

222 

223 evt.add_change("parent_id", None, original_qdef.id) 

224 evt.add_change("title", original_qdef.title, copied_qdef.title) 

225 evt.add_change("question_id", qi.id, new_qi.id) 

226 evt.add_change("question_number", qi.number.dotted, new_qi.number.dotted) 

227 session.add(evt) 

228 

229 update_qmeta_table(session, {copied_qdef.id}) 

230 

231 return serial.Node( 

232 id=new_qi.id, number=new_qi.number.dotted, title=new_qi.question_def.title 

233 ) 

234 

235 

236@http 

237def get_question_instances( 

238 session: Session, user: User, question_id: int 

239) -> List[serial.QuestionInstance]: 

240 """Find shared instances of the given question across all projects""" 

241 qi = ( 

242 session.query(QuestionInstance).filter(QuestionInstance.id == question_id).one() 

243 ) 

244 project = qi.project 

245 validate.check( 

246 user, 

247 perms.PROJECT_VIEW_QUESTIONNAIRE, 

248 project=project, 

249 section_id=qi.section_id, 

250 ) 

251 

252 instances_query = ( 

253 qi.question_def.instances.join(Project) 

254 .join(Participant) 

255 .filter(Participant.organisation == user.organisation) 

256 .options(subqueryload(QuestionInstance.project)) 

257 ) 

258 

259 instances = [ 

260 serial.QuestionInstance.model_validate(inst) for inst in instances_query 

261 ] 

262 return instances 

263 

264 

265@http 

266def put_project_question_element( 

267 session: Session, 

268 user: User, 

269 project_id: int, 

270 question_id: int, 

271 element_id: int, 

272 element_doc: serial.QElement, 

273): 

274 """Update a single Question Element""" 

275 project = fetch.project(session, project_id) 

276 qinstance: QuestionInstance = project.questions.filter( 

277 QuestionInstance.id == question_id 

278 ).one() 

279 element: QElement = qinstance.question_def.get_element(element_id) 

280 validate.check( 

281 user, 

282 perms.PROJECT_SAVE_QUESTIONNAIRE, 

283 project=project, 

284 section_id=qinstance.section_id, 

285 deny_restricted=True, 

286 ) 

287 

288 evt: AuditEvent = AuditEvent.create( 

289 session, 

290 "QUESTION_EDITED", 

291 project=project, 

292 user_id=user.id, 

293 org_id=user.org_id, 

294 object_id=qinstance.id, 

295 question_id=qinstance.id, 

296 private=True, 

297 ) 

298 for doc_field_name in serial.QElement.model_fields.keys(): 

299 doc_field_value = getattr(element_doc, doc_field_name) 

300 if hasattr(element, doc_field_name): 

301 element_field_value = getattr(element, doc_field_name) 

302 if element_field_value != doc_field_value: 

303 evt.add_change( 

304 doc_field_name, getattr(element, doc_field_name), doc_field_value 

305 ) 

306 setattr(element, doc_field_name, doc_field_value) 

307 

308 session.add(evt) 

309 update_qmeta_table(session, {question_id}) 

310 

311 

312@http 

313def post_project_section_question_unlink( 

314 session: Session, user: User, project_id, section_id: int, question_id: int 

315): 

316 """ 

317 Unlink the question from any previous projects so it can be edited freely. 

318 N.B. This breaks answer & score importing between projects 

319 """ 

320 """Create a copy of the question given by question_id""" 

321 section = fetch.section_by_id(session, section_id) 

322 project = section.project 

323 

324 validate.check( 

325 user, perms.PROJECT_SAVE_QUESTIONNAIRE, project=project, section_id=section.id 

326 ) 

327 qi: QuestionInstance = fetch.question_of_section(session, section_id, question_id) 

328 original_qdef = qi.question_def 

329 original_qdef.refcount = original_qdef.refcount - 1 

330 

331 copied_qdef = update.copy_q_definition(original_qdef, session) 

332 qi.question_def = copied_qdef 

333 qi.question_def.title += " (copy unlinked from previous projects)" 

334 

335 evt = AuditEvent.create( 

336 session, 

337 evt_types.QUESTION_COPIED, 

338 project=project, 

339 user_id=user.id, 

340 org_id=user.organisation.id, 

341 object_id=qi.id, 

342 private=True, 

343 question_id=qi.id, 

344 ) 

345 session.flush() 

346 evt.add_change("parent_id", None, copied_qdef.parent_id) 

347 evt.add_change("title", original_qdef.title, copied_qdef.title) 

348 session.add(evt)