Coverage for gws-app/gws/plugin/alkis/data/index.py: 88%

571 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-23 20:06 +0200

1"""ALKIS index tables: storage, search and loading of Flurstuecke and addresses.""" 

2 

3from collections.abc import Iterable 

4 

5import re 

6import datetime 

7 

8from sqlalchemy.dialects.postgresql import JSONB 

9 

10import gws 

11import gws.lib.shape 

12import gws.config.util 

13import gws.lib.crs 

14import gws.plugin.postgres.provider 

15import gws.base.database.util 

16import gws.lib.sa as sa 

17 

18from . import types as dt 

19 

20TABLE_PLACE = 'place' 

21TABLE_FLURSTUECK = 'flurstueck' 

22TABLE_BUCHUNGSBLATT = 'buchungsblatt' 

23TABLE_LAGE = 'lage' 

24TABLE_PART = 'part' 

25 

26TABLE_INDEXFLURSTUECK = 'indexflurstueck' 

27TABLE_INDEXLAGE = 'indexlage' 

28TABLE_INDEXBUCHUNGSBLATT = 'indexbuchungsblatt' 

29TABLE_INDEXPERSON = 'indexperson' 

30TABLE_INDEXGEOM = 'indexgeom' 

31 

32 

33class Object(gws.Node): 

34 """ALKIS index. 

35 

36 Manages the index tables in the index schema, reports their status, runs 

37 Flurstueck and address searches against them and loads the found objects 

38 with their related data. 

39 """ 

40 

41 VERSION = '85' 

42 """Index version, part of the table names.""" 

43 

44 TABLES_BASIC = [ 

45 TABLE_PLACE, 

46 TABLE_FLURSTUECK, 

47 TABLE_LAGE, 

48 TABLE_PART, 

49 TABLE_INDEXFLURSTUECK, 

50 TABLE_INDEXLAGE, 

51 TABLE_INDEXGEOM, 

52 ] 

53 """Tables of the basic index.""" 

54 

55 TABLES_BUCHUNG = [ 

56 TABLE_BUCHUNGSBLATT, 

57 TABLE_INDEXBUCHUNGSBLATT, 

58 ] 

59 """Tables with land register data.""" 

60 

61 TABLES_EIGENTUEMER = [ 

62 TABLE_BUCHUNGSBLATT, 

63 TABLE_INDEXBUCHUNGSBLATT, 

64 TABLE_INDEXPERSON, 

65 ] 

66 """Tables with owner data.""" 

67 

68 ALL_TABLES = TABLES_BASIC + TABLES_BUCHUNG + TABLES_EIGENTUEMER 

69 """All index tables.""" 

70 

71 db: gws.plugin.postgres.provider.Object 

72 """Database provider.""" 

73 crs: gws.Crs 

74 """CRS of the geometries.""" 

75 schema: str 

76 """Schema of the index tables.""" 

77 excludeGemarkung: set[str] 

78 """Gemarkung numbers excluded from indexing.""" 

79 gemarkungFilter: set[str] 

80 """Gemarkung codes searches are restricted to.""" 

81 

82 saMeta: sa.MetaData 

83 """SQLAlchemy metadata for the index tables.""" 

84 tables: dict[str, sa.Table] 

85 """Index tables by table id.""" 

86 

87 columnDct = {} 

88 """Column definitions by table id.""" 

89 

90 def __getstate__(self): 

91 """Return the state for pickling, without the SQLAlchemy objects.""" 

92 

93 return gws.u.omit(vars(self), 'saMeta', 'tables', 'columnDct') 

94 

95 def configure(self): 

96 gws.config.util.configure_database_provider_for(self, ext_type='postgres') 

97 self.crs = gws.lib.crs.get(self.cfg('crs')) 

98 self.schema = self.cfg('schema', default='public') 

99 self.excludeGemarkung = set(self.cfg('excludeGemarkung', default=[])) 

100 self.gemarkungFilter = set(self.cfg('gemarkungFilter', default=[])) 

101 self._init_tables() 

102 

103 def activate(self): 

104 self._init_tables() 

105 

106 def _init_tables(self): 

107 """Create the SQLAlchemy metadata and the column definitions of the index tables.""" 

108 

109 self.saMeta = sa.MetaData(schema=self.schema) 

110 self.tables = {} 

111 

