diff --git a/umap/forms.py b/umap/forms.py
index b9503d76..afba01b8 100644
--- a/umap/forms.py
+++ b/umap/forms.py
@@ -91,7 +91,7 @@ class MapSettingsForm(forms.ModelForm):
return self.cleaned_data["center"]
class Meta:
- fields = ("settings", "name", "center", "slug", "tags")
+ fields = ("settings", "name", "center", "slug", "tags", "is_template")
model = Map
diff --git a/umap/managers.py b/umap/managers.py
index 1fc07d96..3627649c 100644
--- a/umap/managers.py
+++ b/umap/managers.py
@@ -1,10 +1,37 @@
-from django.db.models import Manager
+from django.db import models
-class PublicManager(Manager):
+class PublicManager(models.Manager):
def get_queryset(self):
return (
super(PublicManager, self)
.get_queryset()
.filter(share_status=self.model.PUBLIC)
)
+
+ def starred_by_staff(self):
+ from .models import Star, User
+
+ staff = User.objects.filter(is_staff=True)
+ stars = Star.objects.filter(by__in=staff).values("map")
+ return self.get_queryset().filter(pk__in=stars)
+
+
+class PrivateQuerySet(models.QuerySet):
+ def for_user(self, user):
+ qs = self.exclude(share_status__in=[self.model.DELETED, self.model.BLOCKED])
+ teams = user.teams.all()
+ qs = (
+ qs.filter(owner=user)
+ .union(qs.filter(editors=user))
+ .union(qs.filter(team__in=teams))
+ )
+ return qs
+
+
+class PrivateManager(models.Manager):
+ def get_queryset(self):
+ return PrivateQuerySet(self.model, using=self._db)
+
+ def for_user(self, user):
+ return self.get_queryset().for_user(user)
diff --git a/umap/migrations/0028_map_is_template.py b/umap/migrations/0028_map_is_template.py
new file mode 100644
index 00000000..f38d3eb9
--- /dev/null
+++ b/umap/migrations/0028_map_is_template.py
@@ -0,0 +1,21 @@
+# Generated by Django 5.1.7 on 2025-04-17 09:44
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("umap", "0027_map_tags"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="map",
+ name="is_template",
+ field=models.BooleanField(
+ default=False,
+ help_text="This map is a template map.",
+ verbose_name="save as template",
+ ),
+ ),
+ ]
diff --git a/umap/models.py b/umap/models.py
index 399d033c..182684c0 100644
--- a/umap/models.py
+++ b/umap/models.py
@@ -12,7 +12,7 @@ from django.urls import reverse
from django.utils.functional import classproperty
from django.utils.translation import gettext_lazy as _
-from .managers import PublicManager
+from .managers import PrivateManager, PublicManager
from .utils import _urls_for_js
@@ -238,9 +238,15 @@ class Map(NamedModel):
blank=True, null=True, verbose_name=_("settings"), default=dict
)
tags = ArrayField(models.CharField(max_length=200), blank=True, default=list)
+ is_template = models.BooleanField(
+ default=False,
+ verbose_name=_("save as template"),
+ help_text=_("This map is a template map."),
+ )
objects = models.Manager()
public = PublicManager()
+ private = PrivateManager()
@property
def description(self):
@@ -289,14 +295,17 @@ class Map(NamedModel):
datalayer.delete()
return super().delete(**kwargs)
- def generate_umapjson(self, request):
+ def generate_umapjson(self, request, include_data=True):
umapjson = self.settings
umapjson["type"] = "umap"
+ umapjson["properties"].pop("is_template", None)
umapjson["uri"] = request.build_absolute_uri(self.get_absolute_url())
datalayers = []
for datalayer in self.datalayers:
- with datalayer.geojson.open("rb") as f:
- layer = json.loads(f.read())
+ layer = {}
+ if include_data:
+ with datalayer.geojson.open("rb") as f:
+ layer = json.loads(f.read())
if datalayer.settings:
datalayer.settings.pop("id", None)
layer["_umap_options"] = datalayer.settings
diff --git a/umap/static/umap/content.css b/umap/static/umap/content.css
index 2163af29..dca5bcdc 100644
--- a/umap/static/umap/content.css
+++ b/umap/static/umap/content.css
@@ -165,7 +165,6 @@ h2.tabs a:hover {
min-height: var(--map-fragment-height);
}
.tag-list {
- margin-top: var(--text-margin);
margin-bottom: var(--text-margin);
display: flex;
flex-wrap: wrap;
@@ -205,6 +204,7 @@ h2.tabs a:hover {
margin-bottom: 0;
flex-grow: 1;
gap: var(--gutter);
+ margin-top: var(--text-margin);
}
.card h3 {
margin-bottom: 0;
diff --git a/umap/static/umap/css/dialog.css b/umap/static/umap/css/dialog.css
index b3dcf0ff..dc5b30a4 100644
--- a/umap/static/umap/css/dialog.css
+++ b/umap/static/umap/css/dialog.css
@@ -4,7 +4,7 @@
margin-top: 100px;
width: var(--dialog-width);
max-width: 100vw;
- max-height: 50vh;
+ max-height: 80vh;
padding: 20px;
border: 1px solid #222;
background-color: var(--background-color);
@@ -12,11 +12,14 @@
border-radius: 5px;
overflow-y: auto;
height: fit-content;
- max-height: 90vh;
}
.umap-dialog ul + h4 {
margin-top: var(--box-margin);
}
+.umap-dialog .body {
+ max-height: 50vh;
+ overflow-y: auto;
+}
:where([data-component="no-dialog"]:not([hidden])) {
display: block;
position: relative;
diff --git a/umap/static/umap/js/modules/form/fields.js b/umap/static/umap/js/modules/form/fields.js
index c3a23bd4..17a58326 100644
--- a/umap/static/umap/js/modules/form/fields.js
+++ b/umap/static/umap/js/modules/form/fields.js
@@ -725,7 +725,7 @@ Fields.IconUrl = class extends Fields.BlurInput {
`)
- this.tabs.appendChild(root)
+ ;[recent, symbols, chars, url].forEach((node) => this.tabs.appendChild(node))
if (Icon.RECENT.length) {
recent.addEventListener('click', (event) => {
event.stopPropagation()
diff --git a/umap/static/umap/js/modules/importer.js b/umap/static/umap/js/modules/importer.js
index f2738f08..69f72c7f 100644
--- a/umap/static/umap/js/modules/importer.js
+++ b/umap/static/umap/js/modules/importer.js
@@ -97,6 +97,9 @@ export default class Importer extends Utils.WithTemplate {
case 'banfr':
import('./importers/banfr.js').then(register)
break
+ case 'templates':
+ import('./importers/templates.js').then(register)
+ break
}
}
}
diff --git a/umap/static/umap/js/modules/importers/templates.js b/umap/static/umap/js/modules/importers/templates.js
new file mode 100644
index 00000000..12bf0494
--- /dev/null
+++ b/umap/static/umap/js/modules/importers/templates.js
@@ -0,0 +1,95 @@
+import { DomEvent, DomUtil } from '../../../vendors/leaflet/leaflet-src.esm.js'
+import { uMapAlert as Alert } from '../../components/alerts/alert.js'
+import { BaseAjax, SingleMixin } from '../autocomplete.js'
+import { translate } from '../i18n.js'
+import * as Utils from '../utils.js'
+
+const TEMPLATE = `
+
+
${translate('Load map template')}
+
${translate('Use a template to initialize your map')}.
+
+
+`
+
+export class Importer {
+ constructor(umap, options = {}) {
+ this.umap = umap
+ this.name = options.name || 'Templates'
+ this.id = 'templates'
+ }
+
+ async open(importer) {
+ const [root, { tabs, include_data, body, mine }] =
+ Utils.loadTemplateWithRefs(TEMPLATE)
+ const uri = this.umap.urls.get('template_list')
+ const userIsAuth = Boolean(this.umap.properties.user?.id)
+ const defaultTab = userIsAuth ? 'mine' : 'staff'
+ mine.hidden = !userIsAuth
+
+ const loadTemplates = async (source) => {
+ const [data, response, error] = await this.umap.server.get(
+ `${uri}?source=${source}`
+ )
+ if (!error) {
+ body.innerHTML = ''
+ for (const template of data.templates) {
+ const item = Utils.loadTemplate(
+ `
+
+ - ${template.description} ${translate('Open')}
+
`
+ )
+ body.appendChild(item)
+ }
+ tabs.querySelectorAll('button').forEach((el) => el.classList.remove('on'))
+ tabs.querySelector(`[data-value="${source}"]`).classList.add('on')
+ } else {
+ console.error(response)
+ }
+ }
+ loadTemplates(defaultTab)
+ tabs
+ .querySelectorAll('button')
+ .forEach((el) =>
+ el.addEventListener('click', () => loadTemplates(el.dataset.value))
+ )
+ const confirm = (form) => {
+ console.log(form)
+ if (!form.template) {
+ Alert.error(translate('You must select a template.'))
+ return false
+ }
+ let url = this.umap.urls.get('map_download', {
+ map_id: form.template,
+ })
+ if (!form.include_data) {
+ url = `${url}?include_data=0`
+ }
+ importer.url = url
+ importer.format = 'umap'
+ importer.submit()
+ this.umap.editPanel.close()
+ }
+
+ importer.dialog
+ .open({
+ template: root,
+ className: `${this.id} importer dark`,
+ accept: translate('Use this template'),
+ cancel: false,
+ })
+ .then(confirm)
+ }
+}
diff --git a/umap/static/umap/js/modules/schema.js b/umap/static/umap/js/modules/schema.js
index 0509d7af..455a07ab 100644
--- a/umap/static/umap/js/modules/schema.js
+++ b/umap/static/umap/js/modules/schema.js
@@ -253,6 +253,12 @@ export const SCHEMA = {
inheritable: true,
default: true,
},
+ is_template: {
+ type: Boolean,
+ impacts: ['ui'],
+ label: translate('This map is a template'),
+ default: false,
+ },
labelDirection: {
type: String,
impacts: ['data'],
diff --git a/umap/static/umap/js/modules/ui/bar.js b/umap/static/umap/js/modules/ui/bar.js
index d75ddb42..2b7a154e 100644
--- a/umap/static/umap/js/modules/ui/bar.js
+++ b/umap/static/umap/js/modules/ui/bar.js
@@ -37,6 +37,7 @@ const TOP_BAR_TEMPLATE = `
${translate('Save')}
${translate('Save draft')}
+ ${translate('Save template')}
`
@@ -167,8 +168,11 @@ export class TopBar extends WithTemplate {
const syncEnabled = this._umap.getProperty('syncEnabled')
this.elements.peers.hidden = !syncEnabled
this.elements.view.disabled = this._umap.sync._undoManager.isDirty()
- this.elements.saveLabel.hidden = this._umap.permissions.isDraft()
- this.elements.saveDraftLabel.hidden = !this._umap.permissions.isDraft()
+ const isDraft = this._umap.permissions.isDraft()
+ const isTemplate = this._umap.getProperty('is_template')
+ this.elements.saveLabel.hidden = isDraft || isTemplate
+ this.elements.saveDraftLabel.hidden = !isDraft || isTemplate
+ this.elements.saveTemplateLabel.hidden = !isTemplate
this._umap.sync._undoManager.toggleState()
}
}
diff --git a/umap/static/umap/js/modules/umap.js b/umap/static/umap/js/modules/umap.js
index 26c4fe72..49ad9713 100644
--- a/umap/static/umap/js/modules/umap.js
+++ b/umap/static/umap/js/modules/umap.js
@@ -768,7 +768,11 @@ export default class Umap {
if (!this.editEnabled) return
if (this.properties.editMode !== 'advanced') return
const container = DomUtil.create('div')
- const metadataFields = ['properties.name', 'properties.description']
+ const metadataFields = [
+ 'properties.name',
+ 'properties.description',
+ 'properties.is_template',
+ ]
DomUtil.createTitle(container, translate('Edit map details'), 'icon-caption')
const builder = new MutatingForm(this, metadataFields, {
@@ -1197,6 +1201,7 @@ export default class Umap {
}
const formData = new FormData()
formData.append('name', this.properties.name)
+ formData.append('is_template', Boolean(this.properties.is_template))
formData.append('center', JSON.stringify(this.geometry()))
formData.append('tags', this.properties.tags || [])
formData.append('settings', JSON.stringify(geojson))
diff --git a/umap/templates/umap/design_system.html b/umap/templates/umap/design_system.html
index 837adebb..834cbd1b 100644
--- a/umap/templates/umap/design_system.html
+++ b/umap/templates/umap/design_system.html
@@ -301,6 +301,15 @@
+
+ With tabs
+
+
+
+
+
+
+
Importers
diff --git a/umap/templates/umap/map_list.html b/umap/templates/umap/map_list.html
index 8c3515b2..35c36942 100644
--- a/umap/templates/umap/map_list.html
+++ b/umap/templates/umap/map_list.html
@@ -12,14 +12,14 @@
{% endfor %}
{% endif %}
-
{{ map_inst.name }}
+
{% if map_inst.is_template %}[{% trans "template" %}]{% endif %} {{ map_inst.name }}
{% with author=map_inst.get_author %}
{% if author %}
{% trans "by" %} {{ author }}
{% endif %}
{% endwith %}
- {% translate "See the map" %}
+ {% if map_inst.is_template %}{% translate "See the template" %}{% else %}{% translate "See the map" %}{% endif %}
{% endfor %}
diff --git a/umap/tests/test_views.py b/umap/tests/test_views.py
index 9ecea639..23b3089a 100644
--- a/umap/tests/test_views.py
+++ b/umap/tests/test_views.py
@@ -528,3 +528,62 @@ def test_can_find_small_usernames(client):
data = json.loads(response.content)["data"]
assert len(data) == 1
assert data[0]["label"] == "JoeJoe"
+
+
+@pytest.mark.django_db
+def test_templates_list(client, user, user2):
+ public = MapFactory(
+ owner=user,
+ name="A public template",
+ share_status=Map.PUBLIC,
+ is_template=True,
+ )
+ link_only = MapFactory(
+ owner=user,
+ name="A link-only template",
+ share_status=Map.OPEN,
+ is_template=True,
+ )
+ private = MapFactory(
+ owner=user,
+ name="A link-only template",
+ share_status=Map.PRIVATE,
+ is_template=True,
+ )
+ someone_else = MapFactory(
+ owner=user2,
+ name="A public template from someone else",
+ share_status=Map.PUBLIC,
+ is_template=True,
+ )
+ staff = UserFactory(username="Staff", is_staff=True)
+ Star.objects.create(by=staff, map=someone_else)
+ client.login(username=user.username, password="123123")
+ url = reverse("template_list")
+
+ # Ask for mine
+ response = client.get(f"{url}?source=mine")
+ templates = json.loads(response.content)["templates"]
+ ids = [t["id"] for t in templates]
+ assert public.pk in ids
+ assert link_only.pk in ids
+ assert private.pk in ids
+ assert someone_else.pk not in ids
+
+ # Ask for staff ones
+ response = client.get(f"{url}?source=staff")
+ templates = json.loads(response.content)["templates"]
+ ids = [t["id"] for t in templates]
+ assert public.pk not in ids
+ assert link_only.pk not in ids
+ assert private.pk not in ids
+ assert someone_else.pk in ids
+
+ # Ask for community ones
+ response = client.get(f"{url}?source=community")
+ templates = json.loads(response.content)["templates"]
+ ids = [t["id"] for t in templates]
+ assert public.pk in ids
+ assert link_only.pk not in ids
+ assert private.pk not in ids
+ assert someone_else.pk in ids
diff --git a/umap/urls.py b/umap/urls.py
index 46ed4b5f..60303739 100644
--- a/umap/urls.py
+++ b/umap/urls.py
@@ -73,6 +73,11 @@ i18n_urls = [
views.PictogramJSONList.as_view(),
name="pictogram_list_json",
),
+ re_path(
+ r"^templates/json/$",
+ views.TemplateList.as_view(),
+ name="template_list",
+ ),
]
i18n_urls += decorated_patterns(
[can_view_map, cache_control(must_revalidate=True)],
diff --git a/umap/views.py b/umap/views.py
index d2b08e63..71e3b630 100644
--- a/umap/views.py
+++ b/umap/views.py
@@ -138,9 +138,7 @@ class PublicMapsMixin(object):
return maps
def get_highlighted_maps(self):
- staff = User.objects.filter(is_staff=True)
- stars = Star.objects.filter(by__in=staff).values("map")
- qs = Map.public.filter(pk__in=stars)
+ qs = Map.public.starred_by_staff()
maps = qs.order_by("-modified_at")
return maps
@@ -332,10 +330,10 @@ class TeamMaps(PaginatorMixin, DetailView):
class SearchMixin:
- def get_search_queryset(self, **kwargs):
+ def get_search_queryset(self, qs=None, **kwargs):
q = self.request.GET.get("q")
tags = [t for t in self.request.GET.getlist("tags") if t]
- qs = Map.objects.all()
+ qs = qs or Map.public.all()
if q:
vector = SearchVector("name", config=settings.UMAP_SEARCH_CONFIGURATION)
query = SearchQuery(
@@ -382,14 +380,8 @@ class UserDashboard(PaginatorMixin, DetailView, SearchMixin):
return self.get_queryset().get(pk=self.request.user.pk)
def get_maps(self):
- qs = self.get_search_queryset() or Map.objects.all()
- qs = qs.exclude(share_status__in=[Map.DELETED, Map.BLOCKED])
- teams = self.object.teams.all()
- qs = (
- qs.filter(owner=self.object)
- .union(qs.filter(editors=self.object))
- .union(qs.filter(team__in=teams))
- )
+ qs = Map.private.for_user(self.object)
+ qs = self.get_search_queryset(qs) or qs
return qs.order_by("-modified_at")
def get_context_data(self, **kwargs):
@@ -408,9 +400,9 @@ class UserDownload(DetailView, SearchMixin):
return self.get_queryset().get(pk=self.request.user.pk)
def get_maps(self):
- qs = Map.objects.filter(id__in=self.request.GET.getlist("map_id"))
- qs = qs.filter(owner=self.object).union(qs.filter(editors=self.object))
- return qs.order_by("-modified_at")
+ qs = Map.private.filter(id__in=self.request.GET.getlist("map_id"))
+ qsu = qs.for_user(self.object)
+ return qsu.order_by("-modified_at")
def render_to_response(self, context, *args, **kwargs):
zip_buffer = io.BytesIO()
@@ -802,7 +794,10 @@ class MapDownload(DetailView):
return reverse("map_download", args=(self.object.pk,))
def render_to_response(self, context, *args, **kwargs):
- umapjson = self.object.generate_umapjson(self.request)
+ include_data = self.request.GET.get("include_data") != "0"
+ umapjson = self.object.generate_umapjson(
+ self.request, include_data=include_data
+ )
response = simple_json_response(**umapjson)
response["Content-Disposition"] = (
f'attachment; filename="umap_backup_{self.object.slug}.umap"'
@@ -1456,3 +1451,26 @@ class LoginPopupEnd(TemplateView):
if backend in settings.DEPRECATED_AUTHENTICATION_BACKENDS:
return HttpResponseRedirect(reverse("user_profile"))
return super().get(*args, **kwargs)
+
+
+class TemplateList(ListView):
+ model = Map
+
+ def render_to_response(self, context, **response_kwargs):
+ source = self.request.GET.get("source")
+ if source == "mine":
+ qs = Map.private.filter(is_template=True).for_user(self.request.user)
+ elif source == "community":
+ qs = Map.public.filter(is_template=True)
+ elif source == "staff":
+ qs = Map.public.starred_by_staff().filter(is_template=True)
+ templates = [
+ {
+ "id": m.id,
+ "name": m.name,
+ "description": m.description,
+ "url": m.get_absolute_url(),
+ }
+ for m in qs.order_by("-modified_at")
+ ]
+ return simple_json_response(templates=templates)