Coverage for middle_layer/allocate/application_layer/rest_api/views/proposal_disposition_group.py: 27.06%
85 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 allocate.domain_layer.entities.proposal_disposition import ProposalDisposition
26from allocate.domain_layer.entities.proposal_disposition_group import ProposalDispositionGroup
27from allocate.domain_layer.services.create_proposal_disposition_group_service import (
28 create_proposal_disposition_group,
29)
30from allocate.domain_layer.services.deserialize_csv_to_pdg_comments import deserialize_csv_to_pdg_comments
31from allocate.domain_layer.services.serialize_pdg_comments_to_csv import serialize_pdg_comments_to_csv
32from common.application_layer.rest_api import make_expected_params_message
33from common.application_layer.services.permissions_service import is_active_tac
34from solicit.domain_layer.entities.solicitation import Solicitation
37@view_config(
38 route_name="proposal_disposition_group_list_by_solicitation_id",
39 renderer="json",
40 permission="proposal_disposition_group_list",
41)
42def proposal_disposition_group_list_by_solicitation_id(request: Request) -> Response:
43 """List all Proposal Disposition Groups for a given Solicitation
45 URL: solicitations/{{solicitation_id}}/proposal_disposition_group
47 :param request: GET request
48 :return: Response with JSON-formatted list of all Proposal Disposition Groups on the given Solicitation
49 or 401 response (HTTPUnauthorized) if the requesting user is not a TTA member, TAC member on the
50 solicitation, or author of a proposal on the solicitation.
51 """
52 is_tta_member = "tta_member" in request.identity.roles
54 sol_id = request.matchdict["solicitation_id"]
56 # User making request must be TTA member, an active TAC member on the solicitation, or an author of a
57 # proposal on the solicitation
58 if not (is_tta_member or is_active_tac(sol_id, request.identity.user_id, request.repo)):
59 # Check to see if user is an author of a proposal on this solicitation
60 proposal_disposition_groups = request.repo.proposal_disposition_group_repo.list_by_solicitation_id(
61 solicitation_id=sol_id, author_user_id=request.identity.user_id
62 )
63 if not proposal_disposition_groups:
64 raise HTTPUnauthorized(
65 body=f"User {request.identity.user_id} is not a TTA Member, active TAC Member, "
66 f"or Author of a Proposal on Solicitation {sol_id}"
67 )
68 else:
69 proposal_disposition_groups = request.repo.proposal_disposition_group_repo.list_by_solicitation_id(
70 solicitation_id=sol_id
71 )
73 return Response(json_body=[pdg.__json__() for pdg in proposal_disposition_groups])
76@view_config(
77 route_name="proposal_disposition_group_create", renderer="json", permission="proposal_disposition_group_create"
78)
79def proposal_disposition_group_create(request: Request) -> Response:
80 """Create a Proposal Disposition Group on the given Solicitation
82 URL: proposal_disposition_group_create/{solicitation_id} with JSON body like
83 {
84 "name": <str>,
85 "proposalDispositionIds": <List[int]>,
86 "timeAllocationReviewInstructions": <str>,
87 }
88 where timeAllocationReviewInstructions is optional
90 Note: in the case that the solicitation uses the Observatory Site Review process, timeAllocationReviewInstructions, if provided, is ignored
92 :param request: PUT Request
93 :return: Response with the Proposal Dispositions json.
94 or 404 response (HTTPNotFound) if no Solicitation exists with the given id, or any of the proposal disposition
95 ids are not found
96 or 400 response (HTTPBadRequest) if solicitation_id is not an integer,
97 or 401 response (HTTPUnauthorized) if the requesting user is not a TTA member
98 """
100 params = request.json_body
101 expected_params = [
102 "name",
103 "proposalDispositionIds",
104 ]
106 time_allocation_review_instructions = request.params.get("timeAllocationReviewInstructions")
108 if "tta_member" not in request.identity.roles:
109 raise HTTPUnauthorized(body=f"User {request.identity.user_id} is not a TTA Member")
111 # JSON params do not contain all expected params
112 if not all([expected in params for expected in expected_params]):
113 raise HTTPBadRequest(body=make_expected_params_message(expected_params, params.keys()))
115 # Verify that a Solicitation exists with ID solicitation_ud
116 sol: Solicitation = request.lookup(request.matchdict["solicitation_id"], Solicitation)
118 # look up the proposal dispositions:
119 if len(params["proposalDispositionIds"]) < 1:
120 raise HTTPBadRequest(body="cannot create a PDG with no Proposal Dispositions.")
122 pd_list = [request.lookup(pd_id, ProposalDisposition) for pd_id in params["proposalDispositionIds"]]
124 try:
125 new_group = create_proposal_disposition_group(sol, params["name"], pd_list, time_allocation_review_instructions)
126 except ValueError as e:
127 raise HTTPBadRequest(body=str(e))
129 request.repo.proposal_disposition_group_repo.add(new_group)
131 return Response(json_body=new_group.__json__())
134@view_config(
135 route_name="proposal_disposition_group_update", renderer="json", permission="proposal_disposition_group_create"
136)
137def proposal_disposition_group_update(request: Request) -> Response:
138 """Update a Proposal Disposition Group
140 URL: proposal_disposition_group/{proposal_disposition_group_id} with JSON body like
141 {
142 "name": <str>,
143 "timeAllocationReviewInstructions": <str>,
144 }
145 where timeAllocationReviewInstructions is optional. If not provided, no changes to timeAllocationReviewInstructions will be made
147 Note: in the case that the solicitation associated with this group, uses the Observatory Site Review process, timeAllocationReviewInstructions, if provided, is ignored
149 :param request: PUT Request
150 :return: Response with the Proposal Dispositions json.
151 or 404 response (HTTPNotFound) if no Proposal Disposition Group exists with the given id
152 or 400 response (HTTPBadRequest) if proposal_disposition_group_id is not an integer,
153 or 401 response (HTTPUnauthorized) if the requesting user is not a TTA member
154 """
156 params = request.json_body
157 expected_params = [
158 "name",
159 ]
161 if "tta_member" not in request.identity.roles:
162 raise HTTPUnauthorized(body=f"User {request.identity.user_id} is not a TTA Member")
164 # JSON params do not contain all expected params
165 if not all([expected in params for expected in expected_params]):
166 raise HTTPBadRequest(body=make_expected_params_message(expected_params, params.keys()))
168 # Verify that the Proposal Disposition Group exists
169 pdg = request.lookup(request.matchdict["proposal_disposition_group_id"], ProposalDispositionGroup)
171 pdg.name = params["name"]
172 if pdg.solicitation.proposal_process.proposal_process_name == "Observatory Site Review":
173 pdg.time_allocation_review_instructions = ""
174 elif params.get("timeAllocationReviewInstructions"):
175 pdg.time_allocation_review_instructions = params["timeAllocationReviewInstructions"]
177 request.repo.proposal_disposition_group_repo.update(pdg)
179 return Response(json_body=pdg.__json__())
182@view_config(
183 route_name="proposal_disposition_group_comment_export",
184 renderer="json",
185 permission="proposal_disposition_group_comment_export",
186)
187def proposal_disposition_group_comment_export(request: Request) -> Response:
188 """Export the comments for all proposal dispositions for the give proposal disposition group.
190 URL: proposal_disposition_groups/{{group_id}}/export_comments
193 :param request: GET Request
194 :return: Response with a file of CSV data
195 or 401 response HTTPUnauthorized if the requesting User isn't a TTA member
196 or 404 response (HTTPNotFound) if no Proposal Disposition Group exists with an ID of group_id
197 or 400 response (HTTPBadRequest) if group_id is not an integer
198 """
199 pdg = request.lookup(request.matchdict["group_id"], ProposalDispositionGroup)
201 pdg_comments_csv = serialize_pdg_comments_to_csv(pdg, request.repo)
203 filename = f"proposal_disposition_comments_for_proposal_disposition_group" f"_{pdg.name}.csv"
204 response = Response(status_code=HTTPStatus.OK, body=pdg_comments_csv.encode())
205 response.headers["Content-Disposition"] = "attachment;filename=" + filename
206 response.headers["Access-Control-Expose-Headers"] = "Content-Disposition"
207 return response
210@view_config(
211 route_name="proposal_disposition_group_comment_import", permission="proposal_disposition_group_comment_import"
212)
213def proposal_disposition_group_comment_import(request: Request) -> Response:
214 """Import comments for proposal dispositions given a proposal disposition group from a CSV file
216 URL: proposal_disposition_group/{{pdg_id}}/import_comments
218 :param request: POST request with the ProposalDispositiongroup's ID
219 :return: A response with the given ProposalDispositiongroup's comments imported from the provided CSV file,
220 or 401 response HTTPUnauthorized if the requesting User isn't a TTA member
221 or 404 response (HTTPNotFound) if no Proposal Disposition Group exists with an ID of group_id
222 or 400 response (HTTPBadRequest) if group_id is not an integer
223 or if no file was uploaded
224 or if file couldn't be parsed as a CSV
225 or if errors found when attempting to import
226 """
227 is_tta_member = bool(request.identity) and "tta_member" in request.identity.roles
229 pdg = request.lookup(request.matchdict["group_id"], ProposalDispositionGroup)
231 if not is_tta_member:
232 raise HTTPUnauthorized(
233 body=f"User {request.identity.user_id} is not authorized to import proposal disposition comments"
234 )
236 if "pdg_comment_csv_import" not in request.POST:
237 raise HTTPBadRequest("No file was uploaded")
239 import_file = request.POST["pdg_comment_csv_import"].file.read()
241 pds_to_update, error_messages = deserialize_csv_to_pdg_comments(pdg, import_file, request.repo)
243 if len(error_messages):
244 errors = (
245 f"CSV errors found when attempting to import proposal disposition comments:" f" {', '.join(error_messages)}"
246 )
247 raise HTTPBadRequest(body=errors)
249 # No errors found during parsing - persist the updates to the proposal dispositions that were returned
250 for pd in pds_to_update:
251 request.repo.proposal_disposition_repo.update(pd)
253 # Return all the proposal dispositions for this proposal disposition group
254 pds = pdg.proposal_dispositions
255 json = [pd.__json__() for pd in pds]
257 return Response(json=json)