Coverage for gws-app/gws/base/model/core.py: 91%

184 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-05 13:35 +0200

1"""Base model class.""" 

2 

3from typing import Optional, cast 

4 

5import gws 

6import gws.base.feature 

7import gws.lib.shape 

8import gws.config.util 

9 

10DEFAULT_UID_NAME = 'uid' 

11DEFAULT_GEOMETRY_NAME = 'geometry' 

12 

13 

14class TableViewColumn(gws.Data): 

15 """Column of the feature table view.""" 

16 

17 name: str 

18 """Name of the model field shown in the column.""" 

19 width: Optional[int] 

20 """Column width in pixels.""" 

21 

22 

23class ClientOptions(gws.Data): 

24 """Client-side options for editing features of the model.""" 

25 

26 keepFormOpen: bool = False 

27 """Keep the edit form open after saving.""" 

28 

29 

30class Config(gws.ConfigWithAccess): 

31 """Model configuration""" 

32 

33 fields: Optional[list[gws.ext.config.modelField]] 

34 """Model fields.""" 

35 loadingStrategy: Optional[gws.FeatureLoadingStrategy] 

36 """How the client loads features.""" 

37 exportStrategy: Optional[gws.FeatureExportStrategy] 

38 """How features are obtained for export.""" 

39 title: str = '' 

40 """Model title.""" 

41 isEditable: bool = False 

42 """Features of this model can be edited.""" 

43 withAutoFields: bool = False 

44 """Add fields for unconfigured source columns in addition to the configured ones.""" 

45 excludeColumns: Optional[list[str]] 

46 """Source columns to skip when creating fields automatically.""" 

47 withTableView: bool = True 

48 """Enable the table view of features in the client.""" 

49 tableViewColumns: Optional[list[TableViewColumn]] 

50 """Columns of the table view.""" 

51 templates: Optional[list[gws.ext.config.template]] 

52 """Templates for rendering features of this model.""" 

53 sort: Optional[list[gws.SortOptions]] 

54 """Default sort order of features.""" 

55 clientOptions: Optional[ClientOptions] 

56 """Client-side options for the model.""" 

57 

58 

59class Props(gws.Props): 

60 clientOptions: gws.ModelClientOptions 

61 canCreate: bool 

62 canDelete: bool 

63 canRead: bool 

64 canWrite: bool 

65 isEditable: bool 

66 fields: list[gws.ext.props.modelField] 

67 geometryCrs: Optional[str] 

68 geometryName: Optional[str] 

69 geometryType: Optional[gws.GeometryType] 

70 layerUid: Optional[str] 

71 loadingStrategy: gws.FeatureLoadingStrategy 

72 supportsGeometrySearch: bool 

73 supportsKeywordSearch: bool 

74 tableViewColumns: list[TableViewColumn] 

75 title: str 

76 uid: str 

77 uidName: Optional[str] 

78 

79 

80class Object(gws.Model): 

81 """Base model. 

82 

83 Provides the model configuration, with fields, uid, geometry, sort order and 

84 templates, and the props, table view columns, validation and conversion between 

85 features and props, delegated to the fields. Subclasses call `configure_model` from 

86 their ``configure``, override the ``configure_*`` steps they need and implement 

87 access to the source. 

88 """ 

89 

90 def configure(self): 

91 self.isEditable = self.cfg('isEditable', default=False) 

92 self.withTableView = self.cfg('withTableView', default=True) 

93 self.fields = [] 

94 self.geometryCrs = None 

95 self.geometryName = '' 

96 self.geometryType = None 

97 self.uidName = '' 

98 self.loadingStrategy = self.cfg('loadingStrategy') 

99 self.exportStrategy = self.cfg('exportStrategy', default=gws.FeatureExportStrategy.load) 

100 self.title = self.cfg('title') 

101 self.clientOptions = self.cfg('clientOptions') or gws.Data() 

102 

103 def post_configure(self): 

104 if self.isEditable and not self.uidName: 

105 raise gws.ConfigurationError(f'no primary key found for editable model {self}') 

106 

107 def configure_model(self): 

108 """Run the model configuration steps. 

109 

110 Calls the provider, sources, fields, uid, geometry, sort and templates 

111 configuration methods in this order. 

112 """ 

113 

114 self.configure_provider() 

115 self.configure_sources() 

116 self.configure_fields() 

117 self.configure_uid() 

118 self.configure_geometry() 

119 self.configure_sort() 

120 self.configure_templates() 

121 

122 def configure_provider(self): 

123 """Configure the data provider of the model. 

124 

125 The base implementation does nothing. 

126 

127 Returns: 

128 True if a provider was configured. 

129 """ 

130 return False 

131 

132 def configure_sources(self): 

133 """Configure the data sources of the model. 

134 

135 The base implementation does nothing. 

136 

137 Returns: 

138 True if sources were configured. 

139 """ 

