Coverage for gws-app/gws/plugin/model_field/file/__init__.py: 86%
190 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 12:46 +0200
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 12:46 +0200
1"""File field."""
3from typing import Optional, cast
5import gws
6import gws.base.database.model
7import gws.base.model.field
8import gws.lib.image
9import gws.lib.mime
10import gws.lib.sa as sa
12gws.ext.new.modelField('file')
15class Config(gws.base.model.field.Config):
16 """Configuration for the file field."""
18 contentColumn: str = ''
19 """Column name for the file content, if stored in the database."""
20 pathColumn: str = ''
21 """Column name for the file path, if stored in the filesystem."""
22 nameColumn: str = ''
23 """Column name for the file name, if stored in the database or filesystem."""
26class Props(gws.base.model.field.Props):
27 pass
30class FileInputProps(gws.Data):
31 content: bytes
32 name: str
35class ServerFileProps(gws.Data):
36 downloadUrl: str
37 extension: str
38 label: str
39 previewUrl: str
40 size: int
43class ClientFileProps(gws.Data):
44 name: str
45 content: bytes
48class FileValue(gws.Data):
49 content: bytes
50 name: str
51 path: str
52 size: int
55_PREVIEW_SIZE = 120, 120
56_PREVIEW_MIME = gws.lib.mime.PNG
57_PREVIEW_CACHE_LIFE_TIME = 24 * 3600
58_PREVIEW_MAX_PIXELS = 40_000_000
59_PREVIEW_BIG_FILE_SIZE = 1024 * 1024
62class Object(gws.base.model.field.Object):
63 model: gws.DatabaseModel
65 attributeType = gws.AttributeType.file
67 contentColumn: Optional[sa.Column] = None
68 pathColumn: Optional[sa.Column] = None
69 nameColumn: Optional[sa.Column] = None
71 def __getstate__(self):
72 return gws.u.omit(vars(self), 'cols')
74 def post_configure(self):
75 self.configure_columns()
77 def activate(self):
78 self.configure_columns()
80 def configure_columns(self):
81 model = cast(gws.base.database.model.Object, self.model)
83 p = self.cfg('contentColumn')
84 self.contentColumn = model.column(p) if p else None
86 p = self.cfg('pathColumn')
87 self.pathColumn = model.column(p) if p else None
89 p = self.cfg('nameColumn')
90 self.nameColumn = model.column(p) if p else None
92 if self.contentColumn is None and self.pathColumn is None:
93 raise gws.ConfigurationError('contentColumn or pathColumn must be set')
95 if not self.model.uidName:
96 raise gws.ConfigurationError('file fields require a primary key')
98 def configure_widget(self):
99 if not super().configure_widget():
100 self.widget = self.root.create_shared(gws.ext.object.modelWidget, type='file')
101 return True
103 ##
105 def before_select(self, mc):
106 mc.dbSelect.columns.extend(self.select_columns(mc))
108 def after_select(self, features, mc):
109 for feature in features:
110 self.from_record(feature, mc)
112 def before_create(self, feature, mc):
113 self.to_record(feature, mc)
115 def before_update(self, feature, mc):
116 self.to_record(feature, mc)
118 def from_record(self, feature, mc):
119 feature.set(self.name, self.load_value(feature.record.attributes, mc))
121 def to_record(self, feature, mc):
122 if not mc.user.can_write(self):
123 return
125 # @TODO store in the filesystem
127 fv = cast(FileValue, feature.get(self.name))
128 if not fv:
129 return
130 if self.contentColumn is not None:
131 feature.record.attributes[self.contentColumn.name] = fv.content
132 if self.nameColumn is not None:
133 feature.record.attributes[self.nameColumn.name] = fv.name
135 # @TODO merge with scalar_field?
137 def from_props(self, feature, mc):
138 value = feature.props.attributes.get(self.name)
139 if value is not None:
140 value = self.prop_to_python(feature, value, mc)
141 if value is not None:
142 feature.set(self.name, value)
144 def to_props(self, feature, mc):
145 if not mc.user.can_read(self):
146 return
147 value = feature.get(self.name)
148 if value is not None:
149 value = self.python_to_prop(feature, value, mc)
150 if value is not None:
151 feature.props.attributes[self.name] = value
153 ##
155 def can_preview(self, mime) -> bool:
156 return mime.startswith('image/') and mime != gws.lib.mime.SVG
158 def prop_to_python(self, feature, value, mc) -> FileValue:
159 try:
160 return FileValue(
161 content=gws.u.get(value, 'content'),
162 name=gws.u.get(value, 'name'),
163 )
164 except ValueError:
165 return gws.ErrorValue
167 def python_to_prop(self, feature, value, mc) -> ServerFileProps:
168 fv = cast(FileValue, value)
170 mime = self.get_mime_type(fv)
171 ext = gws.lib.mime.extension_for(mime)
173 p = ServerFileProps(
174 # @TODO use a template
175 label=fv.name or '',
176 extension=ext,
177 size=fv.size or 0,
178 previewUrl='',
179 downloadUrl='',
180 )
182 name = fv.name or f'gws.{ext}'
184 url_args = dict(
185 projectUid=mc.project.uid,
186 modelUid=self.model.uid,
187 fieldName=self.name,
188 featureUid=feature.uid(),
189 )
191 if self.can_preview(mime):
192 p.previewUrl = gws.u.action_url_path('webFile', preview=1, **url_args) + '/' + name
194 p.downloadUrl = gws.u.action_url_path('webFile', **url_args) + '/' + name
196 return p
198 ##
200 def get_mime_type(self, fv: FileValue) -> str:
201 if fv.path:
202 return gws.lib.mime.for_path(fv.path)
203 if fv.name:
204 return gws.lib.mime.for_path(fv.name)
205 # @TODO guess mime from content?
206 return gws.lib.mime.BIN
208 def handle_web_file_request(self, feature_uid: str, preview: bool, mc: gws.ModelContext) -> Optional[gws.ContentResponse]:
209 if not mc.user.can_read(self):
210 return
212 if self.contentColumn is None:
213 # @TODO serve files stored in the filesystem
214 return
216 search = gws.SearchQuery(uids=[feature_uid])
217 if preview:
218 # for small files, fetch content md5 and content, for big files only md5
219 search.extraColumns = [
220 sa.func.md5(self.contentColumn).label(f'{self.name}_preview_md5'),
221 sa.case(
222 (sa.func.length(self.contentColumn) < _PREVIEW_BIG_FILE_SIZE, self.contentColumn),
223 else_=sa.null(),
224 ).label(f'{self.name}_preview_content'),
225 ]
226 else:
227 search.extraColumns = [self.contentColumn]
229 features = self.model.find_features(search, mc)
230 if not features:
231 return
233 feature = features[0]
235 fv = cast(FileValue, feature.get(self.name))
236 if not fv:
237 return
239 if not preview:
240 # download complete file content
241 mime = self.get_mime_type(fv)
242 return gws.ContentResponse(
243 content=fv.content,
244 contentFilename=fv.name or f'gws.{gws.lib.mime.extension_for(mime)}',
245 mime=mime,
246 )
248 # preview
250 if not self.can_preview(self.get_mime_type(fv)):
251 return
253 md5 = feature.record.attributes.get(f'{self.name}_preview_md5')
254 if not md5:
255 return
257 cache_key = gws.u.sha256(
258 [
259 self.model.uid,
260 self.name,
261 feature.uid(),
262 md5,
263 _PREVIEW_SIZE,
264 _PREVIEW_MIME,
265 ]
266 )
268 def make_preview():
269 content = feature.record.attributes.get(f'{self.name}_preview_content')
271 if content is None:
272 # big file
273 search = gws.SearchQuery(uids=[feature.uid()])
274 search.extraColumns = [self.contentColumn]
275 features = self.model.find_features(search, mc)
276 if not features:
277 raise gws.NotFoundError(f'file preview: no feature {feature.uid()!r}')
278 fv = cast(FileValue, features[0].get(self.name))
279 if not fv or fv.content is None:
280 raise gws.NotFoundError(f'file preview: no content for {feature.uid()!r}')
281 content = fv.content
283 try:
284 return gws.lib.image.thumbnail(
285 content,
286 _PREVIEW_SIZE,
287 max_pixels=_PREVIEW_MAX_PIXELS,
288 mime=_PREVIEW_MIME,
289 )
290 except gws.lib.image.Error as exc:
291 raise gws.NotFoundError(f'file preview: {exc}') from exc
293 cache_dir = gws.u.ensure_dir(gws.c.CACHE_DIR + '/preview')
294 cache_path = cache_dir + f'/{cache_key}.{gws.lib.mime.extension_for(_PREVIEW_MIME)}'
296 return gws.ContentResponse(
297 contentPath=gws.u.get_cached_file(cache_path, _PREVIEW_CACHE_LIFE_TIME, make_preview),
298 mime=_PREVIEW_MIME,
299 )
301 ##
303 def select_columns(self, mc):
304 cs = []
306 if self.contentColumn is not None:
307 cs.append(sa.func.length(self.contentColumn).label(f'{self.name}_length'))
308 if self.pathColumn is not None:
309 cs.append(self.pathColumn)
310 if self.nameColumn is not None:
311 cs.append(self.nameColumn)
313 return cs
315 def load_value(self, attributes: dict, mc) -> Optional[FileValue]:
316 d = {}
318 if self.contentColumn is not None:
319 d['size'] = attributes.get(f'{self.name}_length')
320 d['content'] = attributes.get(self.contentColumn.name)
321 if self.pathColumn is not None:
322 d['path'] = attributes.get(self.pathColumn.name)
323 if self.nameColumn is not None:
324 d['name'] = attributes.get(self.nameColumn.name)
326 if d:
327 return FileValue(**d)