Coverage for middle_layer/propose/application_layer/services/proposal_state_change_service.py: 83.91%

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

18 

19from datetime import datetime, timezone 

20 

21from allocate.domain_layer.entities.proposal_disposition import ProposalDisposition 

22from allocate.domain_layer.services.create_proposal_disposition_group_service import ( 

23 create_proposal_disposition_group, 

24) 

25from closeout.domain_layer.services.generate_prototype_project_service import generate_prototype_project 

26from common.application_layer.orm_repositories.orm_repository import ORMRepository 

27from common.application_layer.services.notification_sender_service import send_basic_notification 

28from common.utils.gitlab_secret_loader import NOTIFICATION_MAILING_LIST 

29from propose.domain_layer.entities.observation_specification import ObservationSpecification 

30from propose.domain_layer.entities.proposal import AllocationRequest, CapabilityRequest, Proposal, ProposalCopy 

31from propose.domain_layer.entities.scan import Scan 

32from propose.domain_layer.services.proposal_validator_service import validate_proposal 

33from review.domain_layer.entities.osr_proposal_review import OSRProposalReview 

34 

35 

36def verify_proposal_state_change(current_state: str, desired_state: str) -> bool: 

37 """Return whether or not the specified state transition is currently supported""" 

38 

39 # Note: Per STT-1188 Withdrawn state cannot be set from Hidden or Draft states 

40 if current_state == "Draft": 

41 if desired_state in ["Submitted", "Hidden"]: 

42 return True 

43 elif current_state == "Submitted": 

44 if desired_state in ["Submitted", "In Review", "Withdrawn"]: 

45 return True 

46 elif current_state == "Hidden": # this state change is only allowed by TTA members 

47 if desired_state in ["Submitted"]: 

48 return True 

49 elif current_state == "In Review": 

50 if desired_state in ["Completed", "Withdrawn"]: 

51 return True 

52 elif current_state == "Completed": 

53 if desired_state in ["Withdrawn"]: 

54 return True 

55 

56 # Unsupported destination state or unknown current state 

57 return False 

58 

59 

60def proposal_state_change(proposal: Proposal, desired_state: str, repo: ORMRepository) -> Proposal: 

61 if not verify_proposal_state_change(proposal.state, desired_state): 

62 raise ValueError( 

63 f"Cannot transition proposal from current state {proposal.state} to desired state {desired_state}" 

64 ) 

65 

66 if desired_state == "Submitted": 

67 validation_errors = validate_proposal(proposal) 

68 if len(validation_errors): 

69 raise ValueError(f"Validation errors found in proposal: {', '.join(validation_errors)}") 

70 proposal.submitted_timestamp = datetime.now(timezone.utc) 

71 proposal.observatory_copy = create_or_update_observatory_copy( 

72 proposal.author_copy, repo, proposal.observatory_copy 

73 ) 

74 

75 # Persist the state change. If moved to Submitted state, this will trigger propose code assignment 

76 proposal.state = desired_state 

77 repo.proposal_repo.update(proposal) 

78 context = repo.context_repo.by_id(proposal.solicitation_id) 

79 

80 if proposal.state == "Submitted": 

81 if not context or context.do_notify: 

82 # Send submitted notification after db update so that Proposal code has been generated 

83 send_basic_notification( 

84 f"Proposal {proposal.proposal_code}, with title '{proposal.observatory_copy.title}', has been submitted.", 

85 f"Proposal {proposal.proposal_code} submitted", 

86 NOTIFICATION_MAILING_LIST, 

87 ) 

88 

89 if proposal.state == "In Review": 

90 # create a Proposal Disposition for the proposal, unless one already exists. 

91 proposal.proposal_disposition = ( 

92 ProposalDisposition(proposal) if not proposal.proposal_disposition else proposal.proposal_disposition 

93 ) 

94 repo.proposal_disposition_repo.add(proposal.proposal_disposition) 

95 

96 if proposal.state == "Completed" and proposal.proposal_disposition.approved is True: 

97 # create a Prototype Project for any facilities with positive dispositions. 

