Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions surge/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import dateutil.parser
import datetime
import json
import requests
import urllib.request

from surge.errors import (
SurgeMissingIDError,
Expand All @@ -14,6 +16,8 @@
from surge.tasks import Task
from surge import utils

CUSTOM_RESULTS_CONTENT_TYPE = "text/html"


class Project(APIResource):

Expand Down Expand Up @@ -469,3 +473,85 @@ def download_json(self, poll_time=5 * 60, api_key: str = None):
return self.Report.download_json(self.id,
poll_time=poll_time,
api_key=api_key)

@classmethod
def upload_custom_results(cls, project_id: str, file, api_key: str = None):
"""
Upload a custom HTML results file for a project. The file is rendered
on the project's results page. Re-uploading overwrites the previous file.

Arguments:
project_id (str): UUID of the project.
file (str or IO): Path to a local HTML file, or a binary file-like
object opened for reading.

Returns:
dict: The endpoint response, including ``upload_url``.
"""
endpoint = f"{PROJECTS_ENDPOINT}/{project_id}/custom_results"
response_json = cls.post(endpoint, api_key=api_key)
upload_url = response_json["upload_url"]

if isinstance(file, str):
with open(file, "rb") as f:
data = f.read()
else:
data = file.read()

put_response = requests.put(
upload_url,
data=data,
headers={"Content-Type": CUSTOM_RESULTS_CONTENT_TYPE},
)
put_response.raise_for_status()
return response_json

@classmethod
def get_custom_results_url(cls, project_id: str, api_key: str = None):
"""
Get a temporary presigned URL for a project's custom HTML results file.

Arguments:
project_id (str): UUID of the project.

Returns:
str: A presigned download URL (valid for a limited time).

Raises:
SurgeRequestError: If no custom results file exists (404).
"""
endpoint = f"{PROJECTS_ENDPOINT}/{project_id}/custom_results"
response_json = cls.get(endpoint, api_key=api_key)
return response_json["download_url"]

@classmethod
def download_custom_results(cls, project_id: str, api_key: str = None):
"""
Download the contents of a project's custom HTML results file.

Arguments:
project_id (str): UUID of the project.

Returns:
str: The HTML file contents.

Raises:
SurgeRequestError: If no custom results file exists (404).
"""
url = cls.get_custom_results_url(project_id, api_key=api_key)
with urllib.request.urlopen(url) as remote:
return remote.read().decode("utf-8")

@classmethod
def delete_custom_results(cls, project_id: str, api_key: str = None):
"""
Delete a project's custom HTML results file.

Arguments:
project_id (str): UUID of the project.

Returns:
dict: ``{"success": True}``
"""
endpoint = f"{PROJECTS_ENDPOINT}/{project_id}/custom_results"
return cls.delete_request(endpoint, api_key=api_key)
67 changes: 67 additions & 0 deletions tests/test_projects.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import io
from unittest import mock
from unittest.mock import MagicMock, patch
from datetime import datetime
Expand Down Expand Up @@ -459,3 +460,69 @@ def test_update_with_fields_template():
{"fields_text": "ABC"},
api_key=None,
)


def test_upload_custom_results_posts_then_puts_html():
put_resp = mock.Mock()
with (
mock.patch.object(
Project, "post",
return_value={"upload_url": "https://s3.example/put", "upload_url_expires_in": 900},
) as mock_post,
mock.patch("surge.projects.requests.put", return_value=put_resp) as mock_put,
):
result = Project.upload_custom_results("proj-1", io.BytesIO(b"<html>hi</html>"))

mock_post.assert_called_once_with("projects/proj-1/custom_results", api_key=None)
args, kwargs = mock_put.call_args
assert args[0] == "https://s3.example/put"
assert kwargs["data"] == b"<html>hi</html>"
assert kwargs["headers"]["Content-Type"] == "text/html"
put_resp.raise_for_status.assert_called_once()
assert result["upload_url"] == "https://s3.example/put"


def test_upload_custom_results_reads_from_path(tmp_path):
f = tmp_path / "report.html"
f.write_bytes(b"<html>file</html>")
put_resp = mock.Mock()
with (
mock.patch.object(
Project, "post",
return_value={"upload_url": "https://s3.example/put"},
),
mock.patch("surge.projects.requests.put", return_value=put_resp) as mock_put,
):
Project.upload_custom_results("proj-1", str(f))
assert mock_put.call_args.kwargs["data"] == b"<html>file</html>"


def test_get_custom_results_url_returns_download_url():
with mock.patch.object(
Project, "get",
return_value={"download_url": "https://s3.example/get", "download_url_expires_in": 900},
) as mock_get:
url = Project.get_custom_results_url("proj-1")
mock_get.assert_called_once_with("projects/proj-1/custom_results", api_key=None)
assert url == "https://s3.example/get"


def test_download_custom_results_fetches_html_content():
with (
mock.patch.object(
Project, "get", return_value={"download_url": "https://s3.example/get"},
),
mock.patch("surge.projects.urllib.request.urlopen") as mock_urlopen,
):
mock_urlopen.return_value.__enter__.return_value = io.BytesIO(b"<html>downloaded</html>")
content = Project.download_custom_results("proj-1")
assert content == "<html>downloaded</html>"


def test_delete_custom_results_calls_delete_endpoint():
with mock.patch.object(
Project, "delete_request", return_value={"success": True},
) as mock_delete:
result = Project.delete_custom_results("proj-1")
mock_delete.assert_called_once_with("projects/proj-1/custom_results", api_key=None)
assert result == {"success": True}
Loading