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

123 statements  

« prev     ^ index     » next       coverage.py v7.0.1, created at 2022-12-31 16:00 +0000

1 

2''' 

3Create, edit and fetch questions within a project 

4''' 

5from rfpy.model.composite import update_qmeta_table 

6from typing import Dict, List 

7 

8from sqlalchemy.orm import Session, subqueryload 

9 

10from rfpy.suxint import http 

11from rfpy.model import ( 

12 QuestionInstance, User, AuditEvent, QuestionDefinition, Project, Participant, QElement 

13) 

14from rfpy.api import fetch, validate, update 

15from rfpy.web import serial 

16from rfpy.auth import perms 

17from rfpy.model.audit import evt_types 

18 

19 

20@http 

21def get_project_section_question(session, user, 

22 project_id: int, 

23 section_id: int, 

24 question_id) -> serial.Question: 

25 '''Get the question with the given ID''' 

26 qi = session.query(QuestionInstance).filter(QuestionInstance.id == question_id).one() 

27 project = qi.project 

28 validate.check(user, perms.PROJECT_VIEW_QUESTIONNAIRE, 

29 project=project, 

30 section_id=qi.section_id) 

31 return serial.Question.from_orm(qi) 

32 

33 

34@http 

35def put_project_section_question(session: Session, 

36 user: User, 

37 project_id: int, 

38 section_id: int, 

39 question_id: int, 

40 qdef_doc: serial.QuestionDef) -> serial.Question: 

41 ''' 

42 Update the title and question elements for the given question 

43 

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

45 be retained. 

46 Elements with ID values provided are updated. 

47 Elements without an ID are added as new. 

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

49 

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

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

52 ''' 

53 

54 qinstance: QuestionInstance = (session.query(QuestionInstance) 

55 .filter(QuestionInstance.id == question_id) 

56 .one()) 

57 project = qinstance.project 

58 

59 validate.check(user, perms.PROJECT_SAVE_QUESTIONNAIRE, 

60 project=project, section_id=qinstance.section_id, 

61 deny_restricted=True) 

62 

63 qdef: QuestionDefinition = qinstance.question_def 

64 

65 evt: AuditEvent = AuditEvent.create('QUESTION_EDITED', 

66 project=project, user_id=user.id, 

67 org_id=user.org_id, object_id=qinstance.id, 

68 question_id=qinstance.id, private=True) 

69 new_title = qdef_doc['title'] 

70 if qdef.title != new_title: 

71 evt.add_change('Title', qdef.title, new_title) 

72 qdef.title = new_title 

73 

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

75 

76 for row_number, row in enumerate(qdef_doc['elements'], start=1): 

77 for col_number, el_dict in enumerate(row, start=1): 

78 el_dict['row'] = row_number 

79 el_dict['col'] = col_number 

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

81 

82 update.check_for_saved_answers(session, qdef, el_map) 

83 

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

85 qdef.elements.remove(unwanted_element) 

86 evt.add_change('Element Removed', unwanted_element.el_type, None) 

87 

88 session.flush() 

89 update_qmeta_table(session, {qdef.id}) 

90 session.add(evt) 

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

92 return serial.Question.from_orm(qinstance).dict() 

93 

94 

95@http 

96def post_project_section_question(session, 

97 user, 

98 project_id, 

99 section_id, 

100 qdef_doc: serial.QuestionDef) -> serial.Question: 

101 '''Create a new Question in the given section''' 

102 section = fetch.section_by_id(session, section_id) 

103 project = section.project 

104 validate.check(user, perms.PROJECT_SAVE_QUESTIONNAIRE, 

105 project=project, section_id=section.id, deny_restricted=True) 

106 

107 qi = QuestionInstance(project=project) 

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

109 section.questions.append(qi) 

110 session.flush() 

111 evt = AuditEvent.create('QUESTION_CREATED', 

112 project=project, 

113 user_id=user.id, 

114 org_id=user.organisation.id, 

115 object_id=qi.id, 

116 private=True, 

117 question_id=qi.id) 

118 evt.add_change('Title', None, qi.title) 

119 

120 update_qmeta_table(session, {qi.question_def_id}) 

121 session.add(evt) 

122 return serial.Question.from_orm(qi).dict() 

123 

124 

125@http 

126def delete_project_section_question(session, 

127 user, 

128 project_id, 

129 section_id, 

130 question_id) -> List[serial.QI]: 

131 ''' 

132 Delete the Question with the given ID 

133 

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

135 in other projects 

136 

137 @permission PROJECT_SAVE_QUESTIONNAIRE 

138 ''' 

139 

140 qi: QuestionInstance = (session.query(QuestionInstance) 

141 .filter(QuestionInstance.id == question_id) 

142 .one()) 

143 if qi.section_id != section_id: 

144 raise ValueError(f'Question #{question_id} does not belong to section #{section_id}') 

145 project = qi.project 

146 section = qi.section 

147 

