Coverage for middle_layer/review/application_layer/rest_api/views/ppr_proposal_review.py: 91.74%

121 statements  

« prev     ^ index     » next       coverage.py v7.10.5, created at 2026-03-09 06:13 +0000

1# Copyright 2024 Associated Universities, Inc. 

2# 

3# This file is part of Telescope Time Allocation Tools (TTAT). 

4# 

5# TTAT is free software: you can redistribute it and/or modify 

6# it under the terms of the GNU General Public License as published by 

7# the Free Software Foundation, either version 3 of the License, or 

8# any later version. 

9# 

10# TTAT is distributed in the hope that it will be useful, 

11# but WITHOUT ANY WARRANTY; without even the implied warranty of 

12# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

13# GNU General Public License for more details. 

14# 

15# You should have received a copy of the GNU General Public License 

16# along with TTAT. If not, see <https://www.gnu.org/licenses/>. 

17 

18from http import HTTPStatus 

19 

20from pyramid.httpexceptions import HTTPBadRequest, HTTPUnauthorized 

21from pyramid.request import Request 

22from pyramid.response import Response 

23from pyramid.view import view_config 

24 

25from common.application_layer.rest_api import make_expected_params_message 

26from review.application_layer.services.notify_tta_members_srp_ppr_proposal_reviews_finalized import ( 

27 notify_tta_members_srp_ppr_proposal_reviews_finalized, 

28) 

29from review.application_layer.services.update_review_rules_service import ( 

30 PPRUpdatePermission, 

31 check_ppr_update_permitted, 

32) 

33from review.domain_layer.entities.conflict_declaration import ConflictDeclaration 

34from review.domain_layer.entities.individual_science_review import IndividualScienceReview 

35from review.domain_layer.entities.ppr_proposal_review import PPRProposalReview 

36from review.domain_layer.entities.science_review_panel import ScienceReviewPanel 

37from review.domain_layer.entities.science_reviewer import ScienceReviewer 

38from review.domain_layer.services.anonymize_ppr_proposal_review_service import anonymize_ppr_proposal_reviews 

39from review.domain_layer.services.deserialize_csv_to_ppr_proposal_reviews_service import ( 

40 deserialize_csv_to_ppr_proposal_reviews, 

41) 

42from review.domain_layer.services.finalize_prop_reviews_service import finalize_ppr_proposal_reviews 

43from review.domain_layer.services.serialize_ppr_proposal_reviews_to_csv_service import ( 

44 serialize_ppr_proposal_reviews_to_csv, 

45) 

46from review.domain_layer.services.validate_ppr_prop_rev_score_service import ( 

47 PPR_PROP_REV_SCORE_RANGE_MESSAGE, 

48 validate_ppr_prop_review_score, 

49) 

50from review.domain_layer.services.validate_pprpr_state_change_service import validate_pprpr_state_change 

51 

52 

53@view_config( 

54 route_name="ppr_proposal_review_update", 

55 renderer="json", 

56 permission="ppr_proposal_review_update", 

57) 

58def ppr_proposal_review_update(request: Request) -> Response: 

59 """Enter a PPRProposalReview by updating its comments, state, or scores 

60 

61 URL: ppr_proposal_reviews/{ppr_prop_rev_id} 

62 

63 :param request: PUT Request with JSON body like: 

64 { 

65 "externalTechnicalReviewComments": <str>, 

66 "internalScienceReviewComments": <str>, 

67 "externalTechnicalReviewComments": <str>, 

68 "internalTechnicalReviewComments": <str>, 

69 "externalDataManagementReviewComments": <str>, 

70 "internalDataManagementReviewComments": <str>, 

71 "reviewState": <str>, 

72 ["srpScore": <int>,] 

73 } 

74 where srpScore is optional; 

75 :return: Response with JSON-formatted PPRProposalReview updated according to the request 

76 or 404 response (HTTPNotFound) if no PPRProposalReview exists with an ID of ppr_prop_rev_id 

77 or 400 response (HTTPBadRequest) if ppr_prop_rev_id is not an integer, expected parameters are not given, 

78 or an invalid PPRProposalReview state transition is requested 

79 or 401 response (HTTPUnauthorized) if the requesting user doesn't have permission to enter 

80 this PPRProposalReview 

81 """ 

82 user_id = request.identity.user_id 

83 

84 ppr_prop_review: PPRProposalReview = request.lookup(request.matchdict["ppr_prop_rev_id"], PPRProposalReview) 

