Coverage for gws-app/gws/plugin/model_field/file/__init__.py: 87%
189 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-23 20:06 +0200
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-23 20:06 +0200
1"""File field.
3A field for files attached to features of a database model. The file content
4is stored in a database column (``contentColumn``), optionally with the file
5name in another column (``nameColumn``). A ``pathColumn`` can be configured
6and is read, but storing and serving files from the filesystem is not
7implemented yet. The model must have a primary key.
9The field value is a ``FileValue``. When features are selected, only the
10content length is read, not the content itself. In the client props, a file
11is described by a ``ServerFileProps`` object with a label, extension, size and
12URLs of the ``webFile`` command for downloading the file and, for raster
13images, for a thumbnail preview. Previews are generated on request and cached
14as ephemeral content. Without a configured widget, the field uses a ``file``
15widget.
17Example::
19 fields+ {
20 name "photo"
21 type "file"
22 contentColumn "photo_content"
23 nameColumn "photo_name"
24 }
25"""
27from typing import Optional, cast
29import gws
30import gws.base.model.field
31import gws.lib.image
32import gws.lib.mime
33import gws.lib.sa as sa
36@gws.ext.config.modelField('file')
37class Config(gws.base.model.field.Config):
38 """Field for files stored in the database or the filesystem."""
40 contentColumn: str = ''
41 """Column name for the file content, if stored in the database."""
42 pathColumn: str = ''
43 """Column name for the file path, if stored in the filesystem."""
44 nameColumn: str = ''
45 """Column name for the file name, if stored in the database or filesystem."""
48@gws.ext.props.modelField('file')
49class Props(gws.base.model.field.Props):
50 pass
53class FileInputProps(gws.Data):
54 """File uploaded from the client."""
56 content: bytes
57 """File content."""
58 name: str
59 """File name."""
62class ServerFileProps(gws.Data):
63 """File description sent to the client."""
65 downloadUrl: str
66 """URL to download the file, empty if there is no project."""
67 extension: str
68 """File extension derived from the MIME type."""
69 label: str
70 """Label to display, the file name."""
71 previewUrl: str
72 """URL of a thumbnail preview, empty if the file cannot be previewed."""
73 size: int
74 """File size in bytes."""
77class ClientFileProps(gws.Data):
78 """File sent from the client."""
80 name: str
81 """File name."""
82 content: bytes
83 """File content."""
86class FileValue(gws.Data):
87 """Value of a file field."""
89 content: bytes
90 """File content, if loaded."""
91 name: str
92 """File name."""
93 path: str
94 """File path in the filesystem."""
95 size: int
96 """File size in bytes."""
99_PREVIEW_SIZE = 120, 120
100_PREVIEW_MIME = gws.lib.mime.PNG
101_PREVIEW_MAX_PIXELS = 40_000_000
102_PREVIEW_BIG_FILE_SIZE = 1024 * 1024
105@gws.ext.object.modelField('file')
106class Object(gws.base.model.field.Object):
107 """File field object.
109 Stores files attached to features in columns of a database model and
110 describes them to the client for download and preview.
111 """
113 model: gws.DatabaseModel
114 """The model of this field."""
116 attributeType = gws.AttributeType.file
118 contentColumnName: str = ''
119 """Name of the column for the file content."""
120 pathColumnName: str = ''
121 """Name of the column for the file path."""
122 nameColumnName: str = ''
123 """Name of the column for the file name."""
125 def post_configure(self):
126 self.configure_columns()
128 def configure_columns(self):
129 """Check the configured content, path and name columns of the model and store their names.
131 Raises:
132 ``gws.ConfigurationError``: If neither ``contentColumn`` nor ``pathColumn`` is set,
133 or the model has no primary key.
134 """
135 p = self.cfg('contentColumn')
136 self.contentColumnName = self.model.column(p).name if p else ''
138 p = self.cfg('pathColumn')
139 self.pathColumnName = self.model.column(p).name if p else ''
141 p = self.cfg('nameColumn')
142 self.nameColumnName = self.model.column(p).name if p else ''
144 if not self.contentColumnName and not self.pathColumnName:
145 raise gws.ConfigurationError('contentColumn or pathColumn must be set')
147 if not self.model.uidName:
148 raise gws.ConfigurationError('file fields require a primary key')
150 def configure_widget(self):
151 if not super().configure_widget():
152 self.widget = self.root.create_shared(gws.ext.object.modelWidget, type='file')
153 return True
155 ##
157 def before_select(self, mc):
158 mc.dbSelect.columns.extend(self.select_columns(mc))
160 def after_select(self, features, mc):
161 for feature in features:
162 self.from_record(feature, mc)
164 def before_create(self, feature, mc):
165 self.to_record(feature, mc)
167 def before_update(self, feature, mc):
168 self.to_record(feature, mc)
170 def from_record(self, feature, mc):
171 feature.set(self.name, self.load_value(feature.record.attributes, mc))
173 def to_record(self, feature, mc):
174 if not mc.user.can_write(self):
175 return
177 # @TODO store in the filesystem
179 fv = cast(FileValue, feature.get(self.name))
180 if not fv:
181 return
182 if self.contentColumnName:
183 feature.record.attributes[self.contentColumnName] = fv.content
184 if self.nameColumnName:
185 feature.record.attributes[self.nameColumnName] = fv.name
187 # @TODO merge with scalar_field?
189 def from_props(self, feature, mc):
190 value = feature.props.attributes.get(self.name)
191 if value is not None:
192 value = self.prop_to_python(feature, value, mc)
193 if value is not None:
194 feature.set(self.name, value)
196 def to_props(self, feature, mc):
197 if not mc.user.can_read(self):
198 return
199 value = feature.get(self.name)
200 if value is not None:
201 value = self.python_to_prop(feature, value, mc)
202 if value is not None:
203 feature.props.attributes[self.name] = value
205 ##
207 def can_preview(self, mime: gws.MimeType) -> bool:
208 """Check if a thumbnail preview can be made for a MIME type.
210 Args:
211 mime: The MIME type.
213 Returns:
214 True for image types except SVG.
215 """
216 return mime.startswith('image/') and mime != gws.lib.mime.SVG
218 def prop_to_python(self, feature, value, mc) -> FileValue:
219 try:
220 return FileValue(
221 content=gws.u.get(value, 'content'),
222 name=gws.u.get(value, 'name'),
223 )
224 except ValueError:
225 return gws.ErrorValue
227 def python_to_prop(self, feature, value, mc) -> ServerFileProps:
228 fv = cast(FileValue, value)
230 mime = self.get_mime_type(fv)
231 ext = gws.lib.mime.extension_for(mime)
233 p = ServerFileProps(
234 # @TODO use a template
235 label=fv.name or '',
236 extension=ext,
237 size=fv.size or 0,
238 previewUrl='',
239 downloadUrl='',
240 )
242 if not mc.project:
243 return p
245 name = fv.name or f'gws.{ext}'
247 url_args = dict(
248 projectUid=mc.project.uid,
249 modelUid=self.model.uid,
250 fieldName=self.name,
251 featureUid=feature.uid(),
252 )
254 if self.can_preview(mime):
255 p.previewUrl = gws.u.action_url_path('webFile', preview=1, **url_args) + '/' + name
257 p.downloadUrl = gws.u.action_url_path('webFile', **url_args) + '/' + name
259 return p
261 ##
263 def get_mime_type(self, fv: FileValue) -> str:
264 """Determine the MIME type of a file from its path or name.
266 Args:
267 fv: The file value.
269 Returns:
270 The MIME type, or the generic binary type if it cannot be determined.
271 """
272 if fv.path:
273 return gws.lib.mime.for_path(fv.path)
274 if fv.name:
275 return gws.lib.mime.for_path(fv.name)
276 # @TODO guess mime from content?
277 return gws.lib.mime.BIN
279 def handle_web_file_request(self, feature_uid: str, preview: bool, mc: gws.ModelContext) -> Optional[gws.ContentResponse]:
280 """Serve the file of a feature, or its thumbnail preview.
282 Called by the ``webFile`` command. Only files stored in the database are served.
283 Thumbnails are PNG images, generated from the content and cached as ephemeral
284 content keyed by the content checksum.
286 Args:
287 feature_uid: Uid of the feature.
288 preview: Return a thumbnail preview instead of the file.
289 mc: The model context.
291 Returns:
292 The file content or the preview, or None if the user may not read the field,
293 the feature or the file is not found, or the file cannot be previewed.
295 Raises:
296 ``gws.NotFoundError``: If the content for a preview cannot be loaded or the
297 thumbnail cannot be created.
298 """
299 if not mc.user.can_read(self):
300 return
302 if not self.contentColumnName:
303 # @TODO serve files stored in the filesystem
304 return
306 content_col = self.model.column(self.contentColumnName)
308 search = gws.SearchQuery(uids=[feature_uid])
309 if preview:
310 # for small files, fetch content md5 and content, for big files only md5
311 search.extraColumns = [
312 sa.func.md5(content_col).label(f'{self.name}_preview_md5'),
313 sa.case(
314 (sa.func.length(content_col) < _PREVIEW_BIG_FILE_SIZE, content_col),
315 else_=sa.null(),
316 ).label(f'{self.name}_preview_content'),
317 ]
318 else:
319 search.extraColumns = [content_col]
321 features = self.model.find_features(search, mc)
322 if not features:
323 return
325 feature = features[0]
327 fv = cast(FileValue, feature.get(self.name))
328 if not fv:
329 return
331 if not preview:
332 # download complete file content
333 mime = self.get_mime_type(fv)
334 return gws.ContentResponse(
335 content=fv.content,
336 contentFilename=fv.name or f'gws.{gws.lib.mime.extension_for(mime)}',
337 mimeType=mime,
338 )
340 # preview
342 if not self.can_preview(self.get_mime_type(fv)):
343 return
345 md5 = feature.record.attributes.get(f'{self.name}_preview_md5')
346 if not md5:
347 return
349 def make_preview():
350 content = feature.record.attributes.get(f'{self.name}_preview_content')
352 if content is None:
353 # big file
354 search = gws.SearchQuery(uids=[feature.uid()])
355 search.extraColumns = [content_col]
356 features = self.model.find_features(search, mc)
357 if not features:
358 raise gws.NotFoundError(f'file preview: no feature {feature.uid()!r}')
359 fv = cast(FileValue, features[0].get(self.name))
360 if not fv or fv.content is None:
361 raise gws.NotFoundError(f'file preview: no content for {feature.uid()!r}')
362 content = fv.content
364 try:
365 return gws.lib.image.util.thumbnail(
366 content,
367 _PREVIEW_SIZE,
368 max_pixels=_PREVIEW_MAX_PIXELS,
369 mime=_PREVIEW_MIME,
370 )
371 except gws.lib.image.Error as exc:
372 raise gws.NotFoundError(f'file preview: {exc}') from exc
374 cache_key = gws.u.sha256(
375 [
376 self.model.uid,
377 self.name,
378 feature.uid(),
379 md5,
380 _PREVIEW_SIZE,
381 _PREVIEW_MIME,
382 ]
383 )
385 return gws.ContentResponse(
386 content=gws.u.get_ephemeral_content(f'preview_{cache_key}', make_preview),
387 mimeType=_PREVIEW_MIME,
388 )
390 ##
392 def select_columns(self, mc):
393 """Return the columns to add to a select statement.
395 The content column is not selected; only its length is, labeled ``<name>_length``.
397 Args:
398 mc: The model context.
400 Returns:
401 A list of column expressions.
402 """
403 cs = []
405 if self.contentColumnName:
406 cs.append(sa.func.length(self.model.column(self.contentColumnName)).label(f'{self.name}_length'))
407 if self.pathColumnName:
408 cs.append(self.model.column(self.pathColumnName))
409 if self.nameColumnName:
410 cs.append(self.model.column(self.nameColumnName))
412 return cs
414 def load_value(self, attributes: dict, mc) -> Optional[FileValue]:
415 """Create a file value from the attributes of a database record.
417 Args:
418 attributes: Record attributes.
419 mc: The model context.
421 Returns:
422 The file value, or None if no file columns are configured.
423 """
424 d = {}
426 if self.contentColumnName:
427 d['size'] = attributes.get(f'{self.name}_length')
428 d['content'] = attributes.get(self.contentColumnName)
429 if self.pathColumnName:
430 d['path'] = attributes.get(self.pathColumnName)
431 if self.nameColumnName:
432 d['name'] = attributes.get(self.nameColumnName)
434 if d:
435 return FileValue(**d)