Coverage for gws-app/gws/plugin/alkis/action.py: 86%

458 statements  

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

1"""The ``alkis`` action, the backend for the Flurstückssuche (cadastre parcel search).""" 

2 

3from typing import Optional, cast 

4 

5import os 

6import re 

7 

8import gws 

9import gws.base.action 

10import gws.base.feature 

11import gws.base.model 

12import gws.base.printer 

13import gws.lib.shape 

14import gws.base.storage 

15import gws.config.util 

16import gws.lib.crs 

17import gws.lib.datetimex 

18import gws.lib.sa as sa 

19 

20from .data import index, exporter 

21from .data import types as dt 

22 

23 

24class EigentuemerConfig(gws.ConfigWithAccess): 

25 """Access to the Eigentümer (owner) information.""" 

26 

27 controlMode: bool = False 

28 """Require a control input before owner data is shown.""" 

29 controlRules: Optional[list[str]] 

30 """Regular expressions the control input must match.""" 

31 logTable: str = '' 

32 """Table for logging access to owner data.""" 

33 

34 

35class EigentuemerOptions(gws.Node): 

36 """Access options for owner (Eigentuemer) data. 

37 

38 Holds the read permissions for owner data, the control mode and the 

39 table for logging access to owner data. 

40 """ 

41 

42 controlMode: bool 

43 """Require a control input before owner data is shown.""" 

44 controlRules: list[str] 

45 """Regular expressions the control input must match.""" 

46 logTableName: str 

47 """Name of the table for logging access to owner data.""" 

48 logTable: Optional[sa.Table] 

49 """Table for logging access to owner data, set by the action.""" 

50 

51 def configure(self): 

52 self.controlMode = self.cfg('controlMode') 

53 self.controlRules = self.cfg('controlRules', default=[]) 

54 self.logTableName = self.cfg('logTable') 

55 self.logTable = None 

56 

57 

58class BuchungConfig(gws.ConfigWithAccess): 

59 """Access to the Grundbuch (register) information.""" 

60 

61 pass 

62 

63 

64class BuchungOptions(gws.Node): 

65 """Access options for the Grundbuch (register) data. 

66 

67 Holds the read permissions for register data. 

68 """ 

69 

70 pass 

71 

72 

73class GemarkungListMode(gws.Enum): 

74 """How the Gemarkung list is shown.""" 

75 

76 none = 'none' 

77 """Do not show the list.""" 

78 plain = 'plain' 

79 """Show only the Gemarkung.""" 

80 combined = 'combined' 

81 """Show 'Gemarkung (Gemeinde)'.""" 

82 tree = 'tree' 

83 """A tree with level 1 = Gemeinde and level 2 = Gemarkung.""" 

84 

85 

86class StrasseListMode(gws.Enum): 

87 """Strasse (street) list entry format.""" 

88 

89 plain = 'plain' 

90 """Show only the street name.""" 

91 withGemeinde = 'withGemeinde' 

92 """Show 'Strasse (Gemeinde)'.""" 

93 withGemarkung = 'withGemarkung' 

94 """Show 'Strasse (Gemarkung)'.""" 

95 withGemeindeIfRepeated = 'withGemeindeIfRepeated' 

96 """Show 'Strasse (Gemeinde)' when needed for disambiguation.""" 

97 withGemarkungIfRepeated = 'withGemarkungIfRepeated' 

98 """Show 'Strasse (Gemarkung)' when needed for disambiguation.""" 

99 

100 

101class Ui(gws.Config): 

102 """User interface options for the Flurstückssuche.""" 

103 

104 useExport: bool = False 

105 """Enable the export of search results.""" 

106 useSelect: bool = False 

107 """Enable the selection storage for parcels.""" 

108 usePick: bool = False 

109 """Enable picking single parcels in the map.""" 

110 useHistory: bool = False 

111 """Enable options to search and display historic parcels.""" 

112 searchSelection: bool = False 

113 """Enable searching within the geometries of the current map selection.""" 

114 searchSpatial: bool = False 

115 """Enable spatial search with a search area drawn in the map.""" 

116 gemarkungListMode: GemarkungListMode = GemarkungListMode.combined 

117 """How the Gemarkung list is shown in the search form.""" 

118 strasseListMode: StrasseListMode = StrasseListMode.plain 

119 """How entries in the street list are shown.""" 

120 autoSpatialSearch: bool = False 

121 """Start the spatial search tool after a form search.""" 

122 

123 

124@gws.ext.config.action('alkis') 

125class Config(gws.ConfigWithAccess): 

126 """Parcel search in ALKIS data.""" 

127 

128 dbUid: str = '' 

129 """UID of the PostgreSQL database provider with the ALKIS data.""" 

130 crs: gws.CrsName 

131 """CRS for the ALKIS data.""" 

132 dataSchema: str = 'public' 

133 """Schema where ALKIS tables are stored.""" 

134 indexSchema: str = 'gws8' 

135 """Schema for the GWS search indexes built from the ALKIS data.""" 

136 excludeGemarkung: Optional[list[str]] 

137 """Gemarkung (Administrative Unit) IDs to exclude from indexing.""" 

138 gemarkungFilter: Optional[list[str]] 

139 """Gemarkung IDs to restrict the search to.""" 

140 

141 eigentuemer: Optional[EigentuemerConfig] 

142 """Access to the owner information.""" 

