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:
mohamad
2026-11-03 04:41:30 +08:00
parent 6fd236f518
commit d4ec85add4
22 changed files with 725 additions and 4 deletions
+53
View File
@@ -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
View File
@@ -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.",
}
+35
View File
@@ -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'),
),
]
+110
View File
@@ -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}'
View File
+18
View File
@@ -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]}
+141
View File
@@ -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('&lt;script&gt;', 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('&lt;script&gt;Test&lt;/script&gt;', 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'))