112 self.columnDct = { 

113 TABLE_PLACE: [ 

114 sa.Column('uid', sa.Text, primary_key=True), 

115 sa.Column('data', JSONB), 

116 ], 

117 TABLE_FLURSTUECK: [ 

118 sa.Column('uid', sa.Text, primary_key=True), 

119 sa.Column('rc', sa.Integer), 

120 sa.Column('fshistoric', sa.Boolean), 

121 sa.Column('data', JSONB), 

122 sa.Column('geom', sa.geo.Geometry(srid=self.crs.srid)), 

123 ], 

124 TABLE_BUCHUNGSBLATT: [ 

125 sa.Column('uid', sa.Text, primary_key=True), 

126 sa.Column('rc', sa.Integer), 

127 sa.Column('data', JSONB), 

128 ], 

129 TABLE_LAGE: [ 

130 sa.Column('uid', sa.Text, primary_key=True), 

131 sa.Column('rc', sa.Integer), 

132 sa.Column('data', JSONB), 

133 ], 

134 TABLE_PART: [ 

135 sa.Column('n', sa.Integer, primary_key=True), 

136 sa.Column('fs', sa.Text, index=True), 

137 sa.Column('uid', sa.Text, index=True), 

138 sa.Column('beginnt', sa.DateTime), 

139 sa.Column('endet', sa.DateTime), 

140 sa.Column('kind', sa.Integer), 

141 sa.Column('name', sa.Text), 

142 sa.Column('parthistoric', sa.Boolean), 

143 sa.Column('data', JSONB), 

144 sa.Column('geom', sa.geo.Geometry(srid=self.crs.srid)), 

145 ], 

146 TABLE_INDEXFLURSTUECK: [ 

147 sa.Column('n', sa.Integer, primary_key=True), 

148 sa.Column('fs', sa.Text, index=True), 

149 sa.Column('fshistoric', sa.Boolean, index=True), 

150 sa.Column('land', sa.Text, index=True), 

151 sa.Column('land_t', sa.Text, index=True), 

152 sa.Column('landcode', sa.Text, index=True), 

153 sa.Column('regierungsbezirk', sa.Text, index=True), 

154 sa.Column('regierungsbezirk_t', sa.Text, index=True), 

155 sa.Column('regierungsbezirkcode', sa.Text, index=True), 

156 sa.Column('kreis', sa.Text, index=True), 

157 sa.Column('kreis_t', sa.Text, index=True), 

158 sa.Column('kreiscode', sa.Text, index=True), 

159 sa.Column('gemeinde', sa.Text, index=True), 

160 sa.Column('gemeinde_t', sa.Text, index=True), 

161 sa.Column('gemeindecode', sa.Text, index=True), 

162 sa.Column('gemarkung', sa.Text, index=True), 

163 sa.Column('gemarkung_t', sa.Text, index=True), 

164 sa.Column('gemarkungcode', sa.Text, index=True), 

165 sa.Column('amtlicheflaeche', sa.Float, index=True), 

166 sa.Column('geomflaeche', sa.Float, index=True), 

167 sa.Column('flurnummer', sa.Text, index=True), 

168 sa.Column('zaehler', sa.Text, index=True), 

169 sa.Column('nenner', sa.Text, index=True), 

170 sa.Column('flurstuecksfolge', sa.Text, index=True), 

171 sa.Column('flurstueckskennzeichen', sa.Text, index=True), 

172 sa.Column('x', sa.Float, index=True), 

173 sa.Column('y', sa.Float, index=True), 

174 ], 

175 TABLE_INDEXLAGE: [ 

176 sa.Column('n', sa.Integer, primary_key=True), 

177 sa.Column('fs', sa.Text, index=True), 

178 sa.Column('fshistoric', sa.Boolean, index=True), 

179 sa.Column('land', sa.Text, index=True), 

180 sa.Column('land_t', sa.Text, index=True), 

181 sa.Column('landcode', sa.Text, index=True), 

182 sa.Column('regierungsbezirk', sa.Text, index=True), 

183 sa.Column('regierungsbezirk_t', sa.Text, index=True), 

184 sa.Column('regierungsbezirkcode', sa.Text, index=True), 

185 sa.Column('kreis', sa.Text, index=True), 

186 sa.Column('kreis_t', sa.Text, index=True), 

187 sa.Column('kreiscode', sa.Text, index=True), 

188 sa.Column('gemeinde', sa.Text, index=True), 

189 sa.Column('gemeinde_t', sa.Text, index=True), 

190 sa.Column('gemeindecode', sa.Text, index=True), 

191 sa.Column('gemarkung', sa.Text, index=True), 

192 sa.Column('gemarkung_t', sa.Text, index=True), 

193 sa.Column('gemarkungcode', sa.Text, index=True), 

194 sa.Column('lageuid', sa.Text, index=True), 

195 sa.Column('lagehistoric', sa.Boolean, index=True), 

196 sa.Column('strasse', sa.Text, index=True), 

197 sa.Column('strasse_t', sa.Text, index=True), 

198 sa.Column('hausnummer', sa.Text, index=True), 

199 sa.Column('hausnummer_k', sa.Text, index=True), 

200 sa.Column('x', sa.Float, index=True), 

201 sa.Column('y', sa.Float, index=True), 

202 ], 

203 TABLE_INDEXBUCHUNGSBLATT: [ 

204 sa.Column('n', sa.Integer, primary_key=True), 

205 sa.Column('fs', sa.Text, index=True), 

206 sa.Column('fshistoric', sa.Boolean, index=True), 

207 sa.Column('buchungsblattuid', sa.Text, index=True), 

208 sa.Column('buchungsblattbeginnt', sa.DateTime, index=True), 

209 sa.Column('buchungsblattendet', sa.DateTime, index=True), 

210 sa.Column('buchungsblattkennzeichen', sa.Text, index=True), 

211 sa.Column('buchungsblatthistoric', sa.Boolean, index=True), 

212 ], 

213 TABLE_INDEXPERSON: [ 

214 sa.Column('n', sa.Integer, primary_key=True), 

215 sa.Column('fs', sa.Text, index=True), 

216 sa.Column('fshistoric', sa.Boolean, index=True), 

217 sa.Column('personuid', sa.Text, index=True), 

218 sa.Column('personhistoric', sa.Boolean, index=True), 

219 sa.Column('name', sa.Text, index=True), 

220 sa.Column('name_t', sa.Text, index=True), 

221 sa.Column('vorname', sa.Text, index=True), 

222 sa.Column('vorname_t', sa.Text, index=True), 

223 ], 

224 TABLE_INDEXGEOM: [ 

225 sa.Column('n', sa.Integer, primary_key=True), 

226 sa.Column('fs', sa.Text, index=True), 

227 sa.Column('fshistoric', sa.Boolean, index=True), 

228 sa.Column('geomflaeche', sa.Float, index=True), 

229 sa.Column('x', sa.Float, index=True), 

230 sa.Column('y', sa.Float, index=True), 

231 sa.Column('geom', sa.geo.Geometry(srid=self.crs.srid)), 

232 ], 

233 } 

234 

235 ## 

236 

237 def table(self, table_id: str) -> sa.Table: 

238 """Return an index table. 

239 

240 Args: 

241 table_id: Table id, one of the ``TABLE_`` constants. 

242 

243 Returns: 

244 The table object. 

245 """ 

246 

247 if table_id not in self.tables: 

248 table_name = f'alkis_{self.VERSION}_{table_id}' 

249 self.tables[table_id] = sa.Table( 

250 table_name, 

251 self.saMeta, 

252 *self.columnDct[table_id], 

253 schema=self.schema, 

254 ) 

255 return self.tables[table_id] 

256 

257 def table_size(self, table_id) -> int: 

258 """Return the number of rows in an index table. 

259 

260 Args: 

261 table_id: Table id. 

262 

263 Returns: 

264 The number of rows, 0 if the table does not exist. 

265 """ 

266 

267 sizes = self._table_size_map([table_id]) 

268 return sizes.get(table_id, 0) 

269 

270 def _table_size_map(self, table_ids): 

271 """Return a dict of table id to the number of rows.""" 

272 

273 d = {} 

274 

275 with self.db.begin(): 

276 for table_id in table_ids: 

277 try: 

278 with self.db.begin(nested=True): 

279 d[table_id] = self.db.count(self.table(table_id)) 

280 except sa.exc.SQLAlchemyError: 

281 d[table_id] = 0 

282 

283 return d 

284 

285 def has_schema(self) -> bool: 