148 validate.check(user, perms.PROJECT_SAVE_QUESTIONNAIRE, 

149 project=project, section_id=section_id, 

150 deny_restricted=True) 

151 

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

153 

154 

155@http 

156def post_project_section_question_copy(session: Session, user: User, project_id: int, 

157 section_id: int, question_id: int) -> serial.Node: 

158 '''Create a copy of the question (instance & definition) given by question_id''' 

159 

160 section = fetch.section_by_id(session, section_id) 

161 project = section.project 

162 

163 validate.check(user, perms.PROJECT_SAVE_QUESTIONNAIRE, project=project, section_id=section.id) 

164 

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

166 

167 original_qdef = qi.question_def 

168 copied_qdef = update.copy_q_definition(original_qdef, session) 

169 

170 new_qi: QuestionInstance = QuestionInstance(question_def=copied_qdef) 

171 copied_qdef.title += f' (copied from {qi.number.dotted})' 

172 section.questions.append(new_qi) 

173 

174 session.flush() 

175 

176 evt = AuditEvent.create('QUESTION_COPIED', 

177 project=project, user_id=user.id, 

178 org_id=user.organisation.id, object_id=qi.id, 

179 private=True, question_id=new_qi.id) 

180 

181 evt.add_change('parent_id', None, original_qdef.id) 

182 evt.add_change('title', original_qdef.title, copied_qdef.title) 

183 evt.add_change('question_id', qi.id, new_qi.id) 

184 evt.add_change('question_number', qi.number.dotted, new_qi.number.dotted) 

185 session.add(evt) 

186 

187 update_qmeta_table(session, {copied_qdef.id}) 

188 

189 return serial.Node(id=new_qi.id, number=new_qi.number.dotted, 

190 title=new_qi.question_def.title) 

191 

192 

193@http 

194def get_question_instances(session: Session, 

195 user: User, 

196 question_id: int) -> List[serial.QuestionInstance]: 

197 '''Find shared instances of the given question across all projects''' 

198 qi = session.query(QuestionInstance).filter(QuestionInstance.id == question_id).one() 

199 project = qi.project 

200 validate.check(user, perms.PROJECT_VIEW_QUESTIONNAIRE, 

201 project=project, 

202 section_id=qi.section_id) 

203 

204 instances_query = qi.question_def.instances\ 

205 .join(Project, Participant)\ 

206 .filter(Participant.organisation == user.organisation)\ 

207 .options(subqueryload('project')) 

208 

209 instances = [{'project_id': inst.project.id, 

210 'project_title': inst.project.title, 

211 'number': inst.number.dotted, 

212 'id': inst.id, 

213 'section_id': inst.section_id} 

214 for inst in instances_query.all()] 

215 return instances 

216 

217 

218@http 

219def put_project_question_element(session: Session, user: User, 

220 project_id: int, question_id: int, 

221 element_id: int, element_doc: serial.QElement): 

222 '''Update a single Question Element''' 

223 project = fetch.project(session, project_id) 

224 qinstance: QuestionInstance = project.questions.filter(QuestionInstance.id == question_id).one() 

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

226 validate.check(user, perms.PROJECT_SAVE_QUESTIONNAIRE, 

227 project=project, section_id=qinstance.section_id, 

228 deny_restricted=True) 

229 

230 evt: AuditEvent = AuditEvent.create('QUESTION_EDITED', 

231 project=project, user_id=user.id, 

232 org_id=user.org_id, object_id=qinstance.id, 

233 question_id=qinstance.id, private=True) 

234 for k, v in element_doc.items(): 

235 if hasattr(element, k): 

236 evt.add_change(k, getattr(element, k, v), v) 

237 setattr(element, k, v) 

238 

239 session.add(evt) 

240 update_qmeta_table(session, {question_id}) 

241 

242 

243@http 

244def post_project_section_question_unlink(session: Session, 

245 user: User, 

246 project_id, 

247 section_id: int, 

248 question_id: int): 

249 ''' 

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

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

252 ''' 

253 '''Create a copy of the question given by question_id''' 

254 section = fetch.section_by_id(session, section_id) 

255 project = section.project 

256 

257 validate.check(user, perms.PROJECT_SAVE_QUESTIONNAIRE, project=project, section_id=section.id) 

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

259 original_qdef = qi.question_def 

260 original_qdef.refcount = original_qdef.refcount - 1 

261 

262 copied_qdef = update.copy_q_definition(original_qdef, session) 

263 qi.question_def = copied_qdef 

264 qi.question_def.title += ' (copy unlinked from previous projects)' 

265 

266 evt = AuditEvent.create(evt_types.QUESTION_COPIED, project=project, user_id=user.id, 

267 org_id=user.organisation.id, object_id=qi.id, 

268 private=True, question_id=qi.id) 

269 session.flush() 

270 evt.add_change('parent_id', None, copied_qdef.parent_id) 

271 evt.add_change('title', original_qdef.title, copied_qdef.title) 

272 session.add(evt)