|
| 1 | +from __future__ import annotations |
| 2 | + |
1 | 3 | from collections.abc import Mapping |
2 | 4 | from typing import Any |
3 | 5 |
|
| 6 | +import requests as _requests |
| 7 | + |
4 | 8 | from ...models.work_items import ( |
5 | 9 | UpdateWorkItemAttachment, |
6 | 10 | WorkItemAttachment, |
@@ -96,6 +100,92 @@ def update( |
96 | 100 | ) |
97 | 101 | return WorkItemAttachment.model_validate(response) |
98 | 102 |
|
| 103 | + def upload_from_bytes( |
| 104 | + self, |
| 105 | + workspace_slug: str, |
| 106 | + project_id: str, |
| 107 | + work_item_id: str, |
| 108 | + file_bytes: bytes, |
| 109 | + name: str, |
| 110 | + content_type: str, |
| 111 | + ) -> WorkItemAttachment: |
| 112 | + """Upload a file to a work item as an attachment. |
| 113 | +
|
| 114 | + Handles the full three-step flow: |
| 115 | + 1. Create the attachment record and receive a presigned S3 upload URL. |
| 116 | + 2. Upload the file bytes directly to S3 via the presigned POST. |
| 117 | + 3. Mark the attachment as uploaded (PATCH is_uploaded=True). |
| 118 | +
|
| 119 | + Args: |
| 120 | + workspace_slug: The workspace slug identifier |
| 121 | + project_id: UUID of the project |
| 122 | + work_item_id: UUID of the work item |
| 123 | + file_bytes: Raw file bytes to upload |
| 124 | + name: Filename (e.g. "report.pdf") |
| 125 | + content_type: MIME type (e.g. "application/pdf") |
| 126 | + """ |
| 127 | + size = len(file_bytes) |
| 128 | + |
| 129 | + # Step 1 — create attachment record, get presigned S3 POST URL |
| 130 | + raw = self._post( |
| 131 | + f"{workspace_slug}/projects/{project_id}/work-items/{work_item_id}/attachments", |
| 132 | + {"name": name, "type": content_type, "size": size}, |
| 133 | + ) |
| 134 | + upload_data = raw["upload_data"] |
| 135 | + asset_id = raw["asset_id"] |
| 136 | + attachment = WorkItemAttachment.model_validate(raw["attachment"]) |
| 137 | + |
| 138 | + # Step 2 — upload bytes to S3 (raw requests, no Plane auth headers) |
| 139 | + fields = upload_data.get("fields", {}) |
| 140 | + s3_resp = _requests.post( |
| 141 | + upload_data["url"], |
| 142 | + data=fields, |
| 143 | + files={"file": (name, file_bytes, content_type)}, |
| 144 | + timeout=120, |
| 145 | + ) |
| 146 | + s3_resp.raise_for_status() |
| 147 | + |
| 148 | + # Step 3 — mark as uploaded (returns 204, no body) |
| 149 | + self._patch( |
| 150 | + f"{workspace_slug}/projects/{project_id}/work-items/{work_item_id}/attachments/{asset_id}", |
| 151 | + {"is_uploaded": True}, |
| 152 | + ) |
| 153 | + |
| 154 | + return attachment |
| 155 | + |
| 156 | + def get_download_url( |
| 157 | + self, |
| 158 | + workspace_slug: str, |
| 159 | + project_id: str, |
| 160 | + work_item_id: str, |
| 161 | + attachment_id: str, |
| 162 | + ) -> str: |
| 163 | + """Get a presigned download URL for a work item attachment. |
| 164 | +
|
| 165 | + Calls the attachment detail endpoint which issues a redirect to a |
| 166 | + time-limited presigned S3 URL. The returned URL can be opened in a |
| 167 | + browser or fetched with any HTTP client — no Plane auth required. |
| 168 | +
|
| 169 | + Args: |
| 170 | + workspace_slug: The workspace slug identifier |
| 171 | + project_id: UUID of the project |
| 172 | + work_item_id: UUID of the work item |
| 173 | + attachment_id: UUID of the attachment |
| 174 | +
|
| 175 | + Returns: |
| 176 | + Presigned S3 URL (time-limited, typically ~1 hour) |
| 177 | + """ |
| 178 | + url = self._build_url( |
| 179 | + f"{workspace_slug}/projects/{project_id}/work-items/{work_item_id}/attachments/{attachment_id}" |
| 180 | + ) |
| 181 | + resp = self.session.get(url, headers=self._headers(), allow_redirects=False, timeout=self.config.timeout) |
| 182 | + if resp.status_code in (301, 302, 303, 307, 308): |
| 183 | + location = resp.headers.get("Location") |
| 184 | + if location: |
| 185 | + return location |
| 186 | + # Not a redirect — let _handle_response raise or return |
| 187 | + return self._handle_response(resp) |
| 188 | + |
99 | 189 | def delete( |
100 | 190 | self, workspace_slug: str, project_id: str, work_item_id: str, attachment_id: str |
101 | 191 | ) -> None: |
|
0 commit comments