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
« 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#
19from datetime import datetime, timezone
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
36def verify_proposal_state_change(current_state: str, desired_state: str) -> bool:
37 """Return whether or not the specified state transition is currently supported"""
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
56 # Unsupported destination state or unknown current state
57 return False
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 )
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 )
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)
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 )
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)
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)
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 )
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)
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
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)
136 return proposal
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)
144def create_or_update_observatory_copy(
145 author_copy: ProposalCopy,
146 repo: ORMRepository,
147 observatory_copy: ProposalCopy | None = None,
148) -> ProposalCopy:
150 observatory_copy = author_copy.clone()
151 repo.session.add(observatory_copy)
152 repo.session.flush()
153 return observatory_copy
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 )
168 repo.observation_specification_repo.add(obspec_replica)
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)
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)
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)
187 return obspec_replica