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

1"""File field. 

2 

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. 

8 

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. 

16 

17Example:: 

18 

19 fields+ { 

20 name "photo" 

21 type "file" 

22 contentColumn "photo_content" 

23 nameColumn "photo_name" 

24 } 

25""" 

26 

27from typing import Optional, cast 

28 

29import gws 

30import gws.base.model.field 

31import gws.lib.image 

32import gws.lib.mime 

33import gws.lib.sa as sa 

34 

35 

36@gws.ext.config.modelField('file') 

37class Config(gws.base.model.field.Config): 

38 """Field for files stored in the database or the filesystem.""" 

39 

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.""" 

46 

47 

48@gws.ext.props.modelField('file') 

49class Props(gws.base.model.field.Props): 

50 pass 

51 

52 

53class FileInputProps(gws.Data): 

54 """File uploaded from the client.""" 

55 

56 content: bytes 

57 """File content.""" 

58 name: str 

59 """File name.""" 

60 

61 

62class ServerFileProps(gws.Data): 

63 """File description sent to the client.""" 

64 

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.""" 

75 

76 

77class ClientFileProps(gws.Data): 

78 """File sent from the client.""" 

79 

80 name: str 

81 """File name.""" 

82 content: bytes 

83 """File content.""" 

84 

85 

86class FileValue(gws.Data): 

87 """Value of a file field.""" 

88 

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.""" 

97 

98 

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 

103 

104 

105@gws.ext.object.modelField('file') 

106class Object(gws.base.model.field.Object): 

107 """File field object. 

108 

109 Stores files attached to features in columns of a database model and 

110 describes them to the client for download and preview. 

111 """ 

112 

113 model: gws.DatabaseModel 

114 """The model of this field.""" 

115 

116 attributeType = gws.AttributeType.file 

117 

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.""" 

124 

125 def post_configure(self): 

126 self.configure_columns() 

127 

128 def configure_columns(self): 

129 """Check the configured content, path and name columns of the model and store their names. 

130 

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 '' 

137 

138 p = self.cfg('pathColumn') 

139 self.pathColumnName = self.model.column(p).name if p else '' 

140 

141 p = self.cfg('nameColumn') 

142 self.nameColumnName = self.model.column(p).name if p else '' 

143 

144 if not self.contentColumnName and not self.pathColumnName: 

145 raise gws.ConfigurationError('contentColumn or pathColumn must be set') 

146 

147 if not self.model.uidName: 

148 raise gws.ConfigurationError('file fields require a primary key') 

149 

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 

154 

155 ## 

156 

157 def before_select(self, mc): 

158 mc.dbSelect.columns.extend(self.select_columns(mc)) 

159 

160 def after_select(self, features, mc): 

161 for feature in features: 

162 self.from_record(feature, mc) 

163 

164 def before_create(self, feature, mc): 

165 self.to_record(feature, mc) 

166 

167 def before_update(self, feature, mc): 

168 self.to_record(feature, mc) 

169 

170 def from_record(self, feature, mc): 

171 feature.set(self.name, self.load_value(feature.record.attributes, mc)) 

172 

173 def to_record(self, feature, mc): 

174 if not mc.user.can_write(self): 

175 return 

176 

177 # @TODO store in the filesystem 

178 

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 

186 

187 # @TODO merge with scalar_field? 

188 

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) 

195 

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 

204 

205 ## 

206 

207 def can_preview(self, mime: gws.MimeType) -> bool: 

208 """Check if a thumbnail preview can be made for a MIME type. 

209 

210 Args: 

211 mime: The MIME type. 

212 

213 Returns: 

214 True for image types except SVG. 

215 """ 

216 return mime.startswith('image/') and mime != gws.lib.mime.SVG 

217 

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 

226 

227 def python_to_prop(self, feature, value, mc) -> ServerFileProps: 

228 fv = cast(FileValue, value) 

229 

230 mime = self.get_mime_type(fv) 

231 ext = gws.lib.mime.extension_for(mime) 

232 

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 ) 

241 

242 if not mc.project: 

243 return p 

244 

245 name = fv.name or f'gws.{ext}' 

246 

247 url_args = dict( 

248 projectUid=mc.project.uid, 

249 modelUid=self.model.uid, 

250 fieldName=self.name, 

251 featureUid=feature.uid(), 

252 ) 

253 

254 if self.can_preview(mime): 

255 p.previewUrl = gws.u.action_url_path('webFile', preview=1, **url_args) + '/' + name 

256 

257 p.downloadUrl = gws.u.action_url_path('webFile', **url_args) + '/' + name 

258 

259 return p 

260 

261 ## 

262 

263 def get_mime_type(self, fv: FileValue) -> str: 

264 """Determine the MIME type of a file from its path or name. 

265 

266 Args: 

267 fv: The file value. 

268 

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 

278 

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. 

281 

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. 

285 

286 Args: 

287 feature_uid: Uid of the feature. 

288 preview: Return a thumbnail preview instead of the file. 

289 mc: The model context. 

290 

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. 

294 

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 

301 

302 if not self.contentColumnName: 

303 # @TODO serve files stored in the filesystem 

304 return 

305 

306 content_col = self.model.column(self.contentColumnName) 

307 

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] 

320 

321 features = self.model.find_features(search, mc) 

322 if not features: 

323 return 

324 

325 feature = features[0] 

326 

327 fv = cast(FileValue, feature.get(self.name)) 

328 if not fv: 

329 return 

330 

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 ) 

339 

340 # preview 

341 

342 if not self.can_preview(self.get_mime_type(fv)): 

343 return 

344 

345 md5 = feature.record.attributes.get(f'{self.name}_preview_md5') 

346 if not md5: 

347 return 

348 

349 def make_preview(): 

350 content = feature.record.attributes.get(f'{self.name}_preview_content') 

351 

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 

363 

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 

373 

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 ) 

384 

385 return gws.ContentResponse( 

386 content=gws.u.get_ephemeral_content(f'preview_{cache_key}', make_preview), 

387 mimeType=_PREVIEW_MIME, 

388 ) 

389 

390 ## 

391 

392 def select_columns(self, mc): 

393 """Return the columns to add to a select statement. 

394 

395 The content column is not selected; only its length is, labeled ``<name>_length``. 

396 

397 Args: 

398 mc: The model context. 

399 

400 Returns: 

401 A list of column expressions. 

402 """ 

403 cs = [] 

404 

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)) 

411 

412 return cs 

413 

414 def load_value(self, attributes: dict, mc) -> Optional[FileValue]: 

415 """Create a file value from the attributes of a database record. 

416 

417 Args: 

418 attributes: Record attributes. 

419 mc: The model context. 

420 

421 Returns: 

422 The file value, or None if no file columns are configured. 

423 """ 

424 d = {} 

425 

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) 

433 

434 if d: 

435 return FileValue(**d)