140 return False 

141 

142 def configure_fields(self): 

143 """Create the model fields. 

144 

145 Creates the configured ``fields``. If there are none, or ``withAutoFields`` is set, 

146 also creates fields for the source columns with `configure_auto_fields`. 

147 

148 Returns: 

149 True if any fields were created. 

150 """ 

151 has_conf = False 

152 has_auto = False 

153 

154 p = self.cfg('fields') 

155 if p: 

156 self.fields = self.create_children(gws.ext.object.modelField, p, _defaultModel=self) 

157 has_conf = True 

158 if not has_conf or self.cfg('withAutoFields'): 

159 has_auto = self.configure_auto_fields() 

160 

161 return has_conf or has_auto 

162 

163 def configure_auto_fields(self): 

164 """Create fields for the source columns. 

165 

166 Creates a field for each column returned by ``describe``, except columns listed in 

167 ``excludeColumns``, columns that already have a field, and columns of a type 

168 without a default field type. 

169 

170 Returns: 

171 True if the source could be described, False otherwise. 

172 """ 

173 desc = self.describe() 

174 if not desc: 

175 return False 

176 

177 exclude = set(self.cfg('excludeColumns', default=[])) 

178 exclude.update(fld.name for fld in self.fields) 

179 

180 for col in desc.columns: 

181 if col.name in exclude: 

182 continue 

183 

184 typ = _DEFAULT_FIELD_TYPES.get(col.type) 

185 if not typ: 

186 # gws.log.warning(f'cannot find suitable field type for column {desc.fullName}.{col.name} ({col.type})') 

187 continue 

188 

189 cfg = gws.Config( 

190 type=typ, 

191 name=col.name, 

192 isPrimaryKey=col.isPrimaryKey, 

193 isRequired=not col.isNullable, 

194 ) 

195 fld = self.create_child(gws.ext.object.modelField, cfg, _defaultModel=self) 

196 if fld: 

197 self.fields.append(fld) 

198 exclude.add(fld.name) 

199 

200 return True 

201 

202 def configure_uid(self): 

203 """Set ``uidName`` to the name of the primary key field. 

204 

205 Keeps an already set ``uidName``. Otherwise a name is set only if exactly one field 

206 is a primary key. 

207 

208 Returns: 

209 True if ``uidName`` is set, None otherwise. 

210 """ 

211 if self.uidName: 

212 return True 

213 uids = [] 

214 for fld in self.fields: 

215 if fld.isPrimaryKey: 

216 uids.append(fld.name) 

217 if len(uids) == 1: 

218 self.uidName = uids[0] 

219 return True 

220 

221 def configure_geometry(self): 

222 """Set the geometry name, type and CRS from the first geometry field. 

223 

224 Returns: 

225 True if a geometry field was found, None otherwise. 

226 """ 

227 for fld in self.fields: 

228 if getattr(fld, 'geometryType', None): 

229 self.geometryName = fld.name 

230 self.geometryType = getattr(fld, 'geometryType') 

231 self.geometryCrs = getattr(fld, 'geometryCrs') 

232 return True 

233 

234 def configure_sort(self): 

235 """Set the default sort order. 

236 

237 Uses the configured ``sort``, otherwise sorts by the uid field, if any. 

238 

239 Returns: 

240 True if a sort order was configured, False otherwise. 

241 """ 

242 p = self.cfg('sort') 

243 if p: 

244 self.defaultSort = [gws.SearchSort(c) for c in p] 

245 return True 

246 if self.uidName: 

247 self.defaultSort = [gws.SearchSort(fieldName=self.uidName, reversed=False)] 

248 return False 

249 self.defaultSort = [] 

250 return False 

251 

252 def configure_templates(self): 

253 """Create the configured templates. 

254 

255 Returns: 

256 True if any templates were created. 

257 """ 

258 return gws.config.util.configure_templates_for(self) 

259 

260 ## 

261 

262 def props(self, user): 

263 layer = cast(gws.Layer, self.find_closest(gws.ext.object.layer)) 

264 

265 return gws.Props( 

266 clientOptions=self.clientOptions, 

267 canCreate=user.can_create(self), 

268 canDelete=user.can_delete(self), 

269 canRead=user.can_read(self), 

270 canWrite=user.can_write(self), 

271 fields=self.fields, 

272 geometryCrs=self.geometryCrs.epsg if self.geometryCrs else None, 

273 geometryName=self.geometryName, 

274 geometryType=self.geometryType, 

275 isEditable=self.isEditable, 

276 layerUid=layer.uid if layer else None, 

277 loadingStrategy=self.loadingStrategy or (layer.loadingStrategy if layer else gws.FeatureLoadingStrategy.all), 

278 supportsGeometrySearch=any(fld.supportsGeometrySearch for fld in self.fields), 

279 supportsKeywordSearch=any(fld.supportsKeywordSearch for fld in self.fields), 

280 tableViewColumns=self.table_view_columns(user), 

281 title=self.title or (layer.title if layer else ''), 

282 uid=self.uid, 

283 uidName=self.uidName, 

284 ) 