143 buchung: Optional[BuchungConfig] 

144 """Access to the land register information.""" 

145 limit: int = 100 

146 """Maximum number of parcels returned by a search.""" 

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

148 """Templates for parcel details.""" 

149 printers: Optional[list[gws.base.printer.Config]] 

150 """Print templates for parcels.""" 

151 ui: Optional[Ui] 

152 """User interface options for the search form and results.""" 

153 

154 strasseSearchOptions: Optional[gws.TextSearchOptions] 

155 """How street names are matched.""" 

156 nameSearchOptions: Optional[gws.TextSearchOptions] 

157 """How person names are matched.""" 

158 buchungsblattSearchOptions: Optional[gws.TextSearchOptions] 

159 """How book and page numbers are matched.""" 

160 

161 storage: Optional[gws.base.storage.Config] 

162 """Storage for saved parcel selections.""" 

163 

164 exporters: Optional[list[exporter.Config]] 

165 """Export formats for parcel data.""" 

166 

167 

168## 

169 

170 

171@gws.ext.props.action('alkis') 

172class Props(gws.base.action.Props): 

173 exporters: list[exporter.Props] 

174 limit: int 

175 printer: Optional[gws.base.printer.Props] 

176 ui: Ui 

177 storage: Optional[gws.base.storage.Props] 

178 strasseSearchOptions: Optional[gws.TextSearchOptions] 

179 nameSearchOptions: Optional[gws.TextSearchOptions] 

180 buchungsblattSearchOptions: Optional[gws.TextSearchOptions] 

181 withBuchung: bool 

182 withEigentuemer: bool 

183 withEigentuemerControl: bool 

184 withFlurnummer: bool 

185 

186 

187## 

188 

189 

190class GetToponymsRequest(gws.Request): 

191 """Request for the ``alkisGetToponyms`` command.""" 

192 

193 pass 

194 

195 

196class GetToponymsResponse(gws.Response): 

197 """Response of the ``alkisGetToponyms`` command.""" 

198 

199 gemeinde: list[list[str]] 

200 """Gemeinden (municipalities) as ``[name, code]`` pairs, sorted.""" 

201 gemarkung: list[list[str]] 

202 """Gemarkungen (districts) as ``[name, code, gemeindeCode]`` lists, sorted.""" 

203 strasse: list[list[str]] 

204 """Streets as ``[name, gemeindeCode, gemarkungCode]`` lists, sorted.""" 

205 

206 

207class FindFlurstueckRequest(gws.Request): 

208 """Request for a Flurstück (parcel) search.""" 

209 

210 flurnummer: Optional[str] 

211 """Flur number.""" 

212 flurstuecksfolge: Optional[str] 

213 """Flurstücksfolge (parcel sequence number).""" 

214 zaehler: Optional[str] 

215 """Numerator of the parcel number.""" 

216 nenner: Optional[str] 

217 """Denominator of the parcel number.""" 

218 fsnummer: Optional[str] 

219 """Parcel identifier: a ``DE...`` gml id, a Flurstückskennzeichen or a compound parcel number.""" 

220 

221 flaecheBis: Optional[float] 

222 """Maximum parcel area.""" 

223 flaecheVon: Optional[float] 

224 """Minimum parcel area.""" 

225 

226 gemarkung: Optional[str] 

227 """Gemarkung (district) name.""" 

228 gemarkungCode: Optional[str] 

229 """Gemarkung code.""" 

230 gemeinde: Optional[str] 

231 """Gemeinde (municipality) name.""" 

232 gemeindeCode: Optional[str] 

233 """Gemeinde code.""" 

234 kreis: Optional[str] 

235 """Kreis (county) name.""" 

236 kreisCode: Optional[str] 

237 """Kreis code.""" 

238 land: Optional[str] 

239 """Land (state) name.""" 

240 landCode: Optional[str] 

241 """Land code.""" 

242 regierungsbezirk: Optional[str] 

243 """Regierungsbezirk (administrative region) name.""" 

244 regierungsbezirkCode: Optional[str] 

245 """Regierungsbezirk code.""" 

246 

247 strasse: Optional[str] 

248 """Street name.""" 

249 hausnummer: Optional[str] 

250 """House number.""" 

251 

252 bblatt: Optional[str] 

253 """Buchungsblatt (register sheet) numbers, separated by spaces, commas or semicolons.""" 

254 

255 personName: Optional[str] 

256 """Last name of the owner.""" 

257 personVorname: Optional[str] 

258 """First name of the owner.""" 

259 

260 combinedFlurstueckCode: Optional[str] 

261 """Combined code ``land_gemarkung_flur_zaehler_nenner_folge``; empty and ``0`` parts are ignored.""" 

262 

263 shapes: Optional[list[gws.lib.shape.Props]] 

264 """Search area; several shapes are merged.""" 

265 

266 uids: Optional[list[str]] 

267 """Parcel uids.""" 

268 

269 crs: Optional[gws.CrsName] 

270 """CRS for the returned features, defaults to the project map CRS.""" 

271 eigentuemerControlInput: Optional[str] 

272 """Control input for owner data access in control mode.""" 

273 limit: Optional[int] 

274 """Maximum number of results, capped by the action limit.""" 

275 

276 wantEigentuemer: Optional[bool] 

277 """Request owner data.""" 

278 wantHistorySearch: Optional[bool] 

