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

461 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-05 13:35 +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.datetimex 

17import gws.lib.sa as sa 

18 

19from .data import index, exporter 

20from .data import types as dt 

21 

22 

23class EigentuemerConfig(gws.ConfigWithAccess): 

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

25 

26 controlMode: bool = False 

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

28 controlRules: Optional[list[str]] 

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

30 logTable: str = '' 

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

32 

33 

34class EigentuemerOptions(gws.Node): 

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

36 

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

38 table for logging access to owner data. 

39 """ 

40 

41 controlMode: bool 

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

43 controlRules: list[str] 

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

45 logTableName: str 

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

47 logTable: Optional[sa.Table] 

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

49 

50 def configure(self): 

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

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

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

54 self.logTable = None 

55 

56 

57class BuchungConfig(gws.ConfigWithAccess): 

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

59 

60 pass 

61 

62 

63class BuchungOptions(gws.Node): 

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

65 

66 Holds the read permissions for register data. 

67 """ 

68 

69 pass 

70 

71 

72class GemarkungListMode(gws.Enum): 

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

74 

75 none = 'none' 

76 """Do not show the list.""" 

77 plain = 'plain' 

78 """Show only the Gemarkung.""" 

79 combined = 'combined' 

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

81 tree = 'tree' 

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

83 

84 

85class StrasseListMode(gws.Enum): 

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

87 

88 plain = 'plain' 

89 """Show only the street name.""" 

90 withGemeinde = 'withGemeinde' 

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

92 withGemarkung = 'withGemarkung' 

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

94 withGemeindeIfRepeated = 'withGemeindeIfRepeated' 

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

96 withGemarkungIfRepeated = 'withGemarkungIfRepeated' 

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

98 

99 

100class Ui(gws.Config): 

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

102 

103 useExport: bool = False 

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

105 useSelect: bool = False 

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

107 usePick: bool = False 

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

109 useHistory: bool = False 

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

111 searchSelection: bool = False 

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

113 searchSpatial: bool = False 

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

115 gemarkungListMode: GemarkungListMode = GemarkungListMode.combined 

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

117 strasseListMode: StrasseListMode = StrasseListMode.plain 

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

119 autoSpatialSearch: bool = False 

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

121 

122 

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

124class Config(gws.ConfigWithAccess): 

125 """Parcel search in ALKIS data.""" 

126 

127 dbUid: str = '' 

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

129 crs: gws.CrsName 

130 """CRS for the ALKIS data.""" 

131 dataSchema: str = 'public' 

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

133 indexSchema: str = 'gws8' 

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

135 excludeGemarkung: Optional[list[str]] 

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

137 gemarkungFilter: Optional[list[str]] 

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

139 

140 eigentuemer: Optional[EigentuemerConfig] 

141 """Access to the owner information.""" 

142 buchung: Optional[BuchungConfig] 

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

144 limit: int = 100 

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

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

147 """Templates for parcel details.""" 

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

149 """Print templates for parcels.""" 

150 ui: Optional[Ui] 

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

152 

153 strasseSearchOptions: Optional[gws.TextSearchOptions] 

154 """How street names are matched.""" 

155 nameSearchOptions: Optional[gws.TextSearchOptions] 

156 """How person names are matched.""" 

157 buchungsblattSearchOptions: Optional[gws.TextSearchOptions] 

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

159 

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

161 """Storage for saved parcel selections.""" 

162 

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

164 """Export formats for parcel data.""" 

165 

166 

167## 

168 

169 

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

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

172 exporters: list[exporter.Props] 

173 limit: int 

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

175 ui: Ui 

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

177 strasseSearchOptions: Optional[gws.TextSearchOptions] 

178 nameSearchOptions: Optional[gws.TextSearchOptions] 

179 buchungsblattSearchOptions: Optional[gws.TextSearchOptions] 

180 withBuchung: bool 

181 withEigentuemer: bool 

182 withEigentuemerControl: bool 

183 withFlurnummer: bool 

