Coverage for middle_layer/review/domain_layer/services/deserialize_csv_to_isrs_service.py: 96.43%
56 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"""Deserialize CSV as IndividualScienceReviews to be imported
19The following columns allow the middle layer to update the given ISRs when imported.
20They are:
21 username - With Solicitation, identifies the ScienceReviewer who wrote the ISR via user_id
22 proposal_id (proposal_code) - Identifies a Proposal and Solicitation.
23 comments_for_the_srp - comments to update
24 individual_score - score to update (must be between 0.1 and 9.9 in increments of a tenth)
26All other attributes of ISRs or related objects can't be edited by ScienceReviewers,
27 aren't meaningful to users,
28 or aren't needed to identify an ISR.
29"""
31import csv
32from io import BytesIO, StringIO
34from auth.auth import get_user_by_username
35from common.application_layer.orm_repositories.orm_repository import ORMRepository
36from common.domain_layer.services.csv_service import compile_error_messages, get_rows
37from review.domain_layer.entities.individual_science_review import IndividualScienceReview
38from review.domain_layer.entities.science_reviewer import ScienceReviewer
39from review.domain_layer.services.validate_isr_score_service import (
40 INDIVIDUAL_REV_SCORE_RANGE_MESSAGE,
41 validate_isr_score,
42)
43from review.domain_layer.services.validate_isr_state_change_service import validate_isr_state_change
46def deserialize_csv_to_isrs(
47 file_content: bytes, reviewer: ScienceReviewer, is_tta_member: bool, repo: ORMRepository
48) -> tuple[list[IndividualScienceReview], set[str]]:
49 """Deserialize a given CSV file, updating the IndividualScienceReviews specified in it
51 Note: the caller is responsible for persisting updates to the IndividualScienceReviews being returned, if so desired
53 :param file_content: CSV file contents
54 :param reviewer: the ScienceReviewer that the import is being performed on behalf of
55 :param is_tta_member: whether or not a TTA member is making the request on behalf of a ScienceReviewer
56 :param repo: Repository to query the database
57 :return: Tuple consisting of either:
58 a list of ISRs containing updates parsed from file_content, plus an empty set, if parsing succeeded;
59 or an empty list and a set of errors found during parsing if it failed
60 """
62 rows, error_messages = get_rows(file_content)
63 isrs_to_return = []
64 if error_messages:
65 return isrs_to_return, error_messages
67 # Check for required columns
68 required_columns = ["username", "proposal_id", "comments_for_the_srp", "individual_score"]
69 headers = rows[0].keys()
70 for column in required_columns:
71 if column not in headers:
72 error_messages = compile_error_messages(
73 error_messages, f"Required column {column} not found in CSV file", abort=True
74 )
75 if len(error_messages):
76 return isrs_to_return, error_messages
78 # Process the data contained in the CSV
79 isr_update_data = []
80 for i, row in enumerate(rows):
81 # Verify the username exists and that the user is also the reviewer
82 try:
83 row_reviewer = get_user_by_username(row["username"])
84 if row_reviewer.user_id != reviewer.user_id:
85 error_messages = compile_error_messages(
86 error_messages, f"Row {i}: Username {row['username']} does " f"not match that of the reviewer"
87 )
88 except ValueError:
89 error_messages = compile_error_messages(error_messages, f"Row {i}: Username {row['username']} not found")
91 # Verify that the specified proposal is associated with an ISR and this reviewer
92 science_review = None
94 # Note - CSV references proposal_id which is user-facing. Internally, this is proposal_code
95 science_review = repo.individual_science_review_repo.by_reviewer_id_and_proposal_code(
96 reviewer.science_reviewer_id, row["proposal_id"]
97 )
98 if not science_review:
99 error_messages = compile_error_messages(
100 error_messages, f"Row {i}: Proposal_id {row['proposal_id']} not found"
101 )
103 # Verify that there is a comment
104 if not len(row["comments_for_the_srp"]):
105 error_messages = compile_error_messages(error_messages, f"Row {i}: No comment for the SRP found")
107 # Verify that there is a comment
108 if not validate_isr_score(float(row["individual_score"])):
109 error_messages = compile_error_messages(
110 error_messages,
111 f"Row {i}: invalid individual score found - {INDIVIDUAL_REV_SCORE_RANGE_MESSAGE}",
112 )
114 if science_review:
115 row["individual_science_review_id"] = science_review.individual_science_review_id
117 isr_update_data.append(row)
119 # If no errors found, verify that all ISRs can be updated to a Saved review state
120 if not len(error_messages):
121 for i, isr_update in enumerate(isr_update_data):
122 # Get the ISR to check if the review state can be changed to Saved
123 target_isr = repo.individual_science_review_repo.by_id(isr_update["individual_science_review_id"])
124 if not is_tta_member and target_isr.conflict_declaration.conflict_state != "Available":
125 x = isr_update_data.pop(i) # Don't update the ISR if conflicted
126 else:
127 # Confirm that the ISR can be updated to a Saved review state
128 validation_message = validate_isr_state_change(
129 target_isr.review_state, "Saved", is_tta_member, is_finalize_call=False
130 )
131 if validation_message:
132 error_messages = compile_error_messages(
133 error_messages,
134 f"Invalid IndividualScienceReview state transition requested: {target_isr.review_state}->"
135 f"Saved for Proposal_id {row['proposal_id']}",
136 )
137 # If still no errors found, ready the specific ISRs for update
138 if not len(error_messages):
139 for isr_update in isr_update_data:
140 # Get the ISR to update
141 target_isr = repo.individual_science_review_repo.by_id(isr_update["individual_science_review_id"])
142 target_isr.comments_for_the_srp = isr_update["comments_for_the_srp"]
143 target_isr.individual_score = float(isr_update["individual_score"])
144 target_isr.review_state = "Saved"
145 isrs_to_return.append(target_isr)
147 return isrs_to_return, error_messages