98 generate_prototype_project(proposal.proposal_disposition, repo) 

99 

100 # Determine if this proposal is based on a solicitation specifying a OSR Process 

101 # If so, move it forward to In Review state, create needed entities for review phase 

102 sol = proposal.solicitation 

103 if sol.proposal_process.proposal_process_name == "Observatory Site Review" and proposal.state == "Submitted": 

104 desired_state = "In Review" 

105 if not verify_proposal_state_change(proposal.state, desired_state): 

106 raise ValueError( 

107 f"Cannot transition proposal from current state {proposal.state} to desired state {desired_state}" 

108 ) 

109 proposal.proposal_disposition = ( 

110 ProposalDisposition(proposal) if not proposal.proposal_disposition else proposal.proposal_disposition 

111 ) 

112 

113 proposal.state = desired_state 

114 # downstream needs vetted SC to be set 

115 proposal.vetted_science_category = proposal.author_copy.science_category 

116 proposal.is_vetted = True 

117 repo.proposal_repo.update(proposal) 

118 

119 # Create an OSR Review for this proposal 

120 # TODO: this definitely needs to move elsewhere in middle_layer 

121 create_osr_proposal_review(proposal, repo) 

122 # ProposalDispositionGroup also needs to be created 

123 

124 # check if the group already exists 

125 sol_groups = repo.proposal_disposition_group_repo.list_by_solicitation_id(sol.solicitation_id) 

126 if len(sol_groups) == 0: 

127 group = create_proposal_disposition_group( 

128 solicitation=sol, 

129 name=f"Group for Solicitation {sol.solicitation_name}", 

130 proposal_dispositions=[proposal.proposal_disposition], 

131 ) 

132 repo.proposal_disposition_group_repo.add(group) 

133 else: 

134 sol_groups[0].proposal_dispositions.append(proposal.proposal_disposition) 

135 

136 return proposal 

137 

138 

139def create_osr_proposal_review(proposal: Proposal, repo: ORMRepository): 

140 osr_proposal_review = OSRProposalReview(proposal=proposal, review_state="Blank") 

141 repo.osr_proposal_review_repo.add(osr_proposal_review) 

142 

143 

144def create_or_update_observatory_copy( 

145 author_copy: ProposalCopy, 

146 repo: ORMRepository, 

147 observatory_copy: ProposalCopy | None = None, 

148) -> ProposalCopy: 

149 

150 observatory_copy = author_copy.clone() 

151 repo.session.add(observatory_copy) 

152 repo.session.flush() 

153 return observatory_copy 

154 

155 

156def replicate_obspec( 

157 obspec_orig: ObservationSpecification, ar_replica: AllocationRequest, repo: ORMRepository 

158) -> ObservationSpecification: 

159 obspec_replica = ObservationSpecification( 

160 allocation_request=ar_replica, 

161 scans=[], 

162 is_proposer_modified=obspec_orig.is_proposer_modified, 

163 time_range_start=obspec_orig.time_range_start, 

164 time_range_end=obspec_orig.time_range_end, 

165 is_requested_filler=obspec_orig.is_requested_filler, 

166 ) 

167 

168 repo.observation_specification_repo.add(obspec_replica) 

169 

170 for s_idx, scan in enumerate(obspec_orig.scans): 

171 scan_replica = Scan( 

172 position_in_list=scan.position_in_list, 

173 scan_intents=[], 

174 observing_instruction_name=scan.observing_instruction_name, 

175 ) 

176 obspec_replica.scans.append(scan_replica) 

177 

178 for intent in scan.scan_intents: 

179 scan_intent = repo.scan_intent_repo.by_name(intent.name) 

180 obspec_replica.scans[s_idx].scan_intents.append(scan_intent) 

181 

182 for subscan in scan.subscans: 

183 # hw_config = repo.session.merge(subscan.hardware_configuration.clone()) 

184 subscan_replica = subscan.clone() 

185 obspec_replica.scans[s_idx].subscans.append(subscan_replica) 

186 

187 return obspec_replica