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 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/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)