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 = ` + ` @@ -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)