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

1"""Related multi feature list field. 

2 

3Represents a 1:M relationship between a "parent" and multiple "child" tables:: 

4 

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

22 

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. 

26 

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. 

34 

35Example:: 

36 

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

46 

47import gws 

48import gws.base.model 

49import gws.base.model.related_field as related_field 

50import gws.lib.sa as sa 

51 

52 

53class RelatedItem(gws.Data): 

54 """Related model and its key column.""" 

55 

56 toModel: str 

57 """UID of the related model.""" 

58 toColumn: str 

59 """Foreign key column in the related model.""" 

60 

61 

62@gws.ext.config.modelField('relatedMultiFeatureList') 

63class Config(related_field.Config): 

64 """Field listing related features from several models.""" 

65 

66 fromColumn: str = '' 

67 """Key column in this table, primary key by default.""" 

68 related: list[RelatedItem] 

69 """Related models and keys.""" 

70 

71 

72@gws.ext.props.modelField('relatedMultiFeatureList') 

73class Props(related_field.Props): 

74 pass 

75 

76 

77@gws.ext.object.modelField('relatedMultiFeatureList') 

78class Object(related_field.Object): 

79 """Related multi feature list field object.""" 

80 

81 attributeType = gws.AttributeType.featurelist 

82 

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 ) 

91 

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 ) 

100 

101 ## 

102 

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 

116 

117 def after_select(self, features, mc): 

118 if not mc.user.can_read(self) or mc.relDepth >= mc.maxDepth: 

119 return 

120 

121 for f in features: 

122 f.set(self.name, []) 

123 

124 uid_to_f = {f.uid(): f for f in features} 

125 

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 ) 

142 

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

147 

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) 

155 

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) 

164 

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) 

173 

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. 

176 

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. 

179 

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 

187 

188 for dst in self.rel.dstList: 

189 if not mc.user.can_edit(dst.model): 

190 continue 

191 

192 cur_uids = self.dst_uids_for_key(dst, key, mc) 

193 

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 

201 

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) 

215 

216 self.drop_links(dst, cur_uids - new_uids, mc) 

217 

218 def before_delete(self, feature, mc): 

219 if not mc.user.can_write(self) or mc.relDepth >= mc.maxDepth: 

220 return 

221 

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) 

229 

230 def after_delete(self, features, mc): 

231 if not mc.user.can_write(self) or mc.relDepth >= mc.maxDepth: 

232 return 

233 

234 key = getattr(mc, f'_DELETED_KEY_{self.uid}') 

235 

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) 

241 

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. 

244 

245 Args: 

246 dst: The child side of the relationship. 

247 key: The parent key value. 

248 mc: The model context. 

249 

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

256 

257 def drop_links(self, dst: related_field.RelRef, dst_uids, mc): 

258 """Unlink child features from their parent. 

259 

260 Clears the foreign key of the child features, or deletes them if the 

261 relationship has ``deleteCascade`` set. 

262 

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)