Skip to content

Commit d999460

Browse files
committed
add buttons to copy and paste geospatial metadata based on eoextent
1 parent b0e44ac commit d999460

7 files changed

Lines changed: 344 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- **Copy/paste geometries between the geoextent tool and the contribution form.** A new "Copy extents" button on `/geoextent/` saves all extracted geometries to the browser; a "Paste geoextents" button on the work landing page (when contributing spatial extent) pastes them into the map as editable layers ready to submit.
1313

14+
- **Admin-only "Publish all unpublished works" button on collection pages** — bulk-flips every Harvested or Contributed work in the collection to Published in one click. Curators see no button (admins-only). Draft / Testing / Withdrawn works are deliberately left untouched.
15+
16+
- **Mountain Wetlands and OpenAlex-as-source harvesters now auto-create a Collection** for the source on first run (mirroring the OAI-PMH path from issue #192). New collections start unpublished so admins can review name/description before exposing them on `/collections/`.
1417

1518
- **Separate "Unpublished works" map layer** for admins (main map) and collection curators (collection pages). Features split into two togglable layers, *Published works (N)* and *Unpublished works (N)* (dashed, muted). Popups for unpublished features show a status badge.
1619

tests/test_collections.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,121 @@ def test_publish_then_unpublish(self):
364364
self.assertFalse(self.col.is_published)
365365

366366

367+
class PublishCollectionWorksTests(TestCase):
368+
"""Admin-only bulk action to flip Harvested/Contributed works to Published.
369+
370+
Curators (non-staff) must be rejected — explicitly tested because curators
371+
can otherwise edit collection descriptions and add/remove individual works.
372+
"""
373+
374+
def setUp(self):
375+
self.client = Client()
376+
self.col = Collection.objects.create(identifier='c', name='C', is_published=True)
377+
self.admin = User.objects.create_user(
378+
username='admin@x.com', email='admin@x.com', password='p123', is_staff=True,
379+
)
380+
self.curator = User.objects.create_user(
381+
username='c@x.com', email='c@x.com', password='p123',
382+
)
383+
self.col.curators.add(self.curator)
384+
385+
self.harvested = Work.objects.create(
386+
title='H', status='h', doi='10.1234/h',
387+
geometry=GeometryCollection(Point(0, 0)),
388+
)
389+
self.contributed = Work.objects.create(
390+
title='C', status='c', doi='10.1234/c',
391+
geometry=GeometryCollection(Point(1, 1)),
392+
)
393+
self.draft = Work.objects.create(
394+
title='D', status='d', doi='10.1234/d',
395+
geometry=GeometryCollection(Point(2, 2)),
396+
)
397+
self.withdrawn = Work.objects.create(
398+
title='W', status='w', doi='10.1234/w',
399+
geometry=GeometryCollection(Point(3, 3)),
400+
)
401+
self.already_published = Work.objects.create(
402+
title='P', status='p', doi='10.1234/p',
403+
geometry=GeometryCollection(Point(4, 4)),
404+
)
405+
for w in (self.harvested, self.contributed, self.draft, self.withdrawn, self.already_published):
406+
w.collections.add(self.col)
407+
408+
# Work in another collection — must not be touched.
409+
self.other_col = Collection.objects.create(identifier='other', name='Other', is_published=True)
410+
self.outsider_work = Work.objects.create(
411+
title='O', status='h', doi='10.1234/o',
412+
geometry=GeometryCollection(Point(5, 5)),
413+
)
414+
self.outsider_work.collections.add(self.other_col)
415+
416+
def _url(self):
417+
return f'/collections/{self.col.id}/publish-works/'
418+
419+
def test_admin_publishes_harvested_and_contributed_only(self):
420+
self.client.force_login(self.admin)
421+
resp = self.client.post(self._url())
422+
self.assertEqual(resp.status_code, 200)
423+
self.assertEqual(resp.json(), {'success': True, 'published_count': 2})
424+
425+
for w in (self.harvested, self.contributed):
426+
w.refresh_from_db()
427+
self.assertEqual(w.status, 'p')
428+
# Draft / Withdrawn deliberately untouched.
429+
self.draft.refresh_from_db()
430+
self.assertEqual(self.draft.status, 'd')
431+
self.withdrawn.refresh_from_db()
432+
self.assertEqual(self.withdrawn.status, 'w')
433+
# Other collection's work untouched.
434+
self.outsider_work.refresh_from_db()
435+
self.assertEqual(self.outsider_work.status, 'h')
436+
437+
def test_curator_is_rejected(self):
438+
self.client.force_login(self.curator)
439+
resp = self.client.post(self._url())
440+
# staff_member_required redirects to admin login for non-staff.
441+
self.assertEqual(resp.status_code, 302)
442+
self.harvested.refresh_from_db()
443+
self.assertEqual(self.harvested.status, 'h')
444+
445+
def test_anonymous_is_rejected(self):
446+
resp = self.client.post(self._url())
447+
self.assertEqual(resp.status_code, 302)
448+
self.harvested.refresh_from_db()
449+
self.assertEqual(self.harvested.status, 'h')
450+
451+
def test_get_is_not_allowed(self):
452+
self.client.force_login(self.admin)
453+
resp = self.client.get(self._url())
454+
self.assertEqual(resp.status_code, 405)
455+
456+
def test_button_visible_to_admin_with_count(self):
457+
self.client.force_login(self.admin)
458+
resp = self.client.get(reverse('optimap:collection-page', args=[self.col.identifier]))
459+
self.assertEqual(resp.status_code, 200)
460+
body = resp.content.decode()
461+
self.assertIn('collection-publish-works-btn', body)
462+
# 2 works are h/c, count rendered in the button label.
463+
self.assertIn('Publish all 2 unpublished works', body)
464+
465+
def test_button_hidden_when_no_publishable_works(self):
466+
# Flip the two candidates to Published — button disappears. The class
467+
# name and ``data-publishable-count`` both appear in the admin's JS
468+
# handler block, so assert on the human-visible label that only the
469+
# rendered button carries.
470+
self.harvested.status = 'p'; self.harvested.save()
471+
self.contributed.status = 'p'; self.contributed.save()
472+
self.client.force_login(self.admin)
473+
resp = self.client.get(reverse('optimap:collection-page', args=[self.col.identifier]))
474+
self.assertNotIn('Publish all', resp.content.decode())
475+
476+
def test_button_hidden_for_curator(self):
477+
self.client.force_login(self.curator)
478+
resp = self.client.get(reverse('optimap:collection-page', args=[self.col.identifier]))
479+
self.assertNotIn('Publish all', resp.content.decode())
480+
481+
367482
class CuratorAddRemoveTests(TestCase):
368483
def setUp(self):
369484
self.client = Client()

tests/test_geocoding.py

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -242,28 +242,73 @@ def test_failed_lookups_skip_address_but_count_stays_at_successes(self):
242242
self.assertIn("Berlin", placename)
243243
self.assertEqual(n, 1)
244244

245-
def test_polygon_uses_one_representative_interior_point(self):
246-
# A 4-vertex polygon has 5 ring points (closed) — we must NOT
247-
# geocode each vertex; one representative point is enough.
245+
def test_polygon_samples_envelope_corners_plus_interior(self):
246+
# A polygon must sample the four corners of its bounding box plus an
247+
# interior point, so cross-border polygons aren't reduced to a single
248+
# interior placename. The 4-vertex closing ring is still NOT
249+
# geocoded vertex-by-vertex (would explode for high-resolution rings).
248250
from django.contrib.gis.geos import Polygon
249251
poly = Polygon(
250252
((10.0, 50.0), (11.0, 50.0), (11.0, 51.0), (10.0, 51.0), (10.0, 50.0)),
251253
srid=4326,
252254
)
253255
gc = GeometryCollection(poly, srid=4326)
254-
call_count = {"n": 0}
256+
seen_coords: list[tuple[float, float]] = []
255257

256258
def lookup(lat, lon):
257-
call_count["n"] += 1
259+
seen_coords.append((round(lat, 3), round(lon, 3)))
258260
return {"address": _BERLIN, "display_name": "Berlin"}
259261

260262
with mock.patch.object(
261263
geocoding, "_reverse_geocode_lookup", side_effect=lookup,
262264
):
263265
placename, country, n = geocoding.geocode_geometry(gc)
264-
self.assertEqual(call_count["n"], 1, "polygon should geocode once")
266+
# Four corners + one interior point — five lookups, not the five
267+
# ring vertices (which would include the duplicated closing point).
268+
self.assertEqual(len(seen_coords), 5, f"polygon: corners + interior, got {seen_coords}")
269+
self.assertEqual(set([c for c in seen_coords if c in {
270+
(50.0, 10.0), (50.0, 11.0), (51.0, 10.0), (51.0, 11.0),
271+
}]), {(50.0, 10.0), (50.0, 11.0), (51.0, 10.0), (51.0, 11.0)},
272+
"all four envelope corners must be sampled")
265273
self.assertEqual(country, "DE")
266274

275+
def test_polygon_spanning_two_countries_lca_falls_back(self):
276+
# Bug demoed by work id=10 on the deployed instance: a polygon
277+
# straddling Germany and Poland was getting "Mniszki, …, Poland" as
278+
# placename because only the centroid was geocoded. With corners
279+
# sampled, two corners hit Germany, two hit Poland → LCA collapses
280+
# past country (no shared continent in our test fixture either) and
281+
# the work no longer claims a misleading specific placename.
282+
from django.contrib.gis.geos import Polygon
283+
# Envelope corners → (lat, lon):
284+
# (50, 10) DE, (50, 20) PL, (52, 10) DE, (52, 20) PL
285+
# Interior (point_on_surface for an axis-aligned rectangle) → (51, 15) — DE.
286+
_POLAND = {
287+
"country_code": "pl", "country": "Poland",
288+
"state": "Łódź Voivodeship", "city": "Mniszki",
289+
}
290+
lookup = _LookupTable({
291+
(50.0, 10.0): {"address": _BERLIN, "display_name": "Berlin"},
292+
(50.0, 20.0): {"address": _POLAND, "display_name": "Mniszki"},
293+
(52.0, 10.0): {"address": _BERLIN, "display_name": "Berlin"},
294+
(52.0, 20.0): {"address": _POLAND, "display_name": "Mniszki"},
295+
(51.0, 15.0): {"address": _BERLIN, "display_name": "Berlin"},
296+
})
297+
poly = Polygon(
298+
((10.0, 50.0), (20.0, 50.0), (20.0, 52.0), (10.0, 52.0), (10.0, 50.0)),
299+
srid=4326,
300+
)
301+
gc = GeometryCollection(poly, srid=4326)
302+
with mock.patch.object(
303+
geocoding, "_reverse_geocode_lookup", side_effect=lookup,
304+
):
305+
placename, country, n = geocoding.geocode_geometry(gc)
306+
# Two countries appear among the corners → no shared country → both
307+
# placename and country must be None, not the centroid's interior.
308+
self.assertIsNone(placename, f"expected None, got {placename!r}")
309+
self.assertIsNone(country)
310+
self.assertEqual(n, 5)
311+
267312
def test_max_points_caps_geocoder_calls(self):
268313
# 30 points but max_points=5 → only 5 lookups.
269314
gc = GeometryCollection(
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
# SPDX-FileCopyrightText: 2026 OPTIMETA and KOMET projects <https://projects.tib.eu/komet>
2+
# SPDX-License-Identifier: GPL-3.0-or-later
3+
4+
"""Generalisation of issue #192: every harvest entry point auto-creates a
5+
``Collection`` for its source on first run if none is pre-assigned. The OAI-PMH
6+
path is covered by ``test_oai_collection_auto_create.py``; this module covers
7+
the Mountain Wetlands and OpenAlex-as-source harvesters that previously
8+
required an admin (or fixture) to pre-seed a Collection.
9+
"""
10+
11+
from unittest.mock import patch, Mock
12+
13+
from django.test import TestCase
14+
15+
from works.models import Collection, Source
16+
17+
18+
class MountainWetlandsAutoCreatesCollectionTests(TestCase):
19+
def setUp(self):
20+
self.source = Source.objects.create(
21+
name='Mountain Wetlands Repository',
22+
url_field='https://andes.example/api/v1/items/',
23+
source_type='mountain-wetlands',
24+
)
25+
self.assertIsNone(self.source.collection)
26+
27+
@patch('works.harvesting.mountain_wetlands._mwr_session')
28+
def test_first_harvest_creates_collection_and_links_source(self, mock_session_factory):
29+
from works.tasks import harvest_mountain_wetlands
30+
31+
# Empty page — short-circuit the loop without saving any works.
32+
fake_response = Mock()
33+
fake_response.ok = True
34+
fake_response.status_code = 200
35+
fake_response.json.return_value = {'count': 0, 'data': []}
36+
mock_session = Mock()
37+
mock_session.get.return_value = fake_response
38+
mock_session_factory.return_value = mock_session
39+
40+
harvest_mountain_wetlands(self.source.id)
41+
42+
self.source.refresh_from_db()
43+
self.assertIsNotNone(self.source.collection)
44+
self.assertEqual(self.source.collection.identifier, 'mountain-wetlands-repository')
45+
self.assertFalse(self.source.collection.is_published)
46+
self.assertEqual(Collection.objects.count(), 1)
47+
48+
@patch('works.harvesting.mountain_wetlands._mwr_session')
49+
def test_preassigned_collection_is_left_alone(self, mock_session_factory):
50+
from works.tasks import harvest_mountain_wetlands
51+
52+
preassigned = Collection.objects.create(
53+
identifier='preassigned-mw', name='Pre-assigned MW', is_published=True,
54+
)
55+
self.source.collection = preassigned
56+
self.source.save(update_fields=['collection'])
57+
58+
fake_response = Mock()
59+
fake_response.ok = True
60+
fake_response.status_code = 200
61+
fake_response.json.return_value = {'count': 0, 'data': []}
62+
mock_session = Mock()
63+
mock_session.get.return_value = fake_response
64+
mock_session_factory.return_value = mock_session
65+
66+
harvest_mountain_wetlands(self.source.id)
67+
68+
self.source.refresh_from_db()
69+
self.assertEqual(self.source.collection_id, preassigned.id)
70+
self.source.collection.refresh_from_db()
71+
self.assertTrue(self.source.collection.is_published)
72+
73+
74+
class OpenalexSourceAutoCreatesCollectionTests(TestCase):
75+
def setUp(self):
76+
self.source = Source.objects.create(
77+
name='AGILE GIScience Series (OpenAlex)',
78+
url_field='https://api.openalex.org/sources/S4210203054',
79+
source_type='openalex',
80+
openalex_id='S4210203054',
81+
)
82+
self.assertIsNone(self.source.collection)
83+
84+
@patch('works.harvesting.openalex_source.parse_openalex_response_and_save_works')
85+
def test_first_harvest_creates_collection_and_links_source(self, mock_parser):
86+
from works.tasks import harvest_openalex_source
87+
88+
# Skip the API loop entirely — only the wiring around it matters here.
89+
mock_parser.return_value = (0, 0)
90+
91+
harvest_openalex_source(self.source.id, max_records=0)
92+
93+
self.source.refresh_from_db()
94+
self.assertIsNotNone(self.source.collection)
95+
self.assertEqual(self.source.collection.identifier, 'agile-giscience-series-openalex')
96+
self.assertFalse(self.source.collection.is_published)
97+
self.assertEqual(Collection.objects.count(), 1)
98+
99+
@patch('works.harvesting.openalex_source.parse_openalex_response_and_save_works')
100+
def test_preassigned_collection_is_left_alone(self, mock_parser):
101+
from works.tasks import harvest_openalex_source
102+
103+
mock_parser.return_value = (0, 0)
104+
preassigned = Collection.objects.create(
105+
identifier='preassigned-agile', name='Pre-assigned AGILE', is_published=True,
106+
)
107+
self.source.collection = preassigned
108+
self.source.save(update_fields=['collection'])
109+
110+
harvest_openalex_source(self.source.id, max_records=0)
111+
112+
self.source.refresh_from_db()
113+
self.assertEqual(self.source.collection_id, preassigned.id)
114+
self.source.collection.refresh_from_db()
115+
self.assertTrue(self.source.collection.is_published)

works/services/geocoding.py

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -161,30 +161,61 @@ def _representative_points(geom, max_points: int = 20) -> list[tuple[float, floa
161161
162162
Each ``Point`` in the geometry is sampled individually (so multi-point
163163
GeometryCollections are geocoded per-site). Polygons / lines contribute
164-
one interior representative point. Bounded so a 500-vertex polygon
164+
the four corners of their bounding box plus an interior point — sampling
165+
only an interior point would mask cross-border coverage: a Polygon
166+
spanning Germany and Poland geocodes through its centroid (which lands
167+
in one country) and the LCA blindly reports that country, even though
168+
the polygon's corners clearly cross the border. Sampling the corners
169+
forces ``_common_address`` to detect the divergence and fall back to
170+
the shared ancestor (or to ``None``). Bounded so a 500-vertex polygon
165171
doesn't trigger 500 Nominatim requests.
166172
"""
167173
points: list[tuple[float, float]] = []
174+
seen: set[tuple[float, float]] = set()
175+
176+
def _add(lat: float, lon: float):
177+
# Quantise to the cache resolution (~100 m) so envelope corners that
178+
# round to the same grid cell as an interior point don't waste a
179+
# Nominatim hit on the same neighbourhood.
180+
key = (round(lat, 3), round(lon, 3))
181+
if key in seen:
182+
return
183+
seen.add(key)
184+
points.append((lat, lon))
168185

169186
def _walk(g):
170187
if len(points) >= max_points:
171188
return
172189
gt = g.geom_type
173190
if gt == "Point":
174-
points.append((g.y, g.x))
191+
_add(g.y, g.x)
175192
elif gt in ("MultiPoint", "GeometryCollection",
176193
"MultiPolygon", "MultiLineString"):
177194
for child in g:
178195
_walk(child)
179196
if len(points) >= max_points:
180197
return
181198
else:
182-
# Polygon, LineString, LinearRing — pick one interior point.
199+
# Polygon, LineString, LinearRing — sample the envelope corners
200+
# plus an interior point. See module docstring above.
201+
try:
202+
minx, miny, maxx, maxy = g.extent
203+
except Exception: # pragma: no cover — defensive
204+
minx = miny = maxx = maxy = None
205+
if minx is not None:
206+
for lat, lon in (
207+
(miny, minx), (miny, maxx),
208+
(maxy, minx), (maxy, maxx),
209+
):
210+
if len(points) >= max_points:
211+
return
212+
_add(lat, lon)
183213
try:
184214
pt = g.point_on_surface
185215
except Exception: # pragma: no cover — defensive
186216
pt = g.centroid
187-
points.append((pt.y, pt.x))
217+
if len(points) < max_points:
218+
_add(pt.y, pt.x)
188219

189220
_walk(geom)
190221
return points[:max_points]

0 commit comments

Comments
 (0)