From 754addb03aad03ee9e9196c727072bbdec009cfb Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Thu, 17 Apr 2025 17:03:19 +0200 Subject: [PATCH 1/4] wip: first naive version of map templates Co-authored-by: David Larlet --- umap/forms.py | 2 +- umap/managers.py | 24 +++++- umap/migrations/0028_map_is_template.py | 21 +++++ umap/models.py | 17 +++- umap/static/umap/js/modules/importer.js | 3 + .../umap/js/modules/importers/templates.js | 77 +++++++++++++++++++ umap/static/umap/js/modules/schema.js | 6 ++ umap/static/umap/js/modules/ui/bar.js | 8 +- umap/static/umap/js/modules/umap.js | 7 +- umap/urls.py | 5 ++ umap/views.py | 35 +++++---- 11 files changed, 182 insertions(+), 23 deletions(-) create mode 100644 umap/migrations/0028_map_is_template.py create mode 100644 umap/static/umap/js/modules/importers/templates.js 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..afed1cf1 100644 --- a/umap/managers.py +++ b/umap/managers.py @@ -1,10 +1,30 @@ -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) ) + + +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..0729f849 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"]["is_template"] = False 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/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..b628e638 --- /dev/null +++ b/umap/static/umap/js/modules/importers/templates.js @@ -0,0 +1,77 @@ +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 BOUNDARY_TYPES = { + admin_6: 'département', + admin_7: 'pays (loi Voynet)', + admin_8: 'commune', + admin_9: 'quartier, hameau, arrondissement', + political: 'canton', + local_authority: 'EPCI', +} + +const TEMPLATE = ` +

${translate('Load template')}

+

${translate('GeoDataMine: thematic data from OpenStreetMap')}.

+
+ + +
+` + +export class Importer { + constructor(umap, options = {}) { + this.umap = umap + this.name = options.name || 'Templates' + this.id = 'templates' + } + + async open(importer) { + const container = DomUtil.create('div') + container.innerHTML = TEMPLATE + const select = container.querySelector('select') + const uri = this.umap.urls.get('template_list') + const [data, response, error] = await this.umap.server.get(uri) + if (!error) { + for (const template of data.templates) { + DomUtil.element({ + tagName: 'option', + value: template.id, + textContent: template.name, + parent: select, + }) + } + } else { + console.error(response) + } + const confirm = (form) => { + let url = this.umap.urls.get('map_download', { + map_id: select.options[select.selectedIndex].value, + }) + if (!container.querySelector('[name=include_data]').checked) { + url = `${url}?include_data=0` + } + importer.url = url + importer.format = 'umap' + importer.submit() + this.umap.editPanel.close() + } + + importer.dialog + .open({ + template: container, + 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 = ` + ` @@ -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 980ac1b8..7aa6b2a8 100644 --- a/umap/static/umap/js/modules/umap.js +++ b/umap/static/umap/js/modules/umap.js @@ -763,7 +763,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, { @@ -1192,6 +1196,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/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..3933511e 100644 --- a/umap/views.py +++ b/umap/views.py @@ -332,10 +332,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 +382,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,8 +402,8 @@ 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)) + qs = Map.private.for_user(self.object) + qs = qs.filter(id__in=self.request.GET.getlist("map_id")) return qs.order_by("-modified_at") def render_to_response(self, context, *args, **kwargs): @@ -802,7 +796,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 +1453,15 @@ 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): + if self.request.user.is_authenticated: + qs = Map.private.filter(is_template=True).for_user(self.request.user) + else: + qs = Map.public.filter(is_template=True) + templates = [{"id": m.id, "name": m.name} for m in qs] + return simple_json_response(templates=templates) From 41117d4a4a4198b546c4e9d9e9da3049f4fe93b5 Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Fri, 18 Apr 2025 16:10:46 +0200 Subject: [PATCH 2/4] fixup: fix tests Co-authored-by: David Larlet --- umap/models.py | 2 +- umap/views.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/umap/models.py b/umap/models.py index 0729f849..182684c0 100644 --- a/umap/models.py +++ b/umap/models.py @@ -298,7 +298,7 @@ class Map(NamedModel): def generate_umapjson(self, request, include_data=True): umapjson = self.settings umapjson["type"] = "umap" - umapjson["properties"]["is_template"] = False + umapjson["properties"].pop("is_template", None) umapjson["uri"] = request.build_absolute_uri(self.get_absolute_url()) datalayers = [] for datalayer in self.datalayers: diff --git a/umap/views.py b/umap/views.py index 3933511e..19879924 100644 --- a/umap/views.py +++ b/umap/views.py @@ -402,9 +402,9 @@ class UserDownload(DetailView, SearchMixin): return self.get_queryset().get(pk=self.request.user.pk) def get_maps(self): - qs = Map.private.for_user(self.object) - qs = qs.filter(id__in=self.request.GET.getlist("map_id")) - 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() From 9f2d78caebc33fef437f1f13764eebb556726a8e Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Fri, 18 Apr 2025 18:01:54 +0200 Subject: [PATCH 3/4] wip: display templates in importer by origin (me/staff/community) Co-authored-by: David Larlet --- umap/managers.py | 7 ++ umap/static/umap/css/dialog.css | 7 +- umap/static/umap/js/modules/form/fields.js | 2 +- .../umap/js/modules/importers/templates.js | 90 +++++++++++-------- umap/templates/umap/design_system.html | 9 ++ umap/views.py | 15 ++-- 6 files changed, 85 insertions(+), 45 deletions(-) diff --git a/umap/managers.py b/umap/managers.py index afed1cf1..3627649c 100644 --- a/umap/managers.py +++ b/umap/managers.py @@ -9,6 +9,13 @@ class PublicManager(models.Manager): .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): 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/importers/templates.js b/umap/static/umap/js/modules/importers/templates.js index b628e638..1299284a 100644 --- a/umap/static/umap/js/modules/importers/templates.js +++ b/umap/static/umap/js/modules/importers/templates.js @@ -4,26 +4,22 @@ import { BaseAjax, SingleMixin } from '../autocomplete.js' import { translate } from '../i18n.js' import * as Utils from '../utils.js' -const BOUNDARY_TYPES = { - admin_6: 'département', - admin_7: 'pays (loi Voynet)', - admin_8: 'commune', - admin_9: 'quartier, hameau, arrondissement', - political: 'canton', - local_authority: 'EPCI', -} - const TEMPLATE = ` -

