Coverage for postrfp/buyer/api/endpoints/answers.py: 100%
60 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-22 21:34 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-22 21:34 +0000
1"""
2Operations for fetching vendor's answers
3"""
5from collections import defaultdict
7from sqlalchemy.orm import Session
9from postrfp.model.questionnaire.answering import Answer
10from postrfp.shared import fetch
11from postrfp.shared.decorators import http
12from postrfp.model import User, Issue, QuestionInstance
13from postrfp.buyer.api import authorise
14from postrfp.authorisation import perms
15from postrfp.shared import serial
18# Reusable chunk of documentation for openapi spec documentation of endpoint functions
19lookup_docs = """
20 Results are provided as a nested mapping object allowing easy reference when
21 assigning answers to questions for specific Issues:
22 ```
23 {
24 // ID of the Issue
25 "923": {
27 // Element ID to Answer Value
28 "2342742": "Yes, we can deliver to Jupiter",
29 "7234232": "No, we cannot deliver to Baltimore"
31 }
32 }
33 ```
34 So to reference an answer for Issue 923 to Element 7234232, the notation is
35 data['923']['7234232']
37 (bear in mind that numerical IDs are delivered as Strings when used as Object keys in JSON)
38"""
41@http
42def get_question_answers(
43 session: Session, user: User, question_id: int
44) -> serial.AnswerLookup:
45 """
46 Fetch answers for question `question_id` from project structures as nested object
47 mapping issue ID to question element ID to answer.
48 """
49 # Fetch question (ideally preloading project via fetch.question)
50 question = fetch.question(session, question_id, with_project=True)
51 # Validation remains in the endpoint
52 authorise.check(
53 user,
54 perms.ISSUE_VIEW_ANSWERS,
55 project=question.project,
56 section_id=question.section_id,
57 )
59 answer_data = fetch.visible_answers(session, question)
60 return serial.AnswerLookup.model_construct(answer_data)
63get_question_answers.__doc__ = (get_question_answers.__doc__ or "") + lookup_docs
66@http
67def get_project_issue_question(
68 session: Session, user: User, project_id: int, issue_id: int, q_question_number: str
69) -> serial.SingleRespondentQuestion:
70 """
71 Get the answered question for the given project and issue.
72 """
73 project = fetch.project(session, project_id)
74 question = project.question_by_number(q_question_number)
75 issue = project.issue_by_id(issue_id)
76 authorise.check(
77 user,
78 perms.ISSUE_VIEW_ANSWERS,
79 issue=issue,
80 project=project,
81 section_id=question.section_id,
82 )
83 # TODO Fix schemas to permit answered questions.
84 return question.single_vendor_dict(issue) # type: ignore[return-value]
87@http
88def get_project_section_issue_answers(
89 session: Session, user: User, project_id: int, section_id: int, issue_id: int
90) -> list[serial.Answer]:
91 """
92 Fetch an array of Answer objects for the given Project, Issue and Section
93 """
94 project = fetch.project(session, project_id)
95 issue = fetch.issue(session, issue_id)
97 authorise.check(
98 user,
99 perms.ISSUE_VIEW_ANSWERS,
100 project=project,
101 section_id=section_id,
102 issue=issue,
103 )
105 cols = (
106 Answer.element_id,
107 Answer.answer,
108 Answer.issue_id,
109 Answer.question_instance_id.label("question_id"),
110 )
111 answers = (
112 session.query(*cols)
113 .join(Issue)
114 .join(QuestionInstance)
115 .filter(
116 Issue.project == project,
117 Answer.issue == issue,
118 QuestionInstance.section_id == section_id,
119 )
120 )
122 return [serial.Answer(**a._asdict()) for a in answers]
125@http
126def get_question_issue_answers(
127 session: Session, user: User, question_id: int, issue_id: int
128) -> serial.ElementAnswerList:
129 """
130 Fetch an array of Answer & Element ID for the given Question, Issue
131 """
132 question = fetch.question(session, question_id, with_project=True)
133 issue = question.project.get_issue(issue_id)
135 authorise.check(
136 user,
137 perms.ISSUE_VIEW_ANSWERS,
138 project=question.project,
139 section_id=question.section_id,
140 issue=issue,
141 )
143 result = serial.ElementAnswerList(root=[])
144 answers = question.answers_for_issue(issue.id).all()
145 for answer in answers:
146 result.root.append(
147 serial.ElementAnswer(element_id=answer.element_id, answer=answer.answer)
148 )
150 return result
153@http
154def get_element_answers(
155 session: Session, user: User, element_id: int
156) -> serial.RespondentAnswers:
157 """
158 An array of answers from all projects and all issues for the provided element_id.
160 Project ID, Title and Date Published are provided to be used as a reference when reviewing
161 previous answers.
162 """
163 authorise.check(user, perms.ISSUE_VIEW_ANSWERS, multiproject=True)
164 return serial.RespondentAnswers(
165 [
166 serial.RespondentAnswer.model_validate(a)
167 for a in fetch.element_answers(session, user, element_id)
168 ]
169 )
172@http
173def get_project_answers(
174 session: Session, user: User, project_id: int, issue_ids: set[int]
175) -> serial.AnswerLookup:
176 """
177 Fetch Answers to all questions in the current project for the Issue IDs provided.
179 __N.B.__ This operation is inefficient for projects with very many questions or Issues. A
180 maximum of 2,000 element answers will be returned. An HTTP 400 error will be returned if more
181 than 2,000 answers are found. This total does not include unanswered Question Element / Issue
182 combinations. Operation ID get_project_qstats can be used to check how many
183 answerable elements are in the project.
184 """
185 project = fetch.project(session, project_id)
186 authorise.check(
187 user, perms.ISSUE_VIEW_ANSWERS, project=project, deny_restricted=True
188 )
190 issue_id_set = {i.id for i in project.scoreable_issues} & issue_ids
192 aq = fetch.answers_in_issues_query(session, project_id, issue_id_set)
194 if aq.count() > 2000:
195 m = "More than 2,000 results found: submit fewer issueIds or use a different operation"
196 raise ValueError(m)
198 adict: dict[str, dict[str, str]] = defaultdict(dict)
199 for issue_id, element_id, answer in aq.with_entities(
200 Answer.issue_id, Answer.element_id, Answer.answer
201 ):
202 adict[str(issue_id)][str(element_id)] = answer
204 return serial.AnswerLookup.model_construct(adict)
207get_project_answers.__doc__ = (get_project_answers.__doc__ or "") + lookup_docs