alliance-platform-storage 0.0.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. alliance_platform_storage-0.0.2/PKG-INFO +16 -0
  2. alliance_platform_storage-0.0.2/README.md +0 -0
  3. alliance_platform_storage-0.0.2/alliance_platform/py.typed +0 -0
  4. alliance_platform_storage-0.0.2/alliance_platform/storage/__init__.py +0 -0
  5. alliance_platform_storage-0.0.2/alliance_platform/storage/apps.py +11 -0
  6. alliance_platform_storage-0.0.2/alliance_platform/storage/async_uploads/forms.py +203 -0
  7. alliance_platform_storage-0.0.2/alliance_platform/storage/async_uploads/models.py +730 -0
  8. alliance_platform_storage-0.0.2/alliance_platform/storage/async_uploads/registry.py +110 -0
  9. alliance_platform_storage-0.0.2/alliance_platform/storage/async_uploads/rest_framework/__init__.py +2 -0
  10. alliance_platform_storage-0.0.2/alliance_platform/storage/async_uploads/rest_framework/serializer.py +125 -0
  11. alliance_platform_storage-0.0.2/alliance_platform/storage/async_uploads/storage/azure.py +82 -0
  12. alliance_platform_storage-0.0.2/alliance_platform/storage/async_uploads/storage/base.py +122 -0
  13. alliance_platform_storage-0.0.2/alliance_platform/storage/async_uploads/storage/filesystem.py +105 -0
  14. alliance_platform_storage-0.0.2/alliance_platform/storage/async_uploads/storage/s3.py +76 -0
  15. alliance_platform_storage-0.0.2/alliance_platform/storage/async_uploads/views/__init__.py +2 -0
  16. alliance_platform_storage-0.0.2/alliance_platform/storage/async_uploads/views/api.py +204 -0
  17. alliance_platform_storage-0.0.2/alliance_platform/storage/async_uploads/views/filesystem.py +139 -0
  18. alliance_platform_storage-0.0.2/alliance_platform/storage/management/commands/cleanup_async_temp_files.py +66 -0
  19. alliance_platform_storage-0.0.2/alliance_platform/storage/migrations/0001_initial.py +40 -0
  20. alliance_platform_storage-0.0.2/alliance_platform/storage/migrations/0002_migrate_common_storage.py +37 -0
  21. alliance_platform_storage-0.0.2/alliance_platform/storage/migrations/__init__.py +0 -0
  22. alliance_platform_storage-0.0.2/alliance_platform/storage/models.py +2 -0
  23. alliance_platform_storage-0.0.2/alliance_platform/storage/settings.py +42 -0
  24. alliance_platform_storage-0.0.2/alliance_platform/storage/staticfiles/__init__.py +0 -0
  25. alliance_platform_storage-0.0.2/alliance_platform/storage/staticfiles/storage.py +73 -0
  26. alliance_platform_storage-0.0.2/pyproject.toml +67 -0
  27. alliance_platform_storage-0.0.2/tests/__init__.py +0 -0
  28. alliance_platform_storage-0.0.2/tests/test_async_field.py +758 -0
  29. alliance_platform_storage-0.0.2/tests/test_async_rest_framework.py +175 -0
  30. alliance_platform_storage-0.0.2/tests/test_excluding_manifest_static_files_storage.py +121 -0