285 

286 ## 

287 

288 def table_view_columns(self, user): 

289 """Return the columns of the table view for a user. 

290 

291 Uses the configured ``tableViewColumns``, or all fields otherwise. Fields the user 

292 cannot use and fields without a widget that supports the table view are skipped. 

293 

294 Args: 

295 user: The user. 

296 

297 Returns: 

298 A list of columns, empty if the table view is disabled. 

299 """ 

300 if not self.withTableView: 

301 return [] 

302 

303 cols = [] 

304 

305 p = self.cfg('tableViewColumns') 

306 if p: 

307 fmap = {fld.name: fld for fld in self.fields} 

308 for c in p: 

309 fld = fmap.get(c.name) 

310 if fld and user.can_use(fld) and fld.widget and fld.widget.supportsTableView: 

311 cols.append(TableViewColumn(name=c.name, width=c.width or 0)) 

312 else: 

313 for fld in self.fields: 

314 if fld and user.can_use(fld) and fld.widget and fld.widget.supportsTableView: 

315 cols.append(TableViewColumn(name=fld.name, width=0)) 

316 

317 return cols 

318 

319 def field(self, name): 

320 for fld in self.fields: 

321 if fld.name == name: 

322 return fld 

323 

324 def validate_feature(self, feature, mc): 

325 feature.errors = [] 

326 for fld in self.fields: 

327 fld.do_validate(feature, mc) 

328 return len(feature.errors) == 0 

329 

330 def related_models(self): 

331 d = {} 

332 

333 for fld in self.fields: 

334 for model in fld.related_models(): 

335 d[model.uid] = model 

336 

337 return list(d.values()) 

338 

339 ## 

340 

341 def get_features(self, uids, mc): 

342 if not uids: 

343 return [] 

344 search = gws.SearchQuery(uids=set(uids)) 

345 return self.find_features(search, mc) 

346 

347 def get_feature(self, uid, mc): 

348 features = self.get_features([uid], mc) 

349 if features: 

350 return features[0] 

351 

352 def find_features(self, search, mc): 

353 return [] 

354 

355 ## 

356 

357 def feature_from_props(self, props, mc): 

358 props = cast(gws.FeatureProps, gws.u.to_data_object(props)) 

359 feature = gws.base.feature.new(model=self, props=props) 

360 feature.cssSelector = props.cssSelector or '' 

361 feature.isNew = props.isNew or False 

362 feature.views = props.views or {} 

363 

364 for fld in self.fields: 

365 fld.from_props(feature, mc) 

366 

367 return feature 

368 

369 def feature_to_props(self, feature, mc): 

370 feature.props = gws.FeatureProps( 

371 attributes={}, 

372 cssSelector=feature.cssSelector, 

373 category=feature.category or '', 

374 errors=feature.errors or [], 

375 isNew=feature.isNew, 

376 modelUid=self.uid, 

377 uid=feature.uid(), 

378 views=feature.views, 

379 ) 

380 

381 for fld in self.fields: 

382 fld.to_props(feature, mc) 

383 

384 return feature.props 

385 

386 def feature_to_view_props(self, feature, mc): 

387 props = self.feature_to_props(feature, mc) 

388 

389 a = {} 

390 

391 if self.uidName: 

392 a[DEFAULT_UID_NAME] = props.attributes.get(self.uidName) 

393 if self.geometryName: 

394 a[DEFAULT_GEOMETRY_NAME] = props.attributes.get(self.geometryName) 

395 

396 # only provide "uid" and "geometry" attributes for the view props 

397 props.attributes = a 

398 

399 return props 

400 

401 

402## 

403 

404# @TODO this should be populated dynamically from available gws.ext.object.modelField types 

405 

406_DEFAULT_FIELD_TYPES = { 

407 gws.AttributeType.str: 'text', 

408 gws.AttributeType.int: 'integer', 

409 gws.AttributeType.date: 'date', 

410 gws.AttributeType.bool: 'bool', 

411 # gws.AttributeType.bytes: 'bytea', 

412 gws.AttributeType.datetime: 'datetime', 

413 # gws.AttributeType.feature: 'feature', 

414 # gws.AttributeType.featurelist: 'featurelist', 

415 gws.AttributeType.float: 'float', 

416 # gws.AttributeType.floatlist: 'floatlist', 

417 gws.AttributeType.geometry: 'geometry', 

418 # gws.AttributeType.intlist: 'intlist', 

419 # gws.AttributeType.strlist: 'strlist', 

420 gws.AttributeType.time: 'time', 

421}