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:
@@ -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}'
|
||||
|
||||
Reference in New Issue
Block a user