85 srp: ScienceReviewPanel = request.repo.science_review_panel_repo.by_proposal_id(ppr_prop_review.proposal_id) 

86 sr: ScienceReviewer | None = request.repo.science_reviewer_repo.by_srp_id_and_user_id( 

87 srp.science_review_panel_id, user_id 

88 ) 

89 isr: IndividualScienceReview | None = None 

90 if sr: 

91 isr = request.repo.individual_science_review_repo.by_reviewer_id_and_proposal_code( 

92 sr.science_reviewer_id, ppr_prop_review.proposal.proposal_code 

93 ) 

94 

95 is_tta_member = bool(request.identity) and "tta_member" in request.identity.roles 

96 

97 check: PPRUpdatePermission = check_ppr_update_permitted( 

98 is_tta_member=is_tta_member, 

99 is_chair=sr is not None and sr.is_chair, 

100 is_conflicted=isr is not None and isr.is_conflicted, 

101 review_type=isr is not None and isr.review_type or "", 

102 review_state=isr is not None and isr.review_state or "", 

103 ) 

104 

105 if not check.can_update: 

106 raise HTTPUnauthorized( 

107 body=f"User {user_id} is not authorized to update PPRProposalReview for Proposal {ppr_prop_review.proposal_id}: {check.message}" 

108 ) 

109 

110 params = request.json_body 

111 expected_params = [ 

112 "externalScienceReviewComments", 

113 "internalScienceReviewComments", 

114 "externalTechnicalReviewComments", 

115 "internalTechnicalReviewComments", 

116 "externalDataManagementReviewComments", 

117 "internalDataManagementReviewComments", 

118 "reviewState", 

119 ] 

120 if not all([expected in params for expected in expected_params]): 

121 raise HTTPBadRequest(body=make_expected_params_message(expected_params, params.keys())) 

122 

123 is_unconflicted_chair = (sr is not None and sr.is_chair) and (isr is not None and not isr.is_conflicted) 

124 

125 validation_msg = validate_pprpr_state_change( 

126 ppr_prop_review.review_state, 

127 params["reviewState"], 

128 is_unconflicted_chair=is_unconflicted_chair, 

129 is_tta_member=is_tta_member, 

130 ) 

131 if validation_msg: 

132 raise HTTPBadRequest(body=validation_msg) 

133 

134 ppr_prop_review.review_state = params["reviewState"] 

135 ppr_prop_review.external_science_review_comments = params["externalScienceReviewComments"] 

136 ppr_prop_review.internal_science_review_comments = params["internalScienceReviewComments"] 

137 ppr_prop_review.external_technical_review_comments = params["externalTechnicalReviewComments"] 

138 ppr_prop_review.internal_technical_review_comments = params["internalTechnicalReviewComments"] 

139 ppr_prop_review.external_data_management_review_comments = params["externalDataManagementReviewComments"] 

140 ppr_prop_review.internal_data_management_review_comments = params["internalDataManagementReviewComments"] 

141 

142 # also update the SRP score, if it is set and the user has permission: 

143 if params.get("srpScore") and params["srpScore"] != ppr_prop_review.calculated_srp_score and check.can_update_score: 

144 # Make sure that the SRP Score being entered is valid 

145 if not validate_ppr_prop_review_score(params["srpScore"]): 

146 raise HTTPBadRequest(body=f"{PPR_PROP_REV_SCORE_RANGE_MESSAGE}") 

147 

148 ppr_prop_review.srp_score = params["srpScore"] 

149 ppr_prop_review.srp_score_updated = True 

150 

151 request.repo.ppr_proposal_review_repo.update(ppr_prop_review) 

152 return Response(json=ppr_prop_review.__json__()) 

153 

154 

155@view_config( 

156 route_name="ppr_proposal_review_finalize", 

157 renderer="json", 

158 permission="ppr_proposal_review_finalize", 

159) 

160def ppr_proposal_review_finalize(request: Request): 

161 """Finalize all PPRProposalReviews for a given ScienceReviewPanel 

162 

163 URL: ppr_proposal_reviews/finalize/{srp_id} 

164 

165 :param request: PUT Request with an empty body 

166 :return: Response with list of JSON-formatted finalized PPRProposalReviews 

167 or 404 response (HTTPNotFound) if no SRP exists with an ID of srp_id 

168 or 400 response (HTTPBadRequest) if srp_id is not an integer, 

169 or one or more PPRProposalReviews on the given SRP weren't finalizable 

170 or if notify_tta_members_srp_ppr_proposal_reviews_finalized detected that not all PPRProposalReviews 

171 were finalized 

172 or 401 response (HTTPUnauthorized) if the requesting user doesn't have permission to finalize 

173 this SRP's PPRProposalReviews 

174 """ 

