Skip to content

Commit c322454

Browse files
authored
Merge pull request #84 from octue/override-clean
Add uploaded_blob context manager, allow calling of model clean() with overridden settings
2 parents 3ac47f6 + 576c27b commit c322454

File tree

7 files changed

+193
-114
lines changed

7 files changed

+193
-114
lines changed

.devcontainer/devcontainer.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@
2626
"python.testing.pytestEnabled": true,
2727
"python.testing.unittestEnabled": false,
2828
"terminal.integrated.defaultProfile.linux": "zsh",
29-
"ruff.format.args": ["--line-length", "120"],
3029
"ruff.importStrategy": "fromEnvironment"
3130
},
3231

django_gcp/storage/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from .blob_utils import BlobFieldMixin, get_blob, get_blob_name, get_path, get_signed_url
44
from .fields import BlobField
55
from .gcloud import GoogleCloudFile, GoogleCloudMediaStorage, GoogleCloudStaticStorage, GoogleCloudStorage
6-
from .operations import upload_blob
6+
from .operations import upload_blob, uploaded_blob
77

88
__all__ = [
99
"BlobField",
@@ -18,4 +18,5 @@
1818
"get_path",
1919
"get_signed_url",
2020
"upload_blob",
21+
"uploaded_blob",
2122
]

django_gcp/storage/fields.py

Lines changed: 103 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -199,125 +199,125 @@ def clean(self, value, model_instance, skip_validation=False):
199199
"""Clean the value to ensure correct state and on_commit hooks.
200200
This method is called during clean_fields on model save.
201201
"""
202-
if not skip_validation and not self._validated:
203-
self.validate(value, model_instance)
204202

205-
try:
206-
# Cross-field validation: ensure value is not greater than another field
207-
add = model_instance._state.adding
203+
# An escape hatch allowing you to set the path directly. This is dangerous and should only be done
204+
# explicitly for the purpose of data migration and manipulation. You should never allow an untrusted
205+
# client to set paths directly, because knowing the path of a pre-existing object allows you to assume
206+
# access to it. Tip: You can use django's override_settings context manager to set this temporarily.
207+
if getattr(
208+
settings,
209+
"GCP_STORAGE_OVERRIDE_BLOBFIELD_VALUE",
210+
DEFAULT_OVERRIDE_BLOBFIELD_VALUE,
211+
):
212+
logger.warning(
213+
"Overriding %s value to %s",
214+
self.__class__.__name__,
215+
value,
216+
)
217+
new_value = value
218+
219+
else:
220+
# Validate the value given
221+
if not skip_validation and not self._validated:
222+
self.validate(value, model_instance)
208223

224+
# Clean {} --> None
209225
value = self._clean_blank_value(getattr(model_instance, self.attname))
210226

227+
# Get model state
228+
add = model_instance._state.adding
211229
existing_path = None if add else self._get_existing_path(model_instance)
212230

