Skip to content
Open
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Optimisation: Only initialise view classes in `PatchWagtailURLsMiddleware` when we know they are being used as a replacement ([#36](https://github.com/torchbox/wagtail-bynder/pull/36)) @ababic

### Fixed

- Improved handling for unexpected server error responses when downloading Bynder assets ([#40](https://github.com/torchbox/wagtail-bynder/issues/40))

## [0.6] - 2024-07-29

### Added
Expand Down
6 changes: 6 additions & 0 deletions src/wagtail_bynder/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,9 @@ class BynderAssetFileTooLarge(Exception):
Raised when an asset file being downloaded from Bynder is found to be
larger than specified in ``settings.BYNDER_MAX_DOWNLOAD_FILE_SIZE``
"""


class BynderAssetDownloadError(Exception):
"""
Raised when a server error occurs while downloading an asset from Bynder.
"""
25 changes: 21 additions & 4 deletions src/wagtail_bynder/management/commands/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from django.utils.translation import gettext_lazy as _
from requests import HTTPError

from wagtail_bynder.exceptions import BynderAssetDownloadError
from wagtail_bynder.models import BynderAssetMixin
from wagtail_bynder.utils import get_bynder_client

Expand Down Expand Up @@ -164,8 +165,16 @@ def update_object(self, obj: BynderAssetMixin, asset_data: dict[str, Any]) -> No
self.stdout.write(f" {key}: {value}")
self.stdout.write("-" * 80)

obj.update_from_asset_data(asset_data)
obj.save()
try:
obj.update_from_asset_data(asset_data)
obj.save()
except BynderAssetDownloadError as e:
self.stdout.write(
f"ERROR: Failed to download asset '{asset_data['id']}': {e}\n"
)
self.stdout.write(
f"Skipping update for {repr(obj)}. The asset will be retried on the next sync.\n"
)


class BaseBynderRefreshCommand(BaseModelCommand):
Expand Down Expand Up @@ -244,5 +253,13 @@ def update_object(self, obj: BynderAssetMixin, asset_data: dict[str, Any]) -> No
self.stdout.write(
f"Updating <{self.model._meta.label}: pk='{obj.pk}' title='{obj.title}'>" # type: ignore[attr-defined]
)
obj.update_from_asset_data(asset_data, force_download=self.force_download)
obj.save()
try:
obj.update_from_asset_data(asset_data, force_download=self.force_download)
obj.save()
except BynderAssetDownloadError as e:
self.stdout.write(
f"ERROR: Failed to download asset '{asset_data['id']}': {e}\n"
)
self.stdout.write(
f"Skipping update for {repr(obj)}. The asset will be retried on the next sync.\n"
)
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,26 @@ class BynderChooserModalOnloadHandlerFactory {
modal.close();
}

onLoadErrorStep(modal, jsonData) {
// Display error message in the modal
const errorHtml = `
<div class="messages" role="status">
<ul>
<li class="error">
<svg class="icon icon-warning messages-icon" aria-hidden="true">
<use href="#icon-warning"></use>
</svg>
${jsonData.error_message || 'An error occurred'}
</li>
</ul>
</div>
`;
// Insert error at the top of the modal body
$(modal.body).prepend(errorHtml);
// Re-initialize the Bynder view so user can try again
this.initBynderCompactView(modal);
}

initBynderCompactView(modal) {
// NOTE: This div is added to the template:
// wagtailadmin/chooser/choose-bynder.html template
Expand Down Expand Up @@ -78,6 +98,9 @@ class BynderChooserModalOnloadHandlerFactory {
[this.chosenStepName]: (modal, jsonData) => {
this.onLoadChosenStep(modal, jsonData);
},
error: (modal, jsonData) => {
this.onLoadErrorStep(modal, jsonData);
},
};
}
}
Expand Down
15 changes: 12 additions & 3 deletions src/wagtail_bynder/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from wagtail.models import Collection
from willow import Image

from .exceptions import BynderAssetFileTooLarge
from .exceptions import BynderAssetDownloadError, BynderAssetFileTooLarge


_DEFAULT_COLLECTION = Local()
Expand All @@ -24,11 +24,20 @@ def download_file(
url: str, max_filesize: int, max_filesize_setting_name: str
) -> InMemoryUploadedFile:
name = os.path.basename(url)
response = requests.get(url, timeout=20, stream=True)

# Make sure we don't store error responses instead of the file requested
if response.status_code != 200:
raise BynderAssetDownloadError(
f"Server error downloading '{name}' from Bynder. "
)

# Stream file to memory
file = BytesIO()
for line in requests.get(url, timeout=20, stream=True):
file.write(line)
for chunk in response.iter_content(chunk_size=8192):
if not chunk:
continue
file.write(chunk)
if file.tell() > max_filesize:
file.truncate(0)
raise BynderAssetFileTooLarge(
Expand Down
28 changes: 22 additions & 6 deletions src/wagtail_bynder/views/document.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from django.conf import settings
from django.views.generic import UpdateView
from wagtail import VERSION as WAGTAIL_VERSION
from wagtail.admin.modal_workflow import render_modal_workflow
from wagtail.documents import get_document_model
from wagtail.documents.views import chooser as chooser_views

Expand All @@ -13,6 +14,8 @@
else:
from wagtail.documents.views.documents import DeleteView, EditView

from wagtail_bynder.exceptions import BynderAssetDownloadError

from .mixins import BynderAssetCopyMixin, RedirectToBynderMixin


Expand Down Expand Up @@ -72,10 +75,23 @@ class DocumentChosenView(BynderAssetCopyMixin, chooser_views.DocumentChosenView)

def get(self, request: "HttpRequest", pk: str) -> "JsonResponse":
try:
obj = self.model.objects.get(bynder_id=pk)
except self.model.DoesNotExist:
obj = self.create_object(pk)
else:
if getattr(settings, "BYNDER_SYNC_EXISTING_DOCUMENTS_ON_CHOOSE", False):
self.update_object(pk, obj)
try:
obj = self.model.objects.get(bynder_id=pk)
except self.model.DoesNotExist:
obj = self.create_object(pk)
else:
if getattr(settings, "BYNDER_SYNC_EXISTING_DOCUMENTS_ON_CHOOSE", False):
self.update_object(pk, obj)
except BynderAssetDownloadError as e:
# Return error step to display message in the chooser modal
return render_modal_workflow(
request,
None,
None,
None,
json_data={
"step": "error",
"error_message": f"<strong>Failed to download document from Bynder:</strong> {str(e)} Please try again later.",
},
)
return self.get_chosen_response(obj)
28 changes: 22 additions & 6 deletions src/wagtail_bynder/views/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from django.conf import settings
from django.views.generic import UpdateView
from wagtail import VERSION as WAGTAIL_VERSION
from wagtail.admin.modal_workflow import render_modal_workflow
from wagtail.images import get_image_model
from wagtail.images.views import chooser as chooser_views

Expand All @@ -13,6 +14,8 @@
else:
from wagtail.images.views.images import DeleteView, EditView

from wagtail_bynder.exceptions import BynderAssetDownloadError

from .mixins import BynderAssetCopyMixin, RedirectToBynderMixin


Expand Down Expand Up @@ -70,10 +73,23 @@ class ImageChosenView(BynderAssetCopyMixin, chooser_views.ImageChosenView):

def get(self, request: "HttpRequest", pk: str) -> "JsonResponse":
try:
obj = self.model.objects.get(bynder_id=pk)
except self.model.DoesNotExist:
obj = self.create_object(pk)
else:
if getattr(settings, "BYNDER_SYNC_EXISTING_IMAGES_ON_CHOOSE", False):
self.update_object(pk, obj)
try:
obj = self.model.objects.get(bynder_id=pk)
except self.model.DoesNotExist:
obj = self.create_object(pk)
else:
if getattr(settings, "BYNDER_SYNC_EXISTING_IMAGES_ON_CHOOSE", False):
self.update_object(pk, obj)
except BynderAssetDownloadError as e:
# Return error step to display message in the chooser modal
return render_modal_workflow(
request,
None,
None,
None,
json_data={
"step": "error",
"error_message": f"<strong>Failed to download image from Bynder:</strong> {str(e)} Please try again later.",
},
)
return self.get_chosen_response(obj)
27 changes: 21 additions & 6 deletions src/wagtail_bynder/views/video.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
from django.conf import settings
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from wagtail.admin.modal_workflow import render_modal_workflow
from wagtail.admin.views.generic.chooser import ChooseView
from wagtail.snippets.views.chooser import SnippetChooserViewSet, SnippetChosenView
from wagtail.snippets.views.snippets import EditView, SnippetViewSet

from wagtail_bynder import get_video_model
from wagtail_bynder.exceptions import BynderAssetDownloadError

from .mixins import BynderAssetCopyMixin, RedirectToBynderMixin

Expand All @@ -34,12 +36,25 @@ def page_subtitle(self):
class VideoChosenView(BynderAssetCopyMixin, SnippetChosenView):
def get(self, request: "HttpRequest", pk: str) -> "JsonResponse":
try:
obj = self.model.objects.get(bynder_id=pk)
except self.model.DoesNotExist:
obj = self.create_object(pk)
else:
if getattr(settings, "BYNDER_SYNC_EXISTING_VIDEOS_ON_CHOOSE", False):
self.update_object(pk, obj)
try:
obj = self.model.objects.get(bynder_id=pk)
except self.model.DoesNotExist:
obj = self.create_object(pk)
else:
if getattr(settings, "BYNDER_SYNC_EXISTING_VIDEOS_ON_CHOOSE", False):
self.update_object(pk, obj)
except BynderAssetDownloadError as e:
# Return error step to display message in the chooser modal
return render_modal_workflow(
request,
None,
None,
None,
json_data={
"step": "error",
"error_message": f"<strong>Failed to fetch video from Bynder:</strong> {str(e)} Please try again later.",
},
)
return self.get_chosen_response(obj)


Expand Down
44 changes: 44 additions & 0 deletions tests/test_image_chooser_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
from testapp.factories import CustomImageFactory
from wagtail.test.utils import WagtailTestUtils

from wagtail_bynder.exceptions import BynderAssetDownloadError

from .utils import TEST_ASSET_ID


Expand Down Expand Up @@ -106,3 +108,45 @@ def test_uses_existing_image_and_updates_it(self, update_object_mock):
},
},
)

def test_returns_error_step_when_download_fails(self):
"""Test that download errors return an error step instead of crashing"""
with mock.patch(
"wagtail_bynder.views.image.ImageChosenView.create_object",
) as create_object_mock:
# Mock create_object to raise download error
create_object_mock.side_effect = BynderAssetDownloadError(
"Server error downloading 'test.jpg' from Bynder. "
)

response = self.client.get(str(self.url))

# Should return error step, not crash
self.assertEqual(response.status_code, 200)
response_data = response.json()
self.assertEqual(response_data["step"], "error")
self.assertIn("error_message", response_data)
self.assertIn("Server error downloading", response_data["error_message"])

@override_settings(BYNDER_SYNC_EXISTING_IMAGES_ON_CHOOSE=True)
def test_returns_error_step_when_update_fails(self):
"""Test that download errors during update return an error step"""
# Create an image with a matching bynder_id
CustomImageFactory.create(bynder_id=TEST_ASSET_ID)

with mock.patch(
"wagtail_bynder.views.image.ImageChosenView.update_object",
) as update_object_mock:
# Mock update_object to raise download error
update_object_mock.side_effect = BynderAssetDownloadError(
"Server error downloading 'test.jpg' from Bynder. "
)

response = self.client.get(str(self.url))

# Should return error step, not crash
self.assertEqual(response.status_code, 200)
response_data = response.json()
self.assertEqual(response_data["step"], "error")
self.assertIn("error_message", response_data)
self.assertIn("Server error downloading", response_data["error_message"])
Loading