279 """Include historic parcels in the search.""" 

280 wantHistoryDisplay: Optional[bool] 

281 """Include history in the parcel details.""" 

282 

283 displayThemes: Optional[list[dt.DisplayTheme]] 

284 """Themes to include in the parcel details.""" 

285 

286 

287class FindFlurstueckResponse(gws.Response): 

288 """Response of a Flurstück (parcel) search.""" 

289 

290 features: list[gws.FeatureProps] 

291 """Found parcels as features, sorted by title.""" 

292 total: int 

293 """Number of found parcels.""" 

294 

295 

296class FindFlurstueckResult(gws.Data): 

297 """Result of a Flurstück (parcel) search.""" 

298 

299 flurstueckList: list[dt.Flurstueck] 

300 """Found parcels.""" 

301 total: int 

302 """Number of found parcels.""" 

303 query: dt.FlurstueckQuery 

304 """The query that was run.""" 

305 

306 

307class FindAdresseRequest(gws.Request): 

308 """Request for an Adresse (address) search.""" 

309 

310 crs: Optional[gws.CrsName] 

311 """CRS for the returned features, defaults to the project map CRS.""" 

312 

313 gemarkung: Optional[str] 

314 """Gemarkung (district) name.""" 

315 gemarkungCode: Optional[str] 

316 """Gemarkung code.""" 

317 gemeinde: Optional[str] 

318 """Gemeinde (municipality) name.""" 

319 gemeindeCode: Optional[str] 

320 """Gemeinde code.""" 

321 kreis: Optional[str] 

322 """Kreis (county) name.""" 

323 kreisCode: Optional[str] 

324 """Kreis code.""" 

325 land: Optional[str] 

326 """Land (state) name.""" 

327 landCode: Optional[str] 

328 """Land code.""" 

329 regierungsbezirk: Optional[str] 

330 """Regierungsbezirk (administrative region) name.""" 

331 regierungsbezirkCode: Optional[str] 

332 """Regierungsbezirk code.""" 

333 

334 strasse: Optional[str] 

335 """Street name.""" 

336 hausnummer: Optional[str] 

337 """House number.""" 

338 bisHausnummer: Optional[str] 

339 """Upper bound of a house number range, inclusive; ``hausnummer`` is the lower bound.""" 

340 hausnummerNotNull: Optional[bool] 

341 """Return only addresses with a house number.""" 

342 

343 wantHistorySearch: Optional[bool] 

344 """Include historic addresses in the search.""" 

345 

346 combinedAdresseCode: Optional[str] 

347 """Combined code ``strasse_hausnummer_plz_gemeinde_bisHausnummer``; empty and ``0`` parts are ignored.""" 

348 

349 

350class FindAdresseResponse(gws.Response): 

351 """Response of an Adresse (address) search.""" 

352 

353 features: list[gws.FeatureProps] 

354 """Found addresses as features.""" 

355 total: int 

356 """Number of found addresses.""" 

357 

358 

359class PrintFlurstueckRequest(gws.Request): 

360 """Request to print found parcels.""" 

361 

362 findRequest: FindFlurstueckRequest 

363 """The parcel search to run.""" 

364 printRequest: gws.PrintRequest 

365 """The print request; its first map is used as the base map for each parcel.""" 

366 featureStyle: gws.StyleProps 

367 """Style for the parcel features.""" 

368 

369 

370class ExportFlurstueckRequest(gws.Request): 

371 """Request to export found parcels.""" 

372 

373 findRequest: FindFlurstueckRequest 

374 """The parcel search to run.""" 

375 exporterUid: str 

376 """Exporter uid, the first usable exporter if empty.""" 

377 modelUids: Optional[list[str]] 

378 """Uids of the export models to use.""" 

379 

380 

381class ExportFlurstueckResponse(gws.Response): 

382 """Response of a parcel export.""" 

383 

384 content: str 

385 """Exported data.""" 

386 mimeType: str 

387 """Mime type of the exported data.""" 

388 

389 

390## 

391 

392 

393_dir = os.path.dirname(__file__) 

394 

395_DEFAULT_TEMPLATES = [ 

396 gws.Config( 

397 subject='flurstueck.title', 

398 type='html', 

399 path=f'{_dir}/templates/flurstueck_title.cx.html', 

400 ), 

401 gws.Config( 

402 subject='flurstueck.teaser', 

403 type='html', 

404 path=f'{_dir}/templates/flurstueck_title.cx.html', 

405 ), 

406 gws.Config( 

407 subject='flurstueck.label', 

408 type='html', 

409 path=f'{_dir}/templates/flurstueck_label.cx.html', 

410 ), 

411 gws.Config( 

412 subject='flurstueck.description', 

413 type='html', 

414 path=f'{_dir}/templates/description.cx.html', 

415 ), 

416 gws.Config( 

417 subject='adresse.title', 

418 type='html', 

419 path=f'{_dir}/templates/adresse_title.cx.html', 

420 ), 

421 gws.Config( 

422 subject='adresse.teaser', 

423 type='html', 

424 path=f'{_dir}/templates/adresse_title.cx.html', 

425 ), 

426 gws.Config( 

427 subject='adresse.label', 

428 type='html', 

429 path=f'{_dir}/templates/adresse_label.cx.html', 

430 ), 

431] 

432 