286 """Check whether the index schema exists. 

287 

288 Returns: 

289 ``True`` if the schema exists. 

290 """ 

291 

292 return self.db.has_schema(self.schema) 

293 

294 def has_table(self, table_id: str) -> bool: 

295 """Check whether an index table exists and has data. 

296 

297 Args: 

298 table_id: Table id. 

299 

300 Returns: 

301 ``True`` if the table has at least one row. 

302 """ 

303 

304 return self.table_size(table_id) > 0 

305 

306 def status(self) -> dt.IndexStatus: 

307 """Return the index status. 

308 

309 Returns: 

310 The status, computed from the number of rows in the index tables. 

311 """ 

312 

313 sizes = self._table_size_map(self.ALL_TABLES) 

314 s = dt.IndexStatus( 

315 basic=all(sizes.get(tid, 0) > 0 for tid in self.TABLES_BASIC), 

316 buchung=all(sizes.get(tid, 0) > 0 for tid in self.TABLES_BUCHUNG), 

317 eigentuemer=all(sizes.get(tid, 0) > 0 for tid in self.TABLES_EIGENTUEMER), 

318 ) 

319 s.complete = s.basic and s.buchung and s.eigentuemer 

320 s.missing = all(v == 0 for v in sizes.values()) 

321 gws.log.debug(f'ALKIS: table sizes {sizes!r}') 

322 return s 

323 

324 def drop_table(self, table_id: str): 

325 """Drop an index table, if it exists. 

326 

327 Args: 

328 table_id: Table id. 

329 """ 

330 

331 with self.db.begin() as conn: 

332 self._drop_table(conn, table_id) 

333 

334 def drop(self): 

335 """Drop all index tables.""" 

336 

337 with self.db.begin() as conn: 

338 for table_id in self.ALL_TABLES: 

339 self._drop_table(conn, table_id) 

340 

341 def _drop_table(self, conn, table_id): 

342 """Drop an index table using an open connection.""" 

343 

344 tab = self.table(table_id) 

345 conn.execute(sa.text(f'DROP TABLE IF EXISTS {self.schema}.{tab.name}')) 

346 

347 def create_table(self, table_id: str, values: list[dict]): 

348 """Create an index table and fill it with rows. 

349 

350 Args: 

351 table_id: Table id. 

352 values: Rows as dicts of column names and values. 

353 """ 

354 

355 tab = self.table(table_id) 

356 

357 with self.db.begin() as conn: 

358 self.saMeta.create_all(conn.saConn, tables=[tab]) 

359 if values: 

360 conn.execute(sa.insert(tab), values) 

361 

362 ## 

363 

364 _defaultLand: dt.EnumPair = None 

365 """Cached default Land.""" 

366 

367 def default_land(self): 

368 """Return the Land of a Gemarkung in the index. 

369 

370 The value is cached once found. 

371 

372 Returns: 

373 The Land as an ``EnumPair``, or ``None`` if there are no Gemarkungen. 

374 """ 

375 

376 if self._defaultLand: 

377 return self._defaultLand 

378 

379 with self.db.begin() as conn: 

380 sel = sa.select(self.table(TABLE_PLACE)).where(sa.text("data->>'kind' = 'gemarkung'")).limit(1) 

381 for r in conn.execute(sel): 

382 p = unserialize(r.data) 

383 self._defaultLand = p.land 

384 gws.log.debug(f'ALKIS: defaultLand={vars(self._defaultLand)}') 

385 return self._defaultLand 

386 

387 _strasseList: list[dt.Strasse] = [] 

388 """Cached street list.""" 

389 

390 def strasse_list(self) -> list[dt.Strasse]: 

391 """Return all streets in the index. 

392 

393 Each distinct combination of Gemeinde, Gemarkung and street name of 

394 current Flurstuecke and Lage records is returned once. The list is cached. 

395 

396 Returns: 

397 A list of streets. 

398 """ 

399 

400 if self._strasseList: 

401 return self._strasseList 

402 

403 self._strasseList = [] 

404 

405 indexlage = self.table(TABLE_INDEXLAGE) 

406 cols = ( 

407 indexlage.c.gemeinde, 

408 indexlage.c.gemeindecode, 

409 indexlage.c.gemarkung, 

410 indexlage.c.gemarkungcode, 

411 indexlage.c.strasse, 

412 ) 

413 

414 sel = sa.select(*cols).where(~indexlage.c.fshistoric, ~indexlage.c.lagehistoric).group_by(*cols) 

415 if self.gemarkungFilter: 

416 sel = sel.where(indexlage.c.gemarkungcode.in_(self.gemarkungFilter)) 

417 

418 with self.db.begin() as conn: 

419 for r in conn.execute(sel): 

420 self._strasseList.append( 

421 dt.Strasse( 

422 gemeinde=dt.EnumPair(r.gemeindecode, r.gemeinde), 

423 gemarkung=dt.EnumPair(r.gemarkungcode, r.gemarkung), 

424 name=r.strasse, 

425 ) 

426 ) 

427 

428 return self._strasseList 

429 

430 def find_adresse(self, q: dt.AdresseQuery) -> list[dt.Adresse]: 

431 """Find addresses. 

432 

433 Args: 

434 q: Search criteria and options. 

435 

436 Returns: 

437 A list of addresses, in the order of the search, after applying offset and page size. 

438 

439 Raises: 

440 ``gws.ResponseTooLargeError``: If there are more results than the configured limit. 

441 ``gws.BadRequestError``: If the query is invalid, e.g. a house number without a street. 

442 """ 

443 

444 indexlage = self.table(TABLE_INDEXLAGE) 

445 

446 qo = q.options or dt.AdresseQueryOptions() 

447 sel = self._make_adresse_select(q, qo) 

448 

449 lage_uids = [] 

450 adresse_map = {} 

451 

452 with self.db.begin() as conn: 

453 for r in conn.execute(sel): 

454 lage_uids.append(r[0]) 

455 

456 if qo.limit and len(lage_uids) > qo.limit: 

457 raise gws.ResponseTooLargeError(len(lage_uids)) 

458 

459 if qo.offset: 

460 lage_uids = lage_uids[qo.offset :] 

461 if qo.pageSize: 

462 lage_uids = lage_uids[: qo.pageSize] 

463 

464 sel = indexlage.select().where(indexlage.c.lageuid.in_(lage_uids)) 

465 

466 for r in conn.execute(sel): 

467 r = gws.u.to_dict(r) 

468 uid = r['lageuid'] 

