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:
@@ -1,11 +1,14 @@
|
||||
from django.contrib import admin
|
||||
from django.http import FileResponse, Http404, HttpResponseRedirect
|
||||
from django.urls import path, reverse
|
||||
from django.templatetags.static import static
|
||||
from django.utils.html import format_html_join
|
||||
from django.utils.html import format_html
|
||||
|
||||
from apps.core.admin_video import video_admin_preview
|
||||
from apps.core.video import VIDEO_ADMIN_FIELDSET
|
||||
|
||||
from .forms import MapMembershipAdminForm
|
||||
from .models import (
|
||||
AboutSection,
|
||||
AboutSectionItem,
|
||||
@@ -20,6 +23,10 @@ from .models import (
|
||||
HomepageSection,
|
||||
HomepageSectionItem,
|
||||
PageVideo,
|
||||
PeopleMap,
|
||||
MapPerson,
|
||||
MapMembership,
|
||||
MapPlacement,
|
||||
)
|
||||
|
||||
|
||||
@@ -319,3 +326,49 @@ class PageVideoAdmin(admin.ModelAdmin):
|
||||
@admin.display(description="Preview")
|
||||
def video_preview(self, obj):
|
||||
return video_admin_preview(obj)
|
||||
|
||||
|
||||
class MapMembershipInline(admin.StackedInline):
|
||||
model = MapMembership
|
||||
form = MapMembershipAdminForm
|
||||
extra = 0
|
||||
autocomplete_fields = ['person']
|
||||
|
||||
|
||||
class MapPlacementInline(admin.TabularInline):
|
||||
model = MapPlacement
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(PeopleMap)
|
||||
class PeopleMapAdmin(admin.ModelAdmin):
|
||||
list_display = ['title', 'style', 'background_style', 'is_active']
|
||||
list_filter = ['style', 'background_style', 'is_active']
|
||||
search_fields = ['title']
|
||||
inlines = [MapMembershipInline, MapPlacementInline]
|
||||
readonly_fields = ['background_gallery']
|
||||
fields = ['title', 'description', 'style', 'background_style', 'background_gallery', 'background_image', 'show_people_details', 'is_active']
|
||||
|
||||
@admin.display(description="Included world maps")
|
||||
def background_gallery(self, obj):
|
||||
return format_html_join('',
|
||||
'<figure style="display:inline-block;width:240px;margin:8px;vertical-align:top">'
|
||||
'<img src="{}" alt="{}" style="width:240px;height:120px;object-fit:contain;background:#0c182a;border-radius:8px" />'
|
||||
'<figcaption style="margin-top:8px">{}</figcaption></figure>',
|
||||
((static(PeopleMap(background_style=key).background_static_path), label, label)
|
||||
for key, label in PeopleMap.BACKGROUND_CHOICES))
|
||||
|
||||
|
||||
|
||||
@admin.register(MapPerson)
|
||||
class MapPersonAdmin(admin.ModelAdmin):
|
||||
list_display = ['name', 'role', 'institution']
|
||||
search_fields = ['name', 'institution', 'role']
|
||||
|
||||
|
||||
@admin.register(MapPlacement)
|
||||
class MapPlacementAdmin(admin.ModelAdmin):
|
||||
list_display = ['people_map', 'page_path', 'position', 'order', 'is_active']
|
||||
search_fields = ['page_path', 'people_map__title']
|
||||
autocomplete_fields = ['people_map']
|
||||
list_filter = ['position', 'is_active']
|
||||
|
||||
+14
-1
@@ -2,7 +2,7 @@ from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from .contact_uploads import validate_contact_attachments
|
||||
from .models import ContactSubmission
|
||||
from .models import ContactSubmission, MapMembership
|
||||
|
||||
|
||||
class ContactForm(forms.ModelForm):
|
||||
@@ -39,3 +39,16 @@ class ContactForm(forms.ModelForm):
|
||||
self.add_error("attachments", exc)
|
||||
cleaned_data["attachments"] = []
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class MapMembershipAdminForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = MapMembership
|
||||
fields = "__all__"
|
||||
help_texts = {
|
||||
"location_method": "City mode calculates coordinates on save. Choose Coordinates to set latitude and longitude yourself.",
|
||||
"country": "For city lookup, enter an English country name or ISO code, e.g. Canada / CA. Optional location label in Coordinates mode.",
|
||||
"city": "Required in city mode. Unknown, small or ambiguous towns can be placed using Coordinates mode instead.",
|
||||
"latitude": "Coordinates mode: -90 to 90. In city mode this value is calculated from the country and city on save.",
|
||||
"longitude": "Coordinates mode: -180 to 180. In city mode this value is calculated from the country and city on save.",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Offline city resolution. No requests or personal data leave the server."""
|
||||
from functools import lru_cache
|
||||
import unicodedata
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
|
||||
def normalize(value):
|
||||
return ''.join(c for c in unicodedata.normalize('NFKD', value.casefold().strip()) if not unicodedata.combining(c))
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def gazetteer():
|
||||
import geonamescache
|
||||
return geonamescache.GeonamesCache()
|
||||
|
||||
|
||||
def resolve_city(country, city):
|
||||
data = gazetteer()
|
||||
country_key = normalize(country)
|
||||
codes = [c['iso'] for c in data.get_countries().values() if country_key in (normalize(c['name']), c['iso'].lower(), c['iso3'].lower())]
|
||||
if not codes:
|
||||
raise ValidationError({'country': 'Enter a country name or ISO code, e.g. Canada or CA.'})
|
||||
city_key = normalize(city)
|
||||
country_cities = [c for c in data.get_cities().values() if c['countrycode'] in codes]
|
||||
# Prefer the city's canonical name over another town's historical aliases.
|
||||
# E.g. Tehran is also listed as an alias of Tiran in GeoNames.
|
||||
matches = [c for c in country_cities if normalize(c['name']) == city_key]
|
||||
if not matches:
|
||||
matches = [c for c in country_cities if city_key in {
|
||||
normalize(alias) for alias in c.get('alternatenames', [])
|
||||
}]
|
||||
if len(matches) != 1:
|
||||
raise ValidationError({'city': 'City not found or ambiguous. Use coordinates for small towns or cities with the same name.'})
|
||||
return matches[0]['latitude'], matches[0]['longitude']
|
||||
@@ -0,0 +1,75 @@
|
||||
# Generated by Django 5.2.16 on 2026-11-02 19:44
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pages', '0028_alter_blogpostpartner_options_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='MapPerson',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=200)),
|
||||
('qualifications', models.CharField(blank=True, max_length=100)),
|
||||
('role', models.CharField(blank=True, max_length=200)),
|
||||
('institution', models.CharField(blank=True, max_length=300)),
|
||||
('biography', models.TextField(blank=True)),
|
||||
('photo', models.ImageField(blank=True, upload_to='maps/people/')),
|
||||
('profile_url', models.URLField(blank=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name_plural': 'Map people',
|
||||
'ordering': ['name', 'pk'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PeopleMap',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=200)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('style', models.CharField(choices=[('aurora', 'Aurora — dark ocean'), ('paper', 'Atlas — light'), ('classic', 'Classic — original blue / turquoise palette')], default='aurora', max_length=20)),
|
||||
('background_image', models.ImageField(blank=True, help_text='Optional original map artwork. Must use an equirectangular world projection.', upload_to='maps/')),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='MapPlacement',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('page_path', models.CharField(help_text='Local page path, e.g. /about/, /projects/project-slug/, or any custom page. No domain or query string.', max_length=400)),
|
||||
('position', models.CharField(choices=[('top', 'Before page content'), ('bottom', 'After page content')], default='bottom', max_length=10)),
|
||||
('order', models.PositiveIntegerField(default=0)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('people_map', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='placements', to='pages.peoplemap')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['order', 'pk'],
|
||||
'constraints': [models.UniqueConstraint(fields=('people_map', 'page_path', 'position'), name='unique_map_placement')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='MapMembership',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('location_method', models.CharField(choices=[('city', 'Country and city'), ('coordinates', 'Coordinates')], default='city', max_length=12)),
|
||||
('country', models.CharField(blank=True, max_length=100)),
|
||||
('city', models.CharField(blank=True, max_length=100)),
|
||||
('latitude', models.FloatField(blank=True, null=True)),
|
||||
('longitude', models.FloatField(blank=True, null=True)),
|
||||
('order', models.PositiveIntegerField(default=0)),
|
||||
('person', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='map_memberships', to='pages.mapperson')),
|
||||
('people_map', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='pages.peoplemap')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['order', 'pk'],
|
||||
'constraints': [models.UniqueConstraint(fields=('people_map', 'person'), name='unique_map_person')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.16 on 2026-11-02 20:14
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pages', '0029_mapperson_peoplemap_mapplacement_mapmembership'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='peoplemap',
|
||||
name='background_style',
|
||||
field=models.CharField(choices=[('classic', 'Classic continents — blue silhouette'), ('atlas', 'World atlas — country borders and coordinate grid'), ('midnight', 'Midnight — dotted continents'), ('ocean', 'Ocean — luminous continents and meridians')], default='classic', help_text='Built-in artwork shipped with the application, also available on the server. An uploaded image overrides this choice.', max_length=20),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.16 on 2026-11-02 20:21
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pages', '0030_peoplemap_background_style'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='peoplemap',
|
||||
name='show_people_details',
|
||||
field=models.BooleanField(default=True, help_text='Disable to display only the map, without the people information cards underneath.', verbose_name='Show people details below the map'),
|
||||
),
|
||||
]
|
||||
@@ -581,3 +581,113 @@ class CustomPageSectionItem(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.section} › {self.title or self.icon or '(item)'}"
|
||||
|
||||
|
||||
class PeopleMap(models.Model):
|
||||
show_people_details = models.BooleanField(
|
||||
"Show people details below the map",
|
||||
default=True,
|
||||
help_text="Disable to display only the map, without the people information cards underneath.",
|
||||
)
|
||||
title = models.CharField(max_length=200)
|
||||
description = models.TextField(blank=True)
|
||||
style = models.CharField(max_length=20, default="aurora", choices=[
|
||||
("aurora", "Aurora — dark ocean"), ("paper", "Atlas — light"),
|
||||
("classic", "Classic — original blue / turquoise palette"),
|
||||
])
|
||||
BACKGROUND_CHOICES = [
|
||||
("classic", "Classic continents — blue silhouette"),
|
||||
("atlas", "World atlas — country borders and coordinate grid"),
|
||||
("midnight", "Midnight — dotted continents"),
|
||||
("ocean", "Ocean — luminous continents and meridians"),
|
||||
]
|
||||
background_style = models.CharField(max_length=20, choices=BACKGROUND_CHOICES, default="classic",
|
||||
help_text="Built-in artwork shipped with the application, also available on the server. An uploaded image overrides this choice.")
|
||||
|
||||
@property
|
||||
def background_static_path(self):
|
||||
return {
|
||||
"classic": "images/world-map.svg",
|
||||
"atlas": "images/maps/world-atlas.svg",
|
||||
"midnight": "images/maps/world-midnight.svg",
|
||||
"ocean": "images/maps/world-ocean.svg",
|
||||
}.get(self.background_style, "images/world-map.svg")
|
||||
|
||||
background_image = models.ImageField(upload_to="maps/", blank=True,
|
||||
help_text="Optional original map artwork. Must use an equirectangular world projection.")
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
|
||||
class MapPerson(models.Model):
|
||||
name = models.CharField(max_length=200)
|
||||
qualifications = models.CharField(max_length=100, blank=True)
|
||||
role = models.CharField(max_length=200, blank=True)
|
||||
institution = models.CharField(max_length=300, blank=True)
|
||||
biography = models.TextField(blank=True)
|
||||
photo = models.ImageField(upload_to="maps/people/", blank=True)
|
||||
profile_url = models.URLField(blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["name", "pk"]
|
||||
verbose_name_plural = "Map people"
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class MapMembership(models.Model):
|
||||
people_map = models.ForeignKey(PeopleMap, on_delete=models.CASCADE, related_name="memberships")
|
||||
person = models.ForeignKey(MapPerson, on_delete=models.CASCADE, related_name="map_memberships")
|
||||
location_method = models.CharField(max_length=12, default="city", choices=[("city", "Country and city"), ("coordinates", "Coordinates")])
|
||||
country = models.CharField(max_length=100, blank=True)
|
||||
city = models.CharField(max_length=100, blank=True)
|
||||
latitude = models.FloatField(blank=True, null=True)
|
||||
longitude = models.FloatField(blank=True, null=True)
|
||||
order = models.PositiveIntegerField(default=0)
|
||||
|
||||
class Meta:
|
||||
ordering = ["order", "pk"]
|
||||
constraints = [models.UniqueConstraint(fields=["people_map", "person"], name="unique_map_person")]
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
if self.location_method == "city":
|
||||
from .maps import resolve_city
|
||||
if not self.city or not self.country:
|
||||
raise ValidationError('Country and city are required for city locations.')
|
||||
self.latitude, self.longitude = resolve_city(self.country, self.city)
|
||||
import math
|
||||
errors = {}
|
||||
for field, limit in (("latitude", 90), ("longitude", 180)):
|
||||
value = getattr(self, field)
|
||||
if value is None or not math.isfinite(value) or not -limit <= value <= limit:
|
||||
errors[field] = f'Enter a coordinate between {-limit} and {limit}.'
|
||||
if errors:
|
||||
raise ValidationError(errors)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.full_clean()
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class MapPlacement(models.Model):
|
||||
people_map = models.ForeignKey(PeopleMap, on_delete=models.CASCADE, related_name="placements")
|
||||
page_path = models.CharField(max_length=400, help_text="Local page path, e.g. /about/, /projects/project-slug/, or any custom page. No domain or query string.")
|
||||
position = models.CharField(max_length=10, choices=[("top", "Before page content"), ("bottom", "After page content")], default="bottom")
|
||||
order = models.PositiveIntegerField(default=0)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["order", "pk"]
|
||||
constraints = [models.UniqueConstraint(fields=["people_map", "page_path", "position"], name="unique_map_placement")]
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
if not self.page_path.startswith('/') or self.page_path.startswith('//') or any(c in self.page_path for c in ('?', '#', '://')):
|
||||
raise ValidationError({'page_path': 'Use a local path beginning with /, without a domain, query or fragment.'})
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.people_map} → {self.page_path}'
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
from django import template
|
||||
from apps.pages.models import MapPlacement
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.inclusion_tag('partials/_people_maps.html', takes_context=True)
|
||||
def people_maps(context, position):
|
||||
request = context.get('request')
|
||||
if request is None:
|
||||
return {'placements': []}
|
||||
cache_key = '_people_maps_for_path'
|
||||
if not hasattr(request, cache_key):
|
||||
placements = list(MapPlacement.objects.filter(
|
||||
page_path=request.path, is_active=True, people_map__is_active=True,
|
||||
).select_related('people_map').prefetch_related('people_map__memberships__person'))
|
||||
setattr(request, cache_key, placements)
|
||||
return {'placements': [p for p in getattr(request, cache_key) if p.position == position]}
|
||||
@@ -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