Skip to content

Commit f53aee5

Browse files
feat: add file upload and download functionality for work item attachments
1 parent 491a923 commit f53aee5

2 files changed

Lines changed: 91 additions & 1 deletion

File tree

plane/api/work_items/attachments.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1+
from __future__ import annotations
2+
13
from collections.abc import Mapping
24
from typing import Any
35

6+
import requests as _requests
7+
48
from ...models.work_items import (
59
UpdateWorkItemAttachment,
610
WorkItemAttachment,
@@ -96,6 +100,92 @@ def update(
96100
)
97101
return WorkItemAttachment.model_validate(response)
98102

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+
99189
def delete(
100190
self, workspace_slug: str, project_id: str, work_item_id: str, attachment_id: str
101191
) -> None:

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "plane-sdk"
7-
version = "0.2.15"
7+
version = "0.2.16"
88
description = "Python SDK for Plane API"
99
readme = "README.md"
1010
requires-python = ">=3.10"

0 commit comments

Comments
 (0)