175 is_tta_member = bool(request.identity) and "tta_member" in request.identity.roles 

176 

177 srp: ScienceReviewPanel = request.lookup(request.matchdict["srp_id"], ScienceReviewPanel) 

178 

179 if not is_tta_member: 

180 chairs = [ 

181 sr 

182 for sr in request.repo.science_reviewer_repo.list_by_science_review_panel_id(srp.science_review_panel_id) 

183 if sr.is_chair 

184 ] 

185 is_chair = request.identity.user_id in [sr.user_id for sr in chairs] 

186 if not is_chair: 

187 raise HTTPUnauthorized( 

188 body=f"User with ID {request.identity.user_id} is not allowed to finalize PPRProposalReviews for " 

189 f"ScienceReviewPanel with ID {srp.science_review_panel_id}" 

190 ) 

191 try: 

192 finalized_ppr_prop_reviews = finalize_ppr_proposal_reviews(srp, request.repo, is_tta_member=is_tta_member) 

193 except ValueError as e: 

194 raise HTTPBadRequest(body=str(e)) 

195 

196 for ppr_prop_rev in finalized_ppr_prop_reviews: 

197 request.repo.ppr_proposal_review_repo.update(ppr_prop_rev) 

198 

199 # Notify TTA members that reviews for this SRP have been finalized 

200 if not notify_tta_members_srp_ppr_proposal_reviews_finalized(srp, request.repo): 

201 # If, for some reason, all PPRs have not been finalized 

202 raise HTTPBadRequest( 

203 body=f"Not all PPRProposalReviews have been finalized for ScienceReviewPanel with " 

204 f"ID {srp.science_review_panel_id}" 

205 ) 

206 

207 return Response(json=[ppr_prop_rev.__json__() for ppr_prop_rev in finalized_ppr_prop_reviews]) 

208 

209 

210@view_config( 

211 route_name="ppr_proposal_review_list", 

212 renderer="json", 

213 permission="ppr_proposal_review_list", 

214) 

215def ppr_proposal_review_list(request: Request) -> Response: 

216 """List PPRProposalReviews that the requesting user is allowed to see on the given Solicitation 

217 

218 URL: ppr_proposal_reviews/{solicitation_id} 

219 

220 :param request: GET request 

221 :return: Response with list of JSON-formatted PPRProposalReviews on the given Solicitation, anonymized where needed; 

222 or 401 response (HTTPUnauthorized) if the requesting user isn't a ScienceReviewer on the given Solicitation 

223 """ 

224 is_tta_member = bool(request.identity) and "tta_member" in request.identity.roles 

225 solicitation_id = request.matchdict["solicitation_id"] 

226 ppr_proposal_reviews: list[PPRProposalReview] = list() 

227 reviewer = None 

228 if is_tta_member: 

229 ppr_proposal_reviews = request.repo.ppr_proposal_review_repo.list_by_solicitation_id(solicitation_id) 

230 else: 

231 user_id = request.identity.user_id 

232 reviewer: ScienceReviewer = request.repo.science_reviewer_repo.by_user_id_and_sol_id(user_id, solicitation_id) 

233 if not reviewer: 

234 raise HTTPUnauthorized( 

235 f"User with ID {user_id} cannot view PPRProposalReviews on Solicitation with ID {solicitation_id} " 

236 f"since they are not a ScienceReviewer on it" 

237 ) 

238 ppr_proposal_reviews = request.repo.ppr_proposal_review_repo.list_by_srp_id(reviewer.science_review_panel_id) 

239 

240 reviewer_id = -1 if reviewer is None else reviewer.science_reviewer_id 

241 ppr_proposal_reviews_json = anonymize_ppr_proposal_reviews( 

242 ppr_proposal_reviews, is_tta_member, reviewer_id=reviewer_id 

243 ) 

244 return Response(json=ppr_proposal_reviews_json) 

245 

246 

247@view_config(route_name="ppr_proposal_review_export", permission="ppr_proposal_review_export") 

248def ppr_proposal_review_export(request: Request) -> Response: 