469 adresse_map[uid] = dt.Adresse( 

470 uid=uid, 

471 land=dt.EnumPair(r['landcode'], r['land']), 

472 regierungsbezirk=dt.EnumPair(r['regierungsbezirkcode'], r['regierungsbezirk']), 

473 kreis=dt.EnumPair(r['kreiscode'], r['kreis']), 

474 gemeinde=dt.EnumPair(r['gemeindecode'], r['gemeinde']), 

475 gemarkung=dt.EnumPair(r['gemarkungcode'], r['gemarkung']), 

476 strasse=r['strasse'], 

477 hausnummer=r['hausnummer'], 

478 x=r['x'], 

479 y=r['y'], 

480 shape=gws.lib.shape.from_xy(r['x'], r['y'], crs=self.crs), 

481 ) 

482 

483 return gws.u.compact(adresse_map.get(uid) for uid in lage_uids) 

484 

485 def find_flurstueck(self, q: dt.FlurstueckQuery) -> list[dt.Flurstueck]: 

486 """Find Flurstuecke and load them with their related data. 

487 

488 Args: 

489 q: Search criteria and options. 

490 

491 Returns: 

492 A list of Flurstuecke, in the order of the search, after applying offset and page size. 

493 

494 Raises: 

495 ``gws.ResponseTooLargeError``: If there are more results than the configured limit. 

496 ``gws.BadRequestError``: If the query is invalid, e.g. a house number without a street. 

497 """ 

498 

499 qo = q.options or dt.FlurstueckQueryOptions() 

500 sel = self._make_flurstueck_select(q, qo) 

501 

502 fs_uids = [] 

503 

504 with self.db.begin() as conn: 

505 for r in conn.execute(sel): 

506 uid = r[0].partition('_')[0] 

507 if uid not in fs_uids: 

508 fs_uids.append(uid) 

509 

510 if qo.limit and len(fs_uids) > qo.limit: 

511 raise gws.ResponseTooLargeError(len(fs_uids)) 

512 

513 if qo.offset: 

514 fs_uids = fs_uids[qo.offset :] 

515 if qo.pageSize: 

516 fs_uids = fs_uids[: qo.pageSize] 

517 

518 fs_list = self._load_flurstueck(conn, fs_uids, qo) 

519 

520 return fs_list 

521 

522 def count_all(self, qo: dt.FlurstueckQueryOptions) -> int: 

523 """Count all Flurstueck records in the index. 

524 

525 Args: 

526 qo: Query options. Historic records are counted only with ``withHistorySearch``. 

527 

528 Returns: 

529 The number of rows in the Flurstueck index table. 

530 """ 

531 

532 indexfs = self.table(TABLE_INDEXFLURSTUECK) 

533 sel = sa.select(sa.func.count()).select_from(indexfs) 

534 if not qo.withHistorySearch: 

535 sel = sel.where(~indexfs.c.fshistoric) 

536 

537 with self.db.begin() as conn: 

538 r = list(conn.execute(sel)) 

539 return r[0][0] 

540 

541 def iter_all(self, qo: dt.FlurstueckQueryOptions) -> Iterable[dt.Flurstueck]: 

542 """Iterate over all Flurstuecke in the index. 

543 

544 Flurstuecke are loaded in chunks of ``qo.pageSize``, with a new 

545 database connection for each chunk. 

546 

547 Args: 

548 qo: Query options. ``pageSize`` must be set. 

549 

550 Yields: 

551 Flurstuecke with their related data. 

552 """ 

553 

554 indexfs = self.table(TABLE_INDEXFLURSTUECK) 

555 sel = sa.select(indexfs.c.fs).with_only_columns(indexfs.c.fs).order_by(indexfs.c.n) 

556 if not qo.withHistorySearch: 

557 sel = sel.where(~indexfs.c.fshistoric) 

558 

559 offset = 0 

560 

561 # NB the consumer might be slow, close connection on each chunk 

562 

563 while True: 

564 with self.db.begin() as conn: 

565 sel2 = sel.offset(offset).limit(qo.pageSize) 

566 fs_uids = [r[0] for r in conn.execute(sel2)] 

567 if not fs_uids: 

568 break 

569 fs_list = self._load_flurstueck(conn, fs_uids, qo) 

570 

571 yield from fs_list 

572 

573 offset += qo.pageSize 

574 

575 HAUSNUMMER_NOT_NULL_VALUE = '*' 

576 """House number value that matches any non-empty house number.""" 

577 

578 def _make_flurstueck_select(self, q: dt.FlurstueckQuery, qo: dt.FlurstueckQueryOptions): 

579 """Build a select statement for matching Flurstueck uids.""" 

580 

581 indexfs = self.table(TABLE_INDEXFLURSTUECK) 

582 indexbuchungsblatt = self.table(TABLE_INDEXBUCHUNGSBLATT) 

583 indexgeom = self.table(TABLE_INDEXGEOM) 

584 indexlage = self.table(TABLE_INDEXLAGE) 

585 indexperson = self.table(TABLE_INDEXPERSON) 

586 

587 where = [] 

588 

589 has_buchungsblatt = False 

590 has_geom = False 

591 has_lage = False 

592 has_person = False 

593 

594 where.extend(self._make_places_where(q, indexfs)) 

595 

596 if q.uids: 

597 where.append(indexfs.c.fs.in_(q.uids)) 

598 

599 for f in 'flurnummer', 'flurstuecksfolge', 'zaehler', 'nenner': 

600 val = getattr(q, f, None) 

601 if val is not None: 

602 where.append(getattr(indexfs.c, f.lower()) == val) 

603 

604 if q.flurstueckskennzeichen: 

605 val = re.sub(r'[^0-9_]', '', q.flurstueckskennzeichen) 

606 if not val: 

607 raise gws.BadRequestError(f'invalid flurstueckskennzeichen {q.flurstueckskennzeichen!r}') 

608 where.append(indexfs.c.flurstueckskennzeichen.like(val + '%')) 

609 

610 if q.flaecheVon: 

611 try: 

612 where.append(indexfs.c.amtlicheflaeche >= float(q.flaecheVon)) 

613 except ValueError: 

614 raise gws.BadRequestError(f'invalid flaecheVon {q.flaecheVon!r}') 

615 

616 if q.flaecheBis: 

617 try: 

618 where.append(indexfs.c.amtlicheflaeche <= float(q.flaecheBis)) 

619 except ValueError: 