184 

185 

186## 

187 

188 

189class GetToponymsRequest(gws.Request): 

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

191 

192 pass 

193 

194 

195class GetToponymsResponse(gws.Response): 

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

197 

198 gemeinde: list[list[str]] 

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

200 gemarkung: list[list[str]] 

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

202 strasse: list[list[str]] 

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

204 

205 

206class FindFlurstueckRequest(gws.Request): 

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

208 

209 flurnummer: Optional[str] 

210 """Flur number.""" 

211 flurstuecksfolge: Optional[str] 

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

213 zaehler: Optional[str] 

214 """Numerator of the parcel number.""" 

215 nenner: Optional[str] 

216 """Denominator of the parcel number.""" 

217 fsnummer: Optional[str] 

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

219 

220 flaecheBis: Optional[float] 

221 """Maximum parcel area.""" 

222 flaecheVon: Optional[float] 

223 """Minimum parcel area.""" 

224 

225 gemarkung: Optional[str] 

226 """Gemarkung (district) name.""" 

227 gemarkungCode: Optional[str] 

228 """Gemarkung code.""" 

229 gemeinde: Optional[str] 

230 """Gemeinde (municipality) name.""" 

231 gemeindeCode: Optional[str] 

232 """Gemeinde code.""" 

233 kreis: Optional[str] 

234 """Kreis (county) name.""" 

235 kreisCode: Optional[str] 

236 """Kreis code.""" 

237 land: Optional[str] 

238 """Land (state) name.""" 

239 landCode: Optional[str] 

240 """Land code.""" 

241 regierungsbezirk: Optional[str] 

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

243 regierungsbezirkCode: Optional[str] 

244 """Regierungsbezirk code.""" 

245 

246 strasse: Optional[str] 

247 """Street name.""" 

248 hausnummer: Optional[str] 

249 """House number.""" 

250 

251 bblatt: Optional[str] 

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

253 

254 personName: Optional[str] 

255 """Last name of the owner.""" 

256 personVorname: Optional[str] 

257 """First name of the owner.""" 

258 

259 combinedFlurstueckCode: Optional[str] 

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

261 

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

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

264 

265 uids: Optional[list[str]] 

266 """Parcel uids.""" 

267 

268 crs: Optional[gws.CrsName] 

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

270 eigentuemerControlInput: Optional[str] 

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

272 limit: Optional[int] 

273 """Unused, the action limit applies.""" 

274 

275 wantEigentuemer: Optional[bool] 

276 """Request owner data.""" 

277 wantHistorySearch: Optional[bool] 

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

279 wantHistoryDisplay: Optional[bool] 

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

281 

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

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

284 

285 

286class FindFlurstueckResponse(gws.Response): 

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

288 

289 features: list[gws.FeatureProps] 

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

291 total: int 

292 """Number of found parcels.""" 

293 

294 

295class FindFlurstueckResult(gws.Data): 

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

297 

298 flurstueckList: list[dt.Flurstueck] 

299 """Found parcels.""" 

300 total: int 

301 """Number of found parcels.""" 

302 query: dt.FlurstueckQuery 

303 """The query that was run.""" 

304 

305 

306class FindAdresseRequest(gws.Request): 

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

308 

309 crs: Optional[gws.CrsName] 

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

311 

312 gemarkung: Optional[str] 

313 """Gemarkung (district) name.""" 

314 gemarkungCode: Optional[str] 

315 """Gemarkung code.""" 

316 gemeinde: Optional[str] 

317 """Gemeinde (municipality) name.""" 

318 gemeindeCode: Optional[str] 

319 """Gemeinde code.""" 

320 kreis: Optional[str] 

321 """Kreis (county) name.""" 

322 kreisCode: Optional[str] 

323 """Kreis code.""" 

324 land: Optional[str] 

325 """Land (state) name.""" 

326 landCode: Optional[str] 

327 """Land code.""" 

328 regierungsbezirk: Optional[str] 

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

