Skip to content

Commit 101bfdd

Browse files
authored
Merge pull request #93 from silvioheinze/improve-anonymous-mapping
Add anonymous data input features and related settings
2 parents 6a19be9 + fc9378b commit 101bfdd

21 files changed

Lines changed: 710 additions & 33 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Generated manually for anonymous map visibility setting
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('datasets', '0033_map_default_zoom_range_1_20'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='dataset',
15+
name='anonymous_show_all_points',
16+
field=models.BooleanField(
17+
default=False,
18+
help_text='When anonymous data input is enabled: show all geometry points on the map to anonymous contributors (not only their own). Editing stays limited to own points.',
19+
),
20+
),
21+
]
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Generated by Django 6.0.4 on 2026-04-29 12:31
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('datasets', '0034_add_anonymous_show_all_points'),
10+
]
11+
12+
operations = [
13+
migrations.AlterModelOptions(
14+
name='mappingarea',
15+
options={'ordering': ['name'], 'verbose_name_plural': 'Mapping Areas'},
16+
),
17+
migrations.AlterField(
18+
model_name='dataset',
19+
name='anonymous_show_all_points',
20+
field=models.BooleanField(default=False, help_text='When anonymous data input is enabled: show all geometry points on the map; anonymous contributors may load details and edit entries on any geometry in this dataset (not only their own). When disabled, they only see and edit their own points.'),
21+
),
22+
]
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Generated manually for anonymous "deactivate new points" setting
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('datasets', '0035_alter_anonymous_show_all_points_help_text'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='dataset',
15+
name='anonymous_disable_new_points',
16+
field=models.BooleanField(
17+
default=False,
18+
help_text='When anonymous data input is enabled: anonymous contributors cannot create new geometry points; they can only open existing points and add or edit entries.',
19+
),
20+
),
21+
]
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Anonymous show all mapping areas outlines on data-input map
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('datasets', '0036_add_anonymous_disable_new_points'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='dataset',
15+
name='anonymous_show_all_mapping_areas',
16+
field=models.BooleanField(
17+
default=False,
18+
help_text='When anonymous data input and mapping areas are enabled: show all mapping area outlines (with names) on the anonymous data-input map. Does not grant anonymous mapping-area editing.',
19+
),
20+
),
21+
]

app/datasets/models.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,18 @@ class DataSet(models.Model):
3030
allow_multiple_entries = models.BooleanField(default=False, help_text="Allow multiple data entries per geometry point")
3131
enable_mapping_areas = models.BooleanField(default=False, help_text="Enable mapping areas functionality for this dataset")
3232
allow_anonymous_data_input = models.BooleanField(default=False, help_text="Allow data input without login via shareable URL")
33+
anonymous_show_all_points = models.BooleanField(
34+
default=False,
35+
help_text="When anonymous data input is enabled: show all geometry points on the map; anonymous contributors may load details and edit entries on any geometry in this dataset (not only their own). When disabled, they only see and edit their own points.",
36+
)
37+
anonymous_disable_new_points = models.BooleanField(
38+
default=False,
39+
help_text="When anonymous data input is enabled: anonymous contributors cannot create new geometry points; they can only open existing points and add or edit entries.",
40+
)
41+
anonymous_show_all_mapping_areas = models.BooleanField(
42+
default=False,
43+
help_text="When anonymous data input and mapping areas are enabled: show all mapping area outlines (with names) on the anonymous data-input map. Does not grant anonymous mapping-area editing.",
44+
)
3345
anonymous_access_token = models.CharField(max_length=64, unique=True, null=True, blank=True, help_text="Secret token for anonymous access URL")
3446
map_default_lat = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True, help_text="Default map center latitude when opening data input")
3547
map_default_lng = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True, help_text="Default map center longitude when opening data input")
@@ -137,6 +149,18 @@ def user_has_geometry_access(self, user, geometry_obj):
137149
geometry__covers=geometry_obj.geometry
138150
).exists()
139151