620 raise gws.BadRequestError(f'invalid flaecheBis {q.flaecheBis!r}') 

621 

622 if q.buchungsblattkennzeichenList: 

623 ws = [] 

624 

625 for s in q.buchungsblattkennzeichenList: 

626 w = gws.base.database.util.text_search_clause( 

627 indexbuchungsblatt.c.buchungsblattkennzeichen, 

628 s, 

629 qo.buchungsblattSearchOptions, 

630 ) 

631 if w is not None: 

632 ws.append(w) 

633 if ws: 

634 has_buchungsblatt = True 

635 where.append(sa.or_(*ws)) 

636 

637 if q.strasse: 

638 w = gws.base.database.util.text_search_clause( 

639 indexlage.c.strasse_t, 

640 strasse_key(q.strasse), 

641 qo.strasseSearchOptions, 

642 ) 

643 if w is not None: 

644 has_lage = True 

645 where.append(w) 

646 

647 if q.hausnummer: 

648 if not has_lage: 

649 raise gws.BadRequestError(f'hausnummer without strasse') 

650 if q.hausnummer == self.HAUSNUMMER_NOT_NULL_VALUE: 

651 where.append(indexlage.c.hausnummer != '') 

652 else: 

653 where.append(indexlage.c.hausnummer == normalize_hausnummer(q.hausnummer)) 

654 

655 if q.personName: 

656 w = gws.base.database.util.text_search_clause(indexperson.c.name_t, text_key(q.personName), qo.nameSearchOptions) 

657 if w is not None: 

658 has_person = True 

659 where.append(w) 

660 

661 if q.personVorname: 

662 if not has_person: 

663 raise gws.BadRequestError(f'personVorname without personName') 

664 w = gws.base.database.util.text_search_clause(indexperson.c.vorname_t, text_key(q.personVorname), qo.nameSearchOptions) 

665 if w is not None: 

666 where.append(w) 

667 

668 if q.shape: 

669 has_geom = True 

670 where.append(gws.base.database.util.shape_intersects_clause(indexgeom.c.geom, q.shape, self.crs)) 

671 

672 join = [] 

673 

674 if has_buchungsblatt: 

675 join.append([indexbuchungsblatt, indexbuchungsblatt.c.fs == indexfs.c.fs]) 

676 if not qo.withHistorySearch: 

677 where.append(~indexbuchungsblatt.c.fshistoric) 

678 where.append(~indexbuchungsblatt.c.buchungsblatthistoric) 

679 

680 if has_geom: 

681 join.append([indexgeom, indexgeom.c.fs == indexfs.c.fs]) 

682 if not qo.withHistorySearch: 

683 where.append(~indexgeom.c.fshistoric) 

684 

685 if has_lage: 

686 join.append([indexlage, indexlage.c.fs == indexfs.c.fs]) 

687 if not qo.withHistorySearch: 

688 where.append(~indexlage.c.fshistoric) 

689 where.append(~indexlage.c.lagehistoric) 

690 

691 if has_person: 

692 join.append([indexperson, indexperson.c.fs == indexfs.c.fs]) 

693 if not qo.withHistorySearch: 

694 where.append(~indexperson.c.fshistoric) 

695 where.append(~indexperson.c.personhistoric) 

696 

697 if not qo.withHistorySearch: 

698 where.append(~indexfs.c.fshistoric) 

699 

700 sel = sa.select(indexfs.c.fs).distinct() 

701 

702 for tab, cond in join: 

703 sel = sel.join(tab, cond) 

704 

705 sel = sel.where(*where) 

706 

707 return self._make_sort(sel, qo.sort, indexfs) 

708 

709 def _make_adresse_select(self, q: dt.AdresseQuery, qo: dt.AdresseQueryOptions): 

710 """Build a select statement for matching Lage uids.""" 

711 

712 indexlage = self.table(TABLE_INDEXLAGE) 

713 where = [] 

714 

715 where.extend(self._make_places_where(q, indexlage)) 

716 

717 has_strasse = False 

718 

719 if q.strasse: 

720 w = gws.base.database.util.text_search_clause( 

721 indexlage.c.strasse_t, 

722 strasse_key(q.strasse), 

723 qo.strasseSearchOptions, 

724 ) 

725 if w is not None: 

726 has_strasse = True 

727 where.append(w) 

728 

729 hausnummer_k = sa.collate(indexlage.c.hausnummer_k, 'C') 

730 

731 if q.hausnummer: 

732 if not has_strasse: 

733 raise gws.BadRequestError(f'hausnummer without strasse') 

734 if q.hausnummer == self.HAUSNUMMER_NOT_NULL_VALUE: 

735 where.append(indexlage.c.hausnummer != '') 

736 elif q.bisHausnummer: 

737 where.append(hausnummer_k >= _hausnummer_key_or_error(q.hausnummer)) 

738 else: 

739 where.append(indexlage.c.hausnummer == normalize_hausnummer(q.hausnummer)) 

740 

741 if q.bisHausnummer: 

742 if not has_strasse: 

743 raise gws.BadRequestError(f'hausnummer without strasse') 

744 where.append(hausnummer_k <= _hausnummer_key_or_error(q.bisHausnummer, upper=True)) 

745 

746 if q.hausnummerNotNull: 

747 if not has_strasse: 

748 raise gws.BadRequestError(f'hausnummer without strasse') 

749 where.append(indexlage.c.hausnummer != '') 

750 

751 if not qo.withHistorySearch: 

752 where.append(~indexlage.c.fshistoric) 

753 where.append(~indexlage.c.lagehistoric) 

754 

755 sel = sa.select(indexlage.c.lageuid).distinct() 

756 

757 sel = sel.where(*where) 

758 

759 return self._make_sort(sel, qo.sort, indexlage) 

760 

761 def _make_places_where(self, q: dt.FlurstueckQuery | dt.AdresseQuery, table: sa.Table): 

762 """Build where clauses for place names and codes.""" 

763 

764 where = [] 

765 land_code = '' 

766 

767 for f in 'land', 'regierungsbezirk', 'kreis', 'gemarkung', 'gemeinde': 

768 val = getattr(q, f, None) 

769 if val is not None: 

770 where.append(getattr(table.c, f.lower() + '_t') == text_key(val)) 

771 

772 val = getattr(q, f + 'Code', None) 

773 if val is not None: 

774 if f == 'land': 

775 land_code = val 

776 elif f == 'gemarkung' and len(val) <= 4: 

777 if not land_code: 

778 land = self.default_land() 

