feat: add configurable people maps
Add independent map profiles, offline city lookup, bundled world artwork and collision-free portrait placement with optional person details. Deploy with updated requirements, migrations 0029-0031 and collectstatic.
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.template import Context, Template
|
||||
from django.test import RequestFactory, TestCase
|
||||
|
||||
from apps.pages.maps import resolve_city
|
||||
from apps.pages.models import MapMembership, MapPerson, MapPlacement, PeopleMap
|
||||
|
||||
|
||||
class PeopleMapTests(TestCase):
|
||||
def setUp(self):
|
||||
self.world = PeopleMap.objects.create(title='Our global network')
|
||||
self.person = MapPerson.objects.create(name='<script>Test</script>', institution='Institute')
|
||||
|
||||
def test_offline_city_and_country_resolution(self):
|
||||
lat, lon = resolve_city('CA', 'Vancouver')
|
||||
self.assertAlmostEqual(lat, 49.24966, places=2)
|
||||
self.assertAlmostEqual(lon, -123.11934, places=2)
|
||||
self.assertEqual(resolve_city('Canada', 'Vancouver'), (lat, lon))
|
||||
|
||||
def test_invalid_or_ambiguous_city_is_rejected(self):
|
||||
for country, city in [('Canada', 'Not a real city'), ('Unknown country', 'Paris'), ('US', 'Springfield')]:
|
||||
with self.subTest(country=country, city=city), self.assertRaises(ValidationError):
|
||||
resolve_city(country, city)
|
||||
|
||||
def test_membership_saves_resolved_coordinates(self):
|
||||
member = MapMembership.objects.create(people_map=self.world, person=self.person, country='CA', city='Vancouver')
|
||||
self.assertLess(member.longitude, -120)
|
||||
member.city = 'Toronto'
|
||||
member.save()
|
||||
member.refresh_from_db()
|
||||
self.assertGreater(member.longitude, -80)
|
||||
|
||||
def test_explicit_coordinates_and_invalid_ranges(self):
|
||||
member = MapMembership(people_map=self.world, person=self.person, location_method='coordinates', latitude=0, longitude=0)
|
||||
member.full_clean()
|
||||
for latitude, longitude in [(91, 0), (0, -181), (None, 0), (float('nan'), 0), (0, float('inf'))]:
|
||||
member.latitude, member.longitude = latitude, longitude
|
||||
with self.subTest(latitude=latitude, longitude=longitude), self.assertRaises(ValidationError):
|
||||
member.full_clean()
|
||||
|
||||
def test_different_maps_can_have_different_members(self):
|
||||
second = PeopleMap.objects.create(title='Other map')
|
||||
MapMembership.objects.create(people_map=self.world, person=self.person, location_method='coordinates', latitude=0, longitude=0)
|
||||
self.assertEqual(second.memberships.count(), 0)
|
||||
with self.assertRaises(ValidationError):
|
||||
MapMembership.objects.create(people_map=self.world, person=self.person, location_method='coordinates', latitude=1, longitude=1)
|
||||
|
||||
def test_placement_path_validation(self):
|
||||
for path in ['https://example.com/', '//example.com/', '/about/?a=1', '/about/#x']:
|
||||
with self.subTest(path=path), self.assertRaises(ValidationError):
|
||||
MapPlacement(people_map=self.world, page_path=path).full_clean()
|
||||
|
||||
def render_maps(self, path, position='bottom'):
|
||||
request = RequestFactory().get(path)
|
||||
return Template('{% load people_maps %}{% people_maps position %}').render(Context({'request': request, 'position': position}))
|
||||
|
||||
def test_placement_filters_and_escaped_person_data(self):
|
||||
MapPlacement.objects.create(people_map=self.world, page_path='/about/')
|
||||
MapMembership.objects.create(people_map=self.world, person=self.person, location_method='coordinates', latitude=0, longitude=0)
|
||||
html = self.render_maps('/about/')
|
||||
self.assertIn('Our global network', html)
|
||||
self.assertIn('<script>', html)
|
||||
self.assertNotIn('<script>Test', html)
|
||||
self.assertNotIn('Our global network', self.render_maps('/contact/'))
|
||||
self.assertNotIn('Our global network', self.render_maps('/about/', 'top'))
|
||||
self.world.is_active = False
|
||||
self.world.save()
|
||||
self.assertNotIn('Our global network', self.render_maps('/about/'))
|
||||
|
||||
def test_placement_supports_any_project_or_page_path(self):
|
||||
for path in ['/', '/about/', '/my-page/', '/projects/alpha/', '/projects/alpha/beta/']:
|
||||
MapPlacement.objects.create(people_map=self.world, page_path=path, position='top')
|
||||
self.assertIn('Our global network', self.render_maps(path, 'top'))
|
||||
|
||||
def test_admin_map_inline_resolves_city(self):
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.urls import reverse
|
||||
user = get_user_model().objects.create_superuser(username='mapadmin', password='test-password', email='map@example.com')
|
||||
self.client.force_login(user)
|
||||
url = reverse('admin:pages_peoplemap_change', args=[self.world.pk])
|
||||
self.assertEqual(self.client.get(url).status_code, 200)
|
||||
response = self.client.post(url, {
|
||||
'title': self.world.title, 'style': 'classic', 'background_style': 'atlas', 'is_active': 'on',
|
||||
'memberships-TOTAL_FORMS': '1', 'memberships-INITIAL_FORMS': '0',
|
||||
'memberships-MIN_NUM_FORMS': '0', 'memberships-MAX_NUM_FORMS': '1000',
|
||||
'memberships-0-person': str(self.person.pk), 'memberships-0-location_method': 'city',
|
||||
'memberships-0-country': 'CA', 'memberships-0-city': 'Vancouver', 'memberships-0-order': '0',
|
||||
'placements-TOTAL_FORMS': '1', 'placements-INITIAL_FORMS': '0',
|
||||
'placements-MIN_NUM_FORMS': '0', 'placements-MAX_NUM_FORMS': '1000',
|
||||
'placements-0-page_path': '/about/', 'placements-0-position': 'bottom',
|
||||
'placements-0-order': '0', 'placements-0-is_active': 'on', '_save': 'Save',
|
||||
})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
member = self.world.memberships.get()
|
||||
self.assertAlmostEqual(member.latitude, 49.25, places=1)
|
||||
self.assertIn('Our global network', self.client.get('/about/').content.decode())
|
||||
|
||||
def test_all_builtin_backgrounds_are_bundled_and_rendered(self):
|
||||
from django.contrib.staticfiles import finders
|
||||
MapPlacement.objects.create(people_map=self.world, page_path='/about/')
|
||||
for key, label in PeopleMap.BACKGROUND_CHOICES:
|
||||
self.world.background_style = key
|
||||
self.world.full_clean()
|
||||
self.world.save()
|
||||
self.assertTrue(finders.find(self.world.background_static_path), label)
|
||||
self.assertIn(self.world.background_static_path, self.render_maps('/about/'))
|
||||
|
||||
def test_uploaded_background_overrides_preset(self):
|
||||
MapPlacement.objects.create(people_map=self.world, page_path='/about/')
|
||||
self.world.background_image = 'maps/custom-world.png'
|
||||
self.world.background_style = 'atlas'
|
||||
self.world.save()
|
||||
html = self.render_maps('/about/')
|
||||
self.assertIn('/media/maps/custom-world.png', html)
|
||||
self.assertNotIn('src="/static/images/maps/world-atlas.svg"', html)
|
||||
|
||||
def test_people_details_can_be_hidden_without_hiding_map_or_leaving_dead_links(self):
|
||||
placement = MapPlacement.objects.create(people_map=self.world, page_path='/about/')
|
||||
member = MapMembership.objects.create(people_map=self.world, person=self.person,
|
||||
location_method='coordinates', latitude=0, longitude=0)
|
||||
target = f'map-{placement.pk}-person-{member.pk}'
|
||||
html = self.render_maps('/about/')
|
||||
self.assertIn('people-map-directory', html)
|
||||
self.assertIn(f'href="#{target}"', html)
|
||||
self.world.show_people_details = False
|
||||
self.world.save()
|
||||
html = self.render_maps('/about/')
|
||||
self.assertNotIn('people-map-directory', html)
|
||||
self.assertNotIn(target, html)
|
||||
self.assertNotIn('Institute', html)
|
||||
self.assertIn('people-map-pin', html)
|
||||
self.assertIn('data-lat=', html)
|
||||
self.assertIn('<script>Test</script>', html)
|
||||
|
||||
def test_canonical_city_name_takes_priority_over_another_towns_alias(self):
|
||||
lat, lon = resolve_city('IR', 'Tehran')
|
||||
self.assertAlmostEqual(lat, 35.69, places=1)
|
||||
self.assertAlmostEqual(lon, 51.42, places=1)
|
||||
|
||||
def test_city_alias_remains_supported(self):
|
||||
self.assertEqual(resolve_city('GB', 'Londres'), resolve_city('GB', 'London'))
|
||||
Reference in New Issue
Block a user