330 regierungsbezirkCode: Optional[str] 

331 """Regierungsbezirk code.""" 

332 

333 strasse: Optional[str] 

334 """Street name.""" 

335 hausnummer: Optional[str] 

336 """House number.""" 

337 bisHausnummer: Optional[str] 

338 """Upper bound of a house number range.""" 

339 hausnummerNotNull: Optional[bool] 

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

341 

342 wantHistorySearch: Optional[bool] 

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

344 

345 combinedAdresseCode: Optional[str] 

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

347 

348 

349class FindAdresseResponse(gws.Response): 

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

351 

352 features: list[gws.FeatureProps] 

353 """Found addresses as features.""" 

354 total: int 

355 """Number of found addresses.""" 

356 

357 

358class PrintFlurstueckRequest(gws.Request): 

359 """Request to print found parcels.""" 

360 

361 findRequest: FindFlurstueckRequest 

362 """The parcel search to run.""" 

363 printRequest: gws.PrintRequest 

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

365 featureStyle: gws.StyleProps 

366 """Style for the parcel features.""" 

367 

368 

369class ExportFlurstueckRequest(gws.Request): 

370 """Request to export found parcels.""" 

371 

372 findRequest: FindFlurstueckRequest 

373 """The parcel search to run.""" 

374 exporterUid: str 

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

376 modelUids: Optional[list[str]] 

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

378 eigentuemerControlInput: Optional[str] 

379 """Control input for owner data access, unused; the one in ``findRequest`` is checked.""" 

380 

381 

382class ExportFlurstueckResponse(gws.Response): 

383 """Response of a parcel export.""" 

384 

385 content: str 

386 """Exported data.""" 

387 mimeType: str 

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

389 

390 

391## 

392 

393 

394_dir = os.path.dirname(__file__) 

395 

396_DEFAULT_TEMPLATES = [ 

397 gws.Config( 

398 subject='flurstueck.title', 

399 type='html', 

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

401 ), 

402 gws.Config( 

403 subject='flurstueck.teaser', 

404 type='html', 

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

406 ), 

407 gws.Config( 

408 subject='flurstueck.label', 

409 type='html', 

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

411 ), 

412 gws.Config( 

413 subject='flurstueck.description', 

414 type='html', 

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

416 ), 

417 gws.Config( 

418 subject='adresse.title', 

419 type='html', 

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

421 ), 

422 gws.Config( 

423 subject='adresse.teaser', 

424 type='html', 

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

426 ), 

427 gws.Config( 

428 subject='adresse.label', 

429 type='html', 

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

431 ), 

432] 

433 

434_DEFAULT_PRINTER = gws.Config( 

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

436 template=gws.Config( 

437 type='html', 

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

439 ), 

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

441) 

442 

443 

444## 

445 

446 

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

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

449 

450 def configure(self): 

451 self.uidName = 'uid' 

452 self.geometryName = 'geometry' 

453 self.loadingStrategy = gws.FeatureLoadingStrategy.all 

454 

455 

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

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

458 """The ``alkis`` action. 

459 

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

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

462 and stores selections of parcels. 

463 """ 

464 

465 db: gws.DatabaseProvider 

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

467 

468 ix: index.Object 

469 """The ALKIS index.""" 

470 ixStatus: dt.IndexStatus 

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

472 

473 buchung: BuchungOptions 

474 """Access options for register data.""" 

475 eigentuemer: EigentuemerOptions 

476 """Access options for owner data.""" 

477 

478 dataSchema: str 

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

480 indexSchema: str 

481 """Schema with the index tables.""" 

482 

483 model: gws.Model 

484 """Model for the returned features.""" 

485 ui: Ui 

486 """User interface options.""" 

487 limit: int 

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

489 

490 templates: list[gws.Template] 

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

492 printers: list[gws.Printer] 

493 """Printers for parcels.""" 

494 

495 exporters: list[exporter.Object] 

496 """Exporters for parcel data.""" 

497 

498 strasseSearchOptions: gws.TextSearchOptions 

