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
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-05 13:35 +0200
1"""Base model class."""
3from typing import Optional, cast
5import gws
6import gws.base.feature
7import gws.lib.shape
8import gws.config.util
10DEFAULT_UID_NAME = 'uid'
11DEFAULT_GEOMETRY_NAME = 'geometry'
14class TableViewColumn(gws.Data):
15 """Column of the feature table view."""
17 name: str
18 """Name of the model field shown in the column."""
19 width: Optional[int]
20 """Column width in pixels."""
23class ClientOptions(gws.Data):
24 """Client-side options for editing features of the model."""
26 keepFormOpen: bool = False
27 """Keep the edit form open after saving."""
30class Config(gws.ConfigWithAccess):
31 """Model configuration"""
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."""
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]
80class Object(gws.Model):
81 """Base model.
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 """
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()
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}')
107 def configure_model(self):
108 """Run the model configuration steps.
110 Calls the provider, sources, fields, uid, geometry, sort and templates
111 configuration methods in this order.
112 """
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()
122 def configure_provider(self):
123 """Configure the data provider of the model.
125 The base implementation does nothing.
127 Returns:
128 True if a provider was configured.
129 """
130 return False
132 def configure_sources(self):
133 """Configure the data sources of the model.
135 The base implementation does nothing.
137 Returns:
138 True if sources were configured.
139 """
140 return False
142 def configure_fields(self):
143 """Create the model fields.
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`.
148 Returns:
149 True if any fields were created.
150 """
151 has_conf = False
152 has_auto = False
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()
161 return has_conf or has_auto
163 def configure_auto_fields(self):
164 """Create fields for the source columns.
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.
170 Returns:
171 True if the source could be described, False otherwise.
172 """
173 desc = self.describe()
174 if not desc:
175 return False
177 exclude = set(self.cfg('excludeColumns', default=[]))
178 exclude.update(fld.name for fld in self.fields)
180 for col in desc.columns:
181 if col.name in exclude:
182 continue
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
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)
200 return True
202 def configure_uid(self):
203 """Set ``uidName`` to the name of the primary key field.
205 Keeps an already set ``uidName``. Otherwise a name is set only if exactly one field
206 is a primary key.
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
221 def configure_geometry(self):
222 """Set the geometry name, type and CRS from the first geometry field.
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
234 def configure_sort(self):
235 """Set the default sort order.
237 Uses the configured ``sort``, otherwise sorts by the uid field, if any.
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
252 def configure_templates(self):
253 """Create the configured templates.
255 Returns:
256 True if any templates were created.
257 """
258 return gws.config.util.configure_templates_for(self)
260 ##
262 def props(self, user):
263 layer = cast(gws.Layer, self.find_closest(gws.ext.object.layer))
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 )
286 ##
288 def table_view_columns(self, user):
289 """Return the columns of the table view for a user.
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.
294 Args:
295 user: The user.
297 Returns:
298 A list of columns, empty if the table view is disabled.
299 """
300 if not self.withTableView:
301 return []
303 cols = []
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))
317 return cols
319 def field(self, name):
320 for fld in self.fields:
321 if fld.name == name:
322 return fld
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
330 def related_models(self):
331 d = {}
333 for fld in self.fields:
334 for model in fld.related_models():
335 d[model.uid] = model
337 return list(d.values())
339 ##
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)
347 def get_feature(self, uid, mc):
348 features = self.get_features([uid], mc)
349 if features:
350 return features[0]
352 def find_features(self, search, mc):
353 return []
355 ##
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 {}
364 for fld in self.fields:
365 fld.from_props(feature, mc)
367 return feature
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 )
381 for fld in self.fields:
382 fld.to_props(feature, mc)
384 return feature.props
386 def feature_to_view_props(self, feature, mc):
387 props = self.feature_to_props(feature, mc)
389 a = {}
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)
396 # only provide "uid" and "geometry" attributes for the view props
397 props.attributes = a
399 return props
402##
404# @TODO this should be populated dynamically from available gws.ext.object.modelField types
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}