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
« 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/>.
18from http import HTTPStatus
20from pyramid.httpexceptions import HTTPBadRequest, HTTPUnauthorized
21from pyramid.request import Request
22from pyramid.response import Response
23from pyramid.view import view_config
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
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
61 URL: ppr_proposal_reviews/{ppr_prop_rev_id}
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
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 )
95 is_tta_member = bool(request.identity) and "tta_member" in request.identity.roles
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 )
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 )
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()))
123 is_unconflicted_chair = (sr is not None and sr.is_chair) and (isr is not None and not isr.is_conflicted)
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)
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"]
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}")
148 ppr_prop_review.srp_score = params["srpScore"]
149 ppr_prop_review.srp_score_updated = True
151 request.repo.ppr_proposal_review_repo.update(ppr_prop_review)
152 return Response(json=ppr_prop_review.__json__())
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
163 URL: ppr_proposal_reviews/finalize/{srp_id}
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
177 srp: ScienceReviewPanel = request.lookup(request.matchdict["srp_id"], ScienceReviewPanel)
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))
196 for ppr_prop_rev in finalized_ppr_prop_reviews:
197 request.repo.ppr_proposal_review_repo.update(ppr_prop_rev)
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 )
207 return Response(json=[ppr_prop_rev.__json__() for ppr_prop_rev in finalized_ppr_prop_reviews])
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
218 URL: ppr_proposal_reviews/{solicitation_id}
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)
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)
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
251 URL: science_reviewer/{reviewer_id}/export_ppr_proposal_reviews
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
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 )
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 )
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
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
286 URL: science_reviewer/{reviewer_id}/export_ppr_proposal_reviews
288 Note: SRP Score updates are ignored unless the requesting User is either the PPRProposalReview's SRP's chair
289 or a TTA member.
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)
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 )
311 if "ppr_proposal_review_csv_import" not in request.POST:
312 raise HTTPBadRequest("No file was uploaded")
314 import_file = request.POST["ppr_proposal_review_csv_import"].file.read()
316 ppr_proposal_reviews_to_update, error_messages = deserialize_csv_to_ppr_proposal_reviews(
317 import_file, reviewer, is_tta_member, request.repo
318 )
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)
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)