Coverage for rfpy/api/endpoints/scoring.py: 99%

170 statements  

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

1""" 

2Operations for viewing & assigning scores for vendors' answers 

3""" 

4 

5from typing import List, Optional 

6from datetime import datetime 

7 

8from dataclasses import dataclass 

9 

10from sqlalchemy.orm import Session 

11from sqlalchemy.orm.exc import NoResultFound 

12 

13from rfpy.suxint import http 

14from rfpy.model import ( 

15 ScoreComment, 

16 AuditEvent, 

17 Score, 

18 Project, 

19 LiveProject, 

20 Issue, 

21 User, 

22 QuestionInstance, 

23) 

24 

25from rfpy.api import fetch, validate, update 

26from rfpy.auth import AuthorizationFailure, perms 

27from rfpy.web import serial 

28 

29 

30@http 

31def get_project_scores( 

32 session, user: User, project_id: int, scoreset_id: str = "__default__" 

33) -> serial.ScoringData: 

34 """ 

35 Returns all scores. Used for analysis in the browser. 

36 

37 @raise AuthorisationFailue - if the user is not a standard user from the buying organisation 

38 @raise NoResultFound - if the specified project cannot be loaded 

39 """ 

40 project = fetch.project(session, project_id) 

41 validate.check( 

42 user, perms.ISSUE_VIEW_AGREED_SCORES, project=project, deny_restricted=True 

43 ) 

44 scoreset_id = scoreset_id if scoreset_id != "__default__" else "" 

45 return serial.ScoringData( 

46 scores=[ 

47 s._asdict() for s in fetch.scoring_data(project, scoreset_id=scoreset_id) 

48 ], 

49 scoreset_id=scoreset_id, 

50 ) 

51 

52 

53@http 

54def get_question_scores( 

55 session: Session, user: User, question_id: int, scoreset_id: str = "" 

56): 

57 question = fetch.question(session, question_id) 

58 section = question.section 

59 

60 question_filter = QuestionInstance.id == question.id 

61 

62 score_permission = Score.get_permission_for_scoreset(user, scoreset_id) 

63 validate.check( 

64 user, score_permission, project=question.project, section_id=section.id 

65 ) 

66 

67 return fetch.scores( 

68 session, section.project, section, scoreset_id, user, question_filter 

69 ) 

70 

71 

72@http 

73def post_question_score( 

74 session: Session, user: User, question_id: int, score_doc: serial.Score 

75) -> serial.Id: 

76 """Create or update a single score for a given question/issue combination""" 

77 

78 question = fetch.question(session, question_id) 

79 validate.check( 

80 user, 

81 perms.PROJECT_VIEW_QUESTIONNAIRE, 

82 project=question.project, 

83 section_id=question.section_id, 

84 deny_restricted=False, 

85 ) 

86 project = question.project 

87 score, created = fetch.or_create_score(session, project, user, question, score_doc) 

88 initial_score_value = score.score 

89 score_value = score_doc.score_value 

90 Score.check_score_value(score_value, project) 

91 score.score = score_value 

92 

93 score_perm = Score.get_permission_for_scoreset( 

94 user, score_doc.scoreset_id, to_save=True 

95 ) 

96 validate.check( 

97 user, 

98 score_perm, 

99 project=question.project, 

100 section_id=question.section_id, 

101 deny_restricted=False, 

102 ) 

103 

104 # Need the Score record's ID for the audit event record, so flush 

105 session.flush() 

106 update.log_score_event(session, score, initial_score_value, created, project, user) 

107 return serial.Id(id=score.id) 

108 

109 

110@http 

111def get_project_section_issue_scores( 

112 session, user, project_id, section_id, issue_id, scoreset_id="" 

113): 

114 project = fetch.project(session, project_id) 

115 section = fetch.section_of_project(project, section_id) 

116 issue = project.get_issue(issue_id) 

117 

118 score_permission = Score.get_permission_for_scoreset(user, scoreset_id) 

119 validate.check( 

120 user, score_permission, project=project, issue=issue, section_id=section.id 

121 ) 

122 

123 issue_filter = Issue.id == issue.id 

124 return fetch.scores(session, project, section, scoreset_id, user, issue_filter) 

125 

126 

127@http 

128def post_question_score_comment( 

129 session: Session, user: User, question_id: int, score_doc: serial.Score 

130) -> None: 

131 """Add a single score comment. A score value can be provided at the same time""" 

132 question = fetch.question(session, question_id) 

133 validate.check( 

134 user, 

135 perms.PROJECT_VIEW_QUESTIONNAIRE, 

136 project=question.project, 

137 section_id=question.section_id, 

138 ) 

139 project = question.project 

140 

141 score, created = fetch.or_create_score(session, project, user, question, score_doc) 

142 

143 to_save = False if score_doc.score_value is None else True 

144 

145 score_permission = Score.get_permission_for_scoreset( 

146 user, score_doc.scoreset_id, to_save=to_save 

147 ) 

