Coverage for middle_layer/closeout/domain_layer/services/generate_disposition_letters_service.py: 90.16%
61 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/>.
17from typing import List, Tuple
19import astropy.units as u
21from allocate.domain_layer.entities.allocation_disposition import AllocationDisposition
22from allocate.domain_layer.entities.proposal_disposition import ProposalDisposition
23from allocate.domain_layer.entities.publication_destination import PublicationDestination
24from allocate.domain_layer.entities.scheduling_priority import SchedulingPriorityKind
25from closeout.domain_layer.entities.disposition_letter import DispositionLetter
26from closeout.domain_layer.entities.template import Template
27from common.application_layer.orm_repositories.orm_repository import ORMRepository
30def generate_disposition_letter(
31 disposition: ProposalDisposition, repo: ORMRepository, template_content: str = None
32) -> str:
33 """
34 Generate disposition letter for a ProposalDisposition
36 :param disposition: ProposalDisposition to generate letter for
37 :param repo: Repository to query the database
38 :param template_content: string representing the template to use to generate the letter (optional)
40 :return: string representing the letter for the ProposalDisposition
41 """
43 # Get the template to use
44 if template_content is not None:
45 template = Template(template_content)
46 else:
47 template = Template(disposition.proposal.solicitation.disposition_letter_template)
49 # Get a renderer for the template
50 renderer = template.get_renderer()
52 # retrieve the proposal
53 proposal = disposition.proposal
55 ads = disposition.allocation_dispositions
57 allocation_summary = {}
58 total_approved_time = sum((ad.total_approved_time() for ad in ads), ads[0].total_approved_time() * 0) if ads else 0
60 if disposition.approved:
61 # For approved dispositions, we exclude N priorities if non-N priorities exist
62 for ad in ads:
63 facility_name = ad.allocation_request.facility.facility_name
64 # get the requested time:
65 requested_time = 0
66 for os in ad.allocation_request.observation_specifications:
67 requested_time += os.scans.total_duration.to_value(u.h)
69 # Collect priorities for this allocation disposition to check if it has non-N priorities
70 priorities = {osd.scheduling_priority for osd in ad.observation_specification_dispositions}
71 has_non_n_priority = any(priority.is_positive for priority in priorities)
73 # Only add to allocation_summary if there are non-N priorities
74 if has_non_n_priority:
75 if facility_name not in allocation_summary:
76 allocation_summary[facility_name] = {"allocated": {}, "requested": 0}
78 for osd in ad.observation_specification_dispositions:
79 priority = osd.scheduling_priority_name
80 # Skip 'N' priorities
81 if osd.scheduling_priority.is_positive:
82 if priority not in allocation_summary[facility_name]["allocated"]:
83 allocation_summary[facility_name]["allocated"][priority] = 0
85 allocation_summary[facility_name]["allocated"][priority] += osd.total_approved_time.to_value(
86 u.h
87 )
89 allocation_summary[facility_name]["requested"] = round(requested_time, 2)
91 # round all the allocated times before sending the allocation summary to the template
92 for priority in allocation_summary[facility_name]["allocated"]:
93 allocation_summary[facility_name]["allocated"][priority] = round(
94 allocation_summary[facility_name]["allocated"][priority], 2
95 )
97 allocation_summary[facility_name]["allocated"] = dict(
98 sorted(allocation_summary[facility_name]["allocated"].items())
99 )
100 else:
101 # For non-approved dispositions, include every facility with N priority and 0 hours
102 for ad in ads:
103 facility_name = ad.allocation_request.facility.facility_name
104 requested_time = 0
105 for os in ad.allocation_request.observation_specifications:
106 requested_time += os.scans.total_duration.to_value(u.h)
108 if facility_name not in allocation_summary:
109 allocation_summary[facility_name] = {"allocated": {}, "requested": 0}
111 # Add N priority with 0 hours
112 allocation_summary[facility_name]["allocated"]["N"] = 0
113 allocation_summary[facility_name]["requested"] = round(requested_time, 2)
114 allocation_summary[facility_name]["allocated"] = dict(
115 sorted(allocation_summary[facility_name]["allocated"].items())
116 )
117 # ensure there is a published AV for each facility of this proposal
118 facility_av_map = repo.disposition_letter_repo.fetch_facilities_to_allocation_versions_for_proposal(proposal)
119 if len(facility_av_map) != len({ar.facility_id for ar in proposal.observatory_copy.allocation_requests}):
120 raise Exception(f"Proposal {proposal.proposal_code} is missing at least one allocation version")
122 requested_science_category = disposition.proposal.observatory_copy.science_category
123 allocated_science_category = proposal.vetted_science_category
125 # Render the template with the proposal disposition data
126 rendered_letter = renderer.render(
127 approved=disposition.approved,
128 total_approved_time=total_approved_time,
129 disposition=disposition,
130 proposal=proposal,
131 requested_science_category=requested_science_category,
132 allocated_science_category=allocated_science_category,
133 facility_av_map=facility_av_map,
134 review=proposal.review,
135 solicitation=proposal.solicitation,
136 allocation_summary=allocation_summary,
137 )
139 return rendered_letter
142def generate_disposition_letters(dispositions: list[ProposalDisposition], repo: ORMRepository) -> list[str]:
143 """Generate disposition letters for a list of ProposalDispositions
144 :param dispositions: ProposalDispositions to generate letters for
145 :param repo: Repository to query the database
146 :return: list of tuples containing the letter text and generation datetime for each ProposalDisposition
147 """
148 letters = []
149 for pd in dispositions:
150 letters.append(generate_disposition_letter(pd, repo))
152 return letters