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/portals.py ADDED
@@ -0,0 +1,280 @@
1
+ """Portals — create, arrange layers, style them, publish.
2
+
3
+ The editing model is the one the dashboard uses and the one the plugins are written against:
4
+
5
+ GET /api/portals/{id} → mutate `layer_configs` → PUT /api/portals/{id} → POST …/publish
6
+
7
+ Two conventions are load-bearing and easy to get wrong:
8
+
9
+ * **`layer_configs[0]` is the TOP of the layer list and draws on top.** Adding a layer therefore
10
+ prepends by default, matching what "add a layer" does in the editor.
11
+ * **A draft edit changes nothing that is live.** Publishing re-bakes the static bundle; until then
12
+ the published portal serves its previous state. Every mutating method here says so in the message
13
+ the CLI prints, because "I changed it and nothing happened" is otherwise the first support
14
+ question.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ from typing import Any, Dict, List, Optional
20
+
21
+ from .config import split_portal_url
22
+ from .errors import NotFoundError, ValidationError
23
+
24
+ ACCESS_TYPES = ("public", "password", "organization", "owner")
25
+ ARCHETYPES = ("webmap", "storymap", "catalog")
26
+
27
+
28
+ class Portals(object):
29
+ def __init__(self, client: Any):
30
+ self._c = client
31
+
32
+ # ── read ────────────────────────────────────────────────────────────────────────────────────
33
+
34
+ def list(self, published: Optional[bool] = None,
35
+ query: Optional[str] = None) -> List[Dict[str, Any]]:
36
+ rows = self._c.get("/portals") or []
37
+ if published is not None:
38
+ rows = [r for r in rows if bool(r.get("published")) is published]
39
+ if query:
40
+ needle = query.lower()
41
+ rows = [r for r in rows if needle in (r.get("title") or "").lower()
42
+ or needle in (r.get("slug") or "").lower()]
43
+ return rows
44
+
45
+ def get(self, ref: Any) -> Dict[str, Any]:
46
+ """A portal by id, slug, title, or its published URL — the CLI should not force people to
47
+ look up ids, nor to edit a URL they just copied out of the address bar."""
48
+ text = str(ref).strip()
49
+ if "/portals/" in text:
50
+ origin, text = split_portal_url(text)
51
+ if origin and origin != self._c.url:
52
+ raise ValidationError(
53
+ 400, "That portal is on {0}, but this command is talking to {1}.".format(
54
+ origin, self._c.url))
55
+ if text.isdigit():
56
+ return self._c.get("/portals/{0}".format(int(text)))
57
+ rows = self.list()
58
+ for row in rows:
59
+ if (row.get("slug") or "") == text:
60
+ return self._c.get("/portals/{0}".format(row["id"]))
61
+ matches = [r for r in rows if (r.get("title") or "").lower() == text.lower()]
62
+ if len(matches) == 1:
63
+ return self._c.get("/portals/{0}".format(matches[0]["id"]))
64
+ if len(matches) > 1:
65
+ raise ValidationError(400, "Several portals are called {0!r}; use the id.".format(ref))
66
+ partial = [r for r in rows if text.lower() in (r.get("title") or "").lower()]
67
+ if len(partial) == 1:
68
+ return self._c.get("/portals/{0}".format(partial[0]["id"]))
69
+ raise NotFoundError(404, "No portal matching {0!r}.".format(ref))
70
+
71
+ def url(self, portal: Dict[str, Any]) -> str:
72
+ """The public URL of a portal's published bundle (valid once it has been published)."""
73
+ return "{0}/portals/{1}/".format(self._c.url, portal.get("slug"))
74
+
75
+ # ── write ───────────────────────────────────────────────────────────────────────────────────
76
+
77
+ def create(self, title: str, description: Optional[str] = None, template_id: str = "minimal",
78
+ access_type: str = "public", access_password: Optional[str] = None,
79
+ archetype: Optional[str] = None, layer_configs: Optional[List[Dict]] = None,
80
+ theme: Optional[Dict] = None, layout_config: Optional[Dict] = None,
81
+ story: Optional[Dict] = None) -> Dict[str, Any]:
82
+ _check_access(access_type, access_password)
83
+ body = {"title": title, "template_id": template_id, "access_type": access_type,
84
+ "layer_configs": layer_configs or []}
85
+ if description is not None:
86
+ body["description"] = description
87
+ if access_password:
88
+ body["access_password"] = access_password
89
+ if theme:
90
+ body["theme"] = theme
91
+ if story:
92
+ body["story"] = story
93
+ if layout_config or archetype:
94
+ merged = dict(layout_config or {})
95
+ if archetype:
96
+ if archetype not in ARCHETYPES:
97
+ raise ValidationError(400, "Experience must be one of {0}.".format(
98
+ ", ".join(ARCHETYPES)))
99
+ merged["archetype"] = archetype
100
+ body["layout_config"] = merged
101
+ return self._c.post("/portals", body)
102
+
103
+ def update(self, portal_id: Any, **fields: Any) -> Dict[str, Any]:
104
+ """PUT the fields given. Anything omitted is left exactly as it was."""
105
+ body = {k: v for k, v in fields.items() if v is not None}
106
+ if not body:
107
+ raise ValidationError(400, "Nothing to change.")
108
+ if "access_type" in body:
109
+ _check_access(body["access_type"], body.get("access_password"))
110
+ return self._c.put("/portals/{0}".format(int(portal_id)), body)
111
+
112
+ def set_config(self, portal_id: Any, config: Dict[str, Any]) -> Dict[str, Any]:
113
+ """Push a whole portal document back (the `portal-get` → edit → `portal-set` round trip).
114
+
115
+ Read-only and server-owned fields are dropped rather than sent and rejected: `slug` is
116
+ derived from the title, and `id`/`published`/timestamps are not settable.
117
+ """
118
+ return self._c.put("/portals/{0}".format(int(portal_id)), editable_config(config))
119
+
120
+ def delete(self, portal_id: Any) -> Any:
121
+ return self._c.delete("/portals/{0}".format(int(portal_id)))
122
+
123
+ def publish(self, portal_id: Any) -> Dict[str, Any]:
124
+ return self._c.post("/portals/{0}/publish".format(int(portal_id)))
125
+
126
+ def unpublish(self, portal_id: Any) -> Dict[str, Any]:
127
+ return self._c.post("/portals/{0}/unpublish".format(int(portal_id)))
128
+
129
+ def upload_asset(self, portal_id: Any, path: str) -> Dict[str, Any]:
130
+ """A logo or an About-page image. SVG is accepted and served with a strict CSP."""
131
+ from .transport import MultipartBody
132
+ body = MultipartBody(file_path=path, filename=os.path.basename(path))
133
+ return self._c.request("POST", "/portals/{0}/assets".format(int(portal_id)),
134
+ body=body, content_type=body.content_type,
135
+ timeout=self._c.upload_timeout)
136
+
137
+ # ── layers on a portal ──────────────────────────────────────────────────────────────────────
138
+
139
+ def layers(self, portal: Any) -> List[Dict[str, Any]]:
140
+ """The portal's layer configs, top of the list first."""
141
+ doc = portal if isinstance(portal, dict) else self.get(portal)
142
+ return list(doc.get("layer_configs") or [])
143
+
144
+ def add_layer(self, portal_id: Any, layer_id: int, layer_type: str, style: Optional[Dict] = None,
145
+ visible: bool = True, opacity: float = 1.0, popup_fields: Optional[List[str]] = None,
146
+ bottom: bool = False, replace: bool = False) -> Dict[str, Any]:
147
+ """Add a layer to a portal. Prepends, because index 0 is the top of the list.
148
+
149
+ `replace=True` updates the entry when the layer is already there instead of refusing —
150
+ which is what a script re-running its own setup wants.
151
+ """
152
+ doc = self._c.get("/portals/{0}".format(int(portal_id)))
153
+ configs = list(doc.get("layer_configs") or [])
154
+ entry = {"layer_id": int(layer_id), "layer_type": layer_type, "visible": visible,
155
+ "opacity": opacity, "style": dict(style or {}),
156
+ "popup_fields": list(popup_fields or [])}
157
+ existing = _find_config(configs, layer_id, layer_type)
158
+ if existing is not None:
159
+ if not replace:
160
+ raise ValidationError(400, "Layer {0} ({1}) is already on this portal.".format(
161
+ layer_id, layer_type))
162
+ configs[existing] = entry
163
+ elif bottom:
164
+ configs.append(entry)
165
+ else:
166
+ configs.insert(0, entry)
167
+ return self._c.put("/portals/{0}".format(int(portal_id)), {"layer_configs": configs})
168
+
169
+ def remove_layer(self, portal_id: Any, layer_id: int,
170
+ layer_type: Optional[str] = None) -> Dict[str, Any]:
171
+ doc = self._c.get("/portals/{0}".format(int(portal_id)))
172
+ configs = list(doc.get("layer_configs") or [])
173
+ kept = [c for c in configs
174
+ if not (int(c.get("layer_id", -1)) == int(layer_id)
175
+ and (layer_type is None or c.get("layer_type") == layer_type))]
176
+ if len(kept) == len(configs):
177
+ raise NotFoundError(404, "Layer {0} is not on portal {1}.".format(layer_id, portal_id))
178
+ return self._c.put("/portals/{0}".format(int(portal_id)), {"layer_configs": kept})
179
+
180
+ def set_layer_style(self, portal_id: Any, layer_id: int, style: Dict[str, Any],
181
+ layer_type: Optional[str] = None, merge: bool = True,
182
+ visible: Optional[bool] = None, opacity: Optional[float] = None,
183
+ popup_fields: Optional[List[str]] = None) -> Dict[str, Any]:
184
+ """Restyle one layer on one portal, keeping everything else about the entry."""
185
+ doc = self._c.get("/portals/{0}".format(int(portal_id)))
186
+ configs = list(doc.get("layer_configs") or [])
187
+ index = _find_config(configs, layer_id, layer_type)
188
+ if index is None:
189
+ raise NotFoundError(404, "Layer {0} is not on portal {1}.".format(layer_id, portal_id))
190
+ entry = dict(configs[index])
191
+ entry["style"] = dict(entry.get("style") or {}, **style) if merge else dict(style)
192
+ if visible is not None:
193
+ entry["visible"] = visible
194
+ if opacity is not None:
195
+ entry["opacity"] = opacity
196
+ if popup_fields is not None:
197
+ entry["popup_fields"] = list(popup_fields)
198
+ configs[index] = entry
199
+ return self._c.put("/portals/{0}".format(int(portal_id)), {"layer_configs": configs})
200
+
201
+ def move_layer(self, portal_id: Any, layer_id: int, position: Any,
202
+ layer_type: Optional[str] = None) -> Dict[str, Any]:
203
+ """Reorder: `position` is `top`, `bottom`, `up`, `down`, or a 0-based index (0 = top)."""
204
+ doc = self._c.get("/portals/{0}".format(int(portal_id)))
205
+ configs = list(doc.get("layer_configs") or [])
206
+ index = _find_config(configs, layer_id, layer_type)
207
+ if index is None:
208
+ raise NotFoundError(404, "Layer {0} is not on portal {1}.".format(layer_id, portal_id))
209
+ entry = configs.pop(index)
210
+ if position == "top":
211
+ target = 0
212
+ elif position == "bottom":
213
+ target = len(configs)
214
+ elif position == "up":
215
+ target = max(0, index - 1)
216
+ elif position == "down":
217
+ target = min(len(configs), index + 1)
218
+ else:
219
+ try:
220
+ target = max(0, min(int(position), len(configs)))
221
+ except (TypeError, ValueError):
222
+ raise ValidationError(400, "Position must be top, bottom, up, down or an index.")
223
+ configs.insert(target, entry)
224
+ return self._c.put("/portals/{0}".format(int(portal_id)), {"layer_configs": configs})
225
+
226
+ # ── folders (V-13 layer groups) ─────────────────────────────────────────────────────────────
227
+
228
+ def groups(self, portal: Any) -> Optional[List[Dict[str, Any]]]:
229
+ doc = portal if isinstance(portal, dict) else self.get(portal)
230
+ return doc.get("layer_groups")
231
+
232
+ def set_groups(self, portal_id: Any, groups: Optional[List[Dict[str, Any]]]) -> Dict[str, Any]:
233
+ """Replace the folder tree. `None` is not "clear" for the API (it means leave alone), so
234
+ clearing is an explicit empty list."""
235
+ return self._c.put("/portals/{0}".format(int(portal_id)),
236
+ {"layer_groups": [] if groups is None else groups})
237
+
238
+ # ── export (draw-a-box download, the public route) ──────────────────────────────────────────
239
+
240
+ def export_bundle(self, slug: str, bbox: str, items: List[Dict[str, Any]],
241
+ target_crs: str = "4326") -> Dict[str, Any]:
242
+ """Queue a clipped export of portal layers. Public endpoint — no auth needed."""
243
+ return self._c.post("/portals/{0}/export-bundle".format(slug),
244
+ {"bbox": bbox, "items": items, "target_crs": target_crs}, auth=False)
245
+
246
+ def export_status(self, slug: str, job_id: str) -> Dict[str, Any]:
247
+ return self._c.get("/portals/{0}/export-status/{1}".format(slug, job_id), auth=False)
248
+
249
+ def export_download(self, slug: str, job_id: str, sink) -> Any:
250
+ return self._c.download("/portals/{0}/export-download/{1}".format(slug, job_id), sink,
251
+ auth=False)
252
+
253
+
254
+ # ── helpers ──────────────────────────────────────────────────────────────────────────────────────
255
+
256
+ #: Fields `PUT /portals/{id}` accepts. Anything else in a saved document is server-owned; sending
257
+ #: it back is at best ignored and at worst a 422, so the round trip filters rather than hopes.
258
+ EDITABLE_FIELDS = ("title", "description", "template_id", "layer_configs", "layer_groups",
259
+ "layout_config", "story", "theme", "initial_view", "access_type",
260
+ "access_password", "basemap")
261
+
262
+
263
+ def editable_config(config: Dict[str, Any]) -> Dict[str, Any]:
264
+ return {k: v for k, v in (config or {}).items() if k in EDITABLE_FIELDS}
265
+
266
+
267
+ def _find_config(configs: List[Dict[str, Any]], layer_id: Any,
268
+ layer_type: Optional[str]) -> Optional[int]:
269
+ for index, entry in enumerate(configs):
270
+ if int(entry.get("layer_id", -1)) == int(layer_id) and (
271
+ layer_type is None or entry.get("layer_type") == layer_type):
272
+ return index
273
+ return None
274
+
275
+
276
+ def _check_access(access_type: str, password: Optional[str]) -> None:
277
+ if access_type not in ACCESS_TYPES:
278
+ raise ValidationError(400, "Access must be one of {0}.".format(", ".join(ACCESS_TYPES)))
279
+ if access_type == "password" and not password:
280
+ raise ValidationError(400, "A password-protected portal needs --password.")
geodeploy/py.typed ADDED
File without changes
geodeploy/sources.py ADDED
@@ -0,0 +1,70 @@
1
+ """External sources — WMS / XYZ rasters and WFS vectors shown in a portal without ingesting them.
2
+
3
+ A source is a reference, not a copy: nothing is downloaded, and the published portal fetches from
4
+ the provider (raster) or through GeoDeploy's same-origin GeoJSON proxy (WFS, so an unauthenticated
5
+ portal is not blocked by the provider's CORS policy).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, Dict, List, Optional
10
+
11
+ from .errors import NotFoundError, ValidationError
12
+
13
+ SOURCE_TYPES = ("xyz", "wms", "wfs")
14
+
15
+
16
+ class Sources(object):
17
+ def __init__(self, client: Any):
18
+ self._c = client
19
+
20
+ def list(self, query: Optional[str] = None) -> List[Dict[str, Any]]:
21
+ rows = self._c.get("/data/sources") or []
22
+ if query:
23
+ needle = query.lower()
24
+ rows = [r for r in rows if needle in (r.get("name") or "").lower()
25
+ or needle in (r.get("url") or "").lower()]
26
+ return rows
27
+
28
+ def get(self, ref: Any) -> Dict[str, Any]:
29
+ text = str(ref)
30
+ for row in self.list():
31
+ if str(row.get("id")) == text or (row.get("name") or "") == text:
32
+ return row
33
+ raise NotFoundError(404, "No external source matching {0!r}.".format(ref))
34
+
35
+ def create(self, name: str, source_type: str, url: str, layer_name: Optional[str] = None,
36
+ version: Optional[str] = None, image_format: Optional[str] = None,
37
+ attribution: Optional[str] = None) -> Dict[str, Any]:
38
+ """Register a source. A WFS is probed on creation, which is where a wrong `typeName` or an
39
+ unreachable host surfaces — better here than as an empty layer on a published map."""
40
+ if source_type not in SOURCE_TYPES:
41
+ raise ValidationError(400, "Source type must be one of {0}.".format(
42
+ ", ".join(SOURCE_TYPES)))
43
+ if source_type in ("wms", "wfs") and not layer_name:
44
+ raise ValidationError(
45
+ 400, "A {0} source needs --layer-name (the WMS `layers` / WFS `typeName`)."
46
+ .format(source_type.upper()))
47
+ body = {"name": name, "source_type": source_type, "url": url}
48
+ for key, value in (("layer_name", layer_name), ("version", version),
49
+ ("image_format", image_format), ("attribution", attribution)):
50
+ if value is not None:
51
+ body[key] = value
52
+ return self._c.post("/data/sources", body)
53
+
54
+ def usage(self, source_id: Any) -> List[Dict[str, Any]]:
55
+ return self._c.get("/data/sources/{0}/usage".format(int(source_id))) or []
56
+
57
+ def share(self, source_id: Any, visibility: str) -> Dict[str, Any]:
58
+ """private | organization. There is no public tier: a source has no asset of ours to
59
+ expose, so "public" would mean nothing."""
60
+ if visibility not in ("private", "organization"):
61
+ raise ValidationError(400, "External sources are private or organization only.")
62
+ return self._c.put("/data/sources/{0}/sharing".format(int(source_id)),
63
+ {"visibility": visibility})
64
+
65
+ def features(self, source_id: Any) -> Dict[str, Any]:
66
+ """The WFS proxied to GeoJSON — the same feed a published portal reads."""
67
+ return self._c.get("/data/sources/{0}/features.geojson".format(int(source_id)), auth=False)
68
+
69
+ def delete(self, source_id: Any) -> Any:
70
+ return self._c.delete("/data/sources/{0}".format(int(source_id)))