779 if land: 

780 land_code = land.code 

781 val = land_code + val 

782 

783 where.append(getattr(table.c, f.lower() + 'code') == val) 

784 

785 if self.gemarkungFilter: 

786 where.append(table.c.gemarkungcode.in_(self.gemarkungFilter)) 

787 

788 return where 

789 

790 def _make_sort(self, sel, sort, table: sa.Table): 

791 """Add the sort order to a select statement.""" 

792 

793 if not sort: 

794 return sel 

795 

796 order = [] 

797 for s in sort: 

798 fn = sa.desc if s.reverse else sa.asc 

799 order.append(fn(getattr(table.c, s.fieldName))) 

800 sel = sel.order_by(*order) 

801 

802 return sel 

803 

804 def load_flurstueck(self, fs_uids: list[str], qo: dt.FlurstueckQueryOptions) -> list[dt.Flurstueck]: 

805 """Load Flurstuecke with their related data. 

806 

807 Args: 

808 fs_uids: Flurstueck uids. 

809 qo: Query options, the display themes select the related data. 

810 

811 Returns: 

812 A list of Flurstuecke in the order of ``fs_uids``. Missing and, 

813 unless ``withHistoryDisplay`` is set, historic Flurstuecke are left out. 

814 """ 

815 

816 with self.db.begin() as conn: 

817 return self._load_flurstueck(conn, fs_uids, qo) 

818 

819 def _load_flurstueck(self, conn, fs_uids, qo: dt.FlurstueckQueryOptions): 

820 """Load Flurstuecke and related data using an open connection.""" 

821 

822 with_lage = dt.DisplayTheme.lage in qo.displayThemes 

823 with_gebaeude = dt.DisplayTheme.gebaeude in qo.displayThemes 

824 with_nutzung = dt.DisplayTheme.nutzung in qo.displayThemes 

825 with_festlegung = dt.DisplayTheme.festlegung in qo.displayThemes 

826 with_bewertung = dt.DisplayTheme.bewertung in qo.displayThemes 

827 with_buchung = dt.DisplayTheme.buchung in qo.displayThemes 

828 with_eigentuemer = dt.DisplayTheme.eigentuemer in qo.displayThemes 

829 

830 tab = self.table(TABLE_FLURSTUECK) 

831 sel = sa.select(tab).where(tab.c.uid.in_(set(fs_uids))) 

832 

833 hd = qo.withHistoryDisplay 

834 

835 fs_list = [] 

836 

837 for r in conn.execute(sel): 

838 fs = unserialize(r.data) 

839 fs.geom = r.geom 

840 fs_list.append(fs) 

841 

842 fs_list = self._remove_historic(fs_list, hd) 

843 if not fs_list: 

844 return [] 

845 

846 fs_map = {fs.uid: fs for fs in fs_list} 

847 

848 for fs in fs_map.values(): 

849 fs.shape = gws.lib.shape.from_wkb_element(fs.geom, default_crs=self.crs) 

850 

851 fs.lageList = self._remove_historic(fs.lageList, hd) if with_lage else [] 

852 fs.gebaeudeList = self._remove_historic(fs.gebaeudeList, hd) if with_gebaeude else [] 

853 fs.buchungList = self._remove_historic(fs.buchungList, hd) if with_buchung else [] 

854 for bu in fs.buchungList: 

855 bu.recs = [ref for ref in bu.recs if self._remove_historic([ref.buchungsstelle], hd)] 

856 fs.buchungList = [bu for bu in fs.buchungList if bu.recs] 

857 

858 fs.bewertungList = [] 

859 fs.festlegungList = [] 

860 fs.nutzungList = [] 

861 

862 if with_buchung: 

863 bb_uids = set(bu.buchungsblattUid for fs in fs_map.values() for bu in fs.buchungList) 

864 

865 tab = self.table(TABLE_BUCHUNGSBLATT) 

866 sel = sa.select(tab).where(tab.c.uid.in_(bb_uids)) 

867 bb_list = [unserialize(r.data) for r in conn.execute(sel)] 

868 bb_list = self._remove_historic(bb_list, hd) 

869 

870 for bb in bb_list: 

871 bb.buchungsstelleList = self._remove_historic(bb.buchungsstelleList, hd) 

872 bb.namensnummerList = self._remove_historic(bb.namensnummerList, hd) if with_eigentuemer else [] 

873 for nn in bb.namensnummerList: 

874 nn.personList = self._remove_historic(nn.personList, hd) 

875 for pe in nn.personList: 

876 pe.anschriftList = self._remove_historic(pe.anschriftList, hd) 

877 

878 bb_map = {bb.uid: bb for bb in bb_list} 

879 

880 for fs in fs_map.values(): 

881 for bu in fs.buchungList: 

882 bu.buchungsblatt = bb_map.get(bu.buchungsblattUid) 

883 fs.buchungList = [bu for bu in fs.buchungList if bu.buchungsblatt] 

884 

885 if with_nutzung or with_festlegung or with_bewertung: 

886 tab = self.table(TABLE_PART) 

887 sel = sa.select(tab).where(tab.c.fs.in_(list(fs_map))) 

888 if not qo.withHistorySearch: 

889 sel = sel.where(~tab.c.parthistoric) 

890 pa_list = [unserialize(r.data) for r in conn.execute(sel)] 

891 pa_list = self._remove_historic(pa_list, hd) 

892 

893 for pa in pa_list: 

894 fs = fs_map[pa.fs] 

895 if pa.kind == dt.PART_NUTZUNG and with_nutzung: 

896 fs.nutzungList.append(pa) 

897 if pa.kind == dt.PART_FESTLEGUNG and with_festlegung: 

898 fs.festlegungList.append(pa) 

899 if pa.kind == dt.PART_BEWERTUNG and with_bewertung: 

900 fs.bewertungList.append(pa) 

901 

902 return gws.u.compact(fs_map.get(uid) for uid in fs_uids) 

903 

904 _historicKeys = ['vorgaengerFlurstueckskennzeichen'] 

905 """Record attributes removed when history is not displayed.""" 

906 

907 def _remove_historic(self, objects, with_history_display: bool): 

908 """Remove historic objects and records, unless history is displayed.""" 

909 

910 if with_history_display: 

911 return objects 

912 

913 out = [] 

914 

915 for o in objects: 

916 if o.isHistoric: 

917 continue 

918 

919 o.recs = [r for r in o.recs if not r.isHistoric] 