433_DEFAULT_PRINTER = gws.Config( 

434 uid='gws.plugin.alkis.default_printer', 

435 template=gws.Config( 

436 type='html', 

437 path=f'{_dir}/templates/print.cx.html', 

438 ), 

439 qualityLevels=[gws.TemplateQualityLevel(dpi=72)], 

440) 

441 

442 

443## 

444 

445 

446class Model(gws.base.model.default_model.Object): 

447 """Model for the parcel and address features returned by the action.""" 

448 

449 def configure(self): 

450 self.uidName = 'uid' 

451 self.geometryName = 'geometry' 

452 self.loadingStrategy = gws.FeatureLoadingStrategy.all 

453 

454 

455@gws.ext.object.action('alkis') 

456class Object(gws.base.action.Object): 

457 """The ``alkis`` action. 

458 

459 Searches parcels and addresses in the ALKIS index, returns them with 

460 owner and register data where the user may read it, and prints, exports 

461 and stores selections of parcels. 

462 """ 

463 

464 db: gws.DatabaseProvider 

465 """Database provider with the ALKIS data.""" 

466 

467 ix: index.Object 

468 """The ALKIS index.""" 

469 ixStatus: dt.IndexStatus 

470 """Index status, set on activation.""" 

471 

472 buchung: BuchungOptions 

473 """Access options for register data.""" 

474 eigentuemer: EigentuemerOptions 

475 """Access options for owner data.""" 

476 

477 dataSchema: str 

478 """Schema with the ALKIS source tables.""" 

479 indexSchema: str 

480 """Schema with the index tables.""" 

481 

482 model: gws.Model 

483 """Model for the returned features.""" 

484 ui: Ui 

485 """User interface options.""" 

486 limit: int 

487 """Maximum number of parcels returned by a search.""" 

488 

489 templates: list[gws.Template] 

490 """Templates for parcel and address views.""" 

491 printers: list[gws.Printer] 

492 """Printers for parcels.""" 

493 

494 exporters: list[exporter.Object] 

495 """Exporters for parcel data.""" 

496 

497 strasseSearchOptions: gws.TextSearchOptions 

498 """How street names are matched.""" 

499 nameSearchOptions: gws.TextSearchOptions 

500 """How person names are matched.""" 

501 buchungsblattSearchOptions: gws.TextSearchOptions 

502 """How register sheet numbers are matched.""" 

503 

504 storage: Optional[gws.base.storage.Object] 

505 """Storage for saved parcel selections.""" 

506 

507 def configure(self): 

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

509 

510 self.dataSchema = self.cfg('dataSchema') 

511 self.indexSchema = self.cfg('indexSchema') 

512 

513 self.ix = self.root.create( 

514 index.Object, 

515 _defaultDb=self.db, 

516 crs=self.cfg('crs'), 

517 schema=self.indexSchema, 

518 excludeGemarkung=self.cfg('excludeGemarkung'), 

519 gemarkungFilter=self.cfg('gemarkungFilter'), 

520 uid=f'gws.plugin.alkis.data.index.{self.indexSchema}', 

521 ) 

522 

523 self.limit = self.cfg('limit') 

524 self.model = self.create_child(Model) 

525 self.ui = self.cfg( 

526 'ui', 

527 default=Ui( 

528 gemarkungListMode=GemarkungListMode.combined, 

529 strasseListMode=StrasseListMode.plain, 

530 ), 

531 ) 

532 

533 p = self.cfg('templates', default=[]) + _DEFAULT_TEMPLATES 

534 self.templates = [self.create_child(gws.ext.object.template, c) for c in p] 

535 

536 self.printers = self.create_children(gws.ext.object.printer, self.cfg('printers')) 

537 self.printers.append(self.create_child(gws.ext.object.printer, _DEFAULT_PRINTER)) 

538 

539 d = gws.TextSearchOptions(type=gws.TextSearchType.begin, caseSensitive=False) 

540 self.strasseSearchOptions = self.cfg('strasseSearchOptions', default=d) 

541 

542 d = gws.TextSearchOptions(type=gws.TextSearchType.begin, caseSensitive=False) 

543 self.nameSearchOptions = self.cfg('nameSearchOptions', default=d) 

544 

545 d = gws.TextSearchOptions(type=gws.TextSearchType.exact, caseSensitive=False) 

546 self.buchungsblattSearchOptions = self.cfg('buchungsblattSearchOptions', default=d) 

547 

548 deny_all = gws.Config(access='deny all') 

549 

550 self.buchung = self.create_child(BuchungOptions, self.cfg('buchung', default=deny_all)) 

551 

552 self.eigentuemer = self.create_child(EigentuemerOptions, self.cfg('eigentuemer', default=deny_all)) 

553 if self.eigentuemer.logTableName: 

554 self.eigentuemer.logTable = self.ix.db.table(self.eigentuemer.logTableName) 

555 

556 self.storage = self.create_child_if_configured(gws.base.storage.Object, self.cfg('storage'), categoryName='Alkis') 

557 

558 self.exporters = [] 

559 p = self.cfg('exporters') 

560 if p: 

561 self.exporters = self.create_children(exporter.Object, p) 

562 elif self.ui.useExport: 

563 self.exporters.append(self.create_child(exporter.Object)) 

564 

565 if self.ix.status().missing: 

566 self.root.config_warning(f'ALKIS: index not found in schema {self.indexSchema}') 

