geodeploy 1.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- geodeploy/__init__.py +52 -0
- geodeploy/__main__.py +13 -0
- geodeploy/admin.py +191 -0
- geodeploy/catalog.py +96 -0
- geodeploy/cli/__init__.py +7 -0
- geodeploy/cli/commands/__init__.py +2 -0
- geodeploy/cli/commands/_common.py +233 -0
- geodeploy/cli/commands/admin.py +317 -0
- geodeploy/cli/commands/auth.py +280 -0
- geodeploy/cli/commands/browse.py +224 -0
- geodeploy/cli/commands/catalog.py +107 -0
- geodeploy/cli/commands/imports.py +125 -0
- geodeploy/cli/commands/jobs.py +38 -0
- geodeploy/cli/commands/layers.py +536 -0
- geodeploy/cli/commands/portals.py +515 -0
- geodeploy/cli/commands/sources.py +97 -0
- geodeploy/cli/commands/upload.py +154 -0
- geodeploy/cli/main.py +263 -0
- geodeploy/cli/output.py +320 -0
- geodeploy/client.py +327 -0
- geodeploy/config.py +438 -0
- geodeploy/errors.py +93 -0
- geodeploy/imports.py +65 -0
- geodeploy/jobs.py +73 -0
- geodeploy/layers.py +433 -0
- geodeploy/portals.py +280 -0
- geodeploy/py.typed +0 -0
- geodeploy/sources.py +70 -0
- geodeploy/styles.py +467 -0
- geodeploy/transport.py +355 -0
- geodeploy/uploads.py +474 -0
- geodeploy-1.3.0.dist-info/METADATA +102 -0
- geodeploy-1.3.0.dist-info/RECORD +38 -0
- geodeploy-1.3.0.dist-info/WHEEL +5 -0
- geodeploy-1.3.0.dist-info/entry_points.txt +2 -0
- geodeploy-1.3.0.dist-info/licenses/LICENSE +202 -0
- geodeploy-1.3.0.dist-info/licenses/NOTICE +11 -0
- geodeploy-1.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
"""`geodeploy portals …` — build, style and publish a portal.
|
|
2
|
+
|
|
3
|
+
The one thing worth internalising: **editing a portal changes a draft.** The live site keeps
|
|
4
|
+
serving its previous bundle until `geodeploy portals publish`. Every command here that changes
|
|
5
|
+
something says so, because "I set the colour and nothing happened" is otherwise the first thing
|
|
6
|
+
anyone hits.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from typing import Any, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
from ...errors import ValidationError
|
|
14
|
+
from ...portals import ACCESS_TYPES, ARCHETYPES
|
|
15
|
+
from ...styles import describe
|
|
16
|
+
from ..main import add_command, group_parser
|
|
17
|
+
from ..output import EXIT_GENERIC, EXIT_OK
|
|
18
|
+
from ._common import (add_style_args, confirm, parse_fields, read_json_arg, read_text_arg,
|
|
19
|
+
style_from_args, write_json_file)
|
|
20
|
+
|
|
21
|
+
LIST_COLUMNS = ["id", "title", "slug", "published", "access_type", "template_id", "created_by"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def register(subparsers) -> None:
|
|
25
|
+
group = group_parser(subparsers, "portals", "create, arrange, style and publish portals",
|
|
26
|
+
aliases=["portal"])
|
|
27
|
+
|
|
28
|
+
listing = add_command(group, "list", cmd_list, "list portals", aliases=["ls"])
|
|
29
|
+
listing.add_argument("--published", action="store_true", help="only published portals")
|
|
30
|
+
listing.add_argument("--draft", action="store_true", help="only unpublished portals")
|
|
31
|
+
listing.add_argument("--query", dest="search", help="match title or slug")
|
|
32
|
+
|
|
33
|
+
show = add_command(group, "show", cmd_show, "a portal's settings and layers")
|
|
34
|
+
show.add_argument("portal", help="portal id, slug or title")
|
|
35
|
+
|
|
36
|
+
create = add_command(group, "create", cmd_create, "create a portal",
|
|
37
|
+
epilog="""\
|
|
38
|
+
examples:
|
|
39
|
+
geodeploy portals create "Field sites 2026"
|
|
40
|
+
geodeploy portals create "Catalogue" --experience catalog --access organization
|
|
41
|
+
geodeploy portals create "Story" --experience storymap --template minimal
|
|
42
|
+
""")
|
|
43
|
+
create.add_argument("title")
|
|
44
|
+
create.add_argument("--description", help="About text, or @file.md")
|
|
45
|
+
create.add_argument("--template", default="minimal", dest="template_id",
|
|
46
|
+
help="template id (see `geodeploy catalog templates`)")
|
|
47
|
+
create.add_argument("--experience", choices=ARCHETYPES,
|
|
48
|
+
help="webmap (default), storymap or catalog")
|
|
49
|
+
create.add_argument("--access", choices=ACCESS_TYPES, default="public",
|
|
50
|
+
help="who may view the PUBLISHED portal")
|
|
51
|
+
create.add_argument("--password", help="the password, when --access password")
|
|
52
|
+
create.add_argument("--publish", action="store_true", help="publish it immediately")
|
|
53
|
+
|
|
54
|
+
update = add_command(group, "update", cmd_update, "change a portal's settings")
|
|
55
|
+
update.add_argument("portal")
|
|
56
|
+
update.add_argument("--title")
|
|
57
|
+
update.add_argument("--description", help="About text, or @file.md (Markdown)")
|
|
58
|
+
update.add_argument("--template", dest="template_id")
|
|
59
|
+
update.add_argument("--access", choices=ACCESS_TYPES)
|
|
60
|
+
update.add_argument("--password")
|
|
61
|
+
update.add_argument("--basemap", help="basemap id (see `geodeploy catalog basemaps`)")
|
|
62
|
+
update.add_argument("--view", help='start view as JSON: {"center":[lng,lat],"zoom":6,'
|
|
63
|
+
'"pitch":0,"bearing":0}')
|
|
64
|
+
update.add_argument("--theme", help="theme JSON, e.g. '{\"mode\":\"dark\",\"accent\":\"#22c55e\"}'")
|
|
65
|
+
update.add_argument("--layout", help="layout_config JSON (archetype, regions, panels)")
|
|
66
|
+
|
|
67
|
+
export = add_command(group, "export", cmd_export,
|
|
68
|
+
"write a portal's whole editable configuration to a JSON file")
|
|
69
|
+
export.add_argument("portal")
|
|
70
|
+
export.add_argument("output", nargs="?", help="file to write (default: stdout)")
|
|
71
|
+
|
|
72
|
+
imp = add_command(group, "import", cmd_import,
|
|
73
|
+
"push a configuration file back onto a portal")
|
|
74
|
+
imp.add_argument("portal")
|
|
75
|
+
imp.add_argument("config", help="the JSON file written by `portals export`")
|
|
76
|
+
|
|
77
|
+
layers = add_command(group, "layers", cmd_layers, "the layers on a portal, top of the list first")
|
|
78
|
+
layers.add_argument("portal")
|
|
79
|
+
|
|
80
|
+
add_layer = add_command(group, "add-layer", cmd_add_layer,
|
|
81
|
+
"add a data layer to a portal (top of the list = drawn on top)",
|
|
82
|
+
epilog="""\
|
|
83
|
+
examples:
|
|
84
|
+
geodeploy portals add-layer 3 roads
|
|
85
|
+
geodeploy portals add-layer 3 sites --marker star --radius 6 --color '#e11d48'
|
|
86
|
+
geodeploy portals add-layer 3 parcels --color-field population --classify quantile --classes 5
|
|
87
|
+
geodeploy portals add-layer 3 dem --colormap terrain --rescale 0,2400 --bottom
|
|
88
|
+
""")
|
|
89
|
+
add_layer.add_argument("portal")
|
|
90
|
+
add_layer.add_argument("layer", help="layer id, uid or name")
|
|
91
|
+
add_layer.add_argument("--type", dest="layer_type", choices=["vector", "raster"])
|
|
92
|
+
add_layer.add_argument("--bottom", action="store_true", help="add at the bottom of the list")
|
|
93
|
+
add_layer.add_argument("--hidden", action="store_true", help="start with the layer switched off")
|
|
94
|
+
add_layer.add_argument("--popup-fields", help="comma-separated attributes to show in popups")
|
|
95
|
+
add_layer.add_argument("--replace", action="store_true",
|
|
96
|
+
help="update the entry if the layer is already on the portal")
|
|
97
|
+
add_layer.add_argument("--publish", action="store_true", help="publish afterwards")
|
|
98
|
+
add_style_args(add_layer)
|
|
99
|
+
|
|
100
|
+
remove = add_command(group, "remove-layer", cmd_remove_layer, "take a layer off a portal")
|
|
101
|
+
remove.add_argument("portal")
|
|
102
|
+
remove.add_argument("layer")
|
|
103
|
+
remove.add_argument("--type", dest="layer_type", choices=["vector", "raster"])
|
|
104
|
+
remove.add_argument("--publish", action="store_true")
|
|
105
|
+
|
|
106
|
+
style = add_command(group, "style", cmd_style, "restyle a layer ON a portal")
|
|
107
|
+
style.add_argument("portal")
|
|
108
|
+
style.add_argument("layer")
|
|
109
|
+
style.add_argument("--type", dest="layer_type", choices=["vector", "raster"])
|
|
110
|
+
style.add_argument("--visible", dest="visible", action="store_true", default=None)
|
|
111
|
+
style.add_argument("--hidden", dest="visible", action="store_false",
|
|
112
|
+
help="hide the layer without removing it")
|
|
113
|
+
style.add_argument("--popup-fields", help="comma-separated attributes for popups")
|
|
114
|
+
style.add_argument("--replace", action="store_true", help="replace rather than merge the style")
|
|
115
|
+
style.add_argument("--publish", action="store_true")
|
|
116
|
+
add_style_args(style)
|
|
117
|
+
|
|
118
|
+
move = add_command(group, "move-layer", cmd_move_layer, "reorder a layer (0 = top of the list)")
|
|
119
|
+
move.add_argument("portal")
|
|
120
|
+
move.add_argument("layer")
|
|
121
|
+
move.add_argument("position", help="top | bottom | up | down | an index")
|
|
122
|
+
move.add_argument("--type", dest="layer_type", choices=["vector", "raster"])
|
|
123
|
+
move.add_argument("--publish", action="store_true")
|
|
124
|
+
|
|
125
|
+
groups = add_command(group, "folders", cmd_folders,
|
|
126
|
+
"read or replace the layer folder tree (V-13 groups)")
|
|
127
|
+
groups.add_argument("portal")
|
|
128
|
+
groups.add_argument("--set", dest="tree", help="a JSON array, or @file.json")
|
|
129
|
+
groups.add_argument("--clear", action="store_true", help="remove all folders (layers stay)")
|
|
130
|
+
groups.add_argument("--publish", action="store_true")
|
|
131
|
+
|
|
132
|
+
describe_p = add_command(group, "set-description", cmd_description,
|
|
133
|
+
"set the About text (Markdown; drives the published About page)")
|
|
134
|
+
describe_p.add_argument("portal")
|
|
135
|
+
describe_p.add_argument("text", help="the text, or @file.md")
|
|
136
|
+
describe_p.add_argument("--publish", action="store_true")
|
|
137
|
+
|
|
138
|
+
asset = add_command(group, "asset", cmd_asset, "upload a logo or About-page image")
|
|
139
|
+
asset.add_argument("portal")
|
|
140
|
+
asset.add_argument("file")
|
|
141
|
+
|
|
142
|
+
publish = add_command(group, "publish", cmd_publish, "publish (or re-publish) a portal")
|
|
143
|
+
publish.add_argument("portal")
|
|
144
|
+
|
|
145
|
+
unpublish = add_command(group, "unpublish", cmd_unpublish, "take a portal offline")
|
|
146
|
+
unpublish.add_argument("portal")
|
|
147
|
+
|
|
148
|
+
url = add_command(group, "url", cmd_url, "print the public URL of a portal")
|
|
149
|
+
url.add_argument("portal")
|
|
150
|
+
|
|
151
|
+
delete = add_command(group, "delete", cmd_delete, "delete a portal", aliases=["rm"])
|
|
152
|
+
delete.add_argument("portal")
|
|
153
|
+
delete.add_argument("--yes", action="store_true")
|
|
154
|
+
|
|
155
|
+
download = add_command(group, "download-area", cmd_download_area,
|
|
156
|
+
"download portal layers clipped to a bounding box")
|
|
157
|
+
download.add_argument("portal")
|
|
158
|
+
download.add_argument("bbox", help="minx,miny,maxx,maxy in EPSG:4326")
|
|
159
|
+
download.add_argument("-o", "--output", default="export.zip")
|
|
160
|
+
download.add_argument("--format", default="geojson",
|
|
161
|
+
choices=["geojson", "gpkg", "csv", "tif"],
|
|
162
|
+
help="vector format (rasters always come as tif)")
|
|
163
|
+
download.add_argument("--layers", help="comma-separated layer ids; default every layer")
|
|
164
|
+
download.add_argument("--crs", default="4326", choices=["4326", "native"])
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ── read ─────────────────────────────────────────────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
def cmd_list(ctx, args) -> int:
|
|
170
|
+
published = True if args.published else (False if args.draft else None)
|
|
171
|
+
rows = ctx.client().portals.list(published=published, query=args.search)
|
|
172
|
+
ctx.out.render(rows, LIST_COLUMNS, empty="No portals yet — `geodeploy portals create <title>`.")
|
|
173
|
+
return EXIT_OK
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def cmd_show(ctx, args) -> int:
|
|
177
|
+
portal = ctx.client().portals.get(args.portal)
|
|
178
|
+
if ctx.out.json_mode:
|
|
179
|
+
ctx.out.json(portal)
|
|
180
|
+
return EXIT_OK
|
|
181
|
+
ctx.out.record(portal, ["id", "title", "slug", "template_id", "access_type", "basemap",
|
|
182
|
+
"published", "published_at", "created_by"])
|
|
183
|
+
ctx.out.out("")
|
|
184
|
+
_print_layers(ctx, portal)
|
|
185
|
+
ctx.out.out("")
|
|
186
|
+
ctx.out.info("URL: {0}".format(ctx.client().portals.url(portal)))
|
|
187
|
+
return EXIT_OK
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def cmd_layers(ctx, args) -> int:
|
|
191
|
+
portal = ctx.client().portals.get(args.portal)
|
|
192
|
+
if ctx.out.json_mode:
|
|
193
|
+
ctx.out.json(portal.get("layer_configs") or [])
|
|
194
|
+
return EXIT_OK
|
|
195
|
+
_print_layers(ctx, portal)
|
|
196
|
+
return EXIT_OK
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _print_layers(ctx, portal: Dict[str, Any]) -> None:
|
|
200
|
+
configs = portal.get("layer_configs") or []
|
|
201
|
+
if not configs:
|
|
202
|
+
ctx.out.info("No layers on this portal yet.")
|
|
203
|
+
return
|
|
204
|
+
names = {}
|
|
205
|
+
try:
|
|
206
|
+
for row in ctx.client().layers.list():
|
|
207
|
+
names[(row["layer_type"], row["id"])] = row.get("name")
|
|
208
|
+
except Exception: # noqa: BLE001 - names are decoration; never fail the listing for them
|
|
209
|
+
pass
|
|
210
|
+
rows = []
|
|
211
|
+
for index, entry in enumerate(configs):
|
|
212
|
+
rows.append({
|
|
213
|
+
"#": index,
|
|
214
|
+
"layer_id": entry.get("layer_id"),
|
|
215
|
+
"type": entry.get("layer_type"),
|
|
216
|
+
"name": names.get((entry.get("layer_type"), entry.get("layer_id")), "—"),
|
|
217
|
+
"visible": entry.get("visible", True),
|
|
218
|
+
"opacity": entry.get("opacity", 1.0),
|
|
219
|
+
"style": describe(entry.get("style") or {}),
|
|
220
|
+
})
|
|
221
|
+
ctx.out.table(rows, ["#", "layer_id", "type", "name", "visible", "opacity", "style"])
|
|
222
|
+
ctx.out.info("Index 0 is the top of the layer list, and draws on top.")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def cmd_export(ctx, args) -> int:
|
|
226
|
+
portal = ctx.client().portals.get(args.portal)
|
|
227
|
+
if args.output:
|
|
228
|
+
write_json_file(args.output, portal)
|
|
229
|
+
ctx.out.success("Wrote {0}.".format(args.output))
|
|
230
|
+
ctx.out.info("Edit it and push it back with `geodeploy portals import {0} {1}`."
|
|
231
|
+
.format(args.portal, args.output))
|
|
232
|
+
return EXIT_OK
|
|
233
|
+
ctx.out.out(json.dumps(portal, indent=2, ensure_ascii=False, default=str))
|
|
234
|
+
return EXIT_OK
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def cmd_import(ctx, args) -> int:
|
|
238
|
+
config = read_json_arg("@" + args.config, "the configuration file")
|
|
239
|
+
portal = ctx.client().portals.get(args.portal)
|
|
240
|
+
updated = ctx.client().portals.set_config(portal["id"], config)
|
|
241
|
+
ctx.out.render(updated, LIST_COLUMNS)
|
|
242
|
+
if not ctx.out.json_mode:
|
|
243
|
+
ctx.out.success("Configuration applied to {0}.".format(updated.get("title")))
|
|
244
|
+
_publish_hint(ctx, updated)
|
|
245
|
+
return EXIT_OK
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def cmd_url(ctx, args) -> int:
|
|
249
|
+
client = ctx.client()
|
|
250
|
+
portal = client.portals.get(args.portal)
|
|
251
|
+
url = client.portals.url(portal)
|
|
252
|
+
if ctx.out.json_mode:
|
|
253
|
+
ctx.out.json({"id": portal["id"], "slug": portal.get("slug"), "url": url,
|
|
254
|
+
"published": portal.get("published")})
|
|
255
|
+
return EXIT_OK
|
|
256
|
+
ctx.out.out(url)
|
|
257
|
+
if not portal.get("published"):
|
|
258
|
+
ctx.out.warn("Not published yet, so that URL is not live.")
|
|
259
|
+
return EXIT_OK
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# ── write ────────────────────────────────────────────────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
def cmd_create(ctx, args) -> int:
|
|
265
|
+
client = ctx.client()
|
|
266
|
+
portal = client.portals.create(
|
|
267
|
+
args.title, description=read_text_arg(args.description) if args.description else None,
|
|
268
|
+
template_id=args.template_id, access_type=args.access, access_password=args.password,
|
|
269
|
+
archetype=args.experience)
|
|
270
|
+
if args.publish:
|
|
271
|
+
portal = client.portals.publish(portal["id"])
|
|
272
|
+
ctx.out.render(portal, LIST_COLUMNS)
|
|
273
|
+
if not ctx.out.json_mode:
|
|
274
|
+
ctx.out.success("Created portal {0} ({1}).".format(portal["id"], portal.get("slug")))
|
|
275
|
+
if args.publish:
|
|
276
|
+
ctx.out.info("Published: {0}".format(client.portals.url(portal)))
|
|
277
|
+
else:
|
|
278
|
+
ctx.out.info("Add layers with `geodeploy portals add-layer {0} <layer>`, then "
|
|
279
|
+
"`geodeploy portals publish {0}`.".format(portal["id"]))
|
|
280
|
+
return EXIT_OK
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def cmd_update(ctx, args) -> int:
|
|
284
|
+
client = ctx.client()
|
|
285
|
+
portal = client.portals.get(args.portal)
|
|
286
|
+
fields = {"title": args.title, "template_id": args.template_id, "access_type": args.access,
|
|
287
|
+
"access_password": args.password, "basemap": args.basemap}
|
|
288
|
+
if args.description is not None:
|
|
289
|
+
fields["description"] = read_text_arg(args.description)
|
|
290
|
+
if args.view:
|
|
291
|
+
fields["initial_view"] = read_json_arg(args.view, "--view")
|
|
292
|
+
if args.theme:
|
|
293
|
+
fields["theme"] = read_json_arg(args.theme, "--theme")
|
|
294
|
+
if args.layout:
|
|
295
|
+
fields["layout_config"] = read_json_arg(args.layout, "--layout")
|
|
296
|
+
updated = client.portals.update(portal["id"], **fields)
|
|
297
|
+
ctx.out.render(updated, LIST_COLUMNS)
|
|
298
|
+
if not ctx.out.json_mode:
|
|
299
|
+
ctx.out.success("Updated {0}.".format(updated.get("title")))
|
|
300
|
+
_publish_hint(ctx, updated)
|
|
301
|
+
return EXIT_OK
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def cmd_description(ctx, args) -> int:
|
|
305
|
+
client = ctx.client()
|
|
306
|
+
portal = client.portals.get(args.portal)
|
|
307
|
+
text = read_text_arg(args.text)
|
|
308
|
+
updated = client.portals.update(portal["id"], description=text)
|
|
309
|
+
_maybe_publish(ctx, updated, args)
|
|
310
|
+
ctx.out.render({"id": updated["id"], "title": updated["title"],
|
|
311
|
+
"description_chars": len(text)})
|
|
312
|
+
if not ctx.out.json_mode:
|
|
313
|
+
ctx.out.success("About text set ({0} characters).".format(len(text)))
|
|
314
|
+
_publish_hint(ctx, updated, args)
|
|
315
|
+
return EXIT_OK
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def cmd_add_layer(ctx, args) -> int:
|
|
319
|
+
client = ctx.client()
|
|
320
|
+
portal = client.portals.get(args.portal)
|
|
321
|
+
layer = client.layers.resolve(args.layer, args.layer_type)
|
|
322
|
+
style = style_from_args(args, client, layer.get("uid") or layer["id"], {}, ctx.out)
|
|
323
|
+
if not style and (layer.get("default_style") or {}).get("style"):
|
|
324
|
+
style = dict(layer["default_style"]["style"]) # inherit the layer's own default
|
|
325
|
+
|
|
326
|
+
updated = client.portals.add_layer(
|
|
327
|
+
portal["id"], layer["id"], layer["layer_type"], style=style,
|
|
328
|
+
visible=not args.hidden, opacity=args.opacity if args.opacity is not None else 1.0,
|
|
329
|
+
popup_fields=parse_fields(args.popup_fields), bottom=args.bottom, replace=args.replace)
|
|
330
|
+
_maybe_publish(ctx, updated, args)
|
|
331
|
+
if ctx.out.json_mode:
|
|
332
|
+
ctx.out.json(updated.get("layer_configs") or [])
|
|
333
|
+
return EXIT_OK
|
|
334
|
+
ctx.out.success("Added {0} ({1}) to {2}.".format(layer["name"], layer["layer_type"],
|
|
335
|
+
updated.get("title")))
|
|
336
|
+
_print_layers(ctx, updated)
|
|
337
|
+
_publish_hint(ctx, updated, args)
|
|
338
|
+
return EXIT_OK
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def cmd_remove_layer(ctx, args) -> int:
|
|
342
|
+
client = ctx.client()
|
|
343
|
+
portal = client.portals.get(args.portal)
|
|
344
|
+
layer = client.layers.resolve(args.layer, args.layer_type)
|
|
345
|
+
updated = client.portals.remove_layer(portal["id"], layer["id"], layer["layer_type"])
|
|
346
|
+
_maybe_publish(ctx, updated, args)
|
|
347
|
+
if ctx.out.json_mode:
|
|
348
|
+
ctx.out.json(updated.get("layer_configs") or [])
|
|
349
|
+
return EXIT_OK
|
|
350
|
+
ctx.out.success("Removed {0} from {1}.".format(layer["name"], updated.get("title")))
|
|
351
|
+
_publish_hint(ctx, updated, args)
|
|
352
|
+
return EXIT_OK
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def cmd_style(ctx, args) -> int:
|
|
356
|
+
client = ctx.client()
|
|
357
|
+
portal = client.portals.get(args.portal)
|
|
358
|
+
layer = client.layers.resolve(args.layer, args.layer_type)
|
|
359
|
+
current = {}
|
|
360
|
+
for entry in portal.get("layer_configs") or []:
|
|
361
|
+
if entry.get("layer_id") == layer["id"] and entry.get("layer_type") == layer["layer_type"]:
|
|
362
|
+
current = dict(entry.get("style") or {})
|
|
363
|
+
break
|
|
364
|
+
style = style_from_args(args, client, layer.get("uid") or layer["id"],
|
|
365
|
+
{} if args.replace else current, ctx.out)
|
|
366
|
+
updated = client.portals.set_layer_style(
|
|
367
|
+
portal["id"], layer["id"], style, layer_type=layer["layer_type"], merge=False,
|
|
368
|
+
visible=args.visible, opacity=args.opacity, popup_fields=parse_fields(args.popup_fields))
|
|
369
|
+
_maybe_publish(ctx, updated, args)
|
|
370
|
+
if ctx.out.json_mode:
|
|
371
|
+
ctx.out.json(style)
|
|
372
|
+
return EXIT_OK
|
|
373
|
+
ctx.out.success("{0}: {1}".format(layer["name"], describe(style)))
|
|
374
|
+
_publish_hint(ctx, updated, args)
|
|
375
|
+
return EXIT_OK
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def cmd_move_layer(ctx, args) -> int:
|
|
379
|
+
client = ctx.client()
|
|
380
|
+
portal = client.portals.get(args.portal)
|
|
381
|
+
layer = client.layers.resolve(args.layer, args.layer_type)
|
|
382
|
+
updated = client.portals.move_layer(portal["id"], layer["id"], args.position,
|
|
383
|
+
layer["layer_type"])
|
|
384
|
+
_maybe_publish(ctx, updated, args)
|
|
385
|
+
if ctx.out.json_mode:
|
|
386
|
+
ctx.out.json(updated.get("layer_configs") or [])
|
|
387
|
+
return EXIT_OK
|
|
388
|
+
_print_layers(ctx, updated)
|
|
389
|
+
_publish_hint(ctx, updated, args)
|
|
390
|
+
return EXIT_OK
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def cmd_folders(ctx, args) -> int:
|
|
394
|
+
client = ctx.client()
|
|
395
|
+
portal = client.portals.get(args.portal)
|
|
396
|
+
if args.clear:
|
|
397
|
+
updated = client.portals.set_groups(portal["id"], [])
|
|
398
|
+
elif args.tree:
|
|
399
|
+
tree = json.loads(read_text_arg(args.tree))
|
|
400
|
+
if not isinstance(tree, list):
|
|
401
|
+
raise ValidationError(400, "The folder tree is a JSON array of group objects.")
|
|
402
|
+
updated = client.portals.set_groups(portal["id"], tree)
|
|
403
|
+
else:
|
|
404
|
+
ctx.out.render(portal.get("layer_groups") or [], empty="No folders — the layer list is flat.")
|
|
405
|
+
return EXIT_OK
|
|
406
|
+
_maybe_publish(ctx, updated, args)
|
|
407
|
+
ctx.out.render(updated.get("layer_groups") or [], empty="Folders cleared.")
|
|
408
|
+
_publish_hint(ctx, updated, args)
|
|
409
|
+
return EXIT_OK
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def cmd_asset(ctx, args) -> int:
|
|
413
|
+
client = ctx.client()
|
|
414
|
+
portal = client.portals.get(args.portal)
|
|
415
|
+
result = client.portals.upload_asset(portal["id"], args.file)
|
|
416
|
+
ctx.out.render(result)
|
|
417
|
+
return EXIT_OK
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def cmd_publish(ctx, args) -> int:
|
|
421
|
+
client = ctx.client()
|
|
422
|
+
portal = client.portals.get(args.portal)
|
|
423
|
+
published = client.portals.publish(portal["id"])
|
|
424
|
+
ctx.out.render(published, LIST_COLUMNS)
|
|
425
|
+
if not ctx.out.json_mode:
|
|
426
|
+
ctx.out.success("Published: {0}".format(client.portals.url(published)))
|
|
427
|
+
return EXIT_OK
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def cmd_unpublish(ctx, args) -> int:
|
|
431
|
+
client = ctx.client()
|
|
432
|
+
portal = client.portals.get(args.portal)
|
|
433
|
+
result = client.portals.unpublish(portal["id"])
|
|
434
|
+
ctx.out.render(result, LIST_COLUMNS)
|
|
435
|
+
if not ctx.out.json_mode:
|
|
436
|
+
ctx.out.success("{0} is offline. The draft is untouched.".format(portal.get("title")))
|
|
437
|
+
return EXIT_OK
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def cmd_delete(ctx, args) -> int:
|
|
441
|
+
client = ctx.client()
|
|
442
|
+
portal = client.portals.get(args.portal)
|
|
443
|
+
if not confirm(ctx.out, "Delete portal {0!r} (id {1})? Its layers are NOT deleted.".format(
|
|
444
|
+
portal.get("title"), portal["id"]), args.yes):
|
|
445
|
+
ctx.out.info("Nothing deleted.")
|
|
446
|
+
return EXIT_GENERIC
|
|
447
|
+
client.portals.delete(portal["id"])
|
|
448
|
+
ctx.out.render({"ok": True, "deleted": portal["id"], "title": portal.get("title")})
|
|
449
|
+
if not ctx.out.json_mode:
|
|
450
|
+
ctx.out.success("Deleted {0}.".format(portal.get("title")))
|
|
451
|
+
return EXIT_OK
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def cmd_download_area(ctx, args) -> int:
|
|
455
|
+
import time
|
|
456
|
+
|
|
457
|
+
client = ctx.client()
|
|
458
|
+
portal = client.portals.get(args.portal)
|
|
459
|
+
wanted = None
|
|
460
|
+
if args.layers:
|
|
461
|
+
wanted = {int(x) for x in args.layers.split(",") if x.strip()}
|
|
462
|
+
|
|
463
|
+
items = [] # type: List[Dict[str, Any]]
|
|
464
|
+
for entry in portal.get("layer_configs") or []:
|
|
465
|
+
if wanted is not None and entry.get("layer_id") not in wanted:
|
|
466
|
+
continue
|
|
467
|
+
raster = entry.get("layer_type") == "raster"
|
|
468
|
+
items.append({"layer_id": entry.get("layer_id"), "layer_type": entry.get("layer_type"),
|
|
469
|
+
"format": "tif" if raster else args.format})
|
|
470
|
+
if not items:
|
|
471
|
+
ctx.out.error("No layers on this portal match.")
|
|
472
|
+
return EXIT_GENERIC
|
|
473
|
+
|
|
474
|
+
slug = portal.get("slug")
|
|
475
|
+
job = client.portals.export_bundle(slug, args.bbox, items, target_crs=args.crs)
|
|
476
|
+
job_id = job.get("job_id") or job.get("id")
|
|
477
|
+
ctx.out.info("Clipping {0} layer(s) to {1}…".format(len(items), args.bbox))
|
|
478
|
+
while True:
|
|
479
|
+
status = client.portals.export_status(slug, job_id)
|
|
480
|
+
state = (status.get("status") or "").lower()
|
|
481
|
+
if state == "ready":
|
|
482
|
+
break
|
|
483
|
+
if state in ("error", "failed"):
|
|
484
|
+
ctx.out.error(status.get("error") or status.get("message") or "Export failed.")
|
|
485
|
+
return EXIT_GENERIC
|
|
486
|
+
time.sleep(2)
|
|
487
|
+
|
|
488
|
+
with open(args.output, "wb") as fh:
|
|
489
|
+
client.portals.export_download(slug, job_id, fh)
|
|
490
|
+
ctx.out.render({"ok": True, "file": args.output, "layers": len(items)})
|
|
491
|
+
if not ctx.out.json_mode:
|
|
492
|
+
ctx.out.success("Wrote {0}.".format(args.output))
|
|
493
|
+
return EXIT_OK
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
# ── shared bits ──────────────────────────────────────────────────────────────────────────────────
|
|
497
|
+
|
|
498
|
+
def _maybe_publish(ctx, portal: Dict[str, Any], args) -> None:
|
|
499
|
+
if getattr(args, "publish", False):
|
|
500
|
+
ctx.client().portals.publish(portal["id"])
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _publish_hint(ctx, portal: Dict[str, Any], args: Optional[Any] = None) -> None:
|
|
504
|
+
"""Say what is needed to make the change visible — and nothing when it already is."""
|
|
505
|
+
if ctx.out.json_mode:
|
|
506
|
+
return
|
|
507
|
+
if getattr(args, "publish", False):
|
|
508
|
+
ctx.out.info("Published: {0}".format(ctx.client().portals.url(portal)))
|
|
509
|
+
return
|
|
510
|
+
if portal.get("published"):
|
|
511
|
+
ctx.out.info("The live portal still shows the previous version — "
|
|
512
|
+
"`geodeploy portals publish {0}` to update it.".format(portal["id"]))
|
|
513
|
+
else:
|
|
514
|
+
ctx.out.info("Draft saved. `geodeploy portals publish {0}` puts it online."
|
|
515
|
+
.format(portal["id"]))
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""`geodeploy sources …` — external WMS / XYZ / WFS services used without ingesting them."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from ..main import add_command, group_parser
|
|
5
|
+
from ..output import EXIT_GENERIC, EXIT_OK
|
|
6
|
+
from ._common import confirm
|
|
7
|
+
|
|
8
|
+
COLUMNS = ["id", "name", "source_type", "kind", "url", "layer_name", "visibility", "created_by"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def register(subparsers) -> None:
|
|
12
|
+
group = group_parser(subparsers, "sources", "external map services shown alongside your data")
|
|
13
|
+
|
|
14
|
+
listing = add_command(group, "list", cmd_list, "list external sources", aliases=["ls"])
|
|
15
|
+
listing.add_argument("--query", dest="search")
|
|
16
|
+
|
|
17
|
+
add = add_command(group, "add", cmd_add, "register an external service",
|
|
18
|
+
epilog="""\
|
|
19
|
+
examples:
|
|
20
|
+
geodeploy sources add "OSM" 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' --type xyz
|
|
21
|
+
geodeploy sources add "Orthophoto" https://wms.example.org/wms --type wms \\
|
|
22
|
+
--layer-name ortho_2025 --version 1.3.0
|
|
23
|
+
geodeploy sources add "Municipalities" https://wfs.example.org/ows --type wfs \\
|
|
24
|
+
--layer-name ms:kommun
|
|
25
|
+
|
|
26
|
+
A WFS is probed when it is registered, so a wrong typeName fails here rather than as an empty
|
|
27
|
+
layer on a published map.
|
|
28
|
+
""")
|
|
29
|
+
add.add_argument("name")
|
|
30
|
+
add.add_argument("service_url", metavar="url", help="the service endpoint")
|
|
31
|
+
add.add_argument("--type", dest="source_type", required=True, choices=["xyz", "wms", "wfs"])
|
|
32
|
+
add.add_argument("--layer-name", help="WMS `layers` / WFS `typeName` (required for both)")
|
|
33
|
+
add.add_argument("--version", help="WMS (default 1.3.0) or WFS (default 2.0.0) version")
|
|
34
|
+
add.add_argument("--format", dest="image_format", help="WMS image format (default image/png)")
|
|
35
|
+
add.add_argument("--attribution")
|
|
36
|
+
|
|
37
|
+
show = add_command(group, "show", cmd_show, "one source")
|
|
38
|
+
show.add_argument("source")
|
|
39
|
+
|
|
40
|
+
usage = add_command(group, "usage", cmd_usage, "which portals use this source")
|
|
41
|
+
usage.add_argument("source")
|
|
42
|
+
|
|
43
|
+
share = add_command(group, "share", cmd_share, "set visibility (private | organization)")
|
|
44
|
+
share.add_argument("source")
|
|
45
|
+
share.add_argument("visibility", choices=["private", "organization"])
|
|
46
|
+
|
|
47
|
+
delete = add_command(group, "delete", cmd_delete, "delete a source", aliases=["rm"])
|
|
48
|
+
delete.add_argument("source")
|
|
49
|
+
delete.add_argument("--yes", action="store_true")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def cmd_list(ctx, args) -> int:
|
|
53
|
+
ctx.out.render(ctx.client().sources.list(query=args.search), COLUMNS,
|
|
54
|
+
empty="No external sources.")
|
|
55
|
+
return EXIT_OK
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def cmd_add(ctx, args) -> int:
|
|
59
|
+
source = ctx.client().sources.create(
|
|
60
|
+
args.name, args.source_type, args.service_url, layer_name=args.layer_name, version=args.version,
|
|
61
|
+
image_format=args.image_format, attribution=args.attribution)
|
|
62
|
+
ctx.out.render(source, COLUMNS + ["bbox", "geometry_type"])
|
|
63
|
+
if not ctx.out.json_mode:
|
|
64
|
+
ctx.out.success("Added {0}. Put it on a portal with "
|
|
65
|
+
"`geodeploy portals add-layer <portal> {1} --type external`."
|
|
66
|
+
.format(args.name, source.get("id")))
|
|
67
|
+
return EXIT_OK
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def cmd_show(ctx, args) -> int:
|
|
71
|
+
ctx.out.render(ctx.client().sources.get(args.source))
|
|
72
|
+
return EXIT_OK
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def cmd_usage(ctx, args) -> int:
|
|
76
|
+
source = ctx.client().sources.get(args.source)
|
|
77
|
+
ctx.out.render(ctx.client().sources.usage(source["id"]), ["id", "title", "published"],
|
|
78
|
+
empty="Not used by any portal.")
|
|
79
|
+
return EXIT_OK
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def cmd_share(ctx, args) -> int:
|
|
83
|
+
source = ctx.client().sources.get(args.source)
|
|
84
|
+
ctx.out.render(ctx.client().sources.share(source["id"], args.visibility), COLUMNS)
|
|
85
|
+
return EXIT_OK
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def cmd_delete(ctx, args) -> int:
|
|
89
|
+
client = ctx.client()
|
|
90
|
+
source = client.sources.get(args.source)
|
|
91
|
+
if not confirm(ctx.out, "Delete source {0!r}?".format(source.get("name")), args.yes):
|
|
92
|
+
return EXIT_GENERIC
|
|
93
|
+
client.sources.delete(source["id"])
|
|
94
|
+
ctx.out.render({"ok": True, "deleted": source["id"]})
|
|
95
|
+
if not ctx.out.json_mode:
|
|
96
|
+
ctx.out.success("Deleted {0}.".format(source.get("name")))
|
|
97
|
+
return EXIT_OK
|