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

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 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 

35 

36 

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 

44 

45 URL: solicitations/{{solicitation_id}}/proposal_disposition_group 

46 

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 

53 

54 sol_id = request.matchdict["solicitation_id"] 

55 

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 ) 

72 

73 return Response(json_body=[pdg.__json__() for pdg in proposal_disposition_groups]) 

74 

75 

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 

81 

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 

89 

90 Note: in the case that the solicitation uses the Observatory Site Review process, timeAllocationReviewInstructions, if provided, is ignored 

91 

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 """ 

99 

100 params = request.json_body 

101 expected_params = [ 

102 "name", 

103 "proposalDispositionIds", 

104 ] 

105 

106 time_allocation_review_instructions = request.params.get("timeAllocationReviewInstructions") 

107 

108 if "tta_member" not in request.identity.roles: 

109 raise HTTPUnauthorized(body=f"User {request.identity.user_id} is not a TTA Member") 

110 

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())) 

114 

115 # Verify that a Solicitation exists with ID solicitation_ud 

116 sol: Solicitation = request.lookup(request.matchdict["solicitation_id"], Solicitation) 

117 

118 # look up the proposal dispositions: 

119 if len(params["proposalDispositionIds"]) < 1: 

120 raise HTTPBadRequest(body="cannot create a PDG with no Proposal Dispositions.") 

121 

122 pd_list = [request.lookup(pd_id, ProposalDisposition) for pd_id in params["proposalDispositionIds"]] 

123 

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)) 

128 

129 request.repo.proposal_disposition_group_repo.add(new_group) 

130 

131 return Response(json_body=new_group.__json__()) 

132 

133 

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 

139 

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 

146 

147 Note: in the case that the solicitation associated with this group, uses the Observatory Site Review process, timeAllocationReviewInstructions, if provided, is ignored 

148 

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 """ 

155 

156 params = request.json_body 

157 expected_params = [ 

158 "name", 

159 ] 

160 

161 if "tta_member" not in request.identity.roles: 

162 raise HTTPUnauthorized(body=f"User {request.identity.user_id} is not a TTA Member") 

163 

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())) 

167 

168 # Verify that the Proposal Disposition Group exists 

169 pdg = request.lookup(request.matchdict["proposal_disposition_group_id"], ProposalDispositionGroup) 

170 

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"] 

176 

177 request.repo.proposal_disposition_group_repo.update(pdg) 

178 

179 return Response(json_body=pdg.__json__()) 

180 

181 

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. 

189 

190 URL: proposal_disposition_groups/{{group_id}}/export_comments 

191 

192 

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) 

200 

201 pdg_comments_csv = serialize_pdg_comments_to_csv(pdg, request.repo) 

202 

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 

208 

209 

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 

215 

216 URL: proposal_disposition_group/{{pdg_id}}/import_comments 

217 

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 

228 

229 pdg = request.lookup(request.matchdict["group_id"], ProposalDispositionGroup) 

230 

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 ) 

235 

236 if "pdg_comment_csv_import" not in request.POST: 

237 raise HTTPBadRequest("No file was uploaded") 

238 

239 import_file = request.POST["pdg_comment_csv_import"].file.read() 

240 

241 pds_to_update, error_messages = deserialize_csv_to_pdg_comments(pdg, import_file, request.repo) 

242 

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) 

248 

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) 

252 

253 # Return all the proposal dispositions for this proposal disposition group 

254 pds = pdg.proposal_dispositions 

255 json = [pd.__json__() for pd in pds] 

256 

257 return Response(json=json)