499 """How street names are matched.""" 

500 nameSearchOptions: gws.TextSearchOptions 

501 """How person names are matched.""" 

502 buchungsblattSearchOptions: gws.TextSearchOptions 

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

504 

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

506 """Storage for saved parcel selections.""" 

507 

508 def configure(self): 

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

510 

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

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

513 

514 self.ix = self.root.create( 

515 index.Object, 

516 _defaultDb=self.db, 

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

518 schema=self.indexSchema, 

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

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

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

522 ) 

523 

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

525 self.model = self.create_child(Model) 

526 self.ui = self.cfg( 

527 'ui', 

528 default=Ui( 

529 gemarkungListMode=GemarkungListMode.combined, 

530 strasseListMode=StrasseListMode.plain, 

531 ), 

532 ) 

533 

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

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

536 

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

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

539 

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

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

542 

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

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

545 

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

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

548 

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

550 

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

552 

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

554 if self.eigentuemer.logTableName: 

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

556 

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

558 

559 self.exporters = [] 

560 p = self.cfg('exporters') 

561 if p: 

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

563 elif self.ui.useExport: 

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

565 

566 def activate(self): 

567 def _load(): 

568 s = self.ix.status() 

569 if s.missing: 

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

571 return s 

572 

573 self.ixStatus = gws.u.get_server_global(f'gws.plugin.alkis.action.ixStatus.{self.indexSchema}', _load) 

574 

575 def props(self, user): 

576 if not self.ixStatus.basic: 

577 return None 

578 

579 ps: Props = cast( 

580 Props, 

581 gws.u.merge( 

582 super().props(user), 

583 limit=self.limit, 

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

585 ui=self.ui, 

586 storage=self.storage, 

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

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

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

590 ), 

591 ) 

592 

593 ps.exporters = [] 

594 for exp in self.exporters: 

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

596 if p: 

597 ps.exporters.append(p) 

598 

599 ps.strasseSearchOptions = self.strasseSearchOptions 

600 if ps.withBuchung: 

601 ps.buchungsblattSearchOptions = self.buchungsblattSearchOptions 

602 if ps.withEigentuemer: 

603 ps.nameSearchOptions = self.nameSearchOptions 

604 

605 return ps 

606 

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

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

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

610 

611 req.user.require_project(p.projectUid) 

612 

613 gemeinde_dct = {} 

614 gemarkung_dct = {} 

615 strasse_lst = [] 

616 

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

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

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

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

621 

622 return GetToponymsResponse( 

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

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

625 strasse=sorted(strasse_lst), 

626 ) 

627 

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

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

630 """Search for addresses.""" 

631 

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

633 crs = p.get('crs') or project.map.bounds.crs 

634 

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

636 if not ad_list: 

637 return FindAdresseResponse( 

638 features=[], 

639 total=0, 

640 ) 

641 

642 templates = [ 

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

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

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

646 ] 

647 

648 fprops = [] 

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

650 

651 for ad in ad_list: 

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

653 f.attributes = vars(ad) 

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

655 f.transform_to(crs) 

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

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

658 

659 return FindAdresseResponse( 

660 features=fprops, 

661 total=len(ad_list), 

662 ) 

663 

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

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

666 """Search for parcels.""" 

667 

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

669 crs = p.get('crs') or project.map.bounds.crs 

670 

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

672 if not fs_list: 

673 return FindFlurstueckResponse( 

674 features=[], 

675 total=0, 

676 ) 

677 

678 templates = [ 

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

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

681 ] 

682 

683 if query.options.displayThemes: 

684 templates.append( 

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

686 ) 

687 

688 args = dict( 

689 withHistory=query.options.withHistoryDisplay, 

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

691 ) 

692 

693 ps = [] 

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

695 

696 for fs in fs_list: 

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

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

699 f.transform_to(crs) 

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

701 f.attributes.pop('fs') 

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

703 

704 return FindFlurstueckResponse( 

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

706 total=len(fs_list), 

707 ) 

