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

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 

18 

19import astropy.units as u 

20 

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 

28 

29 

30def generate_disposition_letter( 

31 disposition: ProposalDisposition, repo: ORMRepository, template_content: str = None 

32) -> str: 

33 """ 

34 Generate disposition letter for a ProposalDisposition 

35 

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) 

39 

40 :return: string representing the letter for the ProposalDisposition 

41 """ 

42 

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) 

48 

49 # Get a renderer for the template 

50 renderer = template.get_renderer() 

51 

52 # retrieve the proposal 

53 proposal = disposition.proposal 

54 

55 ads = disposition.allocation_dispositions 

56 

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 

59 

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) 

68 

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) 

72 

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} 

77 

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 

84 

85 allocation_summary[facility_name]["allocated"][priority] += osd.total_approved_time.to_value( 

86 u.h 

87 ) 

88 

89 allocation_summary[facility_name]["requested"] = round(requested_time, 2) 

90 

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 ) 

96 

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) 

107 

108 if facility_name not in allocation_summary: 

109 allocation_summary[facility_name] = {"allocated": {}, "requested": 0} 

110 

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

121 

122 requested_science_category = disposition.proposal.observatory_copy.science_category 

123 allocated_science_category = proposal.vetted_science_category 

124 

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 ) 

138 

139 return rendered_letter 

140 

141 

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

151 

152 return letters