249 """Serve a ScienceReviewer's PPRProposalReviews as a CSV file 

250 

251 URL: science_reviewer/{reviewer_id}/export_ppr_proposal_reviews 

252 

253 :param request: GET request with the ScienceReviewer's ID 

254 :return: A response with the given ScienceReviewer's PPRProposalReviews as a CSV file, 

255 or 401 response HTTPUnauthorized if the requesting User isn't either the given ScienceReviewer's User or 

256 a TTA member 

257 or 404 response (HTTPNotFound) if no Reviewer exists with an ID of reviewer_id 

258 or 400 response (HTTPBadRequest) if reviewer_id is not an integer 

259 """ 

260 is_tta_member = bool(request.identity) and "tta_member" in request.identity.roles 

261 

262 reviewer: ScienceReviewer = request.lookup(request.matchdict["reviewer_id"], ScienceReviewer) 

263 ppr_prop_revs: list[PPRProposalReview] = request.repo.ppr_proposal_review_repo.list_by_science_reviewer_id( 

264 reviewer.science_reviewer_id 

265 ) 

266 

267 if not is_tta_member: # verify that the reviewer is attached to the user requesting the action: 

268 if reviewer.user_id != request.identity.user_id: 

269 raise HTTPUnauthorized( 

270 body=f"User {request.identity.user_id} is not authorized to export PPRProposalReviews " 

271 f"for {reviewer.user_id}" 

272 ) 

273 

274 ppr_prop_rev_csv = serialize_ppr_proposal_reviews_to_csv(ppr_prop_revs, request.repo) 

275 filename = f"ppr_proposal_reviews_for_reviewer_with_user_id_{reviewer.user_id}.csv" 

276 response = Response(status_code=HTTPStatus.OK, body=ppr_prop_rev_csv.encode()) 

277 response.headers["Content-Disposition"] = "attachment;filename=" + filename 

278 response.headers["Access-Control-Expose-Headers"] = "Content-Disposition" 

279 return response 

280 

281 

282@view_config(route_name="ppr_proposal_review_import", permission="ppr_proposal_review_import") 

283def ppr_proposal_review_import(request: Request) -> Response: 

284 """Parse an imported CSV file to update a given ScienceReviewer's PPRProposalReviews with its contents 

285 

286 URL: science_reviewer/{reviewer_id}/export_ppr_proposal_reviews 

287 

288 Note: SRP Score updates are ignored unless the requesting User is either the PPRProposalReview's SRP's chair 

289 or a TTA member. 

290 

291 :param request: PUT request with the ScienceReviewer's ID 

292 :return: A response with the given ScienceReviewer's updated PPRProposalReviews 

293 or 401 response HTTPUnauthorized if the requesting User isn't either the given ScienceReviewer's User and 

294 the user isn't a TTA Member 

295 or 404 response (HTTPNotFound) if no Reviewer exists with an ID of reviewer_id 

296 or 400 response (HTTPBadRequest) if reviewer_id is not an integer 

297 or if no file was uploaded 

298 or if file couldn't be parsed as a CSV 

299 or if errors found when attempting to import 

300 """ 

301 is_tta_member = bool(request.identity) and "tta_member" in request.identity.roles 

302 reviewer: ScienceReviewer = request.lookup(request.matchdict["reviewer_id"], ScienceReviewer) 

303 

304 if not is_tta_member: # verify that the reviewer is attached to the user requesting the action: 

305 if reviewer.user_id != request.identity.user_id: 

306 raise HTTPUnauthorized( 

307 body=f"User {request.identity.user_id} is not authorized to import PPR Proposal Reviews " 

308 f"for {reviewer.user_id}" 

309 ) 

310 

311 if "ppr_proposal_review_csv_import" not in request.POST: 

312 raise HTTPBadRequest("No file was uploaded") 

313 

314 import_file = request.POST["ppr_proposal_review_csv_import"].file.read() 

315 

316 ppr_proposal_reviews_to_update, error_messages = deserialize_csv_to_ppr_proposal_reviews( 

317 import_file, reviewer, is_tta_member, request.repo 

318 ) 

319 

320 if len(error_messages): 

321 errors = f"CSV errors found when attempting to import PPR Proposal Reviews: {', '.join(error_messages)}" 

322 raise HTTPBadRequest(body=errors) 

323 

324 for ppr_proposal_review in ppr_proposal_reviews_to_update: 

325 request.repo.ppr_proposal_review_repo.update(ppr_proposal_review) 

326 all_ppr_proposal_reviews_for_reviewer = request.repo.ppr_proposal_review_repo.list_by_science_reviewer_id( 

327 reviewer.science_reviewer_id 

328 ) 

329 json = [ppr_prop_rev.__json__() for ppr_prop_rev in all_ppr_proposal_reviews_for_reviewer] 

330 return Response(json=json)