diff --git a/umap/autocomplete.py b/umap/autocomplete.py index 6a6fde147..c96ce882a 100644 --- a/umap/autocomplete.py +++ b/umap/autocomplete.py @@ -3,7 +3,7 @@ from django.conf import settings from django.contrib.auth import get_user_model from django.db.models.functions import Length - +from .models import Team @register class AutocompleteUser(AgnocompleteModel): @@ -22,3 +22,21 @@ def build_extra_filtered_queryset(self, queryset, **kwargs): field_name = field_name[1:] order_by.append(Length(field_name).asc()) return queryset.order_by(*order_by) + +@register +class AutocompleteTeam(AgnocompleteModel): + model = Team + fields = settings.TEAM_AUTOCOMPLETE_FIELDS + + def item(self, current_item): + data = super().item(current_item) + data["url"] = current_item.get_url() + return data + + def build_extra_filtered_queryset(self, queryset, **kwargs): + order_by = [] + for field_name in self.fields: + if not field_name[0].isalnum(): + field_name = field_name[1:] + order_by.append(Length(field_name).asc()) + return queryset.order_by(*order_by) diff --git a/umap/forms.py b/umap/forms.py index b9503d761..4d230457e 100644 --- a/umap/forms.py +++ b/umap/forms.py @@ -35,7 +35,7 @@ class SendLinkForm(forms.Form): class UpdateMapPermissionsForm(forms.ModelForm): class Meta: model = Map - fields = ("edit_status", "editors", "share_status", "owner", "team") + fields = ("edit_status", "editors", "share_status", "owner", "teams") class AnonymousMapPermissionsForm(forms.ModelForm): @@ -56,7 +56,7 @@ class Meta: class DataLayerPermissionsForm(forms.ModelForm): class Meta: model = DataLayer - fields = ("edit_status",) + fields = ("edit_status","editors", "teams") class AnonymousDataLayerPermissionsForm(forms.ModelForm): diff --git a/umap/migrations/0028_datalayer_editors_alter_datalayer_edit_status.py b/umap/migrations/0028_datalayer_editors_alter_datalayer_edit_status.py new file mode 100644 index 000000000..33f4c3b20 --- /dev/null +++ b/umap/migrations/0028_datalayer_editors_alter_datalayer_edit_status.py @@ -0,0 +1,25 @@ +# Generated by Django 5.2.1 on 2025-05-17 08:13 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('umap', '0027_map_tags'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='datalayer', + name='editors', + field=models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL, verbose_name='editors'), + ), + migrations.AlterField( + model_name='datalayer', + name='edit_status', + field=models.SmallIntegerField(choices=[(0, 'Inherit'), (1, 'Everyone'), (2, 'Editors and team only'), (4, 'Assigned Users and Owner'), (3, 'Owner only')], default=0, verbose_name='edit status'), + ), + ] diff --git a/umap/migrations/0029_alter_datalayer_edit_status.py b/umap/migrations/0029_alter_datalayer_edit_status.py new file mode 100644 index 000000000..a341632fa --- /dev/null +++ b/umap/migrations/0029_alter_datalayer_edit_status.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.1 on 2025-05-17 16:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('umap', '0028_datalayer_editors_alter_datalayer_edit_status'), + ] + + operations = [ + migrations.AlterField( + model_name='datalayer', + name='edit_status', + field=models.SmallIntegerField(choices=[(0, 'Inherit'), (1, 'Everyone'), (2, 'Editors and team only'), (4, 'Selected Users, Teams and Owner'), (3, 'Owner only')], default=0, verbose_name='edit status'), + ), + ] diff --git a/umap/migrations/0030_remove_map_team_datalayer_teams_map_teams_and_more.py b/umap/migrations/0030_remove_map_team_datalayer_teams_map_teams_and_more.py new file mode 100644 index 000000000..e00263eb9 --- /dev/null +++ b/umap/migrations/0030_remove_map_team_datalayer_teams_map_teams_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 5.2.1 on 2025-05-17 21:53 + +import umap.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('umap', '0029_alter_datalayer_edit_status'), + ] + + operations = [ + migrations.RemoveField( + model_name='map', + name='team', + ), + migrations.AddField( + model_name='datalayer', + name='teams', + field=models.ManyToManyField(blank=True, to='umap.team', verbose_name='teams'), + ), + migrations.AddField( + model_name='map', + name='teams', + field=models.ManyToManyField(blank=True, to='umap.team', verbose_name='teams'), + ), + migrations.AlterField( + model_name='datalayer', + name='edit_status', + field=models.SmallIntegerField(choices=[(0, 'Inherit'), (1, 'Everyone'), (2, 'Editors and teams only'), (4, 'Selected Users, Teams and Owner'), (3, 'Owner only')], default=0, verbose_name='edit status'), + ), + migrations.AlterField( + model_name='map', + name='edit_status', + field=models.SmallIntegerField(choices=[(1, 'Everyone'), (2, 'Editors and teams only'), (3, 'Owner only')], default=umap.models.get_default_edit_status, verbose_name='edit status'), + ), + migrations.AlterField( + model_name='map', + name='share_status', + field=models.SmallIntegerField(choices=[(0, 'Draft (private)'), (1, 'Everyone (public)'), (2, 'Anyone with link'), (3, 'Editors and teams only'), (9, 'Blocked'), (99, 'Deleted')], default=umap.models.get_default_share_status, verbose_name='share status'), + ), + ] diff --git a/umap/models.py b/umap/models.py index 5c92e4812..7737c9fc6 100644 --- a/umap/models.py +++ b/umap/models.py @@ -178,7 +178,7 @@ class Map(NamedModel): ) EDIT_STATUS = ( (ANONYMOUS, _("Everyone")), - (COLLABORATORS, _("Editors and team only")), + (COLLABORATORS, _("Editors and teams only")), (OWNER, _("Owner only")), ) ANONYMOUS_SHARE_STATUS = ( @@ -187,7 +187,7 @@ class Map(NamedModel): ) SHARE_STATUS = ANONYMOUS_SHARE_STATUS + ( (OPEN, _("Anyone with link")), - (PRIVATE, _("Editors and team only")), + (PRIVATE, _("Editors and teams only")), (BLOCKED, _("Blocked")), (DELETED, _("Deleted")), ) @@ -217,13 +217,10 @@ class Map(NamedModel): editors = models.ManyToManyField( settings.AUTH_USER_MODEL, blank=True, verbose_name=_("editors") ) - team = models.ForeignKey( - Team, - blank=True, - null=True, - verbose_name=_("team"), - on_delete=models.SET_NULL, + teams = models.ManyToManyField( + Team, blank=True, verbose_name=_("teams") ) + edit_status = models.SmallIntegerField( choices=EDIT_STATUS, default=get_default_edit_status, @@ -314,7 +311,10 @@ def get_anonymous_edit_url(self): return settings.SITE_URL + path def get_author(self): - return self.team or self.owner + # check if te + if not self.teams.all(): + return self.owner + return self.teams.all()[0] def is_owner(self, request=None): if not request: @@ -373,7 +373,7 @@ def can_edit(self, request=None): elif user == self.owner: can = True elif self.edit_status == self.COLLABORATORS: - if user in self.editors.all() or self.team in user.teams.all(): + if user in self.editors.all() or any(e in self.teams.all() for e in user.teams.all()): can = True return can @@ -393,7 +393,7 @@ def can_view(self, request): can = not ( restricted and request.user not in self.editors.all() - and self.team not in request.user.teams.all() + and not any(e in self.teams.all() for e in request.user.teams.all()) ) return can @@ -471,6 +471,7 @@ class DataLayer(NamedModel): ANONYMOUS = 1 COLLABORATORS = 2 OWNER = 3 + USERSANDTEAMS = 4 DELETED = 99 SHARE_STATUS = ( (INHERIT, _("Inherit")), @@ -479,7 +480,8 @@ class DataLayer(NamedModel): EDIT_STATUS = ( (INHERIT, _("Inherit")), (ANONYMOUS, _("Everyone")), - (COLLABORATORS, _("Editors and team only")), + (COLLABORATORS, _("Editors and teams only")), + (USERSANDTEAMS, _("Selected Users, Teams and Owner")), (OWNER, _("Owner only")), ) ANONYMOUS_EDIT_STATUS = ( @@ -490,6 +492,12 @@ class DataLayer(NamedModel): uuid = models.UUIDField(unique=True, primary_key=True, editable=False) old_id = models.IntegerField(null=True, blank=True) map = models.ForeignKey(Map, on_delete=models.CASCADE) + editors = models.ManyToManyField( + settings.AUTH_USER_MODEL, blank=True, verbose_name=_("editors") + ) + teams = models.ManyToManyField( + Team, blank=True, verbose_name=_("teams") + ) description = models.TextField(blank=True, null=True, verbose_name=_("description")) geojson = models.FileField( upload_to=upload_to, blank=True, null=True, storage=set_storage @@ -552,11 +560,31 @@ def metadata(self, request=None): metadata["old_id"] = self.old_id metadata["id"] = self.pk metadata["rank"] = self.rank - metadata["permissions"] = {"edit_status": self.edit_status} - metadata["editMode"] = "advanced" if self.can_edit(request) else "disabled" + metadata["editMode"] = "simple" if self.can_edit(request) else "disabled" + metadata["permissions"] = self.buildPermissions() metadata["_referenceVersion"] = self.reference_version return metadata + def buildPermissions(self): + permissions = {"edit_status": self.edit_status} + permissions["editors"] = [ + { + "id": user.id, + "name": user.get_username(), + "url": user.get_url(), + } + for user in self.editors.all() + ] + permissions["teams"] = [ + { + "id": team.id, + "name": str(team), + "url": team.get_url(), + } + for team in self.teams.all() + ] + return permissions + def clone(self, map_inst=None): new = self.__class__.objects.get(pk=self.pk) new._state.adding = True @@ -582,7 +610,7 @@ def get_version(self, ref): def get_version_path(self, ref): return self.geojson.storage.get_version_path(ref, self) - + def can_edit(self, request=None): """ Define if a user can edit or not the instance, according to his account @@ -602,8 +630,12 @@ def can_edit(self, request=None): elif user.is_authenticated and user == self.map.owner: can = True elif user.is_authenticated and self.edit_status == self.COLLABORATORS: - if user in self.map.editors.all() or self.map.team in user.teams.all(): + if user in self.map.editors.all() or any(e in self.map.teams.all() for e in user.teams.all()): + can = True + elif user.is_authenticated and self.edit_status == self.USERSANDTEAMS: + if user in self.editors.all() or any(e in self.map.teams.all() for e in user.teams.all()): can = True + return can def move_to_trash(self): diff --git a/umap/settings/base.py b/umap/settings/base.py index e30ee66aa..071be9498 100644 --- a/umap/settings/base.py +++ b/umap/settings/base.py @@ -254,6 +254,8 @@ USER_AUTOCOMPLETE_FIELDS = ["^username"] USER_URL_FIELD = "username" +TEAM_AUTOCOMPLETE_FIELDS = ['^name'] + # ============================================================================= # Miscellaneous project settings # ============================================================================= diff --git a/umap/static/umap/js/modules/autocomplete.js b/umap/static/umap/js/modules/autocomplete.js index ffc7cdbd9..67c87a1e7 100644 --- a/umap/static/umap/js/modules/autocomplete.js +++ b/umap/static/umap/js/modules/autocomplete.js @@ -208,23 +208,11 @@ export class BaseAutocomplete { } getLeft(el) { - let tmp = el.offsetLeft - el = el.offsetParent - while (el) { - tmp += el.offsetLeft - el = el.offsetParent - } - return tmp + return el.getBoundingClientRect().left } getTop(el) { - let tmp = el.offsetTop - el = el.offsetParent - while (el) { - tmp += el.offsetTop - el = el.offsetParent - } - return tmp + return el.getBoundingClientRect().top } } @@ -277,7 +265,11 @@ export class BaseAjax extends BaseAutocomplete { class BaseServerAjax extends BaseAjax { setUrl() { - this.url = '/agnocomplete/AutocompleteUser/?q={q}' + if (this.options?.className === 'edit-teams') { + this.url = '/agnocomplete/AutocompleteTeam/?q={q}'; + } else { + this.url = '/agnocomplete/AutocompleteUser/?q={q}'; + } } initRequest() { diff --git a/umap/static/umap/js/modules/form/fields.js b/umap/static/umap/js/modules/form/fields.js index 8ba21a9de..60467d871 100644 --- a/umap/static/umap/js/modules/form/fields.js +++ b/umap/static/umap/js/modules/form/fields.js @@ -1353,22 +1353,44 @@ Fields.ManageEditors = class extends BaseElement { } } } +Fields.ManageTeams = class extends BaseElement { + build() { + super.build() + const options = { + className: 'edit-teams', + on_select: L.bind(this.onSelect, this), + on_unselect: L.bind(this.onUnselect, this), + placeholder: translate("Type team name"), + } + this.autocomplete = new AjaxAutocompleteMultiple(this.container, options) + this._values = this.toHTML() || [] + if (this._values) { + for (let i = 0; i < this._values.length; i++) + this.autocomplete.displaySelected({ + item: { value: this._values[i].id, label: this._values[i].name }, + }) + } + } -Fields.ManageTeam = class extends Fields.IntSelect { - getOptions() { - return [[null, translate('None')]].concat( - this.properties.teams.map((team) => [team.id, team.name]) - ) + value() { + return this._values } - toHTML() { - return this.get()?.id + onSelect(choice) { + this._values.push({ + id: choice.item.value, + name: choice.item.label, + url: choice.item.url, + }) + this.set() } - toJS() { - const value = this.value() - for (const team of this.properties.teams) { - if (team.id === value) return team + onUnselect(choice) { + const index = this._values.findIndex((item) => item.id === choice.item.value) + if (index !== -1) { + this._values.splice(index, 1) + this.set() } } } + diff --git a/umap/static/umap/js/modules/permissions.js b/umap/static/umap/js/modules/permissions.js index fad5c2dd4..34aca4015 100644 --- a/umap/static/umap/js/modules/permissions.js +++ b/umap/static/umap/js/modules/permissions.js @@ -17,7 +17,7 @@ export class MapPermissions { this.properties = Object.assign( { owner: null, - team: null, + teams:[], editors: [], share_status: null, edit_status: null, @@ -123,21 +123,16 @@ export class MapPermissions { 'properties.owner', { handler: 'ManageOwner', label: translate("Map's owner") }, ]) - if (this._umap.properties.user?.teams?.length) { - collaboratorsFields.push([ - 'properties.team', - { - handler: 'ManageTeam', - label: translate('Attach map to a team'), - teams: this._umap.properties.user.teams, - }, - ]) - } + } collaboratorsFields.push([ 'properties.editors', { handler: 'ManageEditors', label: translate("Map's editors") }, ]) + collaboratorsFields.push([ + 'properties.teams', + { handler: 'ManageTeams', label: translate("Attach map to teams") }, + ]) const builder = new MutatingForm(this, topFields) const form = builder.build() @@ -199,13 +194,17 @@ export class MapPermissions { for (let i = 0; i < this.properties.editors.length; i++) formData.append('editors', this.properties.editors[i].id) } + if (!this.isAnonymousMap() && this.properties.teams) { + const editors = this.properties.teams.map((t) => t.id) + for (let i = 0; i < this.properties.teams.length; i++) + formData.append('teams', this.properties.teams[i].id) + } if (this.isOwner() || this.isAnonymousMap()) { formData.append('edit_status', this.properties.edit_status) formData.append('share_status', this.properties.share_status) } if (this.isOwner()) { formData.append('owner', this.properties.owner?.id) - formData.append('team', this.properties.team?.id || '') } const [data, response, error] = await this._umap.server.post( this.getUrl(), @@ -254,25 +253,34 @@ export class MapPermissions { export class DataLayerPermissions { constructor(umap, datalayer) { this._umap = umap + this.setPermissions(datalayer.options.permissions) + + this.datalayer = datalayer + this.sync = umap.syncEngine.proxy(this) + } + setPermissions(permissions) { this.properties = Object.assign( { + editors: [], edit_status: null, }, - datalayer.options.permissions + permissions ) - - this.datalayer = datalayer - this.sync = umap.syncEngine.proxy(this) } - getSyncMetadata() { return { subject: 'datalayerpermissions', metadata: { id: this.datalayer.id }, } } - + isAnonymousMap() { + return !this._umap.properties.permissions.owner + } edit(container) { + const fieldset = Utils.loadTemplate( + `
` + ) + container.appendChild(fieldset) const fields = [ [ 'properties.edit_status', @@ -285,10 +293,19 @@ export class DataLayerPermissions { }, ], ] + fields.push([ + 'properties.editors', + { handler: 'ManageEditors', label: translate("Layers's editors") }, + ]) + fields.push([ + 'properties.teams', + { handler: 'ManageTeams', label: translate("Layers's teams") }, + ]) const builder = new MutatingForm(this, fields, { className: 'umap-form datalayer-permissions', }) const form = builder.build() + container.appendChild(form) } @@ -302,6 +319,16 @@ export class DataLayerPermissions { async save() { const formData = new FormData() formData.append('edit_status', this.properties.edit_status) + if (!this.isAnonymousMap() && this.properties.editors) { + const editors = this.properties.editors.map((u) => u.id) + for (let i = 0; i < this.properties.editors.length; i++) + formData.append('editors', this.properties.editors[i].id) + } + if (!this.isAnonymousMap() && this.properties.teams) { + const editors = this.properties.teams.map((t) => t.id) + for (let i = 0; i < this.properties.teams.length; i++) + formData.append('teams', this.properties.teams[i].id) + } const [data, response, error] = await this._umap.server.post( this.getUrl(), {}, diff --git a/umap/views.py b/umap/views.py index eae9e62fd..82149ab29 100644 --- a/umap/views.py +++ b/umap/views.py @@ -322,7 +322,7 @@ def get_maps(self): user = self.request.user if user.is_authenticated and user in self.object.users.all(): qs = Map.objects - return qs.filter(team=self.object).order_by("-modified_at") + return qs.filter(teams=self.object).order_by("-modified_at") def get_context_data(self, **kwargs): kwargs.update( @@ -388,7 +388,7 @@ def get_maps(self): qs = ( qs.filter(owner=self.object) .union(qs.filter(editors=self.object)) - .union(qs.filter(team__in=teams)) + .union(qs.filter(teams__in=teams)) ) return qs.order_by("-modified_at") @@ -714,8 +714,10 @@ def get_permissions(self): permissions["editors"] = [ editor.get_metadata() for editor in self.object.editors.all() ] - if self.object.team: - permissions["team"] = self.object.team.get_metadata() + permissions["teams"] = [ + team.get_metadata() for team in self.object.teams.all() + ] + if not self.object.owner and self.object.is_anonymous_owner(self.request): permissions["anonymous_edit_url"] = self.object.get_anonymous_edit_url() return permissions