148 

149 validate.check( 

150 user, 

151 score_permission, 

152 issue=score.issue, 

153 section_id=question.section_id, 

154 project=question.project, 

155 ) 

156 

157 if score_doc.score_value is not None: 

158 initial_score_value = score.score 

159 score_value = score_doc.score_value 

160 Score.check_score_value(score_value, project) 

161 score.score = score_value 

162 # Need the Score record's ID for the audit event record, so flush 

163 session.flush() 

164 update.log_score_event( 

165 session, score, initial_score_value, created, project, user 

166 ) 

167 

168 if score_doc.comment is not None: 

169 # make the comment and add to database 

170 comment = ScoreComment( 

171 score=score, 

172 comment_time=datetime.now(), 

173 user_id=user.id, 

174 comment_text=score_doc.comment, 

175 ) 

176 session.add(comment) 

177 session.flush() 

178 evt = AuditEvent.create( 

179 session, 

180 "SCORE_COMMENT_ADDED", 

181 object_id=comment.id, 

182 user=user, 

183 project=project, 

184 issue_id=score_doc.issue_id, 

185 question_id=question.id, 

186 ) 

187 evt.add_change("Comment", "", comment.comment_text) 

188 

189 session.add(evt) 

190 

191 

192def check_autoscore_permissions( 

193 project: Project, initiating_user: User, target_user: User 

194): 

195 if not project.multiscored: 

196 raise ValueError("Project must be using Multiple Score Sets") 

197 if target_user.organisation not in project.participants: 

198 m = f"User {target_user.id} not a participant in project {project.id}" 

199 raise AuthorizationFailure(m) 

200 target_user.check_permission(perms.ISSUE_SAVE_SCORES) 

201 initiating_user.check_permission(perms.ISSUE_SAVE_AGREED_SCORES) 

202 

203 

204@http 

205def get_project_calcautoscores( 

206 session: Session, user: User, project_id: int, target_user: User 

207): 

208 project = fetch.project(session, project_id) 

209 

210 if not isinstance(project, LiveProject): 

211 raise ValueError("Project must be live to generate autoscores") 

212 

213 check_autoscore_permissions(project, user, target_user) 

214 ascores = project.generate_autoscores(session, target_user) 

215 return list(ascores.values()) 

216 

217 

218@http 

219def post_project_calcautoscores( 

220 session: Session, user: User, project_id: int, target_user: User 

221): 

222 """ 

223 Autoscores are not automatically calculated for User Score Sets. This method 

224 allows autoscores to be set for the target_user's score set in the current project. 

225 """ 

226 project = fetch.project(session, project_id) 

227 

228 if not isinstance(project, LiveProject): 

229 raise ValueError("Project must be live to generate autoscores") 

230 

231 check_autoscore_permissions(project, user, target_user) 

232 existing_scores = project.scores_dict() 

233 unsaved_scores = [] 

234 for auto_key, score_dict in project.generate_autoscores( 

235 session, target_user 

236 ).items(): 

237 if auto_key in existing_scores: 

238 score = existing_scores[auto_key] 

239 if score.score is not None and int(score.score) == int( 

240 score_dict.get("score", -1) 

241 ): 

242 continue 

243 initial_score_value = score.score 

244 score.score = score_dict["score"] 

245 update.log_score_event( 

246 session, 

247 score, 

248 initial_score_value, 

249 False, 

250 project, 

251 target_user, 

252 autoscore=True, 

253 ) 

254 else: 

255 score = Score( 

256 question_instance_id=score_dict["question_id"], 

257 scoreset_id=score_dict["scoreset_id"], 

258 issue_id=score_dict["issue_id"], 

259 score=score_dict["score"], 

260 ) 

261 unsaved_scores.append(score) 

262 session.add(score) 

263 # Need ID values for newly created score objects 

264 session.flush() 

265 for score in unsaved_scores: 

266 update.log_score_event( 

267 session, score, None, True, project, target_user, autoscore=True 

268 ) 

269 

270 

271@http 

272def get_section_scoresummaries( 

273 session, user, section_id, scoreset_id 

274) -> serial.ScoreSummary: 

275 """ 

276 A report summarising scores for all submitted Issues for a Section 

277 

278 For each subsection a summary of the number of questions and the number of scored 

279 questions is provided. This data can be used to calculate a percentage completion 

280 for each subsection. A score total for that subsection is also provided. 

281 

282 For each question within the current section a single score value is provided 

283 

284 A user with the permission ISSUE_VIEW_AGREED_SCORES can see other users' 

285 scores sets. 

286 """ 

287 section = fetch.section(session, section_id) 

288 project = section.project 

289 permission = Score.get_permission_for_scoreset(user, scoreset_id) 

290 validate.check(user, permission, project=project, section_id=section.id) 

291 sub = fetch.subsection_scoressummary(session, user, project, section, scoreset_id) 

