Coverage for gws-app/gws/plugin/model_field/related_multi_feature_list/__init__.py: 94%
93 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"""Related multi feature list field.
3Represents a 1:M relationship between a "parent" and multiple "child" tables::
5 +---------+ +------------+
6 | parent | | child 1 |
7 +---------+ +------------+
8 | key |-------<<| parent_key |
9 | | +------------+
10 | |
11 | | +------------+
12 | | | child 2 |
13 | | +------------+
14 | |-------<<| parent_key |
15 | | +------------+
16 | |
17 | | +------------+
18 | | | child 3 |
19 | | +------------+
20 | |-------<<| parent_key |
21 +---------+ +------------+
23The value of the field is the list of child features from all child models.
24``fromColumn`` is the key column in this model's table, by default its primary
25key; each entry in ``related`` names a child model and its foreign key column.
27When a feature is written, child features in the list get their foreign key
28set to the parent key. Child features no longer in the list are unlinked by
29clearing their foreign key. When a parent feature is deleted, its children are
30unlinked in the same way. Child models the user may not edit are skipped.
31When a child feature is created together with a parent feature, its foreign
32key is set to the parent key. Without a configured widget, the field uses a
33``featureList`` widget.
35Example::
37 fields+ {
38 name "documents"
39 type "relatedMultiFeatureList"
40 related [
41 { toModel "model_photo" toColumn "parent_id" }
42 { toModel "model_report" toColumn "parent_id" }
43 ]
44 }
45"""
47import gws
48import gws.base.model
49import gws.base.model.related_field as related_field
50import gws.lib.sa as sa
53class RelatedItem(gws.Data):
54 """Related model and its key column."""
56 toModel: str
57 """UID of the related model."""
58 toColumn: str
59 """Foreign key column in the related model."""
62@gws.ext.config.modelField('relatedMultiFeatureList')
63class Config(related_field.Config):
64 """Field listing related features from several models."""
66 fromColumn: str = ''
67 """Key column in this table, primary key by default."""
68 related: list[RelatedItem]
69 """Related models and keys."""
72@gws.ext.props.modelField('relatedMultiFeatureList')
73class Props(related_field.Props):
74 pass
77@gws.ext.object.modelField('relatedMultiFeatureList')
78class Object(related_field.Object):
79 """Related multi feature list field object."""
81 attributeType = gws.AttributeType.featurelist
83 def configure_relationship(self):
84 self.rel = related_field.Relationship(
85 src=related_field.RelRef(
86 model=self.model,
87 keyName=self.model.column(self.cfg('fromColumn') or self.model.uidName).name,
88 ),
89 dstList=[],
90 )
92 for c in self.cfg('related'):
93 dst_mod = self.get_model(c.toModel)
94 self.rel.dstList.append(
95 related_field.RelRef(
96 model=dst_mod,
97 keyName=dst_mod.column(c.toColumn).name,
98 )
99 )
101 ##
103 def before_create_related(self, dst_feature, mc):
104 for feature in dst_feature.createWithFeatures:
105 if feature.model == self.model:
106 key = self.key_for_uid(
107 self.rel.src.model,
108 self.key_column(self.rel.src),
109 feature.uid(),
110 mc,
111 )
112 for dst in self.rel.dstList:
113 if dst_feature.model == dst.model:
114 dst_feature.record.attributes[dst.keyName] = key
115 return
117 def after_select(self, features, mc):
118 if not mc.user.can_read(self) or mc.relDepth >= mc.maxDepth:
119 return
121 for f in features:
122 f.set(self.name, [])
124 uid_to_f = {f.uid(): f for f in features}
126 for dst in self.rel.dstList:
127 sql = (
128 sa.select(
129 dst.model.uid_column(),
130 self.rel.src.model.uid_column(),
131 )
132 .select_from(
133 dst.model.table().join(
134 self.rel.src.model.table(),
135 self.key_column(self.rel.src) == self.key_column(dst),
136 ),
137 )
138 .where(
139 self.rel.src.model.uid_equals(uid_to_f),
140 )
141 )
143 r_to_uids = {}
144 with self.model.db.connect() as conn:
145 for r, u in conn.execute(sql):
146 r_to_uids.setdefault(str(r), []).append(str(u))
148 for dst_feature in dst.model.get_features(
149 r_to_uids,
150 gws.base.model.secondary_context(mc),
151 ):
152 for uid in r_to_uids.get(dst_feature.uid(), []):
153 feature = uid_to_f.get(uid)
154 feature.get(self.name).append(dst_feature)
156 def after_create(self, feature, mc):
157 key = self.key_for_uid(
158 self.model,
159 self.key_column(self.rel.src),
160 feature.insertedPrimaryKey,
161 mc,
162 )
163 self.after_write(feature, key, mc)
165 def after_update(self, feature, mc):
166 key = self.key_for_uid(
167 self.model,
168 self.key_column(self.rel.src),
169 feature.uid(),
170 mc,
171 )
172 self.after_write(feature, key, mc)
174 def after_write(self, feature: gws.Feature, key, mc: gws.ModelContext):
175 """Link the child features in the field value to a written feature and unlink the others.
177 Does nothing if the user may not write the field or the maximum relation
178 depth is reached. Child models the user may not edit are skipped.
180 Args:
181 feature: The created or updated feature.
182 key: Value of the key column of the feature.
183 mc: The model context.
184 """
185 if not mc.user.can_write(self) or mc.relDepth >= mc.maxDepth:
186 return
188 for dst in self.rel.dstList:
189 if not mc.user.can_edit(dst.model):
190 continue
192 cur_uids = self.dst_uids_for_key(dst, key, mc)
194 # fmt: off
195 new_uids = set(
196 dst_feature.uid()
197 for dst_feature in feature.get(self.name, [])
198 if dst_feature.model == dst.model
199 )
200 # fmt: on
202 ins_uids = new_uids - cur_uids
203 if ins_uids:
204 sql = (
205 sa.update(dst.model.table())
206 .values(
207 {dst.keyName: key},
208 )
209 .where(
210 dst.model.uid_equals(ins_uids),
211 )
212 )
213 with dst.model.db.connect() as conn:
214 conn.execute(sql)
216 self.drop_links(dst, cur_uids - new_uids, mc)
218 def before_delete(self, feature, mc):
219 if not mc.user.can_write(self) or mc.relDepth >= mc.maxDepth:
220 return
222 key = self.key_for_uid(
223 self.model,
224 self.key_column(self.rel.src),
225 feature.uid(),
226 mc,
227 )
228 setattr(mc, f'_DELETED_KEY_{self.uid}', key)
230 def after_delete(self, features, mc):
231 if not mc.user.can_write(self) or mc.relDepth >= mc.maxDepth:
232 return
234 key = getattr(mc, f'_DELETED_KEY_{self.uid}')
236 for dst in self.rel.dstList:
237 if not mc.user.can_edit(dst.model):
238 continue
239 cur_uids = self.dst_uids_for_key(dst, key, mc)
240 self.drop_links(dst, cur_uids, mc)
242 def dst_uids_for_key(self, dst: related_field.RelRef, key, mc):
243 """Find the uids of the child features that refer to a parent key.
245 Args:
246 dst: The child side of the relationship.
247 key: The parent key value.
248 mc: The model context.
250 Returns:
251 A set of child feature uids as strings.
252 """
253 sql = sa.select(dst.model.uid_column()).where(self.key_column(dst) == key)
254 with dst.model.db.connect() as conn:
255 return set(str(u[0]) for u in conn.execute(sql))
257 def drop_links(self, dst: related_field.RelRef, dst_uids, mc):
258 """Unlink child features from their parent.
260 Clears the foreign key of the child features, or deletes them if the
261 relationship has ``deleteCascade`` set.
263 Args:
264 dst: The child side of the relationship.
265 dst_uids: Uids of the child features.
266 mc: The model context.
267 """
268 if not dst_uids:
269 return
270 if self.rel.deleteCascade:
271 sql = sa.delete(dst.model.table()).where(dst.model.uid_equals(dst_uids))
272 else:
273 sql = (
274 sa.update(dst.model.table())
275 .values(
276 {dst.keyName: None},
277 )
278 .where(
279 dst.model.uid_equals(dst_uids),
280 )
281 )
282 with dst.model.db.connect() as conn:
283 conn.execute(sql)