567 

568 def activate(self): 

569 self.ixStatus = gws.u.get_server_global(f'gws.plugin.alkis.action.ixStatus.{self.indexSchema}', self.ix.status) 

570 

571 def props(self, user): 

572 if not self.ixStatus.basic: 

573 return None 

574 

575 ps: Props = cast( 

576 Props, 

577 gws.u.merge( 

578 super().props(user), 

579 limit=self.limit, 

580 printer=gws.u.first(p for p in self.printers if user.can_use(p)), 

581 ui=self.ui, 

582 storage=self.storage, 

583 withBuchung=(self.ixStatus.buchung and user.can_read(self.buchung)), 

584 withEigentuemer=(self.ixStatus.eigentuemer and user.can_read(self.eigentuemer)), 

585 withEigentuemerControl=(self.ixStatus.eigentuemer and user.can_read(self.eigentuemer) and self.eigentuemer.controlMode), 

586 ), 

587 ) 

588 

589 ps.exporters = [] 

590 for exp in self.exporters: 

591 p = exp.props_with_flags(user, withEigentuemer=ps.withEigentuemer, withBuchung=ps.withBuchung) 

592 if p: 

593 ps.exporters.append(p) 

594 

595 ps.strasseSearchOptions = self.strasseSearchOptions 

596 if ps.withBuchung: 

597 ps.buchungsblattSearchOptions = self.buchungsblattSearchOptions 

598 if ps.withEigentuemer: 

599 ps.nameSearchOptions = self.nameSearchOptions 

600 

601 return ps 

602 

603 @gws.ext.command.api('alkisGetToponyms') 

604 def get_toponyms(self, req: gws.WebRequester, p: GetToponymsRequest) -> GetToponymsResponse: 

605 """Return all toponyms (Gemeinde, Gemarkung, Strasse) in the index.""" 

606 

607 req.user.require_project(p.projectUid) 

608 

609 gemeinde_dct = {} 

610 gemarkung_dct = {} 

611 strasse_lst = [] 

612 

613 for s in self.ix.strasse_list(): 

614 gemeinde_dct[s.gemeinde.code] = [s.gemeinde.text, s.gemeinde.code] 

615 gemarkung_dct[s.gemarkung.code] = [s.gemarkung.text, s.gemarkung.code, s.gemeinde.code] 

616 strasse_lst.append([s.name, s.gemeinde.code, s.gemarkung.code]) 

617 

618 return GetToponymsResponse( 

619 gemeinde=sorted(gemeinde_dct.values()), 

620 gemarkung=sorted(gemarkung_dct.values()), 

621 strasse=sorted(strasse_lst), 

622 ) 

623 

624 @gws.ext.command.api('alkisFindAdresse') 

625 def find_adresse(self, req: gws.WebRequester, p: FindAdresseRequest) -> FindAdresseResponse: 

626 """Search for addresses.""" 

627 

628 project = req.user.require_project(p.projectUid) 

629 crs = gws.lib.crs.get(p.get('crs')) or project.map.bounds.crs 

630 

631 ad_list, query = self.find_adresse_objects(req, p) 

632 if not ad_list: 

633 return FindAdresseResponse( 

634 features=[], 

635 total=0, 

636 ) 

637 

638 templates = [ 

639 self.root.app.templateMgr.find_template('adresse.title', where=[self], user=req.user), 

640 self.root.app.templateMgr.find_template('adresse.teaser', where=[self], user=req.user), 

641 self.root.app.templateMgr.find_template('adresse.label', where=[self], user=req.user), 

642 ] 

643 

644 fprops = [] 

645 mc = gws.ModelContext(op=gws.ModelOperation.read, target=gws.ModelReadTarget.map, user=req.user) 

646 

647 for ad in ad_list: 

648 f = gws.base.feature.new(model=self.model) 

649 f.attributes = vars(ad) 

650 f.attributes['geometry'] = f.attributes.pop('shape') 

651 f.transform_to(crs) 

652 f.render_views(templates, user=req.user) 

653 fprops.append(self.model.feature_to_view_props(f, mc)) 

654 

655 return FindAdresseResponse( 

656 features=fprops, 

657 total=len(ad_list), 

658 ) 

659 

660 @gws.ext.command.api('alkisFindFlurstueck') 

661 def find_flurstueck(self, req: gws.WebRequester, p: FindFlurstueckRequest) -> FindFlurstueckResponse: 

662 """Search for parcels.""" 

663 

664 project = req.user.require_project(p.projectUid) 

665 crs = gws.lib.crs.get(p.get('crs')) or project.map.bounds.crs 

666 

667 fs_list, query = self.find_flurstueck_objects(req, p) 

668 if not fs_list: 

669 return FindFlurstueckResponse( 

670 features=[], 

671 total=0, 

672 ) 

673 

674 templates = [ 

675 self.root.app.templateMgr.find_template('flurstueck.title', where=[self], user=req.user), 

676 self.root.app.templateMgr.find_template('flurstueck.teaser', where=[self], user=req.user), 

677 ] 

678 

679 if query.options.displayThemes: 

680 templates.append( 

681 self.root.app.templateMgr.find_template('flurstueck.description', where=[self], user=req.user), 

682 ) 

683 

684 args = dict( 

685 withHistory=query.options.withHistoryDisplay, 

686 withDebug=bool(self.root.app.developer_option('alkis.debug_templates')), 

687 ) 