920 if not o.recs: 

921 continue 

922 

923 for r in o.recs: 

924 for k in self._historicKeys: 

925 try: 

926 delattr(r, k) 

927 except AttributeError: 

928 pass 

929 

930 out.append(o) 

931 

932 return out 

933 

934 

935## 

936 

937 

938def serialize(o: dt.Object, encode_enum_pairs=True) -> dict: 

939 """Convert an object to a JSON-compatible structure. 

940 

941 Objects become dicts with sorted keys, dates become ``DD.MM.YYYY`` 

942 strings. Empty values are kept as they are. 

943 

944 Args: 

945 o: Object to convert. 

946 encode_enum_pairs: If ``True``, ``EnumPair`` values are encoded as 

947 ``$code$text`` strings, otherwise as dicts. 

948 

949 Returns: 

950 A dict. 

951 """ 

952 

953 def encode(r): 

954 if not r: 

955 return r 

956 

957 if isinstance(r, (int, float, str, bool)): 

958 return r 

959 

960 if isinstance(r, (datetime.date, datetime.datetime)): 

961 return f'{r.day:02}.{r.month:02}.{r.year:04}' 

962 

963 if isinstance(r, list): 

964 return [encode(e) for e in r] 

965 

966 if isinstance(r, dt.EnumPair): 

967 if encode_enum_pairs: 

968 return f'${r.code}${r.text}' 

969 return vars(r) 

970 

971 if isinstance(r, dt.Object): 

972 return {k: encode(v) for k, v in sorted(vars(r).items())} 

973 

974 return str(r) 

975 

976 return encode(o) 

977 

978 

979def unserialize(data: dict): 

980 """Convert a structure created by ``serialize`` back to objects. 

981 

982 Dicts become ``types.Object`` instances and ``$code$text`` strings 

983 become ``EnumPair`` values. 

984 

985 Args: 

986 data: Serialized data. 

987 

988 Returns: 

989 The object. 

990 """ 

991 

992 def decode(r): 

993 if not r: 

994 return r 

995 if isinstance(r, str): 

996 if r[0] == '$': 

997 s = r.split('$') 

998 return dt.EnumPair(s[1], s[2]) 

999 return r 

1000 if isinstance(r, list): 

1001 return [decode(e) for e in r] 

1002 if isinstance(r, dict): 

1003 d = {k: decode(v) for k, v in r.items()} 

1004 return dt.Object(**d) 

1005 return r 

1006 

1007 return decode(data) 

1008 

1009 

1010## 

1011 

1012 

1013def text_key(s): 

1014 """Normalize a string for text search. 

1015 

1016 The string is lower-cased, umlauts are transliterated and punctuation is 

1017 replaced by spaces. 

1018 

1019 Args: 

1020 s: String to normalize. 

1021 

1022 Returns: 

1023 The normalized string, an empty string for ``None``. 

1024 """ 

1025 

1026 if s is None: 

1027 return '' 

1028 

1029 s = _text_umlauts(str(s).strip().lower()) 

1030 return _text_nopunct(s) 

1031 

1032 

1033def strasse_key(s): 

1034 """Normalize a street name for text search. 

1035 

1036 Works like ``text_key``, but also expands the abbreviations ``str.`` and 

1037 ``pl.`` and separates common street name suffixes, so that for example 

1038 ``Hauptstr.`` and ``Haupt-Strasse`` give the same key. 

1039 

1040 Args: 

1041 s: Street name. 

1042 

1043 Returns: 

1044 The normalized street name, an empty string for ``None``. 

1045 """ 

1046 

1047 if s is None: 

1048 return '' 

1049 

1050 s = _text_umlauts(str(s).strip().lower()) 

1051 

1052 s = re.sub(r'\s?str\.$', '.strasse', s) 

1053 s = re.sub(r'\s?pl\.$', '.platz', s) 

1054 s = re.sub(r'\s?(strasse|allee|damm|gasse|pfad|platz|ring|steig|wall|weg|zeile)$', r'.\1', s) 

1055 

1056 return _text_nopunct(s) 

1057 

1058 

1059def _text_umlauts(s): 

1060 """Transliterate lower-case umlauts and sharp s.""" 

1061 

1062 s = s.replace('ä', 'ae') 

1063 s = s.replace('ë', 'ee') 

1064 s = s.replace('ö', 'oe') 

1065 s = s.replace('ü', 'ue') 

1066 s = s.replace('ß', 'ss') 

1067 

1068 return s 

1069 

1070 

1071def _text_nopunct(s): 

1072 """Replace runs of non-word characters with a space.""" 

1073 

1074 return re.sub(r'\W+', ' ', s) 

1075 

1076 

1077def normalize_hausnummer(s): 

1078 """Normalize a house number by removing all whitespace. 

1079 

1080 Args: 

1081 s: House number, e.g. ``12 a``. 

1082 

1083 Returns: 

1084 The normalized house number, e.g. ``12a``, an empty string for ``None``. 

1085 """ 

1086 

1087 if s is None: 

1088 return '' 

1089 

1090 # "12 a" -> "12a" 

1091 s = re.sub(r'\s+', '', s.strip()) 

1092 return s 

1093 

1094 

1095def hausnummer_key(s, upper=False): 

1096 """Create a sort key for a house number. 

1097 

1098 The key is the leading number, zero-padded to 6 digits, followed by the 

1099 lower-cased rest, e.g. ``000002b`` for ``2 B``. Keys sort by number first, 

1100 then by suffix, when compared bytewise (``COLLATE "C"``). 

1101 

1102 Args: 

1103 s: House number. 

1104 upper: Create the key for the upper bound of a range. A house number 

1105 without a suffix then gets a ``~`` suffix, which sorts after all 

1106 letters, so that the bound ``5`` includes ``5a`` to ``5z``. 

1107 

1108 Returns: 

1109 The key, or ``None`` if the house number does not start with a number. 

1110 """ 

1111 

1112 m = re.match(r'(\d+)(.*)$', normalize_hausnummer(s).lower()) 

1113 if not m: 

1114 return None 

1115 num, suffix = m.groups() 

1116 if upper and not suffix: 

1117 suffix = '~' 

1118 return f'{int(num):06d}{suffix}' 

1119 

1120 

1121def _hausnummer_key_or_error(s, upper=False): 

1122 k = hausnummer_key(s, upper) 

1123 if k is None: 

1124 raise gws.BadRequestError(f'invalid hausnummer {s!r}') 

