Merge pull request 'VLN-API integriert' (#25) from API-Integration_Plan41 into unstable
Reviewed-on: #25
This commit was merged in pull request #25.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,597 @@
|
||||
<!DOCTYPE qgis PUBLIC 'http://mrcc.com/qgis.dtd' 'SYSTEM'>
|
||||
<qgis autoRefreshMode="Disabled" autoRefreshTime="0" readOnly="0" styleCategories="AllStyleCategories" simplifyLocal="1" symbologyReferenceScale="-1" minScale="100000000" maxScale="0" labelsEnabled="1" simplifyDrawingHints="1" version="3.40.7-Bratislava" simplifyAlgorithm="0" hasScaleBasedVisibilityFlag="0" simplifyMaxScale="1" simplifyDrawingTol="1">
|
||||
<flags>
|
||||
<Identifiable>1</Identifiable>
|
||||
<Removable>1</Removable>
|
||||
<Searchable>1</Searchable>
|
||||
<Private>0</Private>
|
||||
</flags>
|
||||
<temporal enabled="0" limitMode="0" startField="" mode="0" durationField="Anzahl" endField="" endExpression="" durationUnit="min" fixedDuration="0" accumulate="0" startExpression="">
|
||||
<fixedRange>
|
||||
<start></start>
|
||||
<end></end>
|
||||
</fixedRange>
|
||||
</temporal>
|
||||
<elevation zscale="1" type="IndividualFeatures" symbology="Line" respectLayerSymbol="1" extrusionEnabled="0" clamping="Terrain" showMarkerSymbolInSurfacePlots="0" extrusion="0" zoffset="0" binding="Centroid">
|
||||
<data-defined-properties>
|
||||
<Option type="Map">
|
||||
<Option type="QString" name="name" value=""/>
|
||||
<Option name="properties"/>
|
||||
<Option type="QString" name="type" value="collection"/>
|
||||
</Option>
|
||||
</data-defined-properties>
|
||||
<profileLineSymbol>
|
||||
<symbol alpha="1" type="line" name="" force_rhr="0" clip_to_extent="1" is_animated="0" frame_rate="10">
|
||||
<data_defined_properties>
|
||||
<Option type="Map">
|
||||
<Option type="QString" name="name" value=""/>
|
||||
<Option name="properties"/>
|
||||
<Option type="QString" name="type" value="collection"/>
|
||||
</Option>
|
||||
</data_defined_properties>
|
||||
<layer enabled="1" id="{806bb2f8-e515-41c5-931f-aa961a636da4}" locked="0" class="SimpleLine" pass="0">
|
||||
<Option type="Map">
|
||||
<Option type="QString" name="align_dash_pattern" value="0"/>
|
||||
<Option type="QString" name="capstyle" value="square"/>
|
||||
<Option type="QString" name="customdash" value="5;2"/>
|
||||
<Option type="QString" name="customdash_map_unit_scale" value="3x:0,0,0,0,0,0"/>
|
||||
<Option type="QString" name="customdash_unit" value="MM"/>
|
||||
<Option type="QString" name="dash_pattern_offset" value="0"/>
|
||||
<Option type="QString" name="dash_pattern_offset_map_unit_scale" value="3x:0,0,0,0,0,0"/>
|
||||
<Option type="QString" name="dash_pattern_offset_unit" value="MM"/>
|
||||
<Option type="QString" name="draw_inside_polygon" value="0"/>
|
||||
<Option type="QString" name="joinstyle" value="bevel"/>
|
||||
<Option type="QString" name="line_color" value="231,113,72,255,rgb:0.90588235294117647,0.44313725490196076,0.28235294117647058,1"/>
|
||||
<Option type="QString" name="line_style" value="solid"/>
|
||||
<Option type="QString" name="line_width" value="0.6"/>
|
||||
<Option type="QString" name="line_width_unit" value="MM"/>
|
||||
<Option type="QString" name="offset" value="0"/>
|
||||
<Option type="QString" name="offset_map_unit_scale" value="3x:0,0,0,0,0,0"/>
|
||||
<Option type="QString" name="offset_unit" value="MM"/>
|
||||
<Option type="QString" name="ring_filter" value="0"/>
|
||||
<Option type="QString" name="trim_distance_end" value="0"/>
|
||||
<Option type="QString" name="trim_distance_end_map_unit_scale" value="3x:0,0,0,0,0,0"/>
|
||||
<Option type="QString" name="trim_distance_end_unit" value="MM"/>
|
||||
<Option type="QString" name="trim_distance_start" value="0"/>
|
||||
<Option type="QString" name="trim_distance_start_map_unit_scale" value="3x:0,0,0,0,0,0"/>
|
||||
<Option type="QString" name="trim_distance_start_unit" value="MM"/>
|
||||
<Option type="QString" name="tweak_dash_pattern_on_corners" value="0"/>
|
||||
<Option type="QString" name="use_custom_dash" value="0"/>
|
||||
<Option type="QString" name="width_map_unit_scale" value="3x:0,0,0,0,0,0"/>
|
||||
</Option>
|
||||
<data_defined_properties>
|
||||
<Option type="Map">
|
||||
<Option type="QString" name="name" value=""/>
|
||||
<Option name="properties"/>
|
||||
<Option type="QString" name="type" value="collection"/>
|
||||
</Option>
|
||||
</data_defined_properties>
|
||||
</layer>
|
||||
</symbol>
|
||||
</profileLineSymbol>
|
||||
<profileFillSymbol>
|
||||
<symbol alpha="1" type="fill" name="" force_rhr="0" clip_to_extent="1" is_animated="0" frame_rate="10">
|
||||
<data_defined_properties>
|
||||
<Option type="Map">
|
||||
<Option type="QString" name="name" value=""/>
|
||||
<Option name="properties"/>
|
||||
<Option type="QString" name="type" value="collection"/>
|
||||
</Option>
|
||||
</data_defined_properties>
|
||||
<layer enabled="1" id="{5fceef15-676f-4c5c-bb67-84e236dc3eab}" locked="0" class="SimpleFill" pass="0">
|
||||
<Option type="Map">
|
||||
<Option type="QString" name="border_width_map_unit_scale" value="3x:0,0,0,0,0,0"/>
|
||||
<Option type="QString" name="color" value="231,113,72,255,rgb:0.90588235294117647,0.44313725490196076,0.28235294117647058,1"/>
|
||||
<Option type="QString" name="joinstyle" value="bevel"/>
|
||||
<Option type="QString" name="offset" value="0,0"/>
|
||||
<Option type="QString" name="offset_map_unit_scale" value="3x:0,0,0,0,0,0"/>
|
||||
<Option type="QString" name="offset_unit" value="MM"/>
|
||||
<Option type="QString" name="outline_color" value="165,81,51,255,rgb:0.6470588235294118,0.31651789120317386,0.20167849240863661,1"/>
|
||||
<Option type="QString" name="outline_style" value="solid"/>
|
||||
<Option type="QString" name="outline_width" value="0.2"/>
|
||||
<Option type="QString" name="outline_width_unit" value="MM"/>
|
||||
<Option type="QString" name="style" value="solid"/>
|
||||
</Option>
|
||||
<data_defined_properties>
|
||||
<Option type="Map">
|
||||
<Option type="QString" name="name" value=""/>
|
||||
<Option name="properties"/>
|
||||
<Option type="QString" name="type" value="collection"/>
|
||||
</Option>
|
||||
</data_defined_properties>
|
||||
</layer>
|
||||
</symbol>
|
||||
</profileFillSymbol>
|
||||
<profileMarkerSymbol>
|
||||
<symbol alpha="1" type="marker" name="" force_rhr="0" clip_to_extent="1" is_animated="0" frame_rate="10">
|
||||
<data_defined_properties>
|
||||
<Option type="Map">
|
||||
<Option type="QString" name="name" value=""/>
|
||||
<Option name="properties"/>
|
||||
<Option type="QString" name="type" value="collection"/>
|
||||
</Option>
|
||||
</data_defined_properties>
|
||||
<layer enabled="1" id="{6c4ff12c-7cf1-4ddc-a6e1-b7f01dda66ad}" locked="0" class="SimpleMarker" pass="0">
|
||||
<Option type="Map">
|
||||
<Option type="QString" name="angle" value="0"/>
|
||||
<Option type="QString" name="cap_style" value="square"/>
|
||||
<Option type="QString" name="color" value="231,113,72,255,rgb:0.90588235294117647,0.44313725490196076,0.28235294117647058,1"/>
|
||||
<Option type="QString" name="horizontal_anchor_point" value="1"/>
|
||||
<Option type="QString" name="joinstyle" value="bevel"/>
|
||||
<Option type="QString" name="name" value="diamond"/>
|
||||
<Option type="QString" name="offset" value="0,0"/>
|
||||
<Option type="QString" name="offset_map_unit_scale" value="3x:0,0,0,0,0,0"/>
|
||||
<Option type="QString" name="offset_unit" value="MM"/>
|
||||
<Option type="QString" name="outline_color" value="165,81,51,255,rgb:0.6470588235294118,0.31651789120317386,0.20167849240863661,1"/>
|
||||
<Option type="QString" name="outline_style" value="solid"/>
|
||||
<Option type="QString" name="outline_width" value="0.2"/>
|
||||
<Option type="QString" name="outline_width_map_unit_scale" value="3x:0,0,0,0,0,0"/>
|
||||
<Option type="QString" name="outline_width_unit" value="MM"/>
|
||||
<Option type="QString" name="scale_method" value="diameter"/>
|
||||
<Option type="QString" name="size" value="3"/>
|
||||
<Option type="QString" name="size_map_unit_scale" value="3x:0,0,0,0,0,0"/>
|
||||
<Option type="QString" name="size_unit" value="MM"/>
|
||||
<Option type="QString" name="vertical_anchor_point" value="1"/>
|
||||
</Option>
|
||||
<data_defined_properties>
|
||||
<Option type="Map">
|
||||
<Option type="QString" name="name" value=""/>
|
||||
<Option name="properties"/>
|
||||
<Option type="QString" name="type" value="collection"/>
|
||||
</Option>
|
||||
</data_defined_properties>
|
||||
</layer>
|
||||
</symbol>
|
||||
</profileMarkerSymbol>
|
||||
</elevation>
|
||||
<renderer-v2 referencescale="-1" type="singleSymbol" forceraster="0" enableorderby="0" symbollevels="0">
|
||||
<symbols>
|
||||
<symbol alpha="1" type="fill" name="0" force_rhr="0" clip_to_extent="1" is_animated="0" frame_rate="10">
|
||||
<data_defined_properties>
|
||||
<Option type="Map">
|
||||
<Option type="QString" name="name" value=""/>
|
||||
<Option name="properties"/>
|
||||
<Option type="QString" name="type" value="collection"/>
|
||||
</Option>
|
||||
</data_defined_properties>
|
||||
<layer enabled="1" id="{37953f01-68d9-43be-b9d8-546ca454487e}" locked="0" class="SimpleFill" pass="0">
|
||||
<Option type="Map">
|
||||
<Option type="QString" name="border_width_map_unit_scale" value="3x:0,0,0,0,0,0"/>
|
||||
<Option type="QString" name="color" value="0,85,255,255,rgb:0,0.33333333333333331,1,1"/>
|
||||
<Option type="QString" name="joinstyle" value="bevel"/>
|
||||
<Option type="QString" name="offset" value="0,0"/>
|
||||
<Option type="QString" name="offset_map_unit_scale" value="3x:0,0,0,0,0,0"/>
|
||||
<Option type="QString" name="offset_unit" value="MM"/>
|
||||
<Option type="QString" name="outline_color" value="0,85,255,255,rgb:0,0.33333333333333331,1,1"/>
|
||||
<Option type="QString" name="outline_style" value="solid"/>
|
||||
<Option type="QString" name="outline_width" value="1.06"/>
|
||||
<Option type="QString" name="outline_width_unit" value="MM"/>
|
||||
<Option type="QString" name="style" value="no"/>
|
||||
</Option>
|
||||
<data_defined_properties>
|
||||
<Option type="Map">
|
||||
<Option type="QString" name="name" value=""/>
|
||||
<Option name="properties"/>
|
||||
<Option type="QString" name="type" value="collection"/>
|
||||
</Option>
|
||||
</data_defined_properties>
|
||||
</layer>
|
||||
</symbol>
|
||||
</symbols>
|
||||
<rotation/>
|
||||
<sizescale/>
|
||||
<data-defined-properties>
|
||||
<Option type="Map">
|
||||
<Option type="QString" name="name" value=""/>
|
||||
<Option name="properties"/>
|
||||
<Option type="QString" name="type" value="collection"/>
|
||||
</Option>
|
||||
</data-defined-properties>
|
||||
</renderer-v2>
|
||||
<selection mode="Default">
|
||||
<selectionColor invalid="1"/>
|
||||
<selectionSymbol>
|
||||
<symbol alpha="1" type="fill" name="" force_rhr="0" clip_to_extent="1" is_animated="0" frame_rate="10">
|
||||
<data_defined_properties>
|
||||
<Option type="Map">
|
||||
<Option type="QString" name="name" value=""/>
|
||||
<Option name="properties"/>
|
||||
<Option type="QString" name="type" value="collection"/>
|
||||
</Option>
|
||||
</data_defined_properties>
|
||||
<layer enabled="1" id="{37c84ab2-1f8e-4d9f-90bb-400976543063}" locked="0" class="SimpleFill" pass="0">
|
||||
<Option type="Map">
|
||||
<Option type="QString" name="border_width_map_unit_scale" value="3x:0,0,0,0,0,0"/>
|
||||
<Option type="QString" name="color" value="0,0,255,255,rgb:0,0,1,1"/>
|
||||
<Option type="QString" name="joinstyle" value="bevel"/>
|
||||
<Option type="QString" name="offset" value="0,0"/>
|
||||
<Option type="QString" name="offset_map_unit_scale" value="3x:0,0,0,0,0,0"/>
|
||||
<Option type="QString" name="offset_unit" value="MM"/>
|
||||
<Option type="QString" name="outline_color" value="35,35,35,255,rgb:0.13725490196078433,0.13725490196078433,0.13725490196078433,1"/>
|
||||
<Option type="QString" name="outline_style" value="solid"/>
|
||||
<Option type="QString" name="outline_width" value="0.26"/>
|
||||
<Option type="QString" name="outline_width_unit" value="MM"/>
|
||||
<Option type="QString" name="style" value="solid"/>
|
||||
</Option>
|
||||
<data_defined_properties>
|
||||
<Option type="Map">
|
||||
<Option type="QString" name="name" value=""/>
|
||||
<Option name="properties"/>
|
||||
<Option type="QString" name="type" value="collection"/>
|
||||
</Option>
|
||||
</data_defined_properties>
|
||||
</layer>
|
||||
</symbol>
|
||||
</selectionSymbol>
|
||||
</selection>
|
||||
<labeling type="rule-based">
|
||||
<rules key="{31adca09-e30f-437a-bc70-ef63b20670ed}">
|
||||
<rule filter="left("MKZ",3)='222'" key="{79a5bb52-55b4-45ee-89fe-d90e0fe95f62}" description="Wege,geplant">
|
||||
<settings calloutType="simple">
|
||||
<text-style fontKerning="0" useSubstitutions="0" namedStyle="Bold" previewBkgrdColor="255,255,255,255,rgb:1,1,1,1" fontWeight="75" tabStopDistanceMapUnitScale="3x:0,0,0,0,0,0" fontUnderline="0" tabStopDistance="80" fieldName="left("MKZ",3)|| '\n' || '―'|| '\n' || right("MKZ",4)" textColor="5,125,180,255,rgb:0.0196078431372549,0.49019607843137253,0.70588235294117652,1" fontStrikeout="0" forcedBold="0" forcedItalic="0" allowHtml="0" multilineHeight="0.59999999999999998" fontSize="10" fontWordSpacing="-0.6875" stretchFactor="100" blendMode="0" fontSizeMapUnitScale="3x:0,0,0,0,0,0" textOrientation="horizontal" fontItalic="0" fontLetterSpacing="-0.5625" tabStopDistanceUnit="Point" multilineHeightUnit="Percentage" fontFamily="Arial" fontSizeUnit="Point" capitalization="0" isExpression="1" legendString="Aa" textOpacity="1">
|
||||
<families/>
|
||||
<text-buffer bufferColor="255,0,0,255,hsv:0,1,1,1" bufferNoFill="1" bufferSize="1" bufferSizeMapUnitScale="3x:0,0,0,0,0,0" bufferSizeUnits="MM" bufferDraw="0" bufferJoinStyle="128" bufferOpacity="1" bufferBlendMode="0"/>
|
||||
<text-mask maskedSymbolLayers="" maskJoinStyle="128" maskEnabled="0" maskType="0" maskSize2="1.5" maskSizeUnits="MM" maskSizeMapUnitScale="3x:0,0,0,0,0,0" maskSize="1.5" maskOpacity="1"/>
|
||||
<background shapeJoinStyle="64" shapeBlendMode="0" shapeRotationType="0" shapeRadiiX="0" shapeRadiiY="0" shapeSVGFile="" shapeSizeType="0" shapeBorderWidthMapUnitScale="3x:0,0,0,0,0,0" shapeSizeUnit="Point" shapeRadiiMapUnitScale="3x:0,0,0,0,0,0" shapeFillColor="255,255,255,255,rgb:1,1,1,1" shapeOffsetMapUnitScale="3x:0,0,0,0,0,0" shapeDraw="1" shapeRotation="0" shapeType="5" shapeSizeMapUnitScale="3x:0,0,0,0,0,0" shapeOffsetUnit="Point" shapeRadiiUnit="Point" shapeSizeX="3" shapeOpacity="1" shapeBorderWidth="0" shapeOffsetY="0" shapeBorderColor="128,128,128,255,rgb:0.50196078431372548,0.50196078431372548,0.50196078431372548,1" shapeBorderWidthUnit="Point" shapeSizeY="0" shapeOffsetX="0">
|
||||
<symbol alpha="1" type="marker" name="markerSymbol" force_rhr="0" clip_to_extent="1" is_animated="0" frame_rate="10">
|
||||
<data_defined_properties>
|
||||
<Option type="Map">
|
||||
<Option type="QString" name="name" value=""/>
|
||||
<Option name="properties"/>
|
||||
<Option type="QString" name="type" value="collection"/>
|
||||
</Option>
|
||||
</data_defined_properties>
|
||||
<layer enabled="1" id="" locked="0" class="SimpleMarker" pass="0">
|
||||
<Option type="Map">
|
||||
<Option type="QString" name="angle" value="0"/>
|
||||
<Option type="QString" name="cap_style" value="square"/>
|
||||
<Option type="QString" name="color" value="255,255,255,255,rgb:1,1,1,1"/>
|
||||
<Option type="QString" name="horizontal_anchor_point" value="1"/>
|
||||
<Option type="QString" name="joinstyle" value="bevel"/>
|
||||
<Option type="QString" name="name" value="circle"/>
|
||||
<Option type="QString" name="offset" value="0,0"/>
|
||||
<Option type="QString" name="offset_map_unit_scale" value="3x:0,0,0,0,0,0"/>
|
||||
<Option type="QString" name="offset_unit" value="MM"/>
|
||||
<Option type="QString" name="outline_color" value="8,135,203,255,rgb:0.03137254901960784,0.52941176470588236,0.79607843137254897,1"/>
|
||||
<Option type="QString" name="outline_style" value="solid"/>
|
||||
<Option type="QString" name="outline_width" value="0.6"/>
|
||||
<Option type="QString" name="outline_width_map_unit_scale" value="3x:0,0,0,0,0,0"/>
|
||||
<Option type="QString" name="outline_width_unit" value="MM"/>
|
||||
<Option type="QString" name="scale_method" value="diameter"/>
|
||||
<Option type="QString" name="size" value="2"/>
|
||||
<Option type="QString" name="size_map_unit_scale" value="3x:0,0,0,0,0,0"/>
|
||||
<Option type="QString" name="size_unit" value="MM"/>
|
||||
<Option type="QString" name="vertical_anchor_point" value="1"/>
|
||||
</Option>
|
||||
<data_defined_properties>
|
||||
<Option type="Map">
|
||||
<Option type="QString" name="name" value=""/>
|
||||
<Option name="properties"/>
|
||||
<Option type="QString" name="type" value="collection"/>
|
||||
</Option>
|
||||
</data_defined_properties>
|
||||
</layer>
|
||||
</symbol>
|
||||
<symbol alpha="1" type="fill" name="fillSymbol" force_rhr="0" clip_to_extent="1" is_animated="0" frame_rate="10">
|
||||
<data_defined_properties>
|
||||
<Option type="Map">
|
||||
<Option type="QString" name="name" value=""/>
|
||||
<Option name="properties"/>
|
||||
<Option type="QString" name="type" value="collection"/>
|
||||
</Option>
|
||||
</data_defined_properties>
|
||||
<layer enabled="1" id="" locked="0" class="SimpleFill" pass="0">
|
||||
<Option type="Map">
|
||||
<Option type="QString" name="border_width_map_unit_scale" value="3x:0,0,0,0,0,0"/>
|
||||
<Option type="QString" name="color" value="255,255,255,255,rgb:1,1,1,1"/>
|
||||
<Option type="QString" name="joinstyle" value="bevel"/>
|
||||
<Option type="QString" name="offset" value="0,0"/>
|
||||
<Option type="QString" name="offset_map_unit_scale" value="3x:0,0,0,0,0,0"/>
|
||||
<Option type="QString" name="offset_unit" value="MM"/>
|
||||
<Option type="QString" name="outline_color" value="128,128,128,255,rgb:0.50196078431372548,0.50196078431372548,0.50196078431372548,1"/>
|
||||
<Option type="QString" name="outline_style" value="no"/>
|
||||
<Option type="QString" name="outline_width" value="0"/>
|
||||
<Option type="QString" name="outline_width_unit" value="Point"/>
|
||||
<Option type="QString" name="style" value="solid"/>
|
||||
</Option>
|
||||
<data_defined_properties>
|
||||
<Option type="Map">
|
||||
<Option type="QString" name="name" value=""/>
|
||||
<Option name="properties"/>
|
||||
<Option type="QString" name="type" value="collection"/>
|
||||
</Option>
|
||||
</data_defined_properties>
|
||||
</layer>
|
||||
</symbol>
|
||||
</background>
|
||||
<shadow shadowOffsetAngle="135" shadowRadiusAlphaOnly="0" shadowUnder="0" shadowOffsetMapUnitScale="3x:0,0,0,0,0,0" shadowRadius="1.5" shadowOffsetDist="1" shadowScale="100" shadowColor="0,0,0,255,rgb:0,0,0,1" shadowOffsetUnit="MM" shadowRadiusUnit="MM" shadowBlendMode="6" shadowOpacity="0.69999999999999996" shadowDraw="0" shadowRadiusMapUnitScale="3x:0,0,0,0,0,0" shadowOffsetGlobal="1"/>
|
||||
<dd_properties>
|
||||
<Option type="Map">
|
||||
<Option type="QString" name="name" value=""/>
|
||||
<Option name="properties"/>
|
||||
<Option type="QString" name="type" value="collection"/>
|
||||
</Option>
|
||||
</dd_properties>
|
||||
<substitutions/>
|
||||
</text-style>
|
||||
<text-format useMaxLineLengthForAutoWrap="1" formatNumbers="0" leftDirectionSymbol="<" rightDirectionSymbol=">" reverseDirectionSymbol="0" decimals="3" wrapChar="" autoWrapLength="0" placeDirectionSymbol="0" plussign="0" multilineAlign="1" addDirectionSymbol="0"/>
|
||||
<placement placementFlags="14" placement="4" distUnits="MM" maximumDistance="0" fitInPolygonOnly="0" offsetUnits="MM" repeatDistance="0" geometryGeneratorEnabled="0" rotationUnit="AngleDegrees" lineAnchorTextPoint="CenterOfText" lineAnchorClipping="1" centroidWhole="0" priority="6" geometryGeneratorType="PointGeometry" prioritization="PreferCloser" repeatDistanceMapUnitScale="3x:0,0,0,0,0,0" overrunDistance="0" rotationAngle="0" quadOffset="4" dist="5" overlapHandling="PreventOverlap" offsetType="0" layerType="PolygonGeometry" repeatDistanceUnits="MM" predefinedPositionOrder="TR,TL,BR,BL,R,L,TSR,BSR" overrunDistanceMapUnitScale="3x:0,0,0,0,0,0" centroidInside="0" maximumDistanceUnit="MM" allowDegraded="0" maxCurvedCharAngleOut="-25" polygonPlacementFlags="3" labelOffsetMapUnitScale="3x:0,0,0,0,0,0" geometryGenerator="" lineAnchorType="0" lineAnchorPercent="0.5" xOffset="0" preserveRotation="0" maxCurvedCharAngleIn="25" overrunDistanceUnit="MM" yOffset="0" maximumDistanceMapUnitScale="3x:0,0,0,0,0,0" distMapUnitScale="3x:0,0,0,0,0,0"/>
|
||||
<rendering obstacle="1" scaleVisibility="0" maxNumLabels="2000" drawLabels="1" labelPerPart="0" obstacleFactor="1.2" fontMinPixelSize="3" scaleMin="0" unplacedVisibility="0" zIndex="0" upsidedownLabels="0" fontLimitPixelSize="0" limitNumLabels="0" minFeatureSize="0" fontMaxPixelSize="10000" scaleMax="0" obstacleType="1" mergeLines="0"/>
|
||||
<dd_properties>
|
||||
<Option type="Map">
|
||||
<Option type="QString" name="name" value=""/>
|
||||
<Option name="properties"/>
|
||||
<Option type="QString" name="type" value="collection"/>
|
||||
</Option>
|
||||
</dd_properties>
|
||||
<callout type="simple">
|
||||
<Option type="Map">
|
||||
<Option type="QString" name="anchorPoint" value="pole_of_inaccessibility"/>
|
||||
<Option type="int" name="blendMode" value="0"/>
|
||||
<Option type="Map" name="ddProperties">
|
||||
<Option type="QString" name="name" value=""/>
|
||||
<Option name="properties"/>
|
||||
<Option type="QString" name="type" value="collection"/>
|
||||
</Option>
|
||||
<Option type="bool" name="drawToAllParts" value="false"/>
|
||||
<Option type="QString" name="enabled" value="0"/>
|
||||
<Option type="QString" name="labelAnchorPoint" value="point_on_exterior"/>
|
||||
<Option type="QString" name="lineSymbol" value="<symbol alpha="1" type="line" name="symbol" force_rhr="0" clip_to_extent="1" is_animated="0" frame_rate="10"><data_defined_properties><Option type="Map"><Option type="QString" name="name" value=""/><Option name="properties"/><Option type="QString" name="type" value="collection"/></Option></data_defined_properties><layer enabled="1" id="{03dc8a93-2fef-4767-9787-e3607853696d}" locked="0" class="SimpleLine" pass="0"><Option type="Map"><Option type="QString" name="align_dash_pattern" value="0"/><Option type="QString" name="capstyle" value="square"/><Option type="QString" name="customdash" value="5;2"/><Option type="QString" name="customdash_map_unit_scale" value="3x:0,0,0,0,0,0"/><Option type="QString" name="customdash_unit" value="MM"/><Option type="QString" name="dash_pattern_offset" value="0"/><Option type="QString" name="dash_pattern_offset_map_unit_scale" value="3x:0,0,0,0,0,0"/><Option type="QString" name="dash_pattern_offset_unit" value="MM"/><Option type="QString" name="draw_inside_polygon" value="0"/><Option type="QString" name="joinstyle" value="bevel"/><Option type="QString" name="line_color" value="60,60,60,255,rgb:0.23529411764705882,0.23529411764705882,0.23529411764705882,1"/><Option type="QString" name="line_style" value="solid"/><Option type="QString" name="line_width" value="0.3"/><Option type="QString" name="line_width_unit" value="MM"/><Option type="QString" name="offset" value="0"/><Option type="QString" name="offset_map_unit_scale" value="3x:0,0,0,0,0,0"/><Option type="QString" name="offset_unit" value="MM"/><Option type="QString" name="ring_filter" value="0"/><Option type="QString" name="trim_distance_end" value="0"/><Option type="QString" name="trim_distance_end_map_unit_scale" value="3x:0,0,0,0,0,0"/><Option type="QString" name="trim_distance_end_unit" value="MM"/><Option type="QString" name="trim_distance_start" value="0"/><Option type="QString" name="trim_distance_start_map_unit_scale" value="3x:0,0,0,0,0,0"/><Option type="QString" name="trim_distance_start_unit" value="MM"/><Option type="QString" name="tweak_dash_pattern_on_corners" value="0"/><Option type="QString" name="use_custom_dash" value="0"/><Option type="QString" name="width_map_unit_scale" value="3x:0,0,0,0,0,0"/></Option><data_defined_properties><Option type="Map"><Option type="QString" name="name" value=""/><Option name="properties"/><Option type="QString" name="type" value="collection"/></Option></data_defined_properties></layer></symbol>"/>
|
||||
<Option type="double" name="minLength" value="0"/>
|
||||
<Option type="QString" name="minLengthMapUnitScale" value="3x:0,0,0,0,0,0"/>
|
||||
<Option type="QString" name="minLengthUnit" value="MM"/>
|
||||
<Option type="double" name="offsetFromAnchor" value="0"/>
|
||||
<Option type="QString" name="offsetFromAnchorMapUnitScale" value="3x:0,0,0,0,0,0"/>
|
||||
<Option type="QString" name="offsetFromAnchorUnit" value="MM"/>
|
||||
<Option type="double" name="offsetFromLabel" value="0"/>
|
||||
<Option type="QString" name="offsetFromLabelMapUnitScale" value="3x:0,0,0,0,0,0"/>
|
||||
<Option type="QString" name="offsetFromLabelUnit" value="MM"/>
|
||||
</Option>
|
||||
</callout>
|
||||
</settings>
|
||||
</rule>
|
||||
</rules>
|
||||
</labeling>
|
||||
<customproperties>
|
||||
<Option type="Map">
|
||||
<Option type="List" name="dualview/previewExpressions">
|
||||
<Option type="QString" value=""NAME""/>
|
||||
</Option>
|
||||
<Option type="int" name="embeddedWidgets/count" value="0"/>
|
||||
<Option type="QString" name="geopdf/groupName" value="Verfahren"/>
|
||||
<Option name="variableNames"/>
|
||||
<Option name="variableValues"/>
|
||||
<Option type="QString" name="vln_karten/api_path" value="/maps/p41/27027"/>
|
||||
<Option type="QString" name="vln_karten/dataset" value="p41"/>
|
||||
<Option type="QString" name="vln_karten/verfahren" value="27027"/>
|
||||
</Option>
|
||||
</customproperties>
|
||||
<blendMode>0</blendMode>
|
||||
<featureBlendMode>0</featureBlendMode>
|
||||
<layerOpacity>1</layerOpacity>
|
||||
<geometryOptions removeDuplicateNodes="0" geometryPrecision="0">
|
||||
<activeChecks/>
|
||||
<checkConfiguration type="Map">
|
||||
<Option type="Map" name="QgsGeometryGapCheck">
|
||||
<Option type="double" name="allowedGapsBuffer" value="0"/>
|
||||
<Option type="bool" name="allowedGapsEnabled" value="false"/>
|
||||
<Option type="QString" name="allowedGapsLayer" value=""/>
|
||||
</Option>
|
||||
</checkConfiguration>
|
||||
</geometryOptions>
|
||||
<legend type="default-vector" showLabelLegend="0"/>
|
||||
<referencedLayers/>
|
||||
<fieldConfiguration>
|
||||
<field name="Anzahl" configurationFlags="NoFlag">
|
||||
<editWidget type="Range">
|
||||
<config>
|
||||
<Option/>
|
||||
</config>
|
||||
</editWidget>
|
||||
</field>
|
||||
<field name="Baumart" configurationFlags="NoFlag">
|
||||
<editWidget type="TextEdit">
|
||||
<config>
|
||||
<Option/>
|
||||
</config>
|
||||
</editWidget>
|
||||
</field>
|
||||
<field name="Belag" configurationFlags="NoFlag">
|
||||
<editWidget type="TextEdit">
|
||||
<config>
|
||||
<Option/>
|
||||
</config>
|
||||
</editWidget>
|
||||
</field>
|
||||
<field name="Fahrbahn" configurationFlags="NoFlag">
|
||||
<editWidget type="TextEdit">
|
||||
<config>
|
||||
<Option/>
|
||||
</config>
|
||||
</editWidget>
|
||||
</field>
|
||||
<field name="Krone" configurationFlags="NoFlag">
|
||||
<editWidget type="Range">
|
||||
<config>
|
||||
<Option/>
|
||||
</config>
|
||||
</editWidget>
|
||||
</field>
|
||||
<field name="MKZ" configurationFlags="NoFlag">
|
||||
<editWidget type="TextEdit">
|
||||
<config>
|
||||
<Option/>
|
||||
</config>
|
||||
</editWidget>
|
||||
</field>
|
||||
<field name="NAME" configurationFlags="NoFlag">
|
||||
<editWidget type="TextEdit">
|
||||
<config>
|
||||
<Option/>
|
||||
</config>
|
||||
</editWidget>
|
||||
</field>
|
||||
<field name="VKZ" configurationFlags="NoFlag">
|
||||
<editWidget type="TextEdit">
|
||||
<config>
|
||||
<Option/>
|
||||
</config>
|
||||
</editWidget>
|
||||
</field>
|
||||
<field name="qm" configurationFlags="NoFlag">
|
||||
<editWidget type="Range">
|
||||
<config>
|
||||
<Option/>
|
||||
</config>
|
||||
</editWidget>
|
||||
</field>
|
||||
</fieldConfiguration>
|
||||
<aliases>
|
||||
<alias name="" index="0" field="Anzahl"/>
|
||||
<alias name="" index="1" field="Baumart"/>
|
||||
<alias name="" index="2" field="Belag"/>
|
||||
<alias name="" index="3" field="Fahrbahn"/>
|
||||
<alias name="" index="4" field="Krone"/>
|
||||
<alias name="" index="5" field="MKZ"/>
|
||||
<alias name="" index="6" field="NAME"/>
|
||||
<alias name="" index="7" field="VKZ"/>
|
||||
<alias name="" index="8" field="qm"/>
|
||||
</aliases>
|
||||
<splitPolicies>
|
||||
<policy policy="Duplicate" field="Anzahl"/>
|
||||
<policy policy="Duplicate" field="Baumart"/>
|
||||
<policy policy="Duplicate" field="Belag"/>
|
||||
<policy policy="Duplicate" field="Fahrbahn"/>
|
||||
<policy policy="Duplicate" field="Krone"/>
|
||||
<policy policy="Duplicate" field="MKZ"/>
|
||||
<policy policy="Duplicate" field="NAME"/>
|
||||
<policy policy="Duplicate" field="VKZ"/>
|
||||
<policy policy="Duplicate" field="qm"/>
|
||||
</splitPolicies>
|
||||
<duplicatePolicies>
|
||||
<policy policy="Duplicate" field="Anzahl"/>
|
||||
<policy policy="Duplicate" field="Baumart"/>
|
||||
<policy policy="Duplicate" field="Belag"/>
|
||||
<policy policy="Duplicate" field="Fahrbahn"/>
|
||||
<policy policy="Duplicate" field="Krone"/>
|
||||
<policy policy="Duplicate" field="MKZ"/>
|
||||
<policy policy="Duplicate" field="NAME"/>
|
||||
<policy policy="Duplicate" field="VKZ"/>
|
||||
<policy policy="Duplicate" field="qm"/>
|
||||
</duplicatePolicies>
|
||||
<defaults>
|
||||
<default expression="" applyOnUpdate="0" field="Anzahl"/>
|
||||
<default expression="" applyOnUpdate="0" field="Baumart"/>
|
||||
<default expression="" applyOnUpdate="0" field="Belag"/>
|
||||
<default expression="" applyOnUpdate="0" field="Fahrbahn"/>
|
||||
<default expression="" applyOnUpdate="0" field="Krone"/>
|
||||
<default expression="" applyOnUpdate="0" field="MKZ"/>
|
||||
<default expression="" applyOnUpdate="0" field="NAME"/>
|
||||
<default expression="" applyOnUpdate="0" field="VKZ"/>
|
||||
<default expression="" applyOnUpdate="0" field="qm"/>
|
||||
</defaults>
|
||||
<constraints>
|
||||
<constraint exp_strength="0" notnull_strength="0" constraints="0" unique_strength="0" field="Anzahl"/>
|
||||
<constraint exp_strength="0" notnull_strength="0" constraints="0" unique_strength="0" field="Baumart"/>
|
||||
<constraint exp_strength="0" notnull_strength="0" constraints="0" unique_strength="0" field="Belag"/>
|
||||
<constraint exp_strength="0" notnull_strength="0" constraints="0" unique_strength="0" field="Fahrbahn"/>
|
||||
<constraint exp_strength="0" notnull_strength="0" constraints="0" unique_strength="0" field="Krone"/>
|
||||
<constraint exp_strength="0" notnull_strength="0" constraints="0" unique_strength="0" field="MKZ"/>
|
||||
<constraint exp_strength="0" notnull_strength="0" constraints="0" unique_strength="0" field="NAME"/>
|
||||
<constraint exp_strength="0" notnull_strength="0" constraints="0" unique_strength="0" field="VKZ"/>
|
||||
<constraint exp_strength="0" notnull_strength="0" constraints="0" unique_strength="0" field="qm"/>
|
||||
</constraints>
|
||||
<constraintExpressions>
|
||||
<constraint exp="" desc="" field="Anzahl"/>
|
||||
<constraint exp="" desc="" field="Baumart"/>
|
||||
<constraint exp="" desc="" field="Belag"/>
|
||||
<constraint exp="" desc="" field="Fahrbahn"/>
|
||||
<constraint exp="" desc="" field="Krone"/>
|
||||
<constraint exp="" desc="" field="MKZ"/>
|
||||
<constraint exp="" desc="" field="NAME"/>
|
||||
<constraint exp="" desc="" field="VKZ"/>
|
||||
<constraint exp="" desc="" field="qm"/>
|
||||
</constraintExpressions>
|
||||
<expressionfields/>
|
||||
<attributeactions>
|
||||
<defaultAction value="{00000000-0000-0000-0000-000000000000}" key="Canvas"/>
|
||||
</attributeactions>
|
||||
<attributetableconfig sortExpression="" actionWidgetStyle="dropDown" sortOrder="0">
|
||||
<columns>
|
||||
<column type="field" name="Anzahl" width="-1" hidden="0"/>
|
||||
<column type="field" name="Baumart" width="-1" hidden="0"/>
|
||||
<column type="field" name="Belag" width="-1" hidden="0"/>
|
||||
<column type="field" name="Fahrbahn" width="-1" hidden="0"/>
|
||||
<column type="field" name="Krone" width="-1" hidden="0"/>
|
||||
<column type="field" name="MKZ" width="-1" hidden="0"/>
|
||||
<column type="field" name="NAME" width="-1" hidden="0"/>
|
||||
<column type="field" name="VKZ" width="-1" hidden="0"/>
|
||||
<column type="field" name="qm" width="-1" hidden="0"/>
|
||||
<column type="actions" width="-1" hidden="1"/>
|
||||
</columns>
|
||||
</attributetableconfig>
|
||||
<conditionalstyles>
|
||||
<rowstyles/>
|
||||
<fieldstyles/>
|
||||
</conditionalstyles>
|
||||
<storedexpressions/>
|
||||
<editform tolerant="1"></editform>
|
||||
<editforminit/>
|
||||
<editforminitcodesource>0</editforminitcodesource>
|
||||
<editforminitfilepath></editforminitfilepath>
|
||||
<editforminitcode><![CDATA[# -*- coding: utf-8 -*-
|
||||
"""
|
||||
QGIS-Formulare können eine Python-Funktion haben,, die aufgerufen wird, wenn sich das Formular öffnet
|
||||
|
||||
Diese Funktion kann verwendet werden um dem Formular Extralogik hinzuzufügen.
|
||||
|
||||
Der Name der Funktion wird im Feld "Python Init-Function" angegeben
|
||||
Ein Beispiel folgt:
|
||||
"""
|
||||
from qgis.PyQt.QtWidgets import QWidget
|
||||
|
||||
def my_form_open(dialog, layer, feature):
|
||||
geom = feature.geometry()
|
||||
control = dialog.findChild(QWidget, "MyLineEdit")
|
||||
]]></editforminitcode>
|
||||
<featformsuppress>0</featformsuppress>
|
||||
<editorlayout>generatedlayout</editorlayout>
|
||||
<editable>
|
||||
<field name="Anzahl" editable="1"/>
|
||||
<field name="Baumart" editable="1"/>
|
||||
<field name="Belag" editable="1"/>
|
||||
<field name="Fahrbahn" editable="1"/>
|
||||
<field name="Krone" editable="1"/>
|
||||
<field name="MKZ" editable="1"/>
|
||||
<field name="NAME" editable="1"/>
|
||||
<field name="VKZ" editable="1"/>
|
||||
<field name="qm" editable="1"/>
|
||||
</editable>
|
||||
<labelOnTop>
|
||||
<field name="Anzahl" labelOnTop="0"/>
|
||||
<field name="Baumart" labelOnTop="0"/>
|
||||
<field name="Belag" labelOnTop="0"/>
|
||||
<field name="Fahrbahn" labelOnTop="0"/>
|
||||
<field name="Krone" labelOnTop="0"/>
|
||||
<field name="MKZ" labelOnTop="0"/>
|
||||
<field name="NAME" labelOnTop="0"/>
|
||||
<field name="VKZ" labelOnTop="0"/>
|
||||
<field name="qm" labelOnTop="0"/>
|
||||
</labelOnTop>
|
||||
<reuseLastValue>
|
||||
<field reuseLastValue="0" name="Anzahl"/>
|
||||
<field reuseLastValue="0" name="Baumart"/>
|
||||
<field reuseLastValue="0" name="Belag"/>
|
||||
<field reuseLastValue="0" name="Fahrbahn"/>
|
||||
<field reuseLastValue="0" name="Krone"/>
|
||||
<field reuseLastValue="0" name="MKZ"/>
|
||||
<field reuseLastValue="0" name="NAME"/>
|
||||
<field reuseLastValue="0" name="VKZ"/>
|
||||
<field reuseLastValue="0" name="qm"/>
|
||||
</reuseLastValue>
|
||||
<dataDefinedFieldProperties/>
|
||||
<widgets/>
|
||||
<previewExpression>"NAME"</previewExpression>
|
||||
<mapTip enabled="1"></mapTip>
|
||||
<layerGeometryType>2</layerGeometryType>
|
||||
</qgis>
|
||||
@@ -0,0 +1,248 @@
|
||||
"""HTTP-Client für die VLN-Manager-API.
|
||||
|
||||
API-Vertrag (Stand 2026-06-08):
|
||||
|
||||
Basis-URL: https://api.flurneuordnung-sachsen.de/v2
|
||||
|
||||
POST /person/login
|
||||
Body: {"mail": "...", "password": "..."}
|
||||
Antwort: {"data": {"userauth": "<url_token>", "id": ...}}
|
||||
Das userauth-Token ist der API-Key (= PERSON.url_token).
|
||||
|
||||
GET /tgen -> Teilnehmergemeinschaften (Verfahren)
|
||||
|
||||
GET /maps/<layer>/{vkz} -> GeoJSON FeatureCollection (EPSG:25833)
|
||||
PUT /maps/<layer>/{vkz} <- GeoJSON (ersetzt den Layer dieser VKZ)
|
||||
Antwort: {"data": {"vkz", "layer", "art", "speicher_id"}, "status": "ok"}
|
||||
|
||||
Layer: umringe, p41, st, kas, we
|
||||
Auth nach Login über den Header "X-API-Key".
|
||||
Fehlerformat: RFC 7807 (application/problem+json).
|
||||
|
||||
Es wird QgsBlockingNetworkRequest verwendet, damit Proxy- und
|
||||
Zertifikatseinstellungen aus QGIS automatisch greifen.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from sn_basis.functions.qt_wrapper import QUrl, QNetworkRequest
|
||||
from sn_basis.functions.qgiscore_wrapper import QgsBlockingNetworkRequest
|
||||
|
||||
# Fest verdrahtete Produktiv-API des VLN Managers.
|
||||
DEFAULT_BASE_URL = "https://api.flurneuordnung-sachsen.de/v2"
|
||||
|
||||
|
||||
class ApiError(Exception):
|
||||
"""Fehler bei der Kommunikation mit der Karten-API."""
|
||||
|
||||
|
||||
class AuthError(ApiError):
|
||||
"""API-Key fehlt, ist abgelaufen oder hat keine Berechtigung (401/403)."""
|
||||
|
||||
|
||||
class KartenApiClient:
|
||||
def __init__(self, base_url=DEFAULT_BASE_URL, api_key=None, mail=None):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.api_key = api_key
|
||||
self.mail = mail
|
||||
|
||||
@property
|
||||
def is_authenticated(self):
|
||||
return bool(self.api_key)
|
||||
|
||||
def login(self, mail, password):
|
||||
"""Meldet den Benutzer an. Die API liefert das userauth-Token
|
||||
(= API-Key), das für alle weiteren Requests verwendet wird."""
|
||||
data = self._request(
|
||||
"POST",
|
||||
"/person/login",
|
||||
payload={"mail": mail, "password": password},
|
||||
with_auth=False,
|
||||
)
|
||||
payload = data.get("data") if isinstance(data, dict) else None
|
||||
if not isinstance(payload, dict):
|
||||
payload = data if isinstance(data, dict) else {}
|
||||
api_key = payload.get("userauth")
|
||||
if not api_key:
|
||||
raise ApiError(
|
||||
"Login-Antwort enthielt kein userauth-Token. "
|
||||
"Erhaltene Antwort: %s" % json.dumps(data)[:300]
|
||||
)
|
||||
self.api_key = api_key
|
||||
self.mail = mail
|
||||
return api_key
|
||||
|
||||
def logout(self):
|
||||
self.api_key = None
|
||||
self.mail = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Verfahren (Teilnehmergemeinschaften)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_verfahren(self):
|
||||
"""Liste der Verfahren (TGs) als [{"vkz": ..., "name": ...}, ...],
|
||||
sortiert nach VKZ. Quelle: GET /tgen."""
|
||||
data = self._request("GET", "/tgen")
|
||||
|
||||
rows = None
|
||||
if isinstance(data, list):
|
||||
rows = data
|
||||
elif isinstance(data, dict):
|
||||
for key in ("data", "rows"):
|
||||
value = data.get(key)
|
||||
if isinstance(value, list):
|
||||
rows = value
|
||||
break
|
||||
if isinstance(value, dict) and isinstance(value.get("rows"), list):
|
||||
rows = value["rows"]
|
||||
break
|
||||
if rows is None:
|
||||
raise ApiError(
|
||||
"Antwort von /tgen hat ein unerwartetes Format: %s"
|
||||
% json.dumps(data)[:200]
|
||||
)
|
||||
|
||||
verfahren = []
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
vkz = row.get("vkz") or row.get("VKZ")
|
||||
name = (
|
||||
row.get("kurzname")
|
||||
or row.get("Kurzname")
|
||||
or row.get("name")
|
||||
or row.get("Name")
|
||||
or ""
|
||||
)
|
||||
if vkz:
|
||||
verfahren.append({"vkz": str(vkz), "name": str(name)})
|
||||
verfahren.sort(key=lambda v: v["vkz"])
|
||||
return verfahren
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Karten-Layer
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def load_layer_complete(self, layer_key, vkz, page_size=2000):
|
||||
"""Lädt ALLE Objekte eines Layers für eine VKZ aus KARTE_OBJEKT.
|
||||
|
||||
Nutzt den Listen-Endpunkt /maps/<layer>?vkz=… mit limit/offset-Paging,
|
||||
bis keine weitere Seite mehr kommt."""
|
||||
from urllib.parse import quote
|
||||
|
||||
features = []
|
||||
offset = 0
|
||||
while True:
|
||||
data = self._request(
|
||||
"GET",
|
||||
"/maps/%s?vkz=%s&limit=%d&offset=%d"
|
||||
% (quote(str(layer_key)), quote(str(vkz)), page_size, offset),
|
||||
)
|
||||
if not isinstance(data, dict) or data.get("type") != "FeatureCollection":
|
||||
raise ApiError(
|
||||
"Antwort von /maps/%s ist keine GeoJSON FeatureCollection."
|
||||
% layer_key
|
||||
)
|
||||
page = data.get("features", [])
|
||||
features.extend(page)
|
||||
if len(page) < page_size:
|
||||
break
|
||||
offset += page_size
|
||||
return {"type": "FeatureCollection", "features": features}
|
||||
|
||||
def load_feature_collection(self, path):
|
||||
"""Lädt einen Layer als GeoJSON FeatureCollection (dict)."""
|
||||
data = self._request("GET", path)
|
||||
if isinstance(data, dict):
|
||||
if data.get("type") == "FeatureCollection":
|
||||
return data
|
||||
inner = data.get("data")
|
||||
if isinstance(inner, dict) and inner.get("type") == "FeatureCollection":
|
||||
return inner
|
||||
raise ApiError(
|
||||
"Antwort von %s ist keine GeoJSON FeatureCollection." % path
|
||||
)
|
||||
|
||||
def save_feature_collection(self, path, feature_collection):
|
||||
"""Schreibt eine GeoJSON FeatureCollection zurück an die API
|
||||
(PUT ersetzt den kompletten Layer der jeweiligen VKZ)."""
|
||||
return self._request("PUT", path, payload=feature_collection)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Intern
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _request(self, method, path, payload=None, with_auth=True):
|
||||
if with_auth and not self.is_authenticated:
|
||||
raise AuthError("Nicht angemeldet — bitte zuerst einloggen.")
|
||||
|
||||
request = QNetworkRequest(QUrl(self.base_url + path))
|
||||
request.setHeader(
|
||||
QNetworkRequest.KnownHeaders.ContentTypeHeader, "application/json"
|
||||
)
|
||||
request.setRawHeader(b"Accept", b"application/json")
|
||||
if with_auth:
|
||||
request.setRawHeader(b"X-API-Key", self.api_key.encode("utf-8"))
|
||||
|
||||
body = b""
|
||||
if payload is not None:
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
|
||||
blocking = QgsBlockingNetworkRequest()
|
||||
if method == "GET":
|
||||
error = blocking.get(request)
|
||||
elif method == "POST":
|
||||
error = blocking.post(request, body)
|
||||
elif method == "PUT":
|
||||
error = blocking.put(request, body)
|
||||
else:
|
||||
raise ApiError("Nicht unterstützte HTTP-Methode: %s" % method)
|
||||
|
||||
reply = blocking.reply()
|
||||
content = bytes(reply.content())
|
||||
status = reply.attribute(
|
||||
QNetworkRequest.Attribute.HttpStatusCodeAttribute
|
||||
)
|
||||
status = int(status) if status is not None else None
|
||||
|
||||
if status in (401, 403):
|
||||
raise AuthError(
|
||||
"Keine Berechtigung für %s %s (HTTP %s)%s"
|
||||
% (method, path, status, self._error_detail(content))
|
||||
)
|
||||
if error != QgsBlockingNetworkRequest.NoError:
|
||||
raise ApiError(
|
||||
"%s %s fehlgeschlagen: %s%s"
|
||||
% (method, path, blocking.errorMessage(), self._error_detail(content))
|
||||
)
|
||||
if status is not None and status >= 400:
|
||||
raise ApiError(
|
||||
"%s %s lieferte HTTP %s%s"
|
||||
% (method, path, status, self._error_detail(content))
|
||||
)
|
||||
|
||||
if not content:
|
||||
return None
|
||||
try:
|
||||
return json.loads(content.decode("utf-8"))
|
||||
except ValueError:
|
||||
raise ApiError(
|
||||
"Antwort von %s ist kein gültiges JSON." % path
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _error_detail(content):
|
||||
"""Liest title/detail aus einer RFC-7807-Fehlerantwort."""
|
||||
if not content:
|
||||
return ""
|
||||
try:
|
||||
problem = json.loads(content.decode("utf-8"))
|
||||
parts = [
|
||||
str(problem[k]) for k in ("title", "detail") if problem.get(k)
|
||||
]
|
||||
if parts:
|
||||
return " — " + ": ".join(parts)
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
return " — " + content[:300].decode("utf-8", "replace")
|
||||
@@ -0,0 +1,378 @@
|
||||
"""
|
||||
sn_plan41/modules/vln_api_logic.py – Fachlogik für die VLN-API-Integration in Tab A.
|
||||
|
||||
Kapselt:
|
||||
- Anmeldung / Abmeldung (KartenApiClient aus vln_karten)
|
||||
- Persistierung des API-Keys in QSettings
|
||||
- Verfahrensliste laden (GET /tgen)
|
||||
- Persistierung der VKZ-Auswahl in der Projektdatei
|
||||
- Plan 41 laden (2 Layer + Stile aus sn_plan41/assets/)
|
||||
- Aktiven Layer hochladen
|
||||
|
||||
Voraussetzung: Das Plugin `vln_karten` muss installiert sein.
|
||||
Wenn nicht vorhanden, liefert :attr:`is_available` False; die UI
|
||||
kann dann einen entsprechenden Hinweis anzeigen.
|
||||
|
||||
Sitzungsablauf bei AuthError:
|
||||
- :meth:`handle_session_expired` aufrufen → API-Key löschen + logout
|
||||
- In der UI danach :meth:`_update_vln_ui_state` aufrufen und
|
||||
QMessageBox.warning anzeigen. Kein automatischer Login-Dialog.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from sn_basis.functions.qt_wrapper import QSettings
|
||||
from sn_basis.functions.qgiscore_wrapper import QgsProject
|
||||
from sn_basis.functions.sys_wrapper import get_plugin_root, join_path
|
||||
from sn_basis.functions.ly_style_wrapper import apply_style_from_path
|
||||
|
||||
# Lokale API-Module — fallenübrig, sobald QGIS verfügbar ist.
|
||||
# In der reinen Python-Testumgebung (kein qgis-Paket) schlagen die
|
||||
# qgis-Imports in den Untermodulen fehl; daher der try/except-Guard.
|
||||
try:
|
||||
from sn_plan41.modules.vln_api_client import KartenApiClient, ApiError, AuthError
|
||||
from sn_plan41.modules.vln_layer_manager import (
|
||||
feature_collection_to_layers,
|
||||
plugin_layers,
|
||||
layers_to_feature_collection,
|
||||
layer_api_path,
|
||||
PROP_DATASET,
|
||||
PROP_VERFAHREN,
|
||||
)
|
||||
VLN_KARTEN_AVAILABLE = True
|
||||
except (ImportError, ModuleNotFoundError):
|
||||
KartenApiClient = None # type: ignore[assignment,misc]
|
||||
ApiError = Exception # type: ignore[assignment,misc]
|
||||
AuthError = Exception # type: ignore[assignment,misc]
|
||||
VLN_KARTEN_AVAILABLE = False
|
||||
|
||||
# Geometrietyp-Konstanten (QGIS intern: 1 = Linie, 2 = Fläche)
|
||||
try:
|
||||
from qgis.core import Qgis as _Qgis
|
||||
_GEOM_LINE = _Qgis.GeometryType.Line
|
||||
_GEOM_POLYGON = _Qgis.GeometryType.Polygon
|
||||
except (ImportError, AttributeError):
|
||||
try:
|
||||
from qgis.core import QgsWkbTypes as _QgsWkbTypes
|
||||
_GEOM_LINE = _QgsWkbTypes.LineGeometry
|
||||
_GEOM_POLYGON = _QgsWkbTypes.PolygonGeometry
|
||||
except (ImportError, AttributeError):
|
||||
_GEOM_LINE = 1
|
||||
_GEOM_POLYGON = 2
|
||||
|
||||
# Konstanten
|
||||
SETTINGS_GROUP = "vln_karten"
|
||||
PROJECT_SCOPE = "vln_karten"
|
||||
PROJECT_KEY_VKZ = "/vkz"
|
||||
|
||||
P41_DATASET_KEY = "p41"
|
||||
P41_LABEL = "Plan 41 (Wege- und Gewässerplan)"
|
||||
P41_PATH_TEMPLATE = "/maps/p41/{vkz}"
|
||||
P41_GEOMETRY = "MultiPolygon"
|
||||
|
||||
STYLE_FLAECHE = "QGIS_P41_API_Layer_flaeche.qml"
|
||||
STYLE_LINIE = "QGIS_P41_API_Layer_Linie.qml"
|
||||
|
||||
|
||||
class VlnApiLogic:
|
||||
"""
|
||||
Kapselt die VLN-API-Fachlogik für den Plan41-Tab.
|
||||
|
||||
Alle Methoden, die Netzwerkkommunikation erfordern, können
|
||||
``ApiError`` oder ``AuthError`` werfen – die UI ist für die
|
||||
Fehlerdarstellung zuständig.
|
||||
"""
|
||||
|
||||
def __init__(self, pruefmanager=None) -> None:
|
||||
self.pruefmanager = pruefmanager
|
||||
self._client: Optional[object] = None # KartenApiClient | None
|
||||
|
||||
# API-Client aus gespeicherten Credentials initialisieren
|
||||
if VLN_KARTEN_AVAILABLE:
|
||||
api_key, mail = self.load_stored_credentials()
|
||||
if api_key:
|
||||
self._client = KartenApiClient(api_key=api_key, mail=mail)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Verfügbarkeit
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def is_available() -> bool:
|
||||
"""True, wenn die lokalen API-Module geladen werden konnten
|
||||
(erfordert eine QGIS-Laufzeitumgebung)."""
|
||||
return VLN_KARTEN_AVAILABLE
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Authentifizierungsstatus
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def is_authenticated(self) -> bool:
|
||||
"""True, wenn ein gültiger API-Key vorhanden ist."""
|
||||
if self._client is None:
|
||||
return False
|
||||
return bool(getattr(self._client, "is_authenticated", False))
|
||||
|
||||
@property
|
||||
def mail(self) -> Optional[str]:
|
||||
"""E-Mail-Adresse des angemeldeten Benutzers oder None."""
|
||||
if self._client is None:
|
||||
return None
|
||||
return getattr(self._client, "mail", None)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# QSettings: API-Key persistieren
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def load_stored_credentials(self) -> tuple:
|
||||
"""Liest API-Key und Mail aus QSettings.
|
||||
Gibt ``(api_key, mail)`` zurück – leere Strings wenn nicht vorhanden.
|
||||
"""
|
||||
settings = QSettings()
|
||||
settings.beginGroup(SETTINGS_GROUP)
|
||||
api_key = settings.value("api_key", "")
|
||||
mail = settings.value("mail", "")
|
||||
settings.endGroup()
|
||||
return api_key or None, mail or None
|
||||
|
||||
def save_api_key(self, api_key: str, mail: str) -> None:
|
||||
"""Speichert API-Key und Mail in QSettings."""
|
||||
settings = QSettings()
|
||||
settings.beginGroup(SETTINGS_GROUP)
|
||||
settings.setValue("api_key", api_key)
|
||||
settings.setValue("mail", mail or "")
|
||||
settings.endGroup()
|
||||
|
||||
def clear_api_key(self) -> None:
|
||||
"""Entfernt API-Key aus QSettings."""
|
||||
settings = QSettings()
|
||||
settings.beginGroup(SETTINGS_GROUP)
|
||||
settings.remove("api_key")
|
||||
settings.endGroup()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Login / Logout
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def login(self, mail: str, password: str) -> None:
|
||||
"""
|
||||
Meldet den Benutzer an und speichert den API-Key.
|
||||
|
||||
:raises ApiError: Bei ungültigen Zugangsdaten oder Netzwerkproblem.
|
||||
:raises RuntimeError: Wenn QGIS nicht verfügbar ist (kein qgis-Paket).
|
||||
"""
|
||||
if not VLN_KARTEN_AVAILABLE:
|
||||
raise RuntimeError(
|
||||
"VLN-API-Module nicht verfügbar (QGIS-Laufzeitumgebung benötigt)."
|
||||
)
|
||||
client = KartenApiClient()
|
||||
client.login(mail, password) # wirft ApiError bei Fehler
|
||||
self._client = client
|
||||
self.save_api_key(client.api_key, mail)
|
||||
|
||||
def logout(self) -> None:
|
||||
"""Meldet den Benutzer ab (lokal, kein API-Aufruf)."""
|
||||
if self._client is not None:
|
||||
getattr(self._client, "logout", lambda: None)()
|
||||
self._client = None
|
||||
|
||||
def handle_session_expired(self) -> None:
|
||||
"""
|
||||
Behandelt einen abgelaufenen API-Key:
|
||||
entfernt ihn aus QSettings und setzt den Client zurück.
|
||||
"""
|
||||
self.clear_api_key()
|
||||
self.logout()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Verfahrensliste
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_verfahren(self) -> list:
|
||||
"""
|
||||
Lädt alle Verfahren (TGs) vom Server.
|
||||
|
||||
:returns: Sortierte Liste von ``{"vkz": ..., "name": ...}``-Dicts.
|
||||
:raises AuthError: Wenn nicht angemeldet oder Session abgelaufen.
|
||||
:raises ApiError: Bei sonstigem Netzwerkfehler.
|
||||
"""
|
||||
if self._client is None:
|
||||
raise AuthError("Nicht angemeldet.")
|
||||
return self._client.get_verfahren()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# VKZ-Persistenz in Projektdatei
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def load_stored_vkz(self) -> Optional[str]:
|
||||
"""Liest die zuletzt gewählte VKZ aus der Projektdatei."""
|
||||
try:
|
||||
vkz, _ok = QgsProject.instance().readEntry(
|
||||
PROJECT_SCOPE, PROJECT_KEY_VKZ, ""
|
||||
)
|
||||
return vkz if vkz else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def save_vkz(self, vkz: Optional[str]) -> None:
|
||||
"""Schreibt die gewählte VKZ in die Projektdatei."""
|
||||
if not vkz:
|
||||
return
|
||||
try:
|
||||
QgsProject.instance().writeEntry(PROJECT_SCOPE, PROJECT_KEY_VKZ, vkz)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Plan 41 laden
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_p41_layers(self, vkz: str) -> list:
|
||||
"""Gibt alle bereits geladenen P41-Layer für diese VKZ zurück."""
|
||||
return plugin_layers(P41_DATASET_KEY, vkz)
|
||||
|
||||
def remove_p41_layers(self, vkz: str) -> None:
|
||||
"""Entfernt alle P41-Layer dieser VKZ aus dem Projekt."""
|
||||
existing = plugin_layers(P41_DATASET_KEY, vkz)
|
||||
if existing:
|
||||
QgsProject.instance().removeMapLayers([l.id() for l in existing])
|
||||
|
||||
def load_p41(self, vkz: str) -> list:
|
||||
"""
|
||||
Lädt den Plan-41-Datensatz für eine VKZ vom Server und legt
|
||||
Memory-Layer im Projekt an. Auf die entstandenen Layer werden
|
||||
die passenden QML-Stile aus ``sn_plan41/assets/`` angewendet.
|
||||
|
||||
:returns: Liste der erzeugten ``QgsVectorLayer``.
|
||||
:raises AuthError: Wenn nicht angemeldet oder Session abgelaufen.
|
||||
:raises ApiError: Bei sonstigem Netzwerkfehler.
|
||||
:raises RuntimeError: Wenn QGIS nicht verfügbar ist.
|
||||
"""
|
||||
if not VLN_KARTEN_AVAILABLE:
|
||||
raise RuntimeError(
|
||||
"VLN-API-Module nicht verfügbar (QGIS-Laufzeitumgebung benötigt)."
|
||||
)
|
||||
if self._client is None:
|
||||
raise AuthError("Nicht angemeldet.")
|
||||
|
||||
path = P41_PATH_TEMPLATE.format(vkz=vkz)
|
||||
feature_collection = self._client.load_layer_complete(
|
||||
P41_DATASET_KEY, vkz
|
||||
)
|
||||
base_name = "%s %s" % (P41_LABEL, vkz)
|
||||
layers = feature_collection_to_layers(
|
||||
feature_collection,
|
||||
base_name,
|
||||
P41_GEOMETRY,
|
||||
P41_DATASET_KEY,
|
||||
vkz,
|
||||
path,
|
||||
)
|
||||
self._apply_p41_styles(layers)
|
||||
return layers
|
||||
|
||||
def _apply_p41_styles(self, layers: list) -> None:
|
||||
"""Wendet den passenden QML-Stil auf jeden Layer an."""
|
||||
assets_dir = join_path(get_plugin_root(), "sn_plan41", "assets")
|
||||
for layer in layers:
|
||||
try:
|
||||
gtype = layer.geometryType()
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if gtype == _GEOM_POLYGON:
|
||||
style_file = STYLE_FLAECHE
|
||||
elif gtype == _GEOM_LINE:
|
||||
style_file = STYLE_LINIE
|
||||
else:
|
||||
continue # Punkt- und None-Layer erhalten keinen Stil
|
||||
|
||||
style_path = join_path(assets_dir, style_file)
|
||||
apply_style_from_path(layer, style_path)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Upload
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def upload_active_layer(self, active_layer) -> tuple:
|
||||
"""
|
||||
Lädt alle Teil-Layer des aktiven Layers zum Server hoch.
|
||||
|
||||
:param active_layer: Aktiver ``QgsVectorLayer`` (aus ``iface.activeLayer()``).
|
||||
:returns: ``(success: bool, message: str)``
|
||||
:raises AuthError: Wenn Session abgelaufen.
|
||||
:raises ApiError: Bei Netzwerkfehler.
|
||||
"""
|
||||
if not VLN_KARTEN_AVAILABLE:
|
||||
return False, (
|
||||
"VLN-API-Module nicht verfügbar (QGIS-Laufzeitumgebung benötigt)."
|
||||
)
|
||||
|
||||
if self._client is None:
|
||||
raise AuthError("Nicht angemeldet.")
|
||||
|
||||
path = layer_api_path(active_layer) if active_layer else None
|
||||
if not path:
|
||||
return (
|
||||
False,
|
||||
"Der aktive Layer wurde nicht über 'vln_karten' geladen. "
|
||||
"Bitte einen Plan41-Layer aus dem Projekt auswählen.",
|
||||
)
|
||||
|
||||
dataset_key = active_layer.customProperty(PROP_DATASET)
|
||||
vkz = active_layer.customProperty(PROP_VERFAHREN)
|
||||
siblings = plugin_layers(dataset_key, vkz)
|
||||
|
||||
for layer in siblings:
|
||||
if layer.isEditable() and not layer.commitChanges():
|
||||
return (
|
||||
False,
|
||||
"Die Bearbeitungssitzung von '%s' konnte nicht "
|
||||
"gespeichert werden." % layer.name(),
|
||||
)
|
||||
|
||||
feature_collection = layers_to_feature_collection(siblings)
|
||||
self._client.save_feature_collection(path, feature_collection)
|
||||
|
||||
total = sum(layer.featureCount() for layer in siblings)
|
||||
return (
|
||||
True,
|
||||
"%d Objekte aus %d Layer(n) erfolgreich hochgeladen."
|
||||
% (total, len(siblings)),
|
||||
)
|
||||
|
||||
def upload_summary(self, active_layer) -> Optional[dict]:
|
||||
"""
|
||||
Gibt eine Zusammenfassung der zu hochladenden Layer zurück
|
||||
(für den Bestätigungs-Dialog in der UI), ohne den Upload
|
||||
tatsächlich auszuführen.
|
||||
|
||||
:returns: Dict mit ``dataset_label``, ``vkz``, ``siblings``
|
||||
(Layer-Liste), ``total`` (Objektanzahl), ``listing`` (str)
|
||||
oder ``None`` wenn kein gültiger Layer.
|
||||
"""
|
||||
if not VLN_KARTEN_AVAILABLE or active_layer is None:
|
||||
return None
|
||||
path = layer_api_path(active_layer)
|
||||
if not path:
|
||||
return None
|
||||
|
||||
dataset_key = active_layer.customProperty(PROP_DATASET)
|
||||
vkz = active_layer.customProperty(PROP_VERFAHREN)
|
||||
siblings = plugin_layers(dataset_key, vkz)
|
||||
|
||||
total = sum(l.featureCount() for l in siblings)
|
||||
listing = "\n".join(
|
||||
"• %s (%d Objekte)" % (l.name(), l.featureCount())
|
||||
for l in siblings
|
||||
)
|
||||
return {
|
||||
"dataset_label": P41_LABEL if dataset_key == P41_DATASET_KEY else dataset_key,
|
||||
"vkz": vkz,
|
||||
"siblings": siblings,
|
||||
"total": total,
|
||||
"listing": listing,
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Konvertierung GeoJSON <-> QGIS-Layer für die VLN-API.
|
||||
|
||||
Lokale Kopie des Layer-Managers aus dem vln_karten-Plugin — sn_plan41
|
||||
benötigt kein separat installiertes vln_karten-Plugin mehr.
|
||||
|
||||
Die API liefert je Layer (z.B. Plan 41) gemischte Geometrietypen in einer
|
||||
FeatureCollection: Punkte, Linien, Polygone sowie deren Multi-Varianten.
|
||||
QGIS-Memory-Layer können nur einen Geometrietyp halten — beim Laden wird
|
||||
deshalb nach Geometrie-Familie (Punkte/Linien/Flächen) in bis zu drei
|
||||
Layer gesplittet; Single-Geometrien werden zu Multi befördert.
|
||||
|
||||
Beim Hochladen müssen alle Teil-Layer desselben Datensatzes wieder zu
|
||||
EINER FeatureCollection vereint werden, weil der PUT der API den
|
||||
kompletten Layer-Bestand der VKZ ersetzt.
|
||||
|
||||
Custom-Properties an Layern (kompatibel mit vln_karten, falls beide
|
||||
Plugins gleichzeitig installiert sind):
|
||||
vln_karten/dataset — z.B. "p41"
|
||||
vln_karten/verfahren — VKZ-Nummer
|
||||
vln_karten/api_path — API-Pfad für Upload
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from sn_basis.functions.qgiscore_wrapper import (
|
||||
QgsJsonExporter,
|
||||
QgsJsonUtils,
|
||||
QgsProject,
|
||||
QgsVectorLayer,
|
||||
GEOM_POINT,
|
||||
GEOM_LINE,
|
||||
GEOM_POLYGON,
|
||||
)
|
||||
|
||||
_GEOM_POINT = GEOM_POINT
|
||||
_GEOM_LINE = GEOM_LINE
|
||||
_GEOM_POLYGON = GEOM_POLYGON
|
||||
|
||||
# Die VLN-API liefert Koordinaten in ETRS89 / UTM Zone 33 (EPSG:25833).
|
||||
GEOJSON_CRS = "EPSG:25833"
|
||||
|
||||
# Custom-Property-Schlüssel — identisch mit vln_karten für Kompatibilität.
|
||||
PROP_DATASET = "vln_karten/dataset"
|
||||
PROP_VERFAHREN = "vln_karten/verfahren"
|
||||
PROP_PATH = "vln_karten/api_path"
|
||||
|
||||
# Geometrie-Familie → (Memory-Provider-Typ, Namenszusatz).
|
||||
# Reihenfolge = Lade-Reihenfolge: Flächen zuerst, damit Punkte im
|
||||
# Layerbaum oben landen und nicht verdeckt werden.
|
||||
_FAMILIES = (
|
||||
("polygon", "MultiPolygon", "Flächen"),
|
||||
("line", "MultiLineString", "Linien"),
|
||||
("point", "MultiPoint", "Punkte"),
|
||||
("none", "None", "ohne Geometrie"),
|
||||
)
|
||||
|
||||
|
||||
def _family(feature):
|
||||
"""Geometrie-Familie eines Features. Unbekannte Typen (z.B.
|
||||
GeometryCollection) landen bei 'none'."""
|
||||
if not feature.hasGeometry() or feature.geometry().isNull():
|
||||
return "none"
|
||||
gtype = feature.geometry().type()
|
||||
if gtype == _GEOM_POINT:
|
||||
return "point"
|
||||
if gtype == _GEOM_LINE:
|
||||
return "line"
|
||||
if gtype == _GEOM_POLYGON:
|
||||
return "polygon"
|
||||
return "none"
|
||||
|
||||
|
||||
def memory_layer_from_features(
|
||||
uri_type, name, fields, features, dataset_key, verfahren_nr, api_path, promote=True
|
||||
):
|
||||
"""Baut einen Memory-Layer aus fertigen QgsFeatures, setzt die
|
||||
Plugin-Custom-Properties und hängt ihn ins Projekt."""
|
||||
uri = uri_type if uri_type == "None" else "%s?crs=%s" % (uri_type, GEOJSON_CRS)
|
||||
layer = QgsVectorLayer(uri, name, "memory")
|
||||
if not layer.isValid():
|
||||
raise RuntimeError("Memory-Layer '%s' konnte nicht erzeugt werden." % name)
|
||||
provider = layer.dataProvider()
|
||||
provider.addAttributes(fields.toList())
|
||||
layer.updateFields()
|
||||
prepared = []
|
||||
for feature in features:
|
||||
geometry = feature.geometry()
|
||||
if promote and not geometry.isNull() and not geometry.isMultipart():
|
||||
geometry.convertToMultiType()
|
||||
feature.setGeometry(geometry)
|
||||
prepared.append(feature)
|
||||
provider.addFeatures(prepared)
|
||||
layer.updateExtents()
|
||||
layer.setCustomProperty(PROP_DATASET, dataset_key)
|
||||
layer.setCustomProperty(PROP_VERFAHREN, verfahren_nr)
|
||||
layer.setCustomProperty(PROP_PATH, api_path)
|
||||
return layer
|
||||
|
||||
|
||||
def feature_collection_to_layers(
|
||||
feature_collection, base_name, default_geometry, dataset_key, verfahren_nr, api_path
|
||||
):
|
||||
"""Erzeugt aus einer GeoJSON FeatureCollection je vorkommender
|
||||
Geometrie-Familie einen Memory-Layer und hängt sie ins Projekt.
|
||||
Liefert die Liste der erzeugten Layer."""
|
||||
text = json.dumps(feature_collection)
|
||||
fields = QgsJsonUtils.stringToFields(text)
|
||||
features = QgsJsonUtils.stringToFeatureList(text, fields)
|
||||
|
||||
groups = {}
|
||||
for feature in features:
|
||||
groups.setdefault(_family(feature), []).append(feature)
|
||||
|
||||
layers = []
|
||||
multiple = len(groups) > 1
|
||||
|
||||
def make_layer(uri_type, name, group_features, promote):
|
||||
layers.append(
|
||||
memory_layer_from_features(
|
||||
uri_type, name, fields, group_features,
|
||||
dataset_key, verfahren_nr, api_path, promote=promote,
|
||||
)
|
||||
)
|
||||
|
||||
if not groups:
|
||||
# Leerer Datensatz: ein leerer Layer im Default-Typ, damit der
|
||||
# Nutzer digitalisieren und hochladen kann.
|
||||
make_layer(default_geometry, base_name, [], promote=False)
|
||||
else:
|
||||
for family, uri_type, suffix in _FAMILIES:
|
||||
if family not in groups:
|
||||
continue
|
||||
name = "%s — %s" % (base_name, suffix) if multiple else base_name
|
||||
make_layer(uri_type, name, groups[family], promote=(family != "none"))
|
||||
|
||||
QgsProject.instance().addMapLayers(layers)
|
||||
return layers
|
||||
|
||||
|
||||
def plugin_layers(dataset_key, verfahren_nr):
|
||||
"""Alle Layer im Projekt, die zu diesem Datensatz und dieser VKZ
|
||||
gehören (z.B. die Punkte-/Linien-/Flächen-Teil-Layer von Plan 41)."""
|
||||
result = []
|
||||
for layer in QgsProject.instance().mapLayers().values():
|
||||
if (
|
||||
isinstance(layer, QgsVectorLayer)
|
||||
and layer.customProperty(PROP_DATASET) == dataset_key
|
||||
and str(layer.customProperty(PROP_VERFAHREN)) == str(verfahren_nr)
|
||||
):
|
||||
result.append(layer)
|
||||
return result
|
||||
|
||||
|
||||
def layers_to_feature_collection(layers):
|
||||
"""Vereint die Features mehrerer Layer zu EINER GeoJSON
|
||||
FeatureCollection (für den PUT, der den Serverbestand ersetzt).
|
||||
|
||||
Keine Transformation nach WGS84: Die API erwartet die Koordinaten im
|
||||
selben CRS, in dem sie sie liefert (EPSG:25833)."""
|
||||
merged = []
|
||||
for layer in layers:
|
||||
exporter = QgsJsonExporter(layer)
|
||||
exporter.setSourceCrs(layer.crs())
|
||||
exporter.setTransformGeometries(False)
|
||||
collection = json.loads(
|
||||
exporter.exportFeatures(list(layer.getFeatures()))
|
||||
)
|
||||
merged.extend(collection.get("features", []))
|
||||
return {"type": "FeatureCollection", "features": merged}
|
||||
|
||||
|
||||
def layer_api_path(layer):
|
||||
"""API-Pfad, unter dem dieser Layer geladen wurde — oder None,
|
||||
wenn der Layer nicht von diesem Plugin stammt."""
|
||||
if not isinstance(layer, QgsVectorLayer):
|
||||
return None
|
||||
return layer.customProperty(PROP_PATH) or None
|
||||
@@ -5,6 +5,7 @@ Zentraler Test-Runner für sn_plan41.
|
||||
Wrapper-konform, QGIS-unabhängig, CI- und IDE-fähig.
|
||||
"""
|
||||
|
||||
import io
|
||||
import unittest
|
||||
import datetime
|
||||
import inspect
|
||||
@@ -12,6 +13,19 @@ import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Stdout auf UTF-8 umstellen, damit Emoji-Print-Statements in
|
||||
# qt_wrapper.py (Mock-Modus) auf Windows-Konsolen (CP1252) nicht zu
|
||||
# UnicodeEncodeError führen.
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
elif hasattr(sys.stdout, "buffer"):
|
||||
sys.stdout = io.TextIOWrapper(
|
||||
sys.stdout.buffer, encoding="utf-8", errors="replace"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Plugin-Roots bestimmen
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
"""
|
||||
Tests für sn_plan41/modules/vln_api_logic.py
|
||||
|
||||
Alle externen Abhängigkeiten (vln_karten, QSettings, QgsProject, Netzwerk)
|
||||
werden mit unittest.mock gemockt, sodass keine QGIS-Laufzeitumgebung nötig ist.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from typing import Any, Optional
|
||||
from unittest.mock import MagicMock, patch, call
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_logic(api_key: Optional[str] = None, mail: Optional[str] = None):
|
||||
"""Erzeugt eine VlnApiLogic-Instanz mit gemockten QSettings."""
|
||||
from sn_plan41.modules.vln_api_logic import VlnApiLogic
|
||||
|
||||
with patch("sn_plan41.modules.vln_api_logic.QSettings") as mock_qs_cls:
|
||||
mock_qs = MagicMock()
|
||||
mock_qs.value.side_effect = lambda key, default="": (
|
||||
api_key if key == "api_key" else (mail if key == "mail" else default)
|
||||
)
|
||||
mock_qs_cls.return_value = mock_qs
|
||||
|
||||
if api_key and VlnApiLogic.is_available():
|
||||
with patch(
|
||||
"sn_plan41.modules.vln_api_logic.KartenApiClient"
|
||||
) as mock_client_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_authenticated = True
|
||||
mock_client.api_key = api_key
|
||||
mock_client.mail = mail
|
||||
mock_client_cls.return_value = mock_client
|
||||
logic = VlnApiLogic()
|
||||
else:
|
||||
logic = VlnApiLogic()
|
||||
|
||||
return logic
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Verfügbarkeit
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestVlnApiLogicAvailability(unittest.TestCase):
|
||||
|
||||
def test_is_available_reflects_import_state(self):
|
||||
from sn_plan41.modules.vln_api_logic import VlnApiLogic
|
||||
result = VlnApiLogic.is_available()
|
||||
# Ergebnis hängt davon ab, ob vln_karten installiert ist —
|
||||
# wir prüfen nur, dass ein bool zurückkommt
|
||||
self.assertIsInstance(result, bool)
|
||||
|
||||
def test_is_available_false_when_vln_karten_missing(self):
|
||||
"""Wenn VLN_KARTEN_AVAILABLE=False, liefert is_available() False."""
|
||||
import sn_plan41.modules.vln_api_logic as module
|
||||
original = module.VLN_KARTEN_AVAILABLE
|
||||
try:
|
||||
module.VLN_KARTEN_AVAILABLE = False
|
||||
from sn_plan41.modules.vln_api_logic import VlnApiLogic
|
||||
with patch.object(module, "VLN_KARTEN_AVAILABLE", False):
|
||||
self.assertFalse(VlnApiLogic.is_available())
|
||||
finally:
|
||||
module.VLN_KARTEN_AVAILABLE = original
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Credentials-Persistenz
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestVlnApiLogicCredentials(unittest.TestCase):
|
||||
|
||||
def _make(self):
|
||||
from sn_plan41.modules.vln_api_logic import VlnApiLogic
|
||||
|
||||
with patch("sn_plan41.modules.vln_api_logic.QSettings"):
|
||||
with patch("sn_plan41.modules.vln_api_logic.KartenApiClient"):
|
||||
return VlnApiLogic()
|
||||
|
||||
def test_save_api_key_calls_qsettings(self):
|
||||
logic = self._make()
|
||||
with patch("sn_plan41.modules.vln_api_logic.QSettings") as mock_qs_cls:
|
||||
mock_qs = MagicMock()
|
||||
mock_qs_cls.return_value = mock_qs
|
||||
logic.save_api_key("token123", "user@test.de")
|
||||
|
||||
mock_qs.setValue.assert_any_call("api_key", "token123")
|
||||
mock_qs.setValue.assert_any_call("mail", "user@test.de")
|
||||
mock_qs.beginGroup.assert_called_with("vln_karten")
|
||||
|
||||
def test_clear_api_key_calls_remove(self):
|
||||
logic = self._make()
|
||||
with patch("sn_plan41.modules.vln_api_logic.QSettings") as mock_qs_cls:
|
||||
mock_qs = MagicMock()
|
||||
mock_qs_cls.return_value = mock_qs
|
||||
logic.clear_api_key()
|
||||
|
||||
mock_qs.remove.assert_called_with("api_key")
|
||||
|
||||
def test_load_stored_credentials_returns_api_key_and_mail(self):
|
||||
logic = self._make()
|
||||
with patch("sn_plan41.modules.vln_api_logic.QSettings") as mock_qs_cls:
|
||||
mock_qs = MagicMock()
|
||||
mock_qs.value.side_effect = lambda key, default="": {
|
||||
"api_key": "mykey",
|
||||
"mail": "user@test.de",
|
||||
}.get(key, default)
|
||||
mock_qs_cls.return_value = mock_qs
|
||||
|
||||
api_key, mail = logic.load_stored_credentials()
|
||||
|
||||
self.assertEqual(api_key, "mykey")
|
||||
self.assertEqual(mail, "user@test.de")
|
||||
|
||||
def test_load_stored_credentials_returns_none_for_empty(self):
|
||||
logic = self._make()
|
||||
with patch("sn_plan41.modules.vln_api_logic.QSettings") as mock_qs_cls:
|
||||
mock_qs = MagicMock()
|
||||
mock_qs.value.return_value = ""
|
||||
mock_qs_cls.return_value = mock_qs
|
||||
|
||||
api_key, mail = logic.load_stored_credentials()
|
||||
|
||||
self.assertIsNone(api_key)
|
||||
self.assertIsNone(mail)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Login / Logout
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestVlnApiLogicLogin(unittest.TestCase):
|
||||
|
||||
def _make(self):
|
||||
from sn_plan41.modules.vln_api_logic import VlnApiLogic
|
||||
|
||||
with patch("sn_plan41.modules.vln_api_logic.QSettings"):
|
||||
with patch("sn_plan41.modules.vln_api_logic.KartenApiClient"):
|
||||
return VlnApiLogic()
|
||||
|
||||
def test_login_sets_client_and_saves_key(self):
|
||||
import sn_plan41.modules.vln_api_logic as module
|
||||
if not module.VLN_KARTEN_AVAILABLE:
|
||||
self.skipTest("vln_karten nicht verfügbar")
|
||||
|
||||
logic = self._make()
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.api_key = "newtoken"
|
||||
|
||||
with patch("sn_plan41.modules.vln_api_logic.KartenApiClient", return_value=mock_client):
|
||||
with patch.object(logic, "save_api_key") as mock_save:
|
||||
logic.login("user@test.de", "secret")
|
||||
|
||||
mock_client.login.assert_called_once_with("user@test.de", "secret")
|
||||
mock_save.assert_called_once_with("newtoken", "user@test.de")
|
||||
|
||||
def test_login_raises_runtime_error_without_qgis_modules(self):
|
||||
import sn_plan41.modules.vln_api_logic as module
|
||||
logic = self._make()
|
||||
with patch.object(module, "VLN_KARTEN_AVAILABLE", False):
|
||||
with self.assertRaises(RuntimeError):
|
||||
logic.login("user@test.de", "secret")
|
||||
|
||||
def test_login_propagates_api_error(self):
|
||||
import sn_plan41.modules.vln_api_logic as module
|
||||
if not module.VLN_KARTEN_AVAILABLE:
|
||||
self.skipTest("vln_karten nicht verfügbar")
|
||||
|
||||
logic = self._make()
|
||||
mock_client = MagicMock()
|
||||
mock_client.login.side_effect = module.ApiError("Ungültige Zugangsdaten")
|
||||
|
||||
with patch("sn_plan41.modules.vln_api_logic.KartenApiClient", return_value=mock_client):
|
||||
with self.assertRaises(Exception):
|
||||
logic.login("user@test.de", "wrong")
|
||||
|
||||
def test_logout_clears_client(self):
|
||||
logic = self._make()
|
||||
logic._client = MagicMock()
|
||||
logic.logout()
|
||||
self.assertIsNone(logic._client)
|
||||
|
||||
def test_is_authenticated_false_after_logout(self):
|
||||
logic = self._make()
|
||||
logic._client = None
|
||||
self.assertFalse(logic.is_authenticated)
|
||||
|
||||
def test_handle_session_expired_clears_key_and_client(self):
|
||||
logic = self._make()
|
||||
with patch.object(logic, "clear_api_key") as mock_clear:
|
||||
with patch.object(logic, "logout") as mock_logout:
|
||||
logic.handle_session_expired()
|
||||
|
||||
mock_clear.assert_called_once()
|
||||
mock_logout.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Verfahrensliste
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestVlnApiLogicVerfahren(unittest.TestCase):
|
||||
|
||||
def _make_authenticated(self):
|
||||
from sn_plan41.modules.vln_api_logic import VlnApiLogic
|
||||
|
||||
with patch("sn_plan41.modules.vln_api_logic.QSettings"):
|
||||
with patch("sn_plan41.modules.vln_api_logic.KartenApiClient"):
|
||||
logic = VlnApiLogic()
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_authenticated = True
|
||||
logic._client = mock_client
|
||||
return logic, mock_client
|
||||
|
||||
def test_get_verfahren_raises_when_not_authenticated(self):
|
||||
from sn_plan41.modules.vln_api_logic import VlnApiLogic
|
||||
|
||||
with patch("sn_plan41.modules.vln_api_logic.QSettings"):
|
||||
with patch("sn_plan41.modules.vln_api_logic.KartenApiClient"):
|
||||
logic = VlnApiLogic()
|
||||
logic._client = None
|
||||
|
||||
with self.assertRaises(Exception):
|
||||
logic.get_verfahren()
|
||||
|
||||
def test_get_verfahren_delegates_to_client(self):
|
||||
logic, mock_client = self._make_authenticated()
|
||||
mock_client.get_verfahren.return_value = [
|
||||
{"vkz": "27010", "name": "Alpha"},
|
||||
{"vkz": "27001", "name": "Beta"},
|
||||
]
|
||||
result = logic.get_verfahren()
|
||||
mock_client.get_verfahren.assert_called_once()
|
||||
self.assertEqual(len(result), 2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: VKZ-Persistenz
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestVlnApiLogicVkz(unittest.TestCase):
|
||||
|
||||
def _make(self):
|
||||
from sn_plan41.modules.vln_api_logic import VlnApiLogic
|
||||
|
||||
with patch("sn_plan41.modules.vln_api_logic.QSettings"):
|
||||
with patch("sn_plan41.modules.vln_api_logic.KartenApiClient"):
|
||||
return VlnApiLogic()
|
||||
|
||||
def test_load_stored_vkz_reads_from_project(self):
|
||||
logic = self._make()
|
||||
mock_project = MagicMock()
|
||||
mock_project.readEntry.return_value = ("27010", True)
|
||||
|
||||
with patch("sn_plan41.modules.vln_api_logic.QgsProject") as mock_qgsproj_cls:
|
||||
mock_qgsproj_cls.instance.return_value = mock_project
|
||||
vkz = logic.load_stored_vkz()
|
||||
|
||||
self.assertEqual(vkz, "27010")
|
||||
mock_project.readEntry.assert_called_once_with("vln_karten", "/vkz", "")
|
||||
|
||||
def test_load_stored_vkz_returns_none_for_empty(self):
|
||||
logic = self._make()
|
||||
mock_project = MagicMock()
|
||||
mock_project.readEntry.return_value = ("", True)
|
||||
|
||||
with patch("sn_plan41.modules.vln_api_logic.QgsProject") as mock_qgsproj_cls:
|
||||
mock_qgsproj_cls.instance.return_value = mock_project
|
||||
vkz = logic.load_stored_vkz()
|
||||
|
||||
self.assertIsNone(vkz)
|
||||
|
||||
def test_save_vkz_writes_to_project(self):
|
||||
logic = self._make()
|
||||
mock_project = MagicMock()
|
||||
|
||||
with patch("sn_plan41.modules.vln_api_logic.QgsProject") as mock_qgsproj_cls:
|
||||
mock_qgsproj_cls.instance.return_value = mock_project
|
||||
logic.save_vkz("27010")
|
||||
|
||||
mock_project.writeEntry.assert_called_once_with("vln_karten", "/vkz", "27010")
|
||||
|
||||
def test_save_vkz_does_nothing_for_none(self):
|
||||
logic = self._make()
|
||||
mock_project = MagicMock()
|
||||
|
||||
with patch("sn_plan41.modules.vln_api_logic.QgsProject") as mock_qgsproj_cls:
|
||||
mock_qgsproj_cls.instance.return_value = mock_project
|
||||
logic.save_vkz(None)
|
||||
|
||||
mock_project.writeEntry.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Plan 41 laden (Stil-Anwendung)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestVlnApiLogicLoadP41(unittest.TestCase):
|
||||
|
||||
def _make(self):
|
||||
from sn_plan41.modules.vln_api_logic import VlnApiLogic
|
||||
|
||||
with patch("sn_plan41.modules.vln_api_logic.QSettings"):
|
||||
with patch("sn_plan41.modules.vln_api_logic.KartenApiClient"):
|
||||
logic = VlnApiLogic()
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_authenticated = True
|
||||
mock_client.load_layer_complete.return_value = {
|
||||
"type": "FeatureCollection",
|
||||
"features": [],
|
||||
}
|
||||
logic._client = mock_client
|
||||
return logic
|
||||
|
||||
def test_load_p41_raises_without_vln_karten(self):
|
||||
import sn_plan41.modules.vln_api_logic as module
|
||||
logic = self._make()
|
||||
with patch.object(module, "VLN_KARTEN_AVAILABLE", False):
|
||||
with self.assertRaises(RuntimeError):
|
||||
logic.load_p41("27010")
|
||||
|
||||
def test_load_p41_applies_styles_to_polygon_and_line_layers(self):
|
||||
import sn_plan41.modules.vln_api_logic as module
|
||||
if not module.VLN_KARTEN_AVAILABLE:
|
||||
self.skipTest("vln_karten nicht verfügbar")
|
||||
|
||||
logic = self._make()
|
||||
|
||||
# Zwei Mock-Layer: einer Polygon (2), einer Linie (1)
|
||||
mock_layer_polygon = MagicMock()
|
||||
mock_layer_polygon.geometryType.return_value = module._GEOM_POLYGON
|
||||
|
||||
mock_layer_line = MagicMock()
|
||||
mock_layer_line.geometryType.return_value = module._GEOM_LINE
|
||||
|
||||
with patch(
|
||||
"sn_plan41.modules.vln_api_logic.feature_collection_to_layers",
|
||||
return_value=[mock_layer_polygon, mock_layer_line],
|
||||
):
|
||||
with patch(
|
||||
"sn_plan41.modules.vln_api_logic.apply_style_from_path"
|
||||
) as mock_style:
|
||||
with patch("sn_plan41.modules.vln_api_logic.join_path", side_effect=lambda *p: "/".join(p)):
|
||||
with patch("sn_plan41.modules.vln_api_logic.get_plugin_root", return_value="/plugins"):
|
||||
logic.load_p41("27010")
|
||||
|
||||
# Prüfen, dass der richtige Stil für jeden Geometrietyp verwendet wurde
|
||||
style_calls = [str(c) for c in mock_style.call_args_list]
|
||||
polygon_style_used = any(
|
||||
module.STYLE_FLAECHE in c for c in style_calls
|
||||
)
|
||||
line_style_used = any(
|
||||
module.STYLE_LINIE in c for c in style_calls
|
||||
)
|
||||
self.assertTrue(polygon_style_used, "Flächen-Stil wurde nicht angewendet")
|
||||
self.assertTrue(line_style_used, "Linien-Stil wurde nicht angewendet")
|
||||
|
||||
def test_load_p41_no_style_for_point_layer(self):
|
||||
import sn_plan41.modules.vln_api_logic as module
|
||||
if not module.VLN_KARTEN_AVAILABLE:
|
||||
self.skipTest("vln_karten nicht verfügbar")
|
||||
|
||||
logic = self._make()
|
||||
|
||||
mock_layer_point = MagicMock()
|
||||
mock_layer_point.geometryType.return_value = 0 # Point
|
||||
|
||||
with patch(
|
||||
"sn_plan41.modules.vln_api_logic.feature_collection_to_layers",
|
||||
return_value=[mock_layer_point],
|
||||
):
|
||||
with patch(
|
||||
"sn_plan41.modules.vln_api_logic.apply_style_from_path"
|
||||
) as mock_style:
|
||||
with patch("sn_plan41.modules.vln_api_logic.join_path", side_effect=lambda *p: "/".join(p)):
|
||||
with patch("sn_plan41.modules.vln_api_logic.get_plugin_root", return_value="/plugins"):
|
||||
logic.load_p41("27010")
|
||||
|
||||
mock_style.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Upload
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestVlnApiLogicUpload(unittest.TestCase):
|
||||
|
||||
def _make(self):
|
||||
from sn_plan41.modules.vln_api_logic import VlnApiLogic
|
||||
|
||||
with patch("sn_plan41.modules.vln_api_logic.QSettings"):
|
||||
with patch("sn_plan41.modules.vln_api_logic.KartenApiClient"):
|
||||
logic = VlnApiLogic()
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_authenticated = True
|
||||
logic._client = mock_client
|
||||
return logic
|
||||
|
||||
def test_upload_returns_error_for_layer_without_api_path(self):
|
||||
import sn_plan41.modules.vln_api_logic as module
|
||||
if not module.VLN_KARTEN_AVAILABLE:
|
||||
self.skipTest("vln_karten nicht verfügbar")
|
||||
|
||||
logic = self._make()
|
||||
mock_layer = MagicMock()
|
||||
|
||||
with patch(
|
||||
"sn_plan41.modules.vln_api_logic.layer_api_path", return_value=None
|
||||
):
|
||||
success, message = logic.upload_active_layer(mock_layer)
|
||||
|
||||
self.assertFalse(success)
|
||||
self.assertIn("nicht", message.lower())
|
||||
|
||||
def test_upload_returns_error_for_none_layer(self):
|
||||
import sn_plan41.modules.vln_api_logic as module
|
||||
if not module.VLN_KARTEN_AVAILABLE:
|
||||
self.skipTest("vln_karten nicht verfügbar")
|
||||
|
||||
logic = self._make()
|
||||
with patch(
|
||||
"sn_plan41.modules.vln_api_logic.layer_api_path", return_value=None
|
||||
):
|
||||
success, message = logic.upload_active_layer(None)
|
||||
|
||||
self.assertFalse(success)
|
||||
|
||||
def test_upload_returns_false_without_qgis_modules(self):
|
||||
import sn_plan41.modules.vln_api_logic as module
|
||||
logic = self._make()
|
||||
with patch.object(module, "VLN_KARTEN_AVAILABLE", False):
|
||||
success, message = logic.upload_active_layer(MagicMock())
|
||||
|
||||
self.assertFalse(success)
|
||||
|
||||
def test_upload_calls_save_feature_collection(self):
|
||||
import sn_plan41.modules.vln_api_logic as module
|
||||
if not module.VLN_KARTEN_AVAILABLE:
|
||||
self.skipTest("vln_karten nicht verfügbar")
|
||||
|
||||
logic = self._make()
|
||||
|
||||
mock_layer = MagicMock()
|
||||
mock_layer.isEditable.return_value = False
|
||||
mock_layer.featureCount.return_value = 5
|
||||
|
||||
fc = {"type": "FeatureCollection", "features": []}
|
||||
|
||||
with patch("sn_plan41.modules.vln_api_logic.layer_api_path", return_value="/maps/p41/27010"):
|
||||
with patch("sn_plan41.modules.vln_api_logic.plugin_layers", return_value=[mock_layer]):
|
||||
with patch("sn_plan41.modules.vln_api_logic.layers_to_feature_collection", return_value=fc):
|
||||
success, message = logic.upload_active_layer(mock_layer)
|
||||
|
||||
logic._client.save_feature_collection.assert_called_once_with("/maps/p41/27010", fc)
|
||||
self.assertTrue(success)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+368
-9
@@ -9,7 +9,9 @@ from typing import Optional
|
||||
from sn_basis.functions.qt_wrapper import (
|
||||
QWidget,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QToolButton,
|
||||
QFileDialog,
|
||||
@@ -32,7 +34,8 @@ from sn_basis.modules.DataGrabber import DataGrabber
|
||||
from sn_basis.modules.Dateipruefer import Dateipruefer
|
||||
from sn_plan41.ui.tab_a_logic import TabALogic
|
||||
from sn_basis.modules.linkpruefer import Linkpruefer
|
||||
from sn_basis.modules.stilpruefer import Stilpruefer
|
||||
from sn_basis.modules.stilpruefer import Stilpruefer
|
||||
from sn_plan41.modules.vln_api_logic import VlnApiLogic
|
||||
|
||||
# Konstanten
|
||||
RAUMFILTER_VAR = "Raumfilter"
|
||||
@@ -78,7 +81,11 @@ class TabA(QWidget):
|
||||
|
||||
# UI-Referenzen
|
||||
self._raumfilter_combo: Optional[QComboBox] = None
|
||||
|
||||
|
||||
# VLN-API
|
||||
self.vln_logic: Optional[VlnApiLogic] = None
|
||||
self._vln_restoring: bool = False
|
||||
|
||||
self._build_ui()
|
||||
self._restore_state()
|
||||
|
||||
@@ -92,14 +99,17 @@ class TabA(QWidget):
|
||||
self.pruefmanager = pruefmanager
|
||||
self.data_grabber = data_grabber
|
||||
self.logic = TabALogic(
|
||||
pruefmanager=self.pruefmanager,
|
||||
link_pruefer=Linkpruefer(),
|
||||
stil_pruefer=Stilpruefer(),
|
||||
)
|
||||
|
||||
# DataGrabber in die Logik injizieren
|
||||
pruefmanager=self.pruefmanager,
|
||||
link_pruefer=Linkpruefer(),
|
||||
stil_pruefer=Stilpruefer(),
|
||||
)
|
||||
self.logic.data_grabber = self.data_grabber
|
||||
|
||||
# VLN-API-Logik initialisieren
|
||||
self.vln_logic = VlnApiLogic(self.pruefmanager)
|
||||
self._update_vln_ui_state()
|
||||
self._restore_vln_state()
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
"""Erstellt die komplette UI-Hierarchie mit allen Gruppen."""
|
||||
main_layout = QVBoxLayout()
|
||||
@@ -180,7 +190,84 @@ class TabA(QWidget):
|
||||
self.btn_pipeline.clicked.connect(self._on_load_fachdaten)
|
||||
main_layout.addWidget(self.btn_pipeline)
|
||||
|
||||
|
||||
# === VLN API / PLAN 41 ===
|
||||
self._vln_group_button = QToolButton()
|
||||
self._vln_group_button.setText("VLN API / Plan 41")
|
||||
self._vln_group_button.setCheckable(True)
|
||||
self._vln_group_button.setChecked(False)
|
||||
self._vln_group_button.setToolButtonStyle(ToolButtonTextBesideIcon)
|
||||
self._vln_group_button.setArrowType(ArrowRight)
|
||||
self._vln_group_button.setStyleSheet("font-weight: bold; margin-top: 8px;")
|
||||
self._vln_group_button.toggled.connect(self._toggle_vln_group)
|
||||
main_layout.addWidget(self._vln_group_button)
|
||||
|
||||
self._vln_group_content = QWidget()
|
||||
self._vln_group_content.setSizePolicy(SizePolicyPreferred, SizePolicyMaximum)
|
||||
vln_layout = QVBoxLayout()
|
||||
vln_layout.setSpacing(4)
|
||||
vln_layout.setContentsMargins(10, 4, 4, 8)
|
||||
|
||||
# Status
|
||||
self._vln_status_label = QLabel("Nicht angemeldet")
|
||||
self._vln_status_label.setStyleSheet("color: gray; font-style: italic;")
|
||||
vln_layout.addWidget(self._vln_status_label)
|
||||
|
||||
# E-Mail und Passwort
|
||||
vln_layout.addWidget(QLabel("E-Mail:"))
|
||||
self._vln_mail_edit = QLineEdit()
|
||||
self._vln_mail_edit.setPlaceholderText("mail@beispiel.de")
|
||||
vln_layout.addWidget(self._vln_mail_edit)
|
||||
|
||||
vln_layout.addWidget(QLabel("Passwort:"))
|
||||
self._vln_pass_edit = QLineEdit()
|
||||
try:
|
||||
self._vln_pass_edit.setEchoMode(QLineEdit.EchoMode.Password)
|
||||
except AttributeError:
|
||||
self._vln_pass_edit.setEchoMode(QLineEdit.Password)
|
||||
vln_layout.addWidget(self._vln_pass_edit)
|
||||
|
||||
# Login / Logout Buttons
|
||||
btn_row = QHBoxLayout()
|
||||
self._btn_vln_login = QPushButton("Anmelden")
|
||||
self._btn_vln_login.clicked.connect(self._on_vln_login)
|
||||
self._btn_vln_logout = QPushButton("Abmelden")
|
||||
self._btn_vln_logout.clicked.connect(self._on_vln_logout)
|
||||
btn_row.addWidget(self._btn_vln_login)
|
||||
btn_row.addWidget(self._btn_vln_logout)
|
||||
vln_layout.addLayout(btn_row)
|
||||
|
||||
# Verfahren
|
||||
vln_layout.addWidget(QLabel("Verfahren:"))
|
||||
self._vln_verfahren_combo = QComboBox()
|
||||
self._vln_verfahren_combo.setToolTip("Verfahren (Teilnehmergemeinschaft) wählen")
|
||||
self._vln_verfahren_combo.setEnabled(False)
|
||||
self._vln_verfahren_combo.currentIndexChanged.connect(
|
||||
self._on_vln_verfahren_changed
|
||||
)
|
||||
vln_layout.addWidget(self._vln_verfahren_combo)
|
||||
|
||||
# Plan 41 laden
|
||||
self._btn_p41_laden = QPushButton("Plan 41 (Wege- und Gewässerplan) laden")
|
||||
self._btn_p41_laden.setToolTip(
|
||||
"Lädt Flächen- und Linienlayer für das gewählte Verfahren vom Server"
|
||||
)
|
||||
self._btn_p41_laden.setEnabled(False)
|
||||
self._btn_p41_laden.clicked.connect(self._on_p41_laden)
|
||||
vln_layout.addWidget(self._btn_p41_laden)
|
||||
|
||||
# Upload
|
||||
self._btn_upload = QPushButton("Aktiven Layer hochladen")
|
||||
self._btn_upload.setToolTip(
|
||||
"Lädt alle Plan-41-Layer des aktiven Verfahrens zum Server hoch"
|
||||
)
|
||||
self._btn_upload.setEnabled(False)
|
||||
self._btn_upload.clicked.connect(self._on_upload_active)
|
||||
vln_layout.addWidget(self._btn_upload)
|
||||
|
||||
self._vln_group_content.setLayout(vln_layout)
|
||||
self._vln_group_content.setVisible(False)
|
||||
main_layout.addWidget(self._vln_group_content)
|
||||
|
||||
main_layout.addStretch(1)
|
||||
self.setLayout(main_layout)
|
||||
|
||||
@@ -206,7 +293,279 @@ class TabA(QWidget):
|
||||
pass
|
||||
# Raumfilter (schon im _build_ui behandelt)
|
||||
|
||||
def _restore_vln_state(self) -> None:
|
||||
"""Stellt die zuletzt gewählte VKZ aus der Projektdatei wieder her."""
|
||||
if self.vln_logic is None or not self.vln_logic.is_authenticated:
|
||||
return
|
||||
try:
|
||||
self._populate_verfahren_combo(restore_vkz=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# === UI CALLBACKS ===
|
||||
|
||||
def _toggle_vln_group(self, checked: bool) -> None:
|
||||
self._vln_group_button.setArrowType(ArrowDown if checked else ArrowRight)
|
||||
self._vln_group_content.setVisible(checked)
|
||||
|
||||
def _update_vln_ui_state(self) -> None:
|
||||
"""Passt alle VLN-Controls an den aktuellen Authentifizierungsstatus an."""
|
||||
if self.vln_logic is None:
|
||||
authenticated = False
|
||||
available = False
|
||||
else:
|
||||
authenticated = self.vln_logic.is_authenticated
|
||||
available = self.vln_logic.is_available()
|
||||
|
||||
if not available:
|
||||
self._vln_status_label.setText(
|
||||
"Plugin 'vln_karten' nicht installiert"
|
||||
)
|
||||
self._vln_status_label.setStyleSheet("color: red; font-style: italic;")
|
||||
self._btn_vln_login.setEnabled(False)
|
||||
elif authenticated:
|
||||
mail = (self.vln_logic.mail or "API-Key") if self.vln_logic else ""
|
||||
self._vln_status_label.setText("Angemeldet: %s" % mail)
|
||||
self._vln_status_label.setStyleSheet(
|
||||
"color: green; font-style: italic;"
|
||||
)
|
||||
self._btn_vln_login.setEnabled(False)
|
||||
else:
|
||||
self._vln_status_label.setText("Nicht angemeldet")
|
||||
self._vln_status_label.setStyleSheet("color: gray; font-style: italic;")
|
||||
self._btn_vln_login.setEnabled(True)
|
||||
|
||||
self._btn_vln_logout.setEnabled(authenticated)
|
||||
self._vln_verfahren_combo.setEnabled(authenticated)
|
||||
vkz_chosen = (
|
||||
authenticated
|
||||
and self._vln_verfahren_combo.currentData() is not None
|
||||
)
|
||||
self._btn_p41_laden.setEnabled(vkz_chosen)
|
||||
self._btn_upload.setEnabled(authenticated)
|
||||
|
||||
def _populate_verfahren_combo(self, restore_vkz: bool = False) -> None:
|
||||
"""Füllt die Verfahren-Combo und wählt ggf. die gespeicherte VKZ."""
|
||||
if self.vln_logic is None:
|
||||
return
|
||||
verfahren = self.vln_logic.get_verfahren()
|
||||
self._vln_restoring = True
|
||||
self._vln_verfahren_combo.clear()
|
||||
self._vln_verfahren_combo.addItem("— Verfahren wählen —", None)
|
||||
for v in verfahren:
|
||||
label = (
|
||||
"%s — %s" % (v["vkz"], v["name"])
|
||||
if v["name"]
|
||||
else v["vkz"]
|
||||
)
|
||||
self._vln_verfahren_combo.addItem(label, v["vkz"])
|
||||
self._vln_restoring = False
|
||||
|
||||
if restore_vkz:
|
||||
stored_vkz = self.vln_logic.load_stored_vkz()
|
||||
if stored_vkz:
|
||||
idx = self._vln_verfahren_combo.findData(stored_vkz)
|
||||
if idx >= 0:
|
||||
self._vln_verfahren_combo.setCurrentIndex(idx)
|
||||
|
||||
self._vln_verfahren_combo.setEnabled(True)
|
||||
self._update_vln_ui_state()
|
||||
|
||||
def _on_vln_login(self) -> None:
|
||||
"""Führt den API-Login durch."""
|
||||
if self.vln_logic is None:
|
||||
return
|
||||
mail = self._vln_mail_edit.text().strip()
|
||||
password = self._vln_pass_edit.text()
|
||||
if not mail or not password:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Anmeldung",
|
||||
"Bitte E-Mail-Adresse und Passwort eingeben.",
|
||||
)
|
||||
return
|
||||
try:
|
||||
self.vln_logic.login(mail, password)
|
||||
except Exception as exc:
|
||||
QMessageBox.warning(self, "Anmeldung fehlgeschlagen", str(exc))
|
||||
return
|
||||
|
||||
self._vln_pass_edit.clear()
|
||||
try:
|
||||
self._populate_verfahren_combo(restore_vkz=True)
|
||||
except Exception as exc:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Verfahrensliste",
|
||||
"Anmeldung erfolgreich, aber Verfahrensliste konnte nicht "
|
||||
"geladen werden: %s" % exc,
|
||||
)
|
||||
self._update_vln_ui_state()
|
||||
|
||||
def _on_vln_logout(self) -> None:
|
||||
"""Meldet den Benutzer lokal ab."""
|
||||
if self.vln_logic is None:
|
||||
return
|
||||
self.vln_logic.logout()
|
||||
self._vln_restoring = True
|
||||
self._vln_verfahren_combo.clear()
|
||||
self._vln_verfahren_combo.setEnabled(False)
|
||||
self._vln_restoring = False
|
||||
self._update_vln_ui_state()
|
||||
|
||||
def _on_vln_verfahren_changed(self, _index: int) -> None:
|
||||
"""Persistiert die gewählte VKZ."""
|
||||
if self._vln_restoring or self.vln_logic is None:
|
||||
return
|
||||
vkz = self._vln_verfahren_combo.currentData()
|
||||
self.vln_logic.save_vkz(vkz)
|
||||
self._update_vln_ui_state()
|
||||
|
||||
def _on_p41_laden(self) -> None:
|
||||
"""Lädt den Plan-41-Datensatz für die gewählte VKZ."""
|
||||
if self.vln_logic is None:
|
||||
return
|
||||
vkz = self._vln_verfahren_combo.currentData()
|
||||
if not vkz:
|
||||
QMessageBox.warning(
|
||||
self, "Plan 41 laden", "Bitte zuerst ein Verfahren wählen."
|
||||
)
|
||||
return
|
||||
|
||||
# Bereits geladene Layer prüfen
|
||||
existing = self.vln_logic.get_p41_layers(vkz)
|
||||
if existing:
|
||||
try:
|
||||
answer = QMessageBox.question(
|
||||
self,
|
||||
"Layer ersetzen",
|
||||
"Plan 41 für VKZ %s ist bereits geladen.\n"
|
||||
"Beim Neuladen werden die bestehenden Layer ersetzt. Fortfahren?"
|
||||
% vkz,
|
||||
)
|
||||
# Qt6: StandardButton.Yes, Qt5: QMessageBox.Yes
|
||||
yes = getattr(
|
||||
getattr(QMessageBox, "StandardButton", QMessageBox),
|
||||
"Yes",
|
||||
QMessageBox.Yes if hasattr(QMessageBox, "Yes") else None,
|
||||
)
|
||||
if answer != yes:
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
self.vln_logic.remove_p41_layers(vkz)
|
||||
|
||||
try:
|
||||
layers = self.vln_logic.load_p41(vkz)
|
||||
except Exception as exc:
|
||||
# AuthError: Sitzung abgelaufen
|
||||
try:
|
||||
from sn_plan41.modules.vln_api_client import AuthError as _AuthError
|
||||
if isinstance(exc, _AuthError):
|
||||
self.vln_logic.handle_session_expired()
|
||||
self._on_vln_logout()
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Sitzung abgelaufen",
|
||||
"Der API-Key ist nicht mehr gültig — bitte neu anmelden.",
|
||||
)
|
||||
return
|
||||
except ImportError:
|
||||
pass
|
||||
QMessageBox.warning(self, "Laden fehlgeschlagen", str(exc))
|
||||
return
|
||||
|
||||
total = sum(l.featureCount() for l in layers)
|
||||
detail = ", ".join(
|
||||
"%s: %d" % (l.name(), l.featureCount()) for l in layers
|
||||
)
|
||||
if total == 0:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Plan 41 laden",
|
||||
"Keine Objekte vorhanden — ein leerer Layer wurde angelegt.",
|
||||
)
|
||||
else:
|
||||
try:
|
||||
from sn_basis.functions.message_wrapper import info
|
||||
info(
|
||||
"Plan 41 geladen",
|
||||
"%d Layer geladen (%s)." % (len(layers), detail),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _on_upload_active(self) -> None:
|
||||
"""Lädt den aktiven Layer (und Geschwister-Layer) zum Server hoch."""
|
||||
if self.vln_logic is None:
|
||||
return
|
||||
|
||||
# Zusammenfassung für Bestaetigungs-Dialog ermitteln
|
||||
try:
|
||||
from sn_basis.functions.qgisui_wrapper import iface as _iface
|
||||
active_layer = _iface.activeLayer() if _iface else None
|
||||
except Exception:
|
||||
active_layer = None
|
||||
|
||||
summary = self.vln_logic.upload_summary(active_layer)
|
||||
if summary is None:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Hochladen nicht möglich",
|
||||
"Der aktive Layer wurde nicht über 'vln_karten' geladen.",
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
answer = QMessageBox.question(
|
||||
self,
|
||||
"Zum Server hochladen",
|
||||
"%s für VKZ %s übertragen?\n\n%s\n\n"
|
||||
"%d Objekte; der bisherige Bestand dieser VKZ wird ersetzt."
|
||||
% (
|
||||
summary["dataset_label"],
|
||||
summary["vkz"],
|
||||
summary["listing"],
|
||||
summary["total"],
|
||||
),
|
||||
)
|
||||
yes = getattr(
|
||||
getattr(QMessageBox, "StandardButton", QMessageBox),
|
||||
"Yes",
|
||||
QMessageBox.Yes if hasattr(QMessageBox, "Yes") else None,
|
||||
)
|
||||
if answer != yes:
|
||||
return
|
||||
except Exception:
|
||||
return
|
||||
|
||||
try:
|
||||
success, message = self.vln_logic.upload_active_layer(active_layer)
|
||||
except Exception as exc:
|
||||
try:
|
||||
from sn_plan41.modules.vln_api_client import AuthError as _AuthError
|
||||
if isinstance(exc, _AuthError):
|
||||
self.vln_logic.handle_session_expired()
|
||||
self._on_vln_logout()
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Sitzung abgelaufen",
|
||||
"Der API-Key ist nicht mehr gültig — bitte neu anmelden.",
|
||||
)
|
||||
return
|
||||
except ImportError:
|
||||
pass
|
||||
QMessageBox.warning(self, "Hochladen fehlgeschlagen", str(exc))
|
||||
return
|
||||
|
||||
if success:
|
||||
try:
|
||||
from sn_basis.functions.message_wrapper import info
|
||||
info("Upload erfolgreich", message)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
QMessageBox.warning(self, "Hochladen fehlgeschlagen", message)
|
||||
def _toggle_group(self, checked: bool) -> None:
|
||||
"""Zeigt/verbirgt Verfahrens-DB-Gruppe."""
|
||||
self.group_button.setArrowType(ArrowDown if checked else ArrowRight)
|
||||
|
||||
Reference in New Issue
Block a user