708 

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

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

711 

712 Args: 

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

714 user: The user. 

715 

716 Returns: 

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

718 """ 

719 

720 for exp in self.exporters: 

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

722 return exp 

723 

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

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

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

727 

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

729 if not exp: 

730 raise gws.NotFoundError() 

731 

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

733 if not models: 

734 raise gws.NotFoundError() 

735 

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

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

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

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

740 

741 find_request = p.findRequest 

742 find_request.projectUid = p.projectUid 

743 

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

745 if not fs_list: 

746 raise gws.NotFoundError() 

747 

748 args = exporter.Args( 

749 fsList=fs_list, 

750 user=req.user, 

751 models=models, 

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

753 ) 

754 

755 exp.run(args) 

756 

757 return ExportFlurstueckResponse( 

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

759 mimeType=exp.mimeType, 

760 ) 

761 

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

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

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

765 

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

767 

768 find_request = p.findRequest 

769 find_request.projectUid = p.projectUid 

770 

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

772 if not fs_list: 

773 raise gws.NotFoundError() 

774 

775 print_request = p.printRequest 

776 print_request.projectUid = p.projectUid 

777 crs = print_request.get('crs') or project.map.bounds.crs 

778 

779 templates = [ 

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

781 ] 

782 

783 base_map = print_request.maps[0] 

784 fs_maps = [] 

785 

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

787 

788 for fs in fs_list: 

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

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

791 f.transform_to(crs) 

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

793 f.cssSelector = p.featureStyle.cssSelector 

794 

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

796 fs_map = gws.PrintMap(base_map) 

797 # @TODO scale to fit the fs? 

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

799 fs_plane = gws.PrintPlane( 

800 type=gws.PrintPlaneType.features, 

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

802 ) 

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

804 fs_maps.append(fs_map) 

805 

806 print_request.maps = fs_maps 

807 print_request.args = dict( 

808 flurstueckList=fs_list, 

809 withHistory=query.options.withHistoryDisplay, 

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

811 ) 

812 

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

814 

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

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

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

818 

819 if not self.storage: 

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

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

822 

823 ## 

824 

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

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

827 

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

829 

830 Args: 

831 req: Web requester. 

832 p: Search parameters. 

833 

834 Returns: 

835 A tuple of the found parcels and the query. 

836 

837 Raises: 

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

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

840 """ 

841 

842 query = self._prepare_flurstueck_query(req, p) 

843 fs_list = self.ix.find_flurstueck(query) 

844 

845 if query.options.withEigentuemer: 

846 self._log_eigentuemer_access( 

847 req, 

848 p.eigentuemerControlInput or '', 

849 is_ok=True, 

850 total=len(fs_list), 

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

852 ) 

853 

854 return fs_list, query 

855 

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

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

858 

859 Args: 

860 req: Web requester. 

861 p: Search parameters. 

862 

863 Returns: 

864 A tuple of the found addresses and the query. 

