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
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)))
|