Coverage for middle_layer/propose/application_layer/orm_repositories/proposal.py: 87.91%
91 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#
18from typing import Any, Sequence
20from sqlalchemy import Row, RowMapping, func, text
21from sqlalchemy.dialects.postgresql import ts_headline, websearch_to_tsquery
22from sqlalchemy.orm import Session
24from closeout.domain_layer.entities.proposals_search_result import ProposalSearchResult
25from common.application_layer.orm_repositories import add_entity, get_object_by_id
26from propose.domain_layer.entities.proposal import Author, Proposal
27from propose.domain_layer.repositories.proposal import ProposalRepository
28from review.domain_layer.entities.individual_science_review import IndividualScienceReview
29from review.domain_layer.entities.science_reviewer import ScienceReviewer
32class ProposalORMRepository(ProposalRepository):
33 def __init__(self, session: Session):
34 self.session = session
36 def by_id(self, proposal_id: int) -> Proposal:
37 return get_object_by_id(self.session, proposal_id, Proposal, Proposal.proposal_id)
39 def list_all(self) -> list[Proposal]:
40 return list(self.session.query(Proposal).filter(Proposal.state != "Hidden").order_by(Proposal.proposal_id))
42 def list_filtered(
43 self,
44 visibility_user_id: int = None,
45 state: str = None,
46 solicitation_id: int = None,
47 user_id: int = None,
48 ) -> list[Proposal]:
49 """
50 Repository that accepts filtering parameters
52 :kwargs: expecting visibility_user_id, state, solicitation_id, and user_id.
53 :return: list of matching proposals from the DB.
54 """
56 # first, the base query
57 query = self.session.query(Proposal)
59 # check if the result should be restricted to the current user:
60 # if non-tta member, no hidden proposals are returned
61 if visibility_user_id is not None:
62 query = query.filter(Proposal.authors.any(Author.user_id == visibility_user_id))
64 # check if there is a filter on state, and include it:
65 if state is not None:
66 query = query.filter(Proposal.state == state)
68 # check if there is a filter on solicitation_id:
69 if solicitation_id is not None:
70 query = query.filter(Proposal.solicitation_id == solicitation_id)
72 # check if there is a filter on user_id:
73 if user_id is not None:
74 query = query.filter(Proposal.authors.any(Author.user_id == user_id))
76 return list(query.order_by(Proposal.proposal_id).all())
78 def list_by_user_id_author(self, user_id: int, exclude_hidden: bool = True) -> list[Proposal]:
79 query = self.session.query(Proposal).join(Author).filter(Author.user_id == user_id)
80 if exclude_hidden:
81 query = query.filter(Proposal.state != "Hidden")
82 return query.all()
84 def list_by_user_id_science_reviewer(self, user_id: int, exclude_hidden: bool = True) -> list[Proposal]:
85 query = (
86 self.session.query(Proposal)
87 .join(IndividualScienceReview)
88 .join(ScienceReviewer)
89 .filter(ScienceReviewer.user_id == user_id)
90 )
91 if exclude_hidden:
92 query = query.filter(Proposal.state != "Hidden")
93 return query.all()
95 def add(self, proposal: Proposal) -> int:
96 add_entity(self.session, proposal)
97 return proposal.proposal_id
99 def update(self, proposal: Proposal) -> None:
100 p = self.by_id(proposal.proposal_id)
101 p.solicitation_id = proposal.solicitation_id
102 p.state = proposal.state
103 p.submitted_timestamp = proposal.submitted_timestamp
104 p.authors = proposal.authors
105 p.is_discussed = proposal.is_discussed
106 p.is_triggered = proposal.is_triggered
107 p.prototype_projects = proposal.prototype_projects
108 self.session.flush()
110 def summary_filtered(
111 self,
112 solicitation_id: int,
113 published_for: str | None = None,
114 facility_id: int | None = None,
115 is_triggered: bool | None = None,
116 is_joint: bool | None = None,
117 is_thesis: bool | None = None,
118 proposal_class_id: int | None = None,
119 allocation_version_id: int | None = None,
120 ) -> Sequence[RowMapping]:
121 """
122 Repository that accepts filtering parameters and produces proposal summaries
124 :kwargs: expecting solicitation_id, also accepts facility_id, is_triggered, is_joint, is_thesis(ignored),
125 and proposal_class_id.
126 :return: list of matching proposal summaries from the DB.
127 """
128 valid_publish_states = {"DISPOSITION", "APPROVAL", "CLOSEOUT"}
129 if published_for not in valid_publish_states and allocation_version_id is None:
130 raise ValueError(
131 f"'{published_for}' is not a valid publish state. Expected one of {valid_publish_states} "
132 f"or an allocation_version_id"
133 )
135 fields = (
136 "proposals.proposal_id as proposal_id, "
137 "prop_reviews.scientific_merit_metric as scientific_merit_metric, "
138 "pi_name as pi_name, "
139 "coi_names as coi_names, "
140 "proposals.is_triggered as is_triggered, "
141 "false as is_thesis, "
142 "copies.title as title, "
143 "copies.abstract as abstract, "
144 "classes.proposal_class_name as proposal_class, "
145 # Proposal Review process tag (e.g., SRP name)
146 "srp.science_review_panel_name as review_process_tag, "
147 # External Joint facility + hours (e.g., ALMA 5hrs; HST 2 orbits)
148 "'' as external_joint_facilities, " # (not currently tracked)
149 # Internal Joint Facilities (show all that apply despite filtering; e.g., GBT, VLA)
150 "ar_facs.facilities as internal_joint_facilities, "
151 # Total Requested Hours of selected Facility
152 "total_requested_time as total_requested_hours, "
153 # Scheduling Priorities (unique set from OSDs of selected Facility)
154 "prop_osd.priorities as priorities, "
155 # Requested Semesters and VLA Configurations (if applicable) of ADs of selected Facility
156 "coalesce(prop_osd.facility_configurations, 'No HW Config') as facility_configurations, "
157 # TODO:Hardware Bands (unique set from ADs of selected Facility; need to talk through what's possible atm)
158 # "concat(frontends,',',backends) as hardware_bands, "
159 "'FE here, BE here' as hardware_bands, "
160 # Recommended Time with Priority A in Hours of selected Facility
161 "a_time as priority_a, "
162 # Recommended Time with Priority B in Hours of selected Facility
163 "b_time as priority_b, "
164 # Recommended Time with Priority C in Hours of selected Facility
165 "c_time as priority_c, "
166 # Recommended Time with Priority N in Hours of selected Facility
167 "n_time as priority_n, "
168 # Scheduler Comments
169 # (All Scheduler Comments for TAC for ADs of selected facility appended together per proposal)
170 "scheduler_comments as scheduler_comments, "
171 # Science Comments to PI (external science review comments)
172 "prop_reviews.external_science_review_comments as external_science_review_comments, "
173 # Technical Comments to PI (external technical review comments)
174 "prop_reviews.external_technical_review_comments as external_technical_review_comments, "
175 # Data Comments to PI (external data management review comments)
176 "prop_reviews.external_data_management_review_comments as external_data_management_review_comments, "
177 # Science Internal Comments (internal scientific review comments)
178 "prop_reviews.internal_science_review_comments as internal_science_Review_comments, "
179 # Technical Internal Comments (internal technical review comments)
180 "prop_reviews.internal_technical_review_comments as internal_technical_Review_comments, "
181 # Data Internal Comments (internal data management review comments)
182 "prop_reviews.internal_data_management_review_comments as internal_data_management_review_comments, "
183 # Comments to PI (from Proposal Disposition; could be blank)
184 "proposal_dispositions.external_allocation_disposition_comments as external_allocation_disposition_comments, "
185 # Internal Comments (from Proposal Disposition; could be blank)
186 "proposal_dispositions.internal_allocation_disposition_comments as internal_allocation_disposition_comments "
187 )
188 pi_query = (
189 f"select proposal_id, concat(first_name, ' ', last_name) as pi_name "
190 f"from authors where is_primary = True"
191 )
192 coi_query = (
193 f"select proposal_id, string_agg(concat(first_name, ' ', last_name),',') as coi_names "
194 f"from authors where is_primary = False group by proposal_id"
195 )
196 prop_review_fields = (
197 f"proposal_id, scientific_merit_metric, "
198 f"external_science_review_comments, internal_science_review_comments, "
199 f"external_technical_review_comments, internal_technical_review_comments, "
200 f"external_data_management_review_comments, internal_data_management_review_comments"
201 )
202 prop_reviews_query = (
203 f"select {prop_review_fields} from ppr_proposal_reviews "
204 f"union select {prop_review_fields} from osr_proposal_reviews"
205 )
206 ar_facilities_query = (
207 "select string_agg(distinct facility_name, ',') facilities, proposal_copy_id "
208 f"from facilities inner join allocation_requests ar "
209 f"on facilities.facility_id = ar.facility_id "
210 f"where facilities.facility_id != {facility_id} "
211 f"group by ar.proposal_copy_id "
212 )
213 # TODO: this can be simplified with a pivot or crosstab query
214 osd_a_query = (
215 f"select allocation_request_id, allocated_time_per_repeat as priority_a_time "
216 f"from observation_specification_dispositions osd "
217 f"where scheduling_priority_name = 'A' "
218 )
219 osd_b_query = (
220 f"select allocation_request_id, allocated_time_per_repeat as priority_b_time "
221 f"from observation_specification_dispositions osd "
222 f"where scheduling_priority_name = 'B' "
223 )
224 osd_c_query = (
225 f"select allocation_request_id, allocated_time_per_repeat as priority_c_time "
226 f"from observation_specification_dispositions osd "
227 f"where scheduling_priority_name = 'C' "
228 )
229 osd_n_query = (
230 f"select allocation_request_id, allocated_time_per_repeat as priority_n_time "
231 f"from observation_specification_dispositions osd "
232 f"where scheduling_priority_name = 'N' "
233 )
234 # get the configuration (as a string) for each OSD:
235 hw_config_query = (
236 "select hw.hardware_configuration_id, "
237 "string_agg(f.frontend_name, ',') as frontends, "
238 "string_agg(b.backend_name, ',') as backends "
239 "from hardware_configurations hw "
240 "inner join frontend_configurations fe on hw.frontend_configuration_id = fe.frontend_configuration_id "
241 "inner join frontends f on f.frontend_id = fe.frontend_id "
242 "inner join backend_configurations be on hw.backend_configuration_id = be.backend_configuration_id "
243 "inner join backends b on b.backend_id = be.backend_id "
244 "group by hw.hardware_configuration_id "
245 )
246 configuration_query = (
247 f"select osd.observation_specification_disposition_id as osd_id, concat(frontends, backends) as hw_string "
248 f"from observation_specification_dispositions osd "
249 f"inner join ({hw_config_query}) hw "
250 f"on hw.hardware_configuration_id = osd.requested_hardware_configuration_id "
251 )
252 osd_query = (
253 f"select sum(osd.allocated_time_per_repeat) as total_time, "
254 f"string_agg(distinct osd.scheduling_priority_name, ',') as priorities, ar2.proposal_copy_id, "
255 # TODO: facility_configuration updates needed
256 f"string_agg(distinct config.hw_string, ',') as facility_configurations, "
257 # f"'No Configurations' as facility_configurations, "
258 f"string_agg(distinct ad.scheduler_internal_comments_to_tac, ',') as scheduler_comments, "
259 f"sum(priority_a_time) as a_time, sum(priority_b_time) as b_time, "
260 f"sum(priority_c_time) as c_time, sum(priority_n_time) as n_time "
261 f"from observation_specification_dispositions osd "
262 f"inner join allocation_requests ar2 on ar2.allocation_request_id = osd.allocation_request_id "
263 f"inner join allocation_dispositions ad on ad.allocation_request_id = ar2.allocation_request_id "
264 f"inner join allocation_versions vers on vers.allocation_version_id = ad.allocation_version_id "
265 f"left join ({configuration_query}) config on config.osd_id = osd.observation_specification_disposition_id "
266 f"left join ({osd_a_query}) a on ar2.allocation_request_id = a.allocation_request_id "
267 f"left join ({osd_b_query}) b on ar2.allocation_request_id = b.allocation_request_id "
268 f"left join ({osd_c_query}) c on ar2.allocation_request_id = c.allocation_request_id "
269 f"left join ({osd_n_query}) n on ar2.allocation_request_id = n.allocation_request_id "
270 )
271 if allocation_version_id is not None:
272 osd_query = (
273 f"{osd_query} "
274 f"where vers.allocation_version_id ={allocation_version_id} "
275 f"group by proposal_copy_id "
276 )
277 else:
278 osd_query = (
279 f"{osd_query} "
280 f"where vers.published_destination ='{published_for}' "
281 f"and vers.facility_id = {facility_id} "
282 f"group by proposal_copy_id "
283 )
285 # get the total requested time for each proposal copy:
286 # deterministic context has no targets
287 allocation_request_time_query = (
288 f"select sum(st.requested_time) + sum(rt.requested_time) as ar_requested_time, st.allocation_request_id as allocation_request_id "
289 f"from science_targets st "
290 f"left join reference_targets rt on rt.allocation_request_id = st.allocation_request_id "
291 f"group by st.allocation_request_id "
292 )
293 requested_time_query = (
294 f"select sum(ar_requested_time) as total_requested_time, ar.proposal_copy_id "
295 f"from allocation_requests ar "
296 f"left join ({allocation_request_time_query}) ar_time on ar_time.allocation_request_id = ar.allocation_request_id "
297 f"group by ar.proposal_copy_id"
298 )
299 query = (
300 f"select {fields} from proposals "
301 f"inner join proposal_dispositions on proposal_dispositions.proposal_id = proposals.proposal_id "
302 f"left join ({pi_query}) pi on pi.proposal_id = proposals.proposal_id "
303 f"left join ({coi_query}) coi on coi.proposal_id = proposals.proposal_id "
304 f"inner join ({prop_reviews_query}) prop_reviews on prop_reviews.proposal_id = proposals.proposal_id "
305 f"inner join proposal_copies copies on copies.proposal_copy_id = proposals.observatory_copy_id "
306 f"inner join proposal_classes classes on classes.proposal_class_id = copies.proposal_class_id "
307 f"left join ({ar_facilities_query})ar_facs on ar_facs.proposal_copy_id = copies.proposal_copy_id "
308 f"left join proposal_science_review_panels psrp on psrp.proposal_id = proposals.proposal_id "
309 f"left join science_review_panels srp on srp.science_review_panel_id = psrp.science_review_panel_id "
310 f"inner join ({osd_query}) prop_osd on prop_osd.proposal_copy_id = copies.proposal_copy_id "
311 f"inner join ({requested_time_query}) time on time.proposal_copy_id = copies.proposal_copy_id "
312 f"where proposals.solicitation_id = {solicitation_id} "
313 )
314 if is_triggered:
315 query = query + f"and proposals.is_triggered = {is_triggered} "
316 if is_joint:
317 query = query + f"and copies.is_joint = {is_joint} "
318 if proposal_class_id:
319 query = query + f"and classes.proposal_class_id = {proposal_class_id} "
321 # useful debugging output:
322 # print(f"allocation_request_time_query: {allocation_request_time_query}")
323 # rs = self.session.execute(text(allocation_request_time_query))
324 # print(f" result: {[r for r in rs.mappings().all()]}")
325 #
326 # st_query = hw_config_query
327 # print(f"query: {st_query}")
328 # rs = self.session.execute(text(st_query))
329 # print(f" result: {[r for r in rs.mappings().all()]}")
331 # print(f"coi_query: {coi_query}")
332 # print(f"prop_reviews_query: {prop_reviews_query}")
333 # print(f"ar_facilities_query: {ar_facilities_query}")
334 # print(f"osd_query: {osd_query}")
335 # rs = self.session.execute(text(osd_query))
336 # print(f" result: {[r for r in rs.mappings().all()]}")
338 # print(f"requested_time_query: {requested_time_query}")
339 # rs = self.session.execute(text(requested_time_query))
340 # print(f" result: {[r for r in rs.mappings().all()]}")
341 #
342 # print(f"overall query: {query}")
343 rs = self.session.execute(text(query))
345 return rs.mappings().all()
347 def search(
348 self, web_query: str, *, offset: int = 0, limit: int = 0
349 ) -> tuple[int, int, int, str, list[Row[tuple[ProposalSearchResult, Any]]]]:
350 query = (
351 self.session.query(
352 ProposalSearchResult,
353 ts_headline(
354 "english",
355 ProposalSearchResult.title + " " + ProposalSearchResult.abstract,
356 websearch_to_tsquery(web_query),
357 ),
358 )
359 .where(websearch_to_tsquery(web_query).bool_op("@@")(ProposalSearchResult.summary))
360 .order_by(func.ts_rank_cd(ProposalSearchResult.summary, websearch_to_tsquery(web_query)))
361 )
363 total_query = self.session.query(
364 ProposalSearchResult,
365 ).where(websearch_to_tsquery(web_query).bool_op("@@")(ProposalSearchResult.summary))
367 total_count = total_query.count()
369 if offset:
370 query = query.offset(offset)
372 if limit:
373 query = query.limit(limit)
375 return total_count, offset, limit, web_query, query.all()