152+
def anonymous_contributor_can_use_geometry(self, geometry_obj, virtual_contributor):
153+
"""
154+
Whether an anonymous virtual contributor may load details, edit entries, upload files, etc.
155+
for this geometry: always their own points; when anonymous_show_all_points is enabled,
156+
any geometry in this dataset.
157+
"""
158+
if virtual_contributor is None or geometry_obj.dataset_id != self.pk:
159+
return False
160+
if geometry_obj.virtual_contributor_id == virtual_contributor.id:
161+
return True
162+
return bool(getattr(self, 'anonymous_show_all_points', False))
163+
140164
class Meta:
141165
ordering = ['-created_at']
142166

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
"""Anonymous 'show all points' map visibility."""
2+
import json
3+
import uuid
4+
5+
from django.contrib.gis.geos import Point
6+
from django.contrib.auth.models import User
7+
from django.test import Client, TestCase
8+
from django.urls import reverse
9+
10+
from datasets.models import DataEntry, DataGeometry, DataSet, VirtualContributor
11+
12+
13+
class AnonymousShowAllPointsTests(TestCase):
14+
def setUp(self):
15+
self.owner = User.objects.create_user(username='owner', password='pass')
16+
self.dataset = DataSet.objects.create(
17+
name='Anon Dataset',
18+
owner=self.owner,
19+
allow_anonymous_data_input=True,
20+
anonymous_show_all_points=False,
21+
)
22+
self.dataset.ensure_anonymous_access_token()
23+
self.dataset.refresh_from_db()
24+
25+
self.vc_self = VirtualContributor.objects.create(
26+
dataset=self.dataset,
27+
uuid=uuid.uuid4(),
28+
display_name='Self',
29+
)
30+
self.vc_other = VirtualContributor.objects.create(
31+
dataset=self.dataset,
32+
uuid=uuid.uuid4(),
33+
display_name='Other',
34+
)
35+
36+
self.geom_self = DataGeometry.objects.create(
37+
dataset=self.dataset,
38+
id_kurz='A',
39+
address='A',
40+
geometry=Point(16.37, 48.21, srid=4326),
41+
virtual_contributor=self.vc_self,
42+
)
43+
self.geom_other = DataGeometry.objects.create(
44+
dataset=self.dataset,
45+
id_kurz='B',
46+
address='B',
47+
geometry=Point(16.38, 48.22, srid=4326),
48+
virtual_contributor=self.vc_other,
49+
)
50+
self.entry_other = DataEntry.objects.create(
51+
geometry=self.geom_other,
52+
name='Other entry',
53+
year=2020,
54+
virtual_contributor=self.vc_other,
55+
)
56+
57+
self.anon_client = Client()
58+
sid = self.dataset.id
59+
session = self.anon_client.session
60+
session[f'anonymous_token_{sid}'] = self.dataset.anonymous_access_token
61+
session[f'virtual_contributor_uuid_{sid}'] = str(self.vc_self.uuid)
62+
session.save()
63+
64+
def _map_ids(self):
65+
url = reverse('dataset_map_data', kwargs={'dataset_id': self.dataset.id})
66+
r = self.anon_client.get(url)
67+
self.assertEqual(r.status_code, 200)
68+
return {row['id'] for row in r.json().get('map_data', [])}
69+
70+
def test_map_data_only_own_points_when_flag_off(self):
71+
self.assertEqual(self._map_ids(), {self.geom_self.id})
72+
73+
def test_map_data_all_points_when_flag_on(self):
74+
self.dataset.anonymous_show_all_points = True
75+
self.dataset.save(update_fields=['anonymous_show_all_points'])
76+
self.assertSetEqual(self._map_ids(), {self.geom_self.id, self.geom_other.id})
77+
78+
def test_geometry_details_other_denied_when_flag_off(self):
79+
url = reverse('geometry_details', kwargs={'geometry_id': self.geom_other.id})
80+
r = self.anon_client.get(url)
81+
self.assertEqual(r.status_code, 403)
82+
83+
def test_geometry_details_other_allowed_when_flag_on(self):
84+
self.dataset.anonymous_show_all_points = True
85+
self.dataset.save(update_fields=['anonymous_show_all_points'])
86+
url = reverse('geometry_details', kwargs={'geometry_id': self.geom_other.id})
87+
r = self.anon_client.get(url)
88+
self.assertEqual(r.status_code, 200)
89+
self.assertTrue(r.json().get('success'))
90+
91+
def test_save_entries_other_geometry_denied_when_flag_off(self):
92+
url = reverse('save_entries')
93+
r = self.anon_client.post(
94+
url,
95+
{
96+
'geometry_id': str(self.geom_other.id),
97+
'entries[0][id]': str(self.entry_other.id),
98+
},
99+
)
100+
self.assertEqual(r.status_code, 403)
101+
102+
def test_save_entries_other_geometry_allowed_when_flag_on(self):
103+
self.dataset.anonymous_show_all_points = True
104+
self.dataset.save(update_fields=['anonymous_show_all_points'])
105+
url = reverse('save_entries')
106+
r = self.anon_client.post(
107+
url,
108+
{
109+
'geometry_id': str(self.geom_other.id),
110+
'entries[0][id]': str(self.entry_other.id),
111+
},
112+
)
113+
self.assertEqual(r.status_code, 200)
114+
self.assertTrue(r.json().get('success'))
115+
116+
def test_geometry_create_denied_when_anonymous_disable_new_points(self):
117+
self.dataset.anonymous_disable_new_points = True
118+
self.dataset.save(update_fields=['anonymous_disable_new_points'])
119+
url = reverse('geometry_create', kwargs={'dataset_id': self.dataset.id})
120+
payload = {
121+
'id_kurz': 'NEW_PT',
122+
'address': 'New',
123+
'geometry': {'type': 'Point', 'coordinates': [16.4, 48.25]},
124+
}
125+
r = self.anon_client.post(
126+
url,
127+
data=json.dumps(payload),
128+
content_type='application/json',
129+
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
130+
)
131+
self.assertEqual(r.status_code, 403)
132+
body = r.json()
133+
self.assertFalse(body.get('success', True))
134+
self.assertIn('disabled', (body.get('error') or '').lower())
135+
136+
def test_geometry_create_allowed_when_anonymous_disable_new_points_off(self):
137+
url = reverse('geometry_create', kwargs={'dataset_id': self.dataset.id})
138+
payload = {
139+
'id_kurz': 'NEW_PT2',
140+
'address': 'New',
141+
'geometry': {'type': 'Point', 'coordinates': [16.41, 48.26]},
142+
}
143+
r = self.anon_client.post(
144+
url,
145+
data=json.dumps(payload),
146+
content_type='application/json',
147+
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
148+
)
149+
self.assertEqual(r.status_code, 200)
150+
self.assertTrue(r.json().get('success'))
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"""Anonymous mapping area outlines API (read-only, session token)."""
2+
3+
from django.contrib.auth.models import User
4+
from django.contrib.gis.geos import MultiPolygon, Polygon
5+
from django.test import Client, TestCase
6+
from django.urls import reverse
7+
8+
from datasets.models import DataSet, MappingArea
9+
10+
11+
class MappingAreaAnonymousOutlinesTests(TestCase):
12+
def setUp(self):
13+
self.owner = User.objects.create_user(username='owner', password='pass')
14+
self.dataset = DataSet.objects.create(
15+
name='Anon MA',
16+
owner=self.owner,
17+
allow_anonymous_data_input=True,
18+
enable_mapping_areas=True,
19+
anonymous_show_all_mapping_areas=False,
20+
)
21+
self.dataset.ensure_anonymous_access_token()
22+
self.dataset.refresh_from_db()
23+
24+
polygon = Polygon(
25+
(
26+
(-0.1, -0.1),
27+
(-0.1, 0.1),
28+
(0.1, 0.1),
29+
(0.1, -0.1),
30+
(-0.1, -0.1),
31+
),
32+
srid=4326,
33+
)
34+
self.area = MappingArea.objects.create(
35+
dataset=self.dataset,
36+
name='North',
37+
geometry=MultiPolygon(polygon, srid=4326),
38+
created_by=self.owner,
39+
)
40+
41+
self.url = reverse('mapping_area_anonymous_outlines', args=[self.dataset.id])
42+
self.client = Client()
43+
44+
def _set_session_token(self, token):
45+
session = self.client.session
46+
session[f'anonymous_token_{self.dataset.id}'] = token
47+
session.save()
48+
49+
def test_invalid_token_returns_403(self):
50+
self._set_session_token('wrong-token')
51+
r = self.client.get(self.url)
52+
self.assertEqual(r.status_code, 403)
53+
self.assertFalse(r.json().get('success', True))
54+
55+
def test_flag_off_returns_empty_list(self):
56+
self._set_session_token(self.dataset.anonymous_access_token)
57+
r = self.client.get(self.url)
58+
self.assertEqual(r.status_code, 200)
59+
data = r.json()
60+
self.assertTrue(data['success'])
61+
self.assertEqual(data['mapping_areas'], [])
62+
63+
def test_flag_on_returns_all_areas(self):
64+
self.dataset.anonymous_show_all_mapping_areas = True
65+
self.dataset.save(update_fields=['anonymous_show_all_mapping_areas'])
66+
self._set_session_token(self.dataset.anonymous_access_token)
67+
r = self.client.get(self.url)
68+
self.assertEqual(r.status_code, 200)
69+
data = r.json()
70+
self.assertTrue(data['success'])
71+
self.assertEqual(len(data['mapping_areas']), MappingArea.objects.filter(dataset=self.dataset).count())
72+
first = next(a for a in data['mapping_areas'] if a['id'] == self.area.id)
73+
self.assertEqual(first['name'], 'North')
74+
self.assertEqual(first['geometry']['type'], 'MultiPolygon')

0 commit comments

Comments
 (0)