Coverage for postrfp/buyer/api/endpoints/attachments.py: 99%
74 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"""
2Manage file attachments and uploads
3"""
5from sqlalchemy.orm import Session
7from postrfp.model.questionnaire.qelements import (
8 QuestionAttachment,
9)
10from postrfp.shared import attachments, fetch, serial
11from postrfp.shared.decorators import http
12from postrfp.authorisation import perms
13from postrfp.buyer.api import authorise
14from postrfp.shared.response import XAccelAttachmentResponse, XAccelResponse
15from postrfp.model import QuestionInstance, User
16from postrfp.model.audit import AuditEvent, evt_types
19safo = serial.Attachment.model_validate
22@http
23def get_project_attachments(
24 session: Session, user: User, project_id: int
25) -> list[serial.Attachment]:
26 """
27 list details for all Project Attachments (attachments uploaded by the Project author - buyer)
28 """
29 project = fetch.project(session, project_id)
30 authorise.check(user, perms.PROJECT_ACCESS, project=project, deny_restricted=False)
31 return [safo(att) for att in project.list_attachments(user)]
34@http
35def get_project_issueattachments(
36 session: Session, user: User, project_id: int
37) -> list[serial.IssueAttachment]:
38 """
39 list all Issue attachments (uploaded by Respondents) for the current Project
40 """
41 project = fetch.project(session, project_id)
42 authorise.check(
43 user,
44 perms.ISSUE_VIEW_ANSWERS,
45 project=project,
46 section_id=project.section_id,
47 deny_restricted=False,
48 )
49 return [
50 serial.IssueAttachment.model_validate(att)
51 for att in project.list_issue_attachments(user)
52 ]
55@http
56def get_project_answerattachments(
57 session: Session, user: User, project_id: int
58) -> list[serial.AnswerAttachment]:
59 """
60 list Answer Attachments for the current project - attachments uploaded as answers to
61 questions with File Upload question elements
62 """
63 project = fetch.project(session, project_id)
64 authorise.check(
65 user,
66 perms.ISSUE_VIEW_ANSWERS,
67 project=project,
68 deny_restricted=False,
69 section_id=project.section_id,
70 )
71 return [
72 serial.AnswerAttachment.model_validate(att)
73 for att in fetch.answer_attachments_q(project, user)
74 ]
77@http
78def get_project_attachment(
79 session: Session, user: User, project_id: int, attachment_id: int
80) -> XAccelResponse:
81 """
82 Download the Project Attachment with the given ID
83 """
84 project = fetch.project(session, project_id)
85 authorise.check(user, perms.PROJECT_ACCESS, project=project, deny_restricted=False)
86 attachment = project.get_attachment(user, attachment_id)
88 return XAccelAttachmentResponse(attachment)
91@http
92def post_project_attachment(
93 session: Session,
94 user: User,
95 project_id: int,
96 attachment_upload: str,
97 attachment_description: str,
98) -> serial.Id:
99 """
100 Upload a file as an attachment to the project.
102 This action can be performed for Projects at status Draft or Live
103 """
105 project = fetch.project(session, project_id)
106 authorise.check(user, perms.PROJECT_EDIT_COSMETIC, project=project)
107 attachment = attachments.save_project_attachment(
108 session, project_id, user, attachment_upload, attachment_description
109 )
110 session.flush()
111 changes = [("filename", attachment.filename, "")]
112 evt = AuditEvent.create(
113 session,
114 evt_types.PROJECT_ATTACHMENT_ADDED,
115 project=project,
116 user=user,
117 object_id=attachment.id,
118 change_list=changes,
119 )
120 session.add(evt)
121 return serial.Id(id=attachment.id)
124@http
125def delete_project_attachment(
126 session: Session, user: User, project_id: int, attachment_id: int
127):
128 """
129 Delete the Project Attachment with the given ID
131 This action can be performed for Projects at status Draft or Live
132 """
133 project = fetch.project(session, project_id)
134 authorise.check(user, perms.PROJECT_EDIT_COSMETIC, project=project)
135 attachment = project.get_attachment(user, attachment_id)
136 filename = attachment.filename
137 attachments.delete_project_attachment(session, attachment)
138 changes = [("filename", "", filename)]
139 evt = AuditEvent.create(
140 session,
141 evt_types.PROJECT_ATTACHMENT_REMOVED,
142 project=project,
143 user=user,
144 object_id=attachment_id,
145 change_list=changes,
146 )
147 session.add(evt)
150@http
151def get_project_issue_attachment(
152 session: Session, user: User, project_id: int, issue_id: int, attachment_id: int
153) -> XAccelResponse:
154 """
155 Download the Issue Attachment with the given ID
156 """
157 project = fetch.project(session, project_id)
158 issue = fetch.issue(session, issue_id)
159 authorise.check(
160 user,
161 perms.ISSUE_VIEW_ANSWERS,
162 issue=issue,
163 project=project,
164 section_id=project.section_id,
165 )
166 attachment = issue.get_attachment(attachment_id)
168 return XAccelAttachmentResponse(attachment)
171@http
172def get_question_element_attachment(
173 session: Session, user: User, question_id: int, element_id: int
174) -> XAccelResponse:
175 """
176 Download the Question Attachment associated with the given Element ID
177 """
178 q_instance = session.get_one(QuestionInstance, question_id)
179 q_element = session.get_one(QuestionAttachment, element_id)
181 if q_element.question_id != q_instance.question_def_id:
182 raise ValueError(f"Element {element_id} is not part of Question {question_id}")
183 authorise.check(
184 user,
185 perms.PROJECT_VIEW_QUESTIONNAIRE,
186 project=q_instance.project,
187 section_id=q_instance.section_id,
188 )
189 return XAccelAttachmentResponse(q_element.attachment)
192@http
193def get_answer_attachment(
194 session: Session, user: User, answer_id: int
195) -> XAccelResponse:
196 """
197 Download the Answer Attachment with the given ID
198 """
199 answer = fetch.answer(session, answer_id)
200 issue = answer.issue
201 authorise.check(
202 user,
203 perms.ISSUE_VIEW_ANSWERS,
204 issue=issue,
205 project=issue.project,
206 section_id=answer.question_instance.section_id,
207 )
209 if not answer.attachment:
210 raise ValueError(f"No attachment found for answer {answer_id}")
212 return XAccelAttachmentResponse(answer.attachment)