865 """ 

866 

867 query = self._prepare_adresse_query(req, p) 

868 ad_list = self.ix.find_adresse(query) 

869 return ad_list, query 

870 

871 FLURSTUECK_QUERY_FIELDS = [ 

872 'flurnummer', 

873 'flurstuecksfolge', 

874 'zaehler', 

875 'nenner', 

876 'flurstueckskennzeichen', 

877 'flaecheBis', 

878 'flaecheVon', 

879 'gemarkung', 

880 'gemarkungCode', 

881 'gemeinde', 

882 'gemeindeCode', 

883 'kreis', 

884 'kreisCode', 

885 'land', 

886 'landCode', 

887 'regierungsbezirk', 

888 'regierungsbezirkCode', 

889 'strasse', 

890 'hausnummer', 

891 'personName', 

892 'personVorname', 

893 'uids', 

894 ] 

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

896 ADRESSE_QUERY_FIELDS = [ 

897 'gemarkung', 

898 'gemarkungCode', 

899 'gemeinde', 

900 'gemeindeCode', 

901 'kreis', 

902 'kreisCode', 

903 'land', 

904 'landCode', 

905 'regierungsbezirk', 

906 'regierungsbezirkCode', 

907 'strasse', 

908 'hausnummer', 

909 'bisHausnummer', 

910 'hausnummerNotNull', 

911 ] 

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

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

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

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

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

917 

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

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

920 

921 query = dt.FlurstueckQuery() 

922 

923 for f in self.FLURSTUECK_QUERY_FIELDS: 

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

925 

926 if p.combinedFlurstueckCode: 

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

928 

929 if p.fsnummer: 

930 self._query_fsnummer(query, p.fsnummer) 

931 

932 if p.shapes: 

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

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

935 

936 if p.bblatt: 

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

938 

939 options = dt.FlurstueckQueryOptions( 

940 strasseSearchOptions=self.strasseSearchOptions, 

941 nameSearchOptions=self.nameSearchOptions, 

942 buchungsblattSearchOptions=self.buchungsblattSearchOptions, 

943 limit=self.limit, 

944 withEigentuemer=False, 

945 withBuchung=False, 

946 withHistorySearch=bool(p.wantHistorySearch), 

947 withHistoryDisplay=bool(p.wantHistoryDisplay), 

948 displayThemes=p.displayThemes or [], 

949 ) 

950 

951 want_eigentuemer = ( 

952 p.wantEigentuemer 

953 or dt.DisplayTheme.eigentuemer in options.displayThemes 

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

955 ) 

956 if want_eigentuemer: 

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

958 options.withEigentuemer = True 

959 

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

961 if want_buchung: 

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

963 options.withBuchung = True 

964 

965 # "eigentuemer" implies "buchung" 

966 if want_eigentuemer and not want_buchung: 

967 options.withBuchung = True 

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

969 

970 query.options = options 

971 return query 

972 

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

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

975 

976 query = dt.AdresseQuery() 

977 

978 for f in self.ADRESSE_QUERY_FIELDS: 

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

980 

981 if p.combinedAdresseCode: 

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

983 

984 options = dt.AdresseQueryOptions( 

985 strasseSearchOptions=self.strasseSearchOptions, 

986 withHistorySearch=bool(p.wantHistorySearch), 

987 ) 

988 

989 query.options = options 

990 return query 

991 

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

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

994 

995 if vn.startswith('DE'): 

996 # search by gml_id 

997 query.uids = query.uids or [] 

998 query.uids.append(vn) 

999 return 

1000 

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

1002 # search by fs kennzeichen 

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

1004 # (see gdi6, AX_Flurstueck_Kerndaten.flurstueckskennzeichen) 

1005 query.flurstueckskennzeichen = vn 

1006 return 

1007 

1008 # search by a compound fs number 

1009 parts = index.parse_fsnummer(vn) 

1010 if parts is None: 

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

1012 query.update(parts) 

1013 

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

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

1016 

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

1018 if val and val != '0': 

1019 setattr(query, field, val) 

1020 

1021 ## 

1022 

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

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

1025 

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

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

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

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

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

1031 

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

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

1034 

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

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

1037 

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

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

1040 

1041 if self.eigentuemer.logTable is None: 

1042 return 

1043 

1044 data = dict( 

1045 app_name='gws', 

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

1047 ip=req.ip, 

1048 login=req.user.uid, 

1049 user_name=req.user.displayName, 

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

1051 control_result=1 if is_ok else 0, 

1052 fs_count=total or 0, 

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

1054 ) 

1055 

1056 with self.ix.db.connect() as conn: 

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

1058 conn.commit() 

1059 

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

1061 

1062 def _check_eigentuemer_control_input(self, control_input): 

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

1064 

1065 if not self.eigentuemer.controlRules: 

1066 return True 

1067 

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

1069 

1070 for rule in self.eigentuemer.controlRules: 

1071 if re.search(rule, control_input): 

1072 return True 

1073 

1074 return False