Skip to content

Commit 3f88eff

Browse files
authored
Merge pull request #40 from octue/improve-docs
Add on_change callback plus minor fixes
2 parents 99d1932 + 45313c8 commit 3f88eff

File tree

14 files changed

+302
-113
lines changed

14 files changed

+302
-113
lines changed

.devcontainer/devcontainer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,8 @@
7171
"thebarkman.vscode-djaneiro",
7272
"trond-snekvik.simple-rst",
7373
"ms-azuretools.vscode-docker",
74-
"ryanluker.vscode-coverage-gutters"
74+
"ryanluker.vscode-coverage-gutters",
75+
"ms-python.black-formatter"
7576
]
7677
}
7778
},

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ repos:
2020
- id: check-json5
2121

2222
- repo: https://github.com/psf/black
23-
rev: 22.3.0
23+
rev: 22.10.0
2424
hooks:
2525
- id: black
2626
args: ['--line-length', '120']

django_gcp/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
default_app_config = "django_gcp.apps.DjangoGcpAppConfig"
1+
default_app_config = "django_gcp.apps.DjangoGCPAppConfig"

django_gcp/storage/fields.py

Lines changed: 51 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,8 @@ class BlobField(models.JSONField):
5959
We do this to support a clear and maintainable implementation.
6060
6161
:param ingress_to: A string defining the path within the bucket to which direct uploads
62-
will be ingressed, if temporary_ingress=True. These uploaded files will be moved to their
63-
ultimate path in the store on save of the model.
62+
will be ingressed. These uploaded files will be moved to their ultimate path in the store on save of the model.
63+
6464
:param accept_mimetype: A string passed to widgets, suitable for use in the `accept` attribute
6565
of an html filepicker. This will allow you to narrow down, eg to `image/*` or an even more
6666
specific mimetype. No validation is done at the field level that objects actually are of this
@@ -77,7 +77,7 @@ class BlobField(models.JSONField):
7777
7878
:param max_size_bytes: The maximum size in bytes of files that can be uploaded.
7979
80-
:overwrite_mode: One of `OVERWRITE_MODES` determining the circumstances under which overwrite
80+
:param overwrite_mode: One of `OVERWRITE_MODES` determining the circumstances under which overwrite
8181
is allowed. overwrite mode behaves as follows:
8282
- never: Never allows overwrite
8383
- update: Only when updating an object
@@ -86,6 +86,10 @@ class BlobField(models.JSONField):
8686
- add-versioned: Only when adding an object to a versioned bucket (again, for completeness)
8787
- add-update: Always allow (ie when adding or updating an object)
8888
- add-update-versioned: When adding or updating an object to a versioned bucket
89+
90+
:param on_change: A callable that will be executed on change of the field value. This will be called
91+
on commit of the transaction (ie once any file upload is ingressed to its final location) and allows you,
92+
for example, to dispatch a worker task to further process the uploaded blob.
8993
"""
9094

9195
description = _("A GCP Cloud Storage object")
@@ -99,6 +103,7 @@ def __init__(
99103
accept_mimetype=DEFAULT_ACCEPT_MIMETYPE,
100104
max_size_bytes=DEFAULT_MAX_SIZE_BYTES,
101105
overwrite_mode=DEFAULT_OVERWRITE_MODE,
106+
on_change=None,
102107
**kwargs,
103108
):
104109
self._versioning_enabled = None
@@ -112,6 +117,7 @@ def __init__(
112117
self.store_key = store_key
113118
self.accept_mimetype = accept_mimetype
114119
self.max_size_bytes = max_size_bytes
120+
self.on_change = on_change
115121
kwargs["default"] = kwargs.pop("default", None)
116122
kwargs["help_text"] = kwargs.pop("help_text", "GCP cloud storage object")
117123

@@ -144,6 +150,7 @@ def check(self, **kwargs):
144150
*self._check_get_destination_path(),
145151
*self._check_ingress_to(),
146152
*self._check_overwrite_mode(),
153+
*self._check_on_change(),
147154
]
148155

149156
def deconstruct(self):
@@ -153,6 +160,7 @@ def deconstruct(self):
153160
kwargs["accept_mimetype"] = self.accept_mimetype
154161
kwargs["overwrite_mode"] = self.overwrite_mode
155162
kwargs["get_destination_path"] = self.get_destination_path
163+
kwargs["on_change"] = self.on_change
156164
return name, path, args, kwargs
157165

158166
def formfield(self, **kwargs):
@@ -172,7 +180,11 @@ def formfield(self, **kwargs):
172180
@property
173181
def override_blobfield_value(self):
174182
"""Shortcut to access the GCP_STORAGE_OVERRIDE_BLOBFIELD_VALUE setting"""
175-
return getattr(settings, "GCP_STORAGE_OVERRIDE_BLOBFIELD_VALUE", DEFAULT_OVERRIDE_BLOBFIELD_VALUE)
183+
return getattr(
184+
settings,
185+
"GCP_STORAGE_OVERRIDE_BLOBFIELD_VALUE",
186+
DEFAULT_OVERRIDE_BLOBFIELD_VALUE,
187+
)
176188

177189
def pre_save(self, model_instance, add):
178190
"""Return field's value just before saving."""
@@ -184,7 +196,12 @@ def pre_save(self, model_instance, add):
184196
# explicitly for the purpose of data migration and manipulation. You should never allow an untrusted
185197
# client to set paths directly, because knowing the path of a pre-existing object allows you to assume
186198
# access to it. Tip: You can use django's override_settings context manager to set this temporarily.
187-
if getattr(settings, "GCP_STORAGE_OVERRIDE_BLOBFIELD_VALUE", DEFAULT_OVERRIDE_BLOBFIELD_VALUE):
199+
# Note that you'll have to execute any on_changed
200+
if getattr(
201+
settings,
202+
"GCP_STORAGE_OVERRIDE_BLOBFIELD_VALUE",
203+
DEFAULT_OVERRIDE_BLOBFIELD_VALUE,
204+
):
188205
logger.warning(
189206
"Overriding %s value to %s",
190207
self.__class__.__name__,
@@ -206,8 +223,15 @@ def pre_save(self, model_instance, add):
206223
elif adding_blank or updating_valid_to_blank:
207224
new_value = None
208225

209-
elif adding_valid or updating_blank_to_valid or updating_valid_to_valid:
226+
# Trigger the on_change callback at the end of the commit when we know the
227+
# database transaction will work
228+
def on_commit_blank():
229+
if self.on_change is not None:
230+
self.on_change(new_value, instance=model_instance)
231+
232+
transaction.on_commit(on_commit_blank)
210233

234+
elif adding_valid or updating_blank_to_valid or updating_valid_to_valid:
211235
new_value = {}
212236
new_value["path"], allow_overwrite = self.get_destination_path(
213237
instance=model_instance,
@@ -228,8 +252,8 @@ def pre_save(self, model_instance, add):
228252
# capture the dual edge cases of the file not moving correctly, and the database
229253
# row not saving (eg due to validation errors in other model fields).
230254
# https://stackoverflow.com/questions/33180727/trigering-post-save-signal-only-after-transaction-has-completed
231-
transaction.on_commit(
232-
lambda: copy_blob(
255+
def on_commit_valid():
256+
copy_blob(
233257
self.storage.bucket,
234258
value["_tmp_path"],
235259
self.storage.bucket,
@@ -238,9 +262,15 @@ def pre_save(self, model_instance, add):
238262
overwrite=allow_overwrite,
239263
attributes=value.get("attributes", None),
240264
)
241-
)
265+
if self.on_change is not None:
266+
self.on_change(new_value, instance=model_instance)
267+
268+
transaction.on_commit(on_commit_valid)
269+
242270
logger.info(
243-
"Registered move of %s to %s to happen on transaction commit", value["_tmp_path"], new_value["path"]
271+
"Registered move of %s to %s to happen on transaction commit",
272+
value["_tmp_path"],
273+
new_value["path"],
244274
)
245275

246276
else:
@@ -407,6 +437,17 @@ def _check_get_destination_path(self):
407437
]
408438
return []
409439

440+
def _check_on_change(self):
441+
if self.on_change is not None and not callable(self.on_change):
442+
return [
443+
checks.Error(
444+
f"'on_change' argument in {self.__class__.__name__} must be or a callable function, or None",
445+
obj=self,
446+
id="fields.E201",
447+
)
448+
]
449+
return []
450+
410451
def _get_allow_overwrite(self, add):
411452
"""Return a boolean determining if overwrite should be allowed for this operation"""
412453
mode_map = {

django_gcp/storage/forms.py

Lines changed: 0 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -12,59 +12,3 @@ class CloudObjectFormField(JSONField):
1212

1313
empty_value = None
1414
widget = CloudObjectWidget
15-
16-
17-
# from django.forms import FileField
18-
# from django.core.files.uploadedfile import UploadedFile
19-
# from django.core.exceptions import ValidationError
20-
21-
# from .direct_upload_widgets import DirectUploadWidget
22-
# class DirectUploadedFile(UploadedFile):
23-
# """Represents a file that was uploaded direct to bucket"""
24-
25-
26-
# class DirectUploadFormField(FileField):
27-
# """Overrides the usual forms.FileField with a widget that handles direct uploads"""
28-
29-
# widget = DirectUploadWidget
30-
31-
# def to_python(self, data):
32-
# """Override the FileField to_python method to accept data as a string file name"""
33-
34-
# if data in self.empty_values:
35-
# return None
36-
37-
# # A file was attached as an indirect upload (the normal way)
38-
# if isinstance(data, UploadedFile):
39-
# try:
40-
# file_name = data.name
41-
# file_size = data.size
42-
# except AttributeError:
43-
# # pylint: disable=raise-missing-from
44-
# raise ValidationError(self.error_messages["invalid"], code="invalid")
45-
# # pylint: enable=raise-missing-from
46-
47-
# if not self.allow_empty_file and not file_size:
48-
# raise ValidationError(self.error_messages["empty"], code="empty")
49-
50-
# # A file was uploaded direct-to-bucket, data only contains its name
51-
# elif isinstance(data, str):
52-
# file_name = data
53-
# file_size = None
54-
# data = DirectUploadedFile(name=file_name, size=None)
55-
# if not self.allow_empty_file:
56-
# logger.warning(
57-
# "`allow_empty_file` is set but file has been directly uploaded. Cannot validate emptiness of file."
58-
# )
59-
60-
# else:
61-
# raise ValueError(f"Unknown data of class {data.__class__}: {data}")
62-
# print("FILE NAME", file_name)
63-
# if self.max_length is not None and len(file_name) > self.max_length:
64-
# params = {"max": self.max_length, "length": len(file_name)}
65-
# raise ValidationError(self.error_messages["max_length"], code="max_length", params=params)
66-
67-
# if not file_name:
68-
# raise ValidationError(self.error_messages["invalid"], code="invalid")
69-
70-
# return data

django_gcp/storage/gcloud.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import logging
22
import mimetypes
33
from tempfile import SpooledTemporaryFile
4+
from django.conf import settings
45
from django.core.exceptions import SuspiciousOperation
56
from django.core.files.base import File
67
from django.core.files.storage import Storage
@@ -12,7 +13,7 @@
1213

1314
from .compress import CompressedFileMixin, CompressStorageMixin
1415
from .settings import StorageSettings
15-
from .utils import clean_name, get_available_overwrite_name, safe_join, setting, to_bytes
16+
from .utils import clean_name, get_available_overwrite_name, safe_join, to_bytes
1617

1718

1819
CONTENT_ENCODING = "content_encoding"
@@ -44,7 +45,7 @@ def _get_file(self):
4445
self._file = SpooledTemporaryFile(
4546
max_size=self._storage.settings.max_memory_size,
4647
suffix=".GSStorageFile",
47-
dir=setting("FILE_UPLOAD_TEMP_DIR"),
48+
dir=getattr(settings, "FILE_UPLOAD_TEMP_DIR"),
4849
)
4950
if "r" in self._mode:
5051
self._is_dirty = False
@@ -286,7 +287,7 @@ def get_modified_time(self, name):
286287
name = self._normalize_name(clean_name(name))
287288
blob = self._get_blob(name)
288289
updated = blob.updated
289-
return updated if setting("USE_TZ") else timezone.make_naive(updated)
290+
return updated if getattr(settings, "USE_TZ") else timezone.make_naive(updated)
290291

291292
def get_created_time(self, name):
292293
"""
@@ -296,7 +297,7 @@ def get_created_time(self, name):
296297
name = self._normalize_name(clean_name(name))
297298
blob = self._get_blob(name)
298299
created = blob.time_created
299-
return created if setting("USE_TZ") else timezone.make_naive(created)
300+
return created if getattr(settings, "USE_TZ") else timezone.make_naive(created)
300301

301302
def url(self, name):
302303
"""

django_gcp/storage/utils.py

Lines changed: 10 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import os
22
import posixpath
3-
from django.conf import settings
43
from django.core.exceptions import SuspiciousFileOperation
54
from django.utils.encoding import force_bytes
65

@@ -13,37 +12,24 @@ def to_bytes(content):
1312
return force_bytes(content)
1413

1514

16-
def setting(name, default=None):
17-
"""
18-
Helper function to get a Django setting by name. If setting doesn't exists
19-
it will return a default.
20-
21-
:param name: Name of setting
22-
:type name: str
23-
:param default: Value if setting is unfound
24-
:returns: Setting's value
25-
"""
26-
return getattr(settings, name, default)
27-
28-
2915
def clean_name(name):
3016
"""
3117
Cleans the name so that Windows style paths work
3218
"""
3319
# Normalize Windows style paths
34-
clean_name = posixpath.normpath(name).replace("\\", "/")
20+
cleaned = posixpath.normpath(name).replace("\\", "/")
3521

3622
# os.path.normpath() can strip trailing slashes so we implement
3723
# a workaround here.
38-
if name.endswith("/") and not clean_name.endswith("/"):
24+
if name.endswith("/") and not cleaned.endswith("/"):
3925
# Add a trailing slash as it was stripped.
40-
clean_name = clean_name + "/"
26+
cleaned = cleaned + "/"
4127

4228
# Given an empty string, os.path.normpath() will return ., which we don't want
43-
if clean_name == ".":
44-
clean_name = ""
29+
if cleaned == ".":
30+
cleaned = ""
4531

46-
return clean_name
32+
return cleaned
4733

4834

4935
def safe_join(base, *paths):
@@ -77,12 +63,13 @@ def safe_join(base, *paths):
7763
# the base path is /.
7864
base_path_len = len(base_path)
7965
if not final_path.startswith(base_path) or final_path[base_path_len] != "/":
80-
raise ValueError("the joined path is located outside of the base path" " component")
66+
raise ValueError("the joined path is located outside of the base path component")
8167

8268
return final_path.lstrip("/")
8369

8470

8571
def get_available_overwrite_name(name, max_length):
72+
"""Truncate a filename to obey max_length limit"""
8673
if max_length is None or len(name) <= max_length:
8774
return name
8875

@@ -94,8 +81,6 @@ def get_available_overwrite_name(name, max_length):
9481
file_root = file_root[:-truncation]
9582
if not file_root:
9683
raise SuspiciousFileOperation(
97-
'Storage tried to truncate away entire filename "%s". '
98-
"Please make sure that the corresponding file field "
99-
'allows sufficient "max_length".' % name
84+
f'Storage tried to truncate away entire filename "{name}". Please make sure that the corresponding file field allows sufficient "max_length".'
10085
)
101-
return os.path.join(dir_name, "{}{}".format(file_root, file_ext))
86+
return os.path.join(dir_name, f"{file_root}{file_ext}")

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.9.3"
3+
version = "0.10.0"
44
description = "Utilities to run Django on Google Cloud Platform"
55
authors = ["Tom Clark"]
66
license = "MIT"

0 commit comments

Comments
 (0)