688 

689 ps = [] 

690 mc = gws.ModelContext(op=gws.ModelOperation.read, target=gws.ModelReadTarget.map, user=req.user) 

691 

692 for fs in fs_list: 

693 f = gws.base.feature.new(model=self.model) 

694 f.attributes = dict(uid=fs.uid, fs=fs, geometry=fs.shape) 

695 f.transform_to(crs) 

696 f.render_views(templates, user=req.user, **args) 

697 f.attributes.pop('fs') 

698 ps.append(self.model.feature_to_view_props(f, mc)) 

699 

700 return FindFlurstueckResponse( 

701 features=sorted(ps, key=lambda p: p.views['title']), 

702 total=len(fs_list), 

703 ) 

704 

705 def get_exporter(self, uid: Optional[str], user: gws.User) -> Optional[exporter.Object]: 

706 """Find an exporter the user can use. 

707 

708 Args: 

709 uid: Exporter uid. If empty, the first usable exporter is returned. 

710 user: The user. 

711 

712 Returns: 

713 The exporter, or ``None`` if not found. 

714 """ 

715 

716 for exp in self.exporters: 

717 if user.can_use(exp) and (not uid or exp.uid == uid): 

718 return exp 

719 

720 @gws.ext.command.api('alkisExportFlurstueck') 

721 def export_flurstueck(self, req: gws.WebRequester, p: ExportFlurstueckRequest) -> ExportFlurstueckResponse: 

722 """Search for parcels and export them.""" 

723 

724 exp = self.get_exporter(p.exporterUid, req.user) 

725 if not exp: 

726 raise gws.NotFoundError() 

727 

728 models = exp.get_models(req.user, p.modelUids) 

729 if not models: 

730 raise gws.NotFoundError() 

731 

732 if any(m.withEigentuemer for m in models): 

733 self._check_eigentuemer_access(req, p.findRequest.eigentuemerControlInput or '') 

734 if any(m.withBuchung for m in models): 

735 self._check_buchung_access(req, p.findRequest.eigentuemerControlInput or '') 

736 

737 find_request = p.findRequest 

738 find_request.projectUid = p.projectUid 

739 

740 fs_list, _ = self.find_flurstueck_objects(req, find_request) 

741 if not fs_list: 

742 raise gws.NotFoundError() 

743 

744 args = exporter.Args( 

745 fsList=fs_list, 

746 user=req.user, 

747 models=models, 

748 path=gws.u.ephemeral_path('alkis_export'), 

749 ) 

750 

751 exp.run(args) 

752 

753 return ExportFlurstueckResponse( 

754 content=gws.u.read_file_b(args.path), 

755 mimeType=exp.mimeType, 

756 ) 

757 

758 @gws.ext.command.api('alkisPrintFlurstueck') 

759 def print_flurstueck(self, req: gws.WebRequester, p: PrintFlurstueckRequest) -> gws.JobStatusResponse: 

760 """Search for parcels and start a print job for them.""" 

761 

762 project = req.user.require_project(p.projectUid) 

763 

764 find_request = p.findRequest 

765 find_request.projectUid = p.projectUid 

766 

767 fs_list, query = self.find_flurstueck_objects(req, find_request) 

768 if not fs_list: 

769 raise gws.NotFoundError() 

770 

771 print_request = p.printRequest 

772 print_request.projectUid = p.projectUid 

773 crs = gws.lib.crs.get(print_request.get('crs')) or project.map.bounds.crs 

774 

775 templates = [ 

776 self.root.app.templateMgr.find_template('flurstueck.label', where=[self], user=req.user), 

777 ] 

778 

779 base_map = print_request.maps[0] 

780 fs_maps = [] 

781 

782 mc = gws.ModelContext(op=gws.ModelOperation.read, target=gws.ModelReadTarget.map, user=req.user) 

783 

784 for fs in fs_list: 

785 f = gws.base.feature.new(model=self.model) 

786 f.attributes = dict(uid=fs.uid, fs=fs, geometry=fs.shape) 

787 f.transform_to(crs) 

788 f.render_views(templates, user=req.user) 

789 f.cssSelector = p.featureStyle.cssSelector 

790 

791 c = f.shape().centroid() 

792 fs_map = gws.PrintMap(base_map) 

793 # @TODO scale to fit the fs? 

794 fs_map.center = (c.x, c.y) 

795 fs_plane = gws.PrintPlane( 

796 type=gws.PrintPlaneType.features, 

797 features=[self.model.feature_to_view_props(f, mc)], 

798 ) 

799 fs_map.planes = [fs_plane] + base_map.planes 

800 fs_maps.append(fs_map) 

801 

802 print_request.maps = fs_maps 

803 print_request.args = dict( 

804 flurstueckList=fs_list, 

805 withHistory=query.options.withHistoryDisplay, 

806 withDebug=bool(self.root.app.developer_option('alkis.debug_templates')), 

807 ) 

808 

809 return self.root.app.printerMgr.start_print_job(print_request, req.user) 

810 

811 @gws.ext.command.api('alkisSelectionStorage') 

812 def handle_storage(self, req: gws.WebRequester, p: gws.base.storage.Request) -> gws.base.storage.Response: 

813 """Read or write saved parcel selections.""" 

814 

815 if not self.storage: 

816 raise gws.NotFoundError('no storage configured') 