213-
# An escape hatch allowing you to set the path directly. This is dangerous and should only be done
214-
# explicitly for the purpose of data migration and manipulation. You should never allow an untrusted
215-
# client to set paths directly, because knowing the path of a pre-existing object allows you to assume
216-
# access to it. Tip: You can use django's override_settings context manager to set this temporarily.
217-
if getattr(
218-
settings,
219-
"GCP_STORAGE_OVERRIDE_BLOBFIELD_VALUE",
220-
DEFAULT_OVERRIDE_BLOBFIELD_VALUE,
221-
):
222-
logger.warning(
223-
"Overriding %s value to %s",
224-
self.__class__.__name__,
225-
value,
231+
# There are six scenarios to deal with:
232+
adding_blank = add and value is None
233+
adding_valid = add and value is not None
234+
updating_valid_to_valid = not add and self._get_valid_to_valid(model_instance)
235+
updating_valid_to_blank = not add and self._get_valid_to_blank(model_instance)
236+
updating_blank_to_valid = not add and self._get_blank_to_valid(model_instance)
237+
unchanged = not add and self._get_unchanged(model_instance)
238+
239+
# Branch based on scenarios
240+
if unchanged:
241+
new_value = {"path": value["path"]} if value is not None else None
242+
243+
elif adding_blank or updating_valid_to_blank:
244+
new_value = None
245+
246+
# Trigger the on_change callback at the end of the commit when we know the
247+
# database transaction will work
248+
def on_commit_blank():
249+
if self.on_change is not None:
250+
self.on_change(new_value, instance=model_instance)
251+
252+
# clean may happen outside transations so we must
253+
# actually register the transaction during pre-save,
254+
# store the callback here temporarily
255+
self._on_commit_blank = on_commit_blank
256+
257+
elif adding_valid or updating_blank_to_valid or updating_valid_to_valid:
258+
new_value = {}
259+
260+
allow_overwrite = self._get_allow_overwrite(add)
261+
262+
attributes = self._update_attributes(
263+
getattr(value, "attributes", {}),
264+
instance=model_instance,
265+
original_name=value["name"],
266+
existing_path=existing_path,
267+
temporary_path=value["_tmp_path"],
268+
adding=add,
269+
bucket=self.storage.bucket,
226270
)
227-
new_value = value
228-
else:
229-
# There are six scenarios to deal with:
230-
adding_blank = add and value is None
231-
adding_valid = add and value is not None
232-
updating_valid_to_valid = not add and self._get_valid_to_valid(model_instance)
233-
updating_valid_to_blank = not add and self._get_valid_to_blank(model_instance)
234-
updating_blank_to_valid = not add and self._get_blank_to_valid(model_instance)
235-
unchanged = not add and self._get_unchanged(model_instance)
236-
237-
if unchanged:
238-
new_value = {"path": value["path"]} if value is not None else None
239-
240-
elif adding_blank or updating_valid_to_blank:
241-
new_value = None
242-
243-
# Trigger the on_change callback at the end of the commit when we know the
244-
# database transaction will work
245-
def on_commit_blank():
246-
if self.on_change is not None:
247-
self.on_change(new_value, instance=model_instance)
248-
249-
# clean may happen outside transations so we must
250-
# actually register the transaction during pre-save,
251-
# store the callback here temporarily
252-
self._on_commit_blank = on_commit_blank
253-
254-
elif adding_valid or updating_blank_to_valid or updating_valid_to_valid:
255-
new_value = {}
256-
257-
allow_overwrite = self._get_allow_overwrite(add)
258-
259-
attributes = self._update_attributes(
260-
getattr(value, "attributes", {}),
261-
instance=model_instance,
262-
original_name=value["name"],
263-
existing_path=existing_path,
264-
temporary_path=value["_tmp_path"],
265-
adding=add,
266-
bucket=self.storage.bucket,
267-
)
268271

269-
new_value["path"], allow_overwrite = self._get_destination_path(
270-
instance=model_instance,
271-
original_name=value["name"],
272-
attributes=attributes,
273-
allow_overwrite=self._get_allow_overwrite(add),
274-
existing_path=existing_path,
275-
temporary_path=value["_tmp_path"],
276-
bucket=self.storage.bucket,
277-
)
272+
new_value["path"], allow_overwrite = self._get_destination_path(
273+
instance=model_instance,
274+
original_name=value["name"],
275+
attributes=attributes,
276+
allow_overwrite=self._get_allow_overwrite(add),
277+
existing_path=existing_path,
278+
temporary_path=value["_tmp_path"],
279+
bucket=self.storage.bucket,
280+
)
278281

279-
logger.info(
280-
"Adding/updating cloud object via temporary ingress at %s to %s",
281-
value["_tmp_path"],
282-
new_value["path"],
283-
)
282+
logger.info(
283+
"Adding/updating cloud object via temporary ingress at %s to %s",
284+
value["_tmp_path"],
285+
new_value["path"],
286+
)
284287

285-
# Trigger the copy only on successful commit of the transaction. We have to
286-
# capture the dual edge cases of the file not moving correctly, and the database
287-
# row not saving (eg due to validation errors in other model fields).
288-
# https://stackoverflow.com/questions/33180727/trigering-post-save-signal-only-after-transaction-has-completed
289-
def on_commit_valid():
290-
copy_blob(
291-
self.storage.bucket,
292-
value["_tmp_path"],
293-
self.storage.bucket,
294-
new_value["path"],
295-
move=True,
296-
overwrite=allow_overwrite,
297-
attributes=attributes,
298-
)
299-
if self.on_change is not None:
300-
self.on_change(new_value, instance=model_instance)
301-
302-
logger.info(
303-
"Registered move of %s to %s to happen on transaction commit",
288+
# Trigger the copy only on successful commit of the transaction. We have to
289+
# capture the dual edge cases of the file not moving correctly, and the database
290+
# row not saving (eg due to validation errors in other model fields).
291+
# https://stackoverflow.com/questions/33180727/trigering-post-save-signal-only-after-transaction-has-completed
292+
def on_commit_valid():
293+
copy_blob(
294+
self.storage.bucket,
304295
value["_tmp_path"],
296+
self.storage.bucket,
305297
new_value["path"],
298+
move=True,
299+
overwrite=allow_overwrite,
300+
attributes=attributes,
306301
)
307-
self._on_commit_valid = on_commit_valid
302+
if self.on_change is not None:
303+
self.on_change(new_value, instance=model_instance)
308304

309-
else:
310-
# Raise unknown edge cases rather than failing silently
311-
raise ValueError(
312-
f"Unable to determine field state for {self._get_fieldname(model_instance)}. The most likely cause of this doing an operation (like migration) without setting GCP_STORAGE_OVERRIDE_BLOBFIELD_VALUE=True. Otherwise, please contact the django_gcp developers and describe what you're doing along with this exception stacktrace. Value was: {json.dumps(value)}"
313-
)
305+
logger.info(
306+
"Registered move of %s to %s to happen on transaction commit",
307+
value["_tmp_path"],
308+
new_value["path"],
309+
)
310+
self._on_commit_valid = on_commit_valid
314311

315-
# Cache DB values in the instance so you can reuse it without multiple DB queries
316-
# pylint: disable-next=protected-access
317-
model_instance._state.fields_cache[self.attname] = new_value
312+
else:
313+
# Raise unknown edge cases rather than failing silently
314+
raise ValueError(
315+
f"Unable to determine field state for {self._get_fieldname(model_instance)}. The most likely cause of this doing an operation (like migration) without setting GCP_STORAGE_OVERRIDE_BLOBFIELD_VALUE=True. Otherwise, please contact the django_gcp developers and describe what you're doing along with this exception stacktrace. Value was: {json.dumps(value)}"
316+
)
318317

319-
except ValidationError as e:
320-
raise e
318+
# Cache DB values in the instance so you can reuse it without multiple DB queries
319+
# pylint: disable-next=protected-access
320+
model_instance._state.fields_cache[self.attname] = new_value
321321

322322
self._cleaned = True
323323

django_gcp/storage/operations.py

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
from contextlib import contextmanager
12
import datetime
23
import logging
34

5+
from django.test.utils import override_settings
46
from django.utils import timezone
57
from google.cloud.exceptions import NotFound, PreconditionFailed
68
from google.cloud.storage.blob import Blob
@@ -54,8 +56,9 @@ def upload_blob(
5456
instance,
5557
original_name=local_path,
5658
attributes=attributes,
57-
allow_overwrite=allow_overwrite,
5859
existing_path=existing_path,
60+
temporary_path=None,
61+
allow_overwrite=allow_overwrite,
5962
bucket=field.storage.bucket,
6063
)
6164

@@ -195,6 +198,38 @@ def get_signed_upload_url(bucket, blob_name, timedelta=None, max_size_bytes=None
195198
if max_size_bytes is not None:
196199
content_length_range = f"0,{max_size_bytes}"
197200
headers = kwargs.pop("headers", {})
198-
headers["X-Goog-Content-Length-Range"]: content_length_range
201+
headers["X-Goog-Content-Length-Range"] = content_length_range
202+
kwargs["headers"] = headers
199203

200204
return blob.generate_signed_url(expiration=timezone.now() + timedelta, method="PUT", **kwargs)
205+
206+
207+
@contextmanager
208+
def uploaded_blob(instance, field_name, local_path, destination_path=None, allow_overwrite=False, delete_on_exit=False):
209+
"""A context manager enabling the preparation of an instance with a blob uploaded from a local path.
210+
Optionally override the destination path (the ultimate path of the blob within the destination bucket) and allow_overwrite parameter.
211+
212+
Usage:
213+
214+
```py
215+
my_instance = MyModel()
216+
with uploaded_blob(my_instance, 'my_field', '/path/to/local/file') as field_value
217+
my_instance.my_field = field_value
218+
my_instance.save()
219+
```
220+
221+
"""
222+
223+
with override_settings(GCP_STORAGE_OVERRIDE_BLOBFIELD_VALUE=True):
224+
field_value = upload_blob(
225+
instance,
226+
field_name=field_name,
227+
local_path=local_path,
228+
destination_path=destination_path,
229+
allow_overwrite=allow_overwrite,
230+
)
231+
try:
232+
yield field_value
233+
finally:
234+
if delete_on_exit:
235+
raise NotImplementedError("Deletion on exit is not yet implemented")

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "django-gcp"
3-
version = "0.17.1"
3+
version = "0.18.0"
44
description = "Utilities to run Django on Google Cloud Platform"
55
authors = ["Tom Clark"]
66
license = "MIT"

tests/test_storage_fields.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,14 +111,25 @@ def test_add_view_has_presigned_url(self):
111111
widget.signed_ingress_url.startswith("https://storage.googleapis.com/example-media-assets/_tmp")
112112
)
113113

114+
def test_full_clean_executes_in_overridden_context(self):
115+
"""Ensure that full_clean() can be legitimately called
116+
on a model while in an overridden context
117+
"""
118+
119+
blob_name = self._prefix_blob_name("test_full_clean_executes_in_overridden_context.txt")
120+
self._create_test_blob(self.bucket, blob_name, "")
121+
with override_settings(GCP_STORAGE_OVERRIDE_BLOBFIELD_VALUE=True):
122+
obj = ExampleBlobFieldModel(blob={"path": blob_name})
123+
obj.full_clean()
124+
obj.save()
125+
114126
@override_settings(GCP_STORAGE_OVERRIDE_BLOBFIELD_VALUE=True)
115127
def test_change_view_loads_normally(self):
116128
"""Ensure we can load a change view"""
117129

118130
blob_name = self._prefix_blob_name("test_change_view_loads_normally.txt")
119131
self._create_test_blob(self.bucket, blob_name, "")
120-
with override_settings(GCP_STORAGE_OVERRIDE_BLOBFIELD_VALUE=True):
121-
obj = ExampleBlobFieldModel.objects.create(blob={"path": blob_name})
132+
obj = ExampleBlobFieldModel.objects.create(blob={"path": blob_name})
122133

123134
# Assert that the view loads
124135
response = self.client.get(get_admin_change_view_url(obj))

0 commit comments

Comments
 (0)