${translate('Load template')}

-

${translate('GeoDataMine: thematic data from OpenStreetMap')}.

-
- - +
+

${translate('Load map template')}

+

${translate('Use a template to initialize your map')}.

+
+
+ + + +
+
+ +
` @@ -35,28 +31,50 @@ export class Importer { } async open(importer) { - const container = DomUtil.create('div') - container.innerHTML = TEMPLATE - const select = container.querySelector('select') + const [root, { tabs, include_data, body, mine }] = + Utils.loadTemplateWithRefs(TEMPLATE) const uri = this.umap.urls.get('template_list') - const [data, response, error] = await this.umap.server.get(uri) - if (!error) { - for (const template of data.templates) { - DomUtil.element({ - tagName: 'option', - value: template.id, - textContent: template.name, - parent: select, - }) + 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}
+
` + ) + body.appendChild(item) + } + tabs.querySelectorAll('button').forEach((el) => el.classList.remove('on')) + tabs.querySelector(`[data-value="${source}"]`).classList.add('on') + } else { + console.error(response) } - } 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: select.options[select.selectedIndex].value, + map_id: form.template, }) - if (!container.querySelector('[name=include_data]').checked) { + if (!form.include_data) { url = `${url}?include_data=0` } importer.url = url @@ -67,7 +85,7 @@ export class Importer { importer.dialog .open({ - template: container, + template: root, className: `${this.id} importer dark`, accept: translate('Use this template'), cancel: false, 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/views.py b/umap/views.py index 19879924..d2baacba 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 @@ -1459,9 +1457,14 @@ class TemplateList(ListView): model = Map def render_to_response(self, context, **response_kwargs): - if self.request.user.is_authenticated: + source = self.request.GET.get("source") + if source == "mine": qs = Map.private.filter(is_template=True).for_user(self.request.user) - else: + elif source == "community": qs = Map.public.filter(is_template=True) - templates = [{"id": m.id, "name": m.name} for m in qs] + elif source == "staff": + qs = Map.public.starred_by_staff().filter(is_template=True) + templates = [ + {"id": m.id, "name": m.name, "description": m.description} for m in qs + ] return simple_json_response(templates=templates) From 2c8022b422f5721f588d105a212bb241877ea435 Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Sat, 19 Apr 2025 14:52:52 +0200 Subject: [PATCH 4/4] wip: add link to open template in a new window --- umap/static/umap/js/modules/importers/templates.js | 2 +- umap/views.py | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/umap/static/umap/js/modules/importers/templates.js b/umap/static/umap/js/modules/importers/templates.js index 1299284a..12bf0494 100644 --- a/umap/static/umap/js/modules/importers/templates.js +++ b/umap/static/umap/js/modules/importers/templates.js @@ -48,7 +48,7 @@ export class Importer { const item = Utils.loadTemplate( `
-
${template.description}
+
${template.description} ${translate('Open')}
` ) body.appendChild(item) diff --git a/umap/views.py b/umap/views.py index d2baacba..2d40fcd8 100644 --- a/umap/views.py +++ b/umap/views.py @@ -1465,6 +1465,12 @@ class TemplateList(ListView): elif source == "staff": qs = Map.public.starred_by_staff().filter(is_template=True) templates = [ - {"id": m.id, "name": m.name, "description": m.description} for m in qs + { + "id": m.id, + "name": m.name, + "description": m.description, + "url": m.get_absolute_url(), + } + for m in qs ] return simple_json_response(templates=templates)