1125 return k 

1126 

1127 

1128def make_fsnummer(r: dt.FlurstueckRecord): 

1129 """Create a display number for a Flurstueck. 

1130 

1131 The format is ``<gemarkung> <flur>-<zaehler>/<nenner> (<folge>)``. 

1132 The Flur, the denominator and the sequence number are omitted if empty, 

1133 the sequence number also if it is ``00``. 

1134 

1135 Args: 

1136 r: Flurstueck record. 

1137 

1138 Returns: 

1139 The display number. 

1140 """ 

1141 

1142 v = r.gemarkung.code + ' ' 

1143 

1144 s = r.flurnummer 

1145 if s: 

1146 v += str(s) + '-' 

1147 

1148 v += str(r.zaehler) 

1149 s = r.nenner 

1150 if s: 

1151 v += '/' + str(s) 

1152 

1153 s = r.flurstuecksfolge 

1154 if s and str(s) != '00': 

1155 v += ' (' + str(s) + ')' 

1156 

1157 return v 

1158 

1159 

1160# parse a fsnummer in the above format, all parts are optional 

1161 

1162_RE_FSNUMMER = r"""(?x) 

1163 ^ 

1164 ( 

1165 (?P<gemarkungCode> [0-9]+) 

1166 \s+ 

1167 )? 

1168 ( 

1169 (?P<flurnummer> [0-9]+) 

1170 - 

1171 )? 

1172 ( 

1173 (?P<zaehler> [0-9]+) 

1174 (/ 

1175 (?P<nenner> \w+) 

1176 )? 

1177 )? 

1178 ( 

1179 \s* 

1180 \( 

1181 (?P<flurstuecksfolge> [0-9]+) 

1182 \) 

1183 )? 

1184 $ 

1185""" 

1186 

1187 

1188def parse_fsnummer(s): 

1189 """Parse a Flurstueck display number into its parts. 

1190 

1191 The format is the one created by ``make_fsnummer``, all parts are optional. 

1192 

1193 Args: 

1194 s: Display number. 

1195 

1196 Returns: 

1197 A dict with the keys ``gemarkungCode``, ``flurnummer``, ``zaehler``, 

1198 ``nenner`` and ``flurstuecksfolge`` for the parts found, or ``None`` 

1199 if the string does not match the format. 

1200 """ 

1201 

1202 m = re.match(_RE_FSNUMMER, s.strip()) 

1203 if not m: 

1204 return None 

1205 return gws.u.compact(m.groupdict()) 

1206 

1207 

1208## 

1209 

1210 

1211_FLATTEN_EXCLUDE_KEYS = {'fsUids', 'childUids', 'parentUids'} 

1212 

1213 

1214def flatten_fs(fs: dt.Flurstueck, keys_to_extract: set[str]) -> list[dict]: 

1215 """Flatten a Flurstueck into a list of dicts. 

1216 

1217 The nested Flurstueck structure is turned into flat dicts whose keys are 

1218 attribute paths joined with ``_``, starting with ``fs``, e.g. 

1219 ``fs_recs_gemarkung_text``. ``EnumPair`` values give two keys, ``_code`` 

1220 and ``_text``. For list values, the dicts are repeated for each list item, 

1221 so the result is the product of all lists, for example:: 

1222 

1223 record: 

1224 a:x, b:[1,2], c:[3,4] 

1225 

1226 flat list: 

1227 a:x, b:1, c:3 

1228 a:x, b:1, c:4 

1229 a:x, b:2, c:3 

1230 a:x, b:2, c:4 

1231 

1232 Only paths leading to ``keys_to_extract`` are followed, which keeps the 

1233 product small. Still, some combinations of keys produce many rows. 

1234 

1235 Args: 

1236 fs: Flurstueck to flatten. 

1237 keys_to_extract: Flat keys to include. 

1238 

1239 Returns: 

1240 A list of dicts. 

1241 """ 

1242 

1243 return _flatten(fs, 'fs', keys_to_extract, [{}]) 

1244 

1245 

1246def _flatten(val, key, keys_to_extract, flat_lst): 

1247 """Recursively add a value under a key prefix to all dicts in a list.""" 

1248 

1249 if not any(k.startswith(key) for k in keys_to_extract): 

1250 return flat_lst 

1251 

1252 if isinstance(val, list): 

1253 if not val: 

1254 return flat_lst 

1255 

1256 # create a cartesian product of all dicts in flat_dct with each item in val 

1257 new_lst = [] 

1258 for v in val: 

1259 for d2 in _flatten(v, key, keys_to_extract, [{}]): 

1260 for d in flat_lst: 

1261 new_lst.append(d | d2) 

1262 return new_lst 

1263 

1264 if isinstance(val, (dt.Object, dt.EnumPair)): 

1265 for k, v in vars(val).items(): 

1266 if k not in _FLATTEN_EXCLUDE_KEYS: 

1267 flat_lst = _flatten(v, f'{key}_{k}', keys_to_extract, flat_lst) 

1268 return flat_lst 

1269 

1270 for d in flat_lst: 

1271 d[key] = val 

1272 

1273 return flat_lst 

1274 

1275 

1276def all_flat_keys(): 

1277 """Return all flat keys of the Flurstueck structure. 

1278 

1279 The keys are derived from the type annotations in ``types``, see ``flatten_fs``. 

1280 

1281 Returns: 

1282 A dict of flat keys and their types, sorted by key. 

1283 """ 

1284 

1285 return {k: typ for k, typ in sorted(set(_get_flat_keys(dt.Flurstueck, 'fs')))} 

1286 

1287 

1288def _get_flat_keys(cls, key): 

1289 """Yield flat keys and types for a class from its annotations.""" 

1290 

1291 if isinstance(cls, str): 

1292 cls = getattr(dt, cls, None) 

1293 

1294 if cls is dt.EnumPair: 

1295 yield f'{key}_code', int 

1296 yield f'{key}_text', str 

1297 return 

1298 

1299 if not cls or not hasattr(cls, '__annotations__'): 

1300 yield key, cls or str 

1301 return 

1302 

1303 for k, typ in cls.__annotations__.items(): 

1304 if k in _FLATTEN_EXCLUDE_KEYS: 

1305 continue 

1306 if getattr(typ, '__origin__', None) is list: 

1307 yield from _get_flat_keys(typ.__args__[0], f'{key}_{k}') 

1308 else: 

1309 yield from _get_flat_keys(typ, f'{key}_{k}')