817 return self.storage.handle_request(req, p) 

818 

819 ## 

820 

821 def find_flurstueck_objects(self, req: gws.WebRequester, p: FindFlurstueckRequest) -> tuple[list[dt.Flurstueck], dt.FlurstueckQuery]: 

822 """Run a parcel search in the index. 

823 

824 Checks access to owner and register data if requested, and logs owner data access. 

825 

826 Args: 

827 req: Web requester. 

828 p: Search parameters. 

829 

830 Returns: 

831 A tuple of the found parcels and the query. 

832 

833 Raises: 

834 ``gws.ForbiddenError`` if owner or register data is requested without access. 

835 ``gws.BadRequestError`` if the parcel number is invalid. 

836 """ 

837 

838 query = self._prepare_flurstueck_query(req, p) 

839 fs_list = self.ix.find_flurstueck(query) 

840 

841 if query.options.withEigentuemer: 

842 self._log_eigentuemer_access( 

843 req, 

844 p.eigentuemerControlInput or '', 

845 is_ok=True, 

846 total=len(fs_list), 

847 fs_uids=[fs.uid for fs in fs_list], 

848 ) 

849 

850 return fs_list, query 

851 

852 def find_adresse_objects(self, req: gws.WebRequester, p: FindAdresseRequest) -> tuple[list[dt.Adresse], dt.AdresseQuery]: 

853 """Run an address search in the index. 

854 

855 Args: 

856 req: Web requester. 

857 p: Search parameters. 

858 

859 Returns: 

860 A tuple of the found addresses and the query. 

861 """ 

862 

863 query = self._prepare_adresse_query(req, p) 

864 ad_list = self.ix.find_adresse(query) 

865 return ad_list, query 

866 

867 FLURSTUECK_QUERY_FIELDS = [ 

868 'flurnummer', 

869 'flurstuecksfolge', 

870 'zaehler', 

871 'nenner', 

872 'flaecheBis', 

873 'flaecheVon', 

874 'gemarkung', 

875 'gemarkungCode', 

876 'gemeinde', 

877 'gemeindeCode', 

878 'kreis', 

879 'kreisCode', 

880 'land', 

881 'landCode', 

882 'regierungsbezirk', 

883 'regierungsbezirkCode', 

884 'strasse', 

885 'hausnummer', 

886 'personName', 

887 'personVorname', 

888 'uids', 

889 ] 

890 """Request fields copied into a parcel query.""" 

891 ADRESSE_QUERY_FIELDS = [ 

892 'gemarkung', 

893 'gemarkungCode', 

894 'gemeinde', 

895 'gemeindeCode', 

896 'kreis', 

897 'kreisCode', 

898 'land', 

899 'landCode', 

900 'regierungsbezirk', 

901 'regierungsbezirkCode', 

902 'strasse', 

903 'hausnummer', 

904 'bisHausnummer', 

905 'hausnummerNotNull', 

906 ] 

907 """Request fields copied into an address query.""" 

908 COMBINED_FLURSTUECK_FIELDS = ['landCode', 'gemarkungCode', 'flurnummer', 'zaehler', 'nenner', 'flurstuecksfolge'] 

909 """Query fields for the parts of a combined parcel code, in order.""" 

910 COMBINED_ADRESSE_FIELDS = ['strasse', 'hausnummer', 'plz', 'gemeinde', 'bisHausnummer'] 

911 """Query fields for the parts of a combined address code, in order.""" 

912 

913 def _prepare_flurstueck_query(self, req: gws.WebRequester, p: FindFlurstueckRequest) -> dt.FlurstueckQuery: 

914 """Build a parcel query from a request and check access to owner and register data.""" 

915 

916 query = dt.FlurstueckQuery() 

917 

918 for f in self.FLURSTUECK_QUERY_FIELDS: 

919 setattr(query, f, getattr(p, f, None)) 

920 

921 if p.combinedFlurstueckCode: 

922 self._query_combined_code(query, p.combinedFlurstueckCode, self.COMBINED_FLURSTUECK_FIELDS) 

923 

924 if p.fsnummer: 

925 self._query_fsnummer(query, p.fsnummer) 

926 

927 if p.shapes: 

928 shapes = [gws.lib.shape.from_props(s) for s in p.shapes] 

929 query.shape = shapes[0] if len(shapes) == 1 else shapes[0].union(shapes[1:]) 

930 

931 if p.bblatt: 

932 query.buchungsblattkennzeichenList = p.bblatt.replace(';', ' ').replace(',', ' ').strip().split() 

933 

934 options = dt.FlurstueckQueryOptions( 

935 strasseSearchOptions=self.strasseSearchOptions, 

936 nameSearchOptions=self.nameSearchOptions, 

937 buchungsblattSearchOptions=self.buchungsblattSearchOptions, 

938 limit=min(p.limit or self.limit, self.limit), 

939 withEigentuemer=False, 

940 withBuchung=False, 

941 withHistorySearch=bool(p.wantHistorySearch), 

942 withHistoryDisplay=bool(p.wantHistoryDisplay), 

943 displayThemes=p.displayThemes or [], 

944 ) 

945 

946 want_eigentuemer = ( 

947 p.wantEigentuemer 

948 or dt.DisplayTheme.eigentuemer in options.displayThemes 

949 or any(getattr(query, f) is not None for f in dt.EigentuemerAccessRequired) 

950 ) 