@@ -0,0 +1,16 @@
1
+ Metadata-Version: 2.1
2
+ Name: alliance-platform-storage
3
+ Version: 0.0.2
4
+ Summary: Alliance Platform Storage
5
+ Author-Email: Alliance Software <support@alliancesoftware.com.au>
6
+ License: BSD-2-Clause
7
+ Requires-Python: >=3.11
8
+ Requires-Dist: alliance-platform-core
9
+ Requires-Dist: Django>=4.2.11
10
+ Requires-Dist: django-allianceutils>=4.0
11
+ Provides-Extra: s3
12
+ Requires-Dist: django-storages[s3]; extra == "s3"
13
+ Provides-Extra: azure
14
+ Requires-Dist: django-storages[azure]; extra == "azure"
15
+ Description-Content-Type: text/markdown
16
+
File without changes
@@ -0,0 +1,11 @@
1
+ from alliance_platform.storage.settings import ap_storage_settings
2
+ from django.apps.config import AppConfig
3
+
4
+
5
+ class AlliancePlatformStorageConfig(AppConfig):
6
+ name = "alliance_platform.storage"
7
+ verbose_name = "Alliance Platform Storage"
8
+ label = "alliance_platform_storage"
9
+
10
+ def ready(self):
11
+ ap_storage_settings.check_settings()
@@ -0,0 +1,203 @@
1
+ import json
2
+ from json import JSONDecodeError
3
+
4
+ from alliance_platform.storage.async_uploads.models import AsyncFileInputData
5
+ from alliance_platform.storage.async_uploads.models import AsyncTempFile
6
+ from alliance_platform.storage.async_uploads.registry import AsyncFieldRegistry
7
+ from alliance_platform.storage.async_uploads.storage.base import AsyncUploadStorage
8
+ from django import forms
9
+ from django.core import validators
10
+ from django.core.exceptions import ValidationError
11
+ from django.forms.widgets import Input
12
+ from django.urls import reverse
13
+ from django.utils.deconstruct import deconstructible
14
+
15
+
16
+ # This does not extend FileInput as we don't actually receive a file
17
+ # from the frontend. Instead we get a string which represents the key
18
+ # for the storage backend.
19
+ class AsyncFileInput(Input):
20
+ """Input for handling async uploads
21
+
22
+ This handles the submission value from UploadWidget and converts it to an instance
23
+ of :class:`~alliance_platform.storage.async_uploads.models.AsyncFileInputData`. This is then handled
24
+ on the descriptor classes for :class:`~alliance_platform.storage.async_uploads.models.AsyncFileField`
25
+ and :class:`~alliance_platform.storage.async_uploads.models.AsyncImageField`.
26
+
27
+ To customise the widget rendered on the frontend you can override the template
28
+ ``alliance_platform/storage/widgets/async_file_input.html``.
29
+ """
30
+
31
+ input_type = "async-file"
32
+ template_name = "alliance_platform/storage/widgets/async_file_input.html"
33
+
34
+ # This is needed to generate URL to file in case where we only
35
+ # have the AsyncTempFile (see `format_value`)
36
+ storage: AsyncUploadStorage
37
+
38
+ def get_context(self, name, value, attrs):
39
+ context = super().get_context(name, value, attrs)
40
+
41
+ # Resolving the upload url needs to be delayed until render so that the
42
+ # GenerateUploadUrl view has a chance to attach to the registry. Resolve
43
+ # that here.
44
+ async_field_registry = context["widget"]["attrs"].pop("async_field_registry", None)
45
+ if not async_field_registry:
46
+ raise ValueError("AsyncFileInput expects a 'async_field_registry' to be passed in widget attrs")
47
+
48
+ context["widget"]["attrs"]["generate_upload_url"] = reverse(async_field_registry.attached_view)
49
+
50
+ return context
51
+
52
+ def format_value(self, value):
53
+ """Given a value return it in serialized format expected by frontend
54
+
55
+ Frontend expects a json string like:
56
+
57
+ {"key": "/some/storage/location/test.png", "name": "test.png" }
58
+ """
59
+ if not value:
60
+ return None
61
+ if isinstance(value, AsyncFileInputData):
62
+ # If a submission occurs but isn't successful (eg. validation fails on another
63
+ # field) we end up with a string which should match the key on AsyncTempFile
64
+ try:
65
+ temp_file = AsyncTempFile.objects.get(key=value.key)
66
+ return json.dumps(
67
+ {
68
+ "key": temp_file.key,
69
+ "name": temp_file.original_filename,
70
+ "url": self.storage.url(temp_file.key),
71
+ }
72
+ )
73
+ except AsyncTempFile.DoesNotExist:
74
+ return json.dumps(
75
+ {
76
+ "key": value.key,
77
+ "name": value.name,
78
+ }
79
+ )
80
+ try:
81
+ url = value.url
82
+ except Exception:
83
+ # url isn't critical; if it fails ignore it and frontend may just not be able
84
+ # to show a thumbnail
85
+ url = None
86
+ return json.dumps({"key": value.name, "name": value.name.split("/")[-1], "url": url})
87
+
88
+ def value_from_datadict(self, data, files, name):
89
+ """This expects to receive a valid json string that can be used to instantiate AsyncFileInputData
90
+
91
+ If invalid JSON is received this is handled by the ``AsyncFileInputDataValidator`` which will
92
+ raise a ValidationError. We load the json here rather than in the field ``clean`` so that we have
93
+ the ``AsyncFileInputData`` in ``format`` for the case where there's a ValidationError and form
94
+ re-renders.
95
+
96
+ AsyncFileDescriptor & AsyncImageDescriptor handle extracting the ``key`` from this which is
97
+ what is set against the field on the model.
98
+
99
+ We need the AsyncFileInputData to pass across extra details that we may need - eg. width & height
100
+ for image fields
101
+ """
102
+ value = data.get(name)
103
+ try:
104
+ value = json.loads(value)
105
+ if value:
106
+ return AsyncFileInputData.create_from_user_input(value)
107
+ return value
108
+ except JSONDecodeError:
109
+ return value
110
+
111
+
112
+ class AsyncFileInputDataLengthValidator(validators.MaxLengthValidator):
113
+ """Validate length of ``key`` on an AsyncFileInputData
114
+
115
+ FileField passes ``max_length`` to the form field and the field is, at the database level,
116
+ a char field. The normal FileField handles this internally but we can't extend forms.FileField
117
+ as it expects to be handling File submissions which we aren't doing here. This validator
118
+ just extracts the ``key`` and validates the length of that which is what gets written to the
119
+ database.
120
+ """
121
+
122
+ def clean(self, x):
123
+ if isinstance(x, AsyncFileInputData):
124
+ # We want to check the length of the key which is what gets stored in db
125
+ return len(x.name)
126
+ return len(x)
127
+
128
+
129
+ @deconstructible
130
+ class AsyncFileInputDataValidator:
131
+ """AsyncFileInputData has an error key that can be sent from the frontend - this validator just checks that"""
132
+
133
+ def __call__(self, value):
134
+ if isinstance(value, AsyncFileInputData):
135
+ if value.error:
136
+ raise ValidationError(f"There was an unexpected error: {value.error}")
137
+ else:
138
+ raise ValidationError("Bad input for file field")
139
+
140
+
141
+ class AsyncFileField(forms.Field):
142
+ """Form field that renders a :class:`~alliance_platform.storage.async_uploads.models.AsyncFileInput`
143
+
144
+ This is the default form field for :class:`alliance_platform.storage.async_uploads.models.AsyncFileField`
145
+ """
146
+
147
+ widget = AsyncFileInput
148
+
149
+ async_field_registry: AsyncFieldRegistry
150
+ async_field_id: str
151
+
152
+ def __init__(
153
+ self,
154
+ *args,
155
+ async_field_registry: AsyncFieldRegistry,
156
+ async_field_id: str,
157
+ storage: AsyncUploadStorage,
158
+ max_length=None,
159
+ **kwargs,
160
+ ):
161
+ """
162
+
163
+ Args:
164
+ *args: Any additional arguments to pass through to :class:`django.forms.Field`
165
+ async_field_registry: The async field registry that should used on the frontend to create
166
+ unique upload urls for files. This typically comes from :code:`field.async_field_registry` where :code:`field`
167
+ is an :class:`~alliance_platform.storage.async_uploads.models.AsyncFileField`.
168
+ async_field_id: The string ID of the field used in the registry. Generated with ``field.async_field_registry.generate_id(field)``.
169
+ storage: The storage class. This comes from the field ``field.storage``.
170
+ max_length: Max length for the filename
171
+ **kwargs:ny additional keyword arguments to pass through to :class:`django.forms.Field`
172
+ """
173
+ self.async_field_registry = async_field_registry
174
+ self.async_field_id = async_field_id
175
+ # This comes from FileField
176
+ self.max_length = max_length
177
+ super().__init__(*args, **kwargs)
178
+
179
+ # No way to pass args to a widget with how forms.Field work.. just
180
+ # set it after creation
181
+ self.widget.storage = storage
182
+
183
+ if max_length is not None:
184
+ self.validators.append(AsyncFileInputDataLengthValidator(int(max_length)))
185
+ self.validators.append(AsyncFileInputDataValidator())
186
+
187
+ def widget_attrs(self, widget):
188
+ attrs = super().widget_attrs(widget)
189
+ attrs["async_field_registry"] = self.async_field_registry
190
+ attrs["async_field_id"] = self.async_field_id
191
+ return attrs
192
+
193
+
194
+ class AsyncImageField(AsyncFileField):
195
+ widget = AsyncFileInput
196
+
197
+ def widget_attrs(self, widget):
198
+ attrs = super().widget_attrs(widget)
199
+ if "accept" not in attrs:
200
+ attrs.setdefault("accept", "image/*")
201
+ if "list_type" not in attrs:
202
+ attrs.setdefault("list_type", "picture")
203
+ return attrs