This commit is contained in:
Yohan Boniface 2025-04-18 16:13:25 +00:00 committed by GitHub
commit c451b7cdae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 229 additions and 30 deletions

View file

@ -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

View file

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

View file

@ -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",
),
),
]

View file

@ -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,12 +295,15 @@ 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:
layer = {}
if include_data:
with datalayer.geojson.open("rb") as f:
layer = json.loads(f.read())
if datalayer.settings:

View file

@ -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;

View file

@ -725,7 +725,7 @@ Fields.IconUrl = class extends Fields.BlurInput {
<button class="flat tab-url" data-ref=url>${translate('URL')}</button>
</div>
`)
this.tabs.appendChild(root)
;[recent, symbols, chars, url].forEach((node) => this.tabs.appendChild(node))
if (Icon.RECENT.length) {
recent.addEventListener('click', (event) => {
event.stopPropagation()

View file

@ -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
}
}
}

View file

@ -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 = `
<div>
<h3>${translate('Load map template')}</h3>
<p>${translate('Use a template to initialize your map')}.</p>
<div class="formbox">
<div class="flat-tabs" data-ref="tabs">
<button type="button" class="flat" data-value="mine" data-ref="mine">${translate('My templates')}</button>
<button type="button" class="flat" data-value="staff">${translate('Staff templates')}</button>
<button type="button" class="flat" data-value="community">${translate('Community templates')}</button>
</div>
<div data-ref="body" class="body"></div>
<label>
<input type="checkbox" name="include_data" />
${translate('Include template data, if any')}
</label>
</div>
</div>
`
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(
`<dl>
<dt><label><input type="radio" value="${template.id}" name="template" />${template.name}</label></dt>
<dd>${template.description}</dd>
</dl>`
)
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)
}
}

View file

@ -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'],

View file

@ -37,6 +37,7 @@ const TOP_BAR_TEMPLATE = `
<i class="icon icon-16 icon-save-disabled"></i>
<span hidden data-ref="saveLabel">${translate('Save')}</span>
<span hidden data-ref="saveDraftLabel">${translate('Save draft')}</span>
<span hidden data-ref="saveTemplateLabel">${translate('Save template')}</span>
</button>
</div>
</div>`
@ -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()
}
}

View file

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

View file

@ -301,6 +301,15 @@
</form>
</fieldset>
</details>
<details open>
<summary>With tabs</summary>
<div class="flat-tabs" data-ref="tabs">
<button class="flat on" data-ref="recent">Récents</button>
<button class="flat" data-ref="symbols">Symbole</button>
<button class="flat" data-ref="chars">Emoji &amp; texte</button>
<button class="flat" data-ref="url">URL</button>
</div>
</details>
</div>
<h4>Importers</h4>
<div class="umap-dialog window importers dark">

View file

@ -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)],

View file

@ -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,20 @@ 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} for m in qs
]
return simple_json_response(templates=templates)