951 if want_eigentuemer: 

952 self._check_eigentuemer_access(req, p.eigentuemerControlInput or '') 

953 options.withEigentuemer = True 

954 

955 want_buchung = dt.DisplayTheme.buchung in options.displayThemes or any(getattr(query, f) is not None for f in dt.BuchungAccessRequired) 

956 if want_buchung: 

957 self._check_buchung_access(req, p.eigentuemerControlInput or '') 

958 options.withBuchung = True 

959 

960 # "eigentuemer" implies "buchung" 

961 if want_eigentuemer and not want_buchung: 

962 self._check_buchung_access(req, p.eigentuemerControlInput or '') 

963 options.withBuchung = True 

964 options.displayThemes.append(dt.DisplayTheme.buchung) 

965 

966 query.options = options 

967 return query 

968 

969 def _prepare_adresse_query(self, req: gws.WebRequester, p: FindAdresseRequest) -> dt.AdresseQuery: 

970 """Build an address query from a request.""" 

971 

972 query = dt.AdresseQuery() 

973 

974 for f in self.ADRESSE_QUERY_FIELDS: 

975 setattr(query, f, getattr(p, f, None)) 

976 

977 if p.combinedAdresseCode: 

978 self._query_combined_code(query, p.combinedAdresseCode, self.COMBINED_ADRESSE_FIELDS) 

979 

980 options = dt.AdresseQueryOptions( 

981 strasseSearchOptions=self.strasseSearchOptions, 

982 withHistorySearch=bool(p.wantHistorySearch), 

983 ) 

984 

985 query.options = options 

986 return query 

987 

988 def _query_fsnummer(self, query: dt.FlurstueckQuery, vn: str): 

989 """Add a parcel identifier (gml id, Flurstueckskennzeichen or compound number) to the query.""" 

990 

991 if vn.startswith('DE'): 

992 # search by gml_id 

993 query.uids = query.uids or [] 

994 query.uids.append(vn) 

995 return 

996 

997 if re.match('^[0-9_]+$', vn) and len(vn) >= 14: 

998 # search by fs kennzeichen 

999 # the length must be at least 2+4+3+5 

1000 # (see gdi6, AX_Flurstueck_Kerndaten.flurstueckskennzeichen) 

1001 query.flurstueckskennzeichen = vn 

1002 return 

1003 

1004 # search by a compound fs number 

1005 parts = index.parse_fsnummer(vn) 

1006 if parts is None: 

1007 raise gws.BadRequestError(f'invalid fsnummer {vn!r}') 

1008 query.update(parts) 

1009 

1010 def _query_combined_code(self, query: dt.FlurstueckQuery | dt.AdresseQuery, code_value: str, code_fields: list[str]): 

1011 """Set query fields from the underscore-separated parts of a combined code.""" 

1012 

1013 for val, field in zip(code_value.split('_'), code_fields): 

1014 if val and val != '0': 

1015 setattr(query, field, val) 

1016 

1017 ## 

1018 

1019 def _check_eigentuemer_access(self, req: gws.WebRequester, control_input: str): 

1020 """Raise ``gws.ForbiddenError`` if the user may not read owner data or the control input fails.""" 

1021 

1022 if not req.user.can_read(self.eigentuemer): 

1023 raise gws.ForbiddenError('cannot read eigentuemer') 

1024 if self.eigentuemer.controlMode and not self._check_eigentuemer_control_input(control_input): 

1025 self._log_eigentuemer_access(req, is_ok=False, control_input=control_input) 

1026 raise gws.ForbiddenError('eigentuemer control input failed') 

1027 

1028 def _check_buchung_access(self, req: gws.WebRequester, control_input: str): 

1029 """Raise ``gws.ForbiddenError`` if the user may not read register data.""" 

1030 

1031 if not req.user.can_read(self.buchung): 

1032 raise gws.ForbiddenError('cannot read buchung') 

1033 

1034 def _log_eigentuemer_access(self, req: gws.WebRequester, control_input: str, is_ok: bool, total=None, fs_uids=None): 

1035 """Write an owner data access record to the log table, if configured.""" 

1036 

1037 if self.eigentuemer.logTable is None: 

1038 return 

1039 

1040 data = dict( 

1041 app_name='gws', 

1042 date_time=gws.lib.datetimex.now(), 

1043 ip=req.ip, 

1044 login=req.user.uid, 

1045 user_name=req.user.displayName, 

1046 control_input=(control_input or '').strip(), 

1047 control_result=1 if is_ok else 0, 

1048 fs_count=total or 0, 

1049 fs_ids=','.join(fs_uids or []), 

1050 ) 

1051 

1052 with self.ix.db.begin() as conn: 

1053 conn.execute(sa.insert(self.eigentuemer.logTable).values([data])) 

1054 

1055 gws.log.debug(f'alkis: _log_eigentuemer_access {is_ok=}') 

1056 

1057 def _check_eigentuemer_control_input(self, control_input): 

1058 """Return ``True`` if there are no control rules or the input matches one of them.""" 

1059 

1060 if not self.eigentuemer.controlRules: 

1061 return True 

1062 

1063 control_input = (control_input or '').strip() 

1064 

1065 for rule in self.eigentuemer.controlRules: 

1066 if re.search(rule, control_input): 

1067 return True 

1068 

1069 return False