292 return serial.ScoreSummary( 

293 subsections=[ 

294 serial.SectionScore.model_validate(row) 

295 for row in fetch.section_scoresummary(session, user, project, section, sub) 

296 ], 

297 questions=[ 

298 serial.QuestionScore.model_validate(q) 

299 for q in fetch.question_scoresummary( 

300 session, user, project, section, scoreset_id 

301 ) 

302 ], 

303 ) 

304 

305 

306@http 

307def get_project_scoresets( 

308 session, user, project_id, scoreset_id="" 

309) -> List[serial.ScoreSet]: 

310 """ 

311 Returns a list of scoresets that the user can view 

312 """ 

313 

314 project = fetch.project(session, project_id) 

315 

316 # VIEW_AGREED_SCORE permission allows a user to view other user's score sets 

317 if user.has_permission(perms.ISSUE_VIEW_AGREED_SCORES): 

318 sq = ( 

319 session.query(Score.scoreset_id, User.fullname) 

320 .join(Issue) 

321 .outerjoin(User, Score.scoreset_id == User.id) 

322 .filter(Issue.project == project, Score.scoreset_id != "") 

323 .distinct() 

324 ) 

325 

326 sc = [serial.ScoreSet.model_validate(row) for row in sq] 

327 sc.append( 

328 serial.ScoreSet(scoreset_id="__default__", fullname="Agreed Scoring Set") 

329 ) 

330 return sc 

331 

332 else: 

333 user.check_permission(perms.ISSUE_VIEW_SCORES) 

334 return [serial.ScoreSet(scoreset_id=user.id, fullname=user.fullname)] 

335 

336 

337@http 

338def get_question_issue_comments(session, user, question_id, issue_id, scoreset_id=""): 

339 question = fetch.question(session, question_id) 

340 validate.check( 

341 user, 

342 perms.PROJECT_VIEW_QUESTIONNAIRE, 

343 project=question.project, 

344 section_id=question.section_id, 

345 ) 

346 issue = question.project.get_issue(issue_id) 

347 

348 score_permission = Score.get_permission_for_scoreset(user, scoreset_id) 

349 

350 validate.check( 

351 user, 

352 score_permission, 

353 issue=issue, 

354 section_id=question.section_id, 

355 project=question.project, 

356 ) 

357 

358 try: 

359 score = ( 

360 session.query(Score) 

361 .filter( 

362 Score.question_instance_id == question.id, 

363 Score.issue_id == issue.id, 

364 Score.scoreset_id == scoreset_id, 

365 ) 

366 .one() 

367 ) 

368 except NoResultFound: 

369 return [] 

370 

371 return [comment.as_dict() for comment in score.comments] 

372 

373 

374@dataclass 

375class ScoreData: 

376 score: Score 

377 initial_score_value: Optional[float] 

378 created: bool 

379 

380 

381@http 

382def post_section_scoreset_scores( 

383 session: Session, 

384 user: User, 

385 section_id: int, 

386 scoreset_id: str, 

387 section_score_docs: serial.SectionScoreDocs, 

388) -> List[serial.Id]: 

389 """Bulk create/update scores""" 

390 

391 section = fetch.section(session, section_id) 

392 project = section.project 

393 validate.check( 

394 user, 

395 perms.PROJECT_VIEW_QUESTIONNAIRE, 

396 project=project, 

397 section_id=section_id, 

398 deny_restricted=False, 

399 ) 

400 

401 score_perm = Score.get_permission_for_scoreset(user, scoreset_id, to_save=True) 

402 validate.check( 

403 user, score_perm, project=project, section_id=section_id, deny_restricted=False 

404 ) 

405 

406 # Use the dataclass instead of a named tuple 

407 data: list[ScoreData] = [] 

408 

409 for doc in section_score_docs.root: 

410 question = fetch.question_of_section(session, section_id, doc.question_id) 

411 for score_doc in doc.scores: 

412 issue = project.get_issue(score_doc.issue_id) 

413 sd = serial.Score( 

414 issue_id=issue.id, 

415 score_value=score_doc.score_value, 

416 scoreset_id=scoreset_id, 

417 ) 

418 score, created = fetch.or_create_score(session, project, user, question, sd) 

419 initial_score_value = score.score 

420 score_value = sd.score_value 

421 Score.check_score_value(score_value, project) 

422 score.score = score_value 

423 data.append( 

424 ScoreData( 

425 score=score, 

426 initial_score_value=initial_score_value, 

427 created=created, 

428 ) 

429 ) 

430 

431 session.flush() 

432 score_ids: list[serial.Id] = [] 

433 

434 for item in data: 

435 session.flush() 

436 update.log_score_event( 

437 session, 

438 item.score, 

439 item.initial_score_value, 

440 item.created, 

441 project, 

442 user, 

443 ) 

444 score_ids.append(serial.Id(id=score.id)) 

445 

446 return score_ids