diff --git a/umap/decorators.py b/umap/decorators.py index 3ae667c5..fbe70429 100644 --- a/umap/decorators.py +++ b/umap/decorators.py @@ -35,7 +35,7 @@ def can_edit_map(view_func): map_inst = get_object_or_404(Map, pk=kwargs["map_id"]) user = request.user kwargs["map_inst"] = map_inst # Avoid rerequesting the map in the view - if map_inst.edit_status >= map_inst.EDITORS: + if map_inst.edit_status >= map_inst.COLLABORATORS: can_edit = map_inst.can_edit(user=user, request=request) if not can_edit: if map_inst.owner and not user.is_authenticated: diff --git a/umap/forms.py b/umap/forms.py index d1225b22..c7813a61 100644 --- a/umap/forms.py +++ b/umap/forms.py @@ -36,7 +36,7 @@ class SendLinkForm(forms.Form): class UpdateMapPermissionsForm(forms.ModelForm): class Meta: model = Map - fields = ("edit_status", "editors", "share_status", "owner") + fields = ("edit_status", "editors", "share_status", "owner", "group") class AnonymousMapPermissionsForm(forms.ModelForm): diff --git a/umap/migrations/0022_map_group_alter_datalayer_edit_status_and_more.py b/umap/migrations/0022_map_group_alter_datalayer_edit_status_and_more.py new file mode 100644 index 00000000..bea2ef72 --- /dev/null +++ b/umap/migrations/0022_map_group_alter_datalayer_edit_status_and_more.py @@ -0,0 +1,67 @@ +# Generated by Django 5.1 on 2024-08-15 11:33 + +import django.db.models.deletion +import umap.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ("umap", "0021_remove_map_description"), + ] + + operations = [ + migrations.AddField( + model_name="map", + name="group", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="auth.group", + verbose_name="group", + ), + ), + migrations.AlterField( + model_name="datalayer", + name="edit_status", + field=models.SmallIntegerField( + choices=[ + (0, "Inherit"), + (1, "Everyone"), + (2, "Editors and team only"), + (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 team 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=[ + (1, "Everyone (public)"), + (2, "Anyone with link"), + (3, "Editors and team only"), + (9, "Blocked"), + ], + default=umap.models.get_default_share_status, + verbose_name="share status", + ), + ), + ] diff --git a/umap/models.py b/umap/models.py index 5efe3aba..fe3e3753 100644 --- a/umap/models.py +++ b/umap/models.py @@ -5,7 +5,7 @@ import time import uuid from django.conf import settings -from django.contrib.auth.models import User +from django.contrib.auth.models import Group, User from django.contrib.gis.db import models from django.core.files.base import File from django.core.signing import Signer @@ -36,9 +36,19 @@ def get_user_stars_url(self): return reverse("user_stars", kwargs={"identifier": identifier}) +def get_group_url(self): + return "TODO" + + +def get_group_metadata(self): + return {"id": self.pk, "name": self.name, "url": self.get_url()} + + User.add_to_class("__str__", display_name) User.add_to_class("get_url", get_user_url) User.add_to_class("get_stars_url", get_user_stars_url) +Group.add_to_class("get_url", get_group_url) +Group.add_to_class("get_metadata", get_group_metadata) def get_default_share_status(): @@ -137,7 +147,7 @@ class Map(NamedModel): """ ANONYMOUS = 1 - EDITORS = 2 + COLLABORATORS = 2 OWNER = 3 PUBLIC = 1 OPEN = 2 @@ -145,13 +155,13 @@ class Map(NamedModel): BLOCKED = 9 EDIT_STATUS = ( (ANONYMOUS, _("Everyone")), - (EDITORS, _("Editors only")), + (COLLABORATORS, _("Editors and team only")), (OWNER, _("Owner only")), ) SHARE_STATUS = ( (PUBLIC, _("Everyone (public)")), (OPEN, _("Anyone with link")), - (PRIVATE, _("Editors only")), + (PRIVATE, _("Editors and team only")), (BLOCKED, _("Blocked")), ) slug = models.SlugField(db_index=True) @@ -180,6 +190,13 @@ class Map(NamedModel): editors = models.ManyToManyField( settings.AUTH_USER_MODEL, blank=True, verbose_name=_("editors") ) + group = models.ForeignKey( + "auth.Group", + blank=True, + null=True, + verbose_name=_("team"), + on_delete=models.SET_NULL, + ) edit_status = models.SmallIntegerField( choices=EDIT_STATUS, default=get_default_edit_status, @@ -281,7 +298,7 @@ class Map(NamedModel): In owner mode: - only owner by default (OWNER) - - any editor if mode is EDITORS + - any editor or group member if mode is COLLABORATORS - anyone otherwise (ANONYMOUS) In anonymous owner mode: - only owner (has ownership cookie) by default (OWNER) @@ -297,8 +314,9 @@ class Map(NamedModel): can = False elif user == self.owner: can = True - elif self.edit_status == self.EDITORS and user in self.editors.all(): - can = True + elif self.edit_status == self.COLLABORATORS: + if user in self.editors.all() or self.group in user.groups.all(): + can = True return can def can_view(self, request): @@ -308,12 +326,15 @@ class Map(NamedModel): can = True elif self.share_status in [self.PUBLIC, self.OPEN]: can = True + elif request.user is None: + can = False elif request.user == self.owner: can = True else: can = not ( self.share_status == self.PRIVATE and request.user not in self.editors.all() + and self.group not in request.user.groups.all() ) return can @@ -383,12 +404,12 @@ class DataLayer(NamedModel): INHERIT = 0 ANONYMOUS = 1 - EDITORS = 2 + COLLABORATORS = 2 OWNER = 3 EDIT_STATUS = ( (INHERIT, _("Inherit")), (ANONYMOUS, _("Everyone")), - (EDITORS, _("Editors only")), + (COLLABORATORS, _("Editors and team only")), (OWNER, _("Owner only")), ) uuid = models.UUIDField( @@ -538,8 +559,9 @@ class DataLayer(NamedModel): can = True elif user is not None and user == self.map.owner: can = True - elif self.edit_status == self.EDITORS and user in self.map.editors.all(): - can = True + elif user is not None and self.edit_status == self.COLLABORATORS: + if user in self.map.editors.all() or self.map.group in user.groups.all(): + can = True return can diff --git a/umap/static/umap/js/modules/permissions.js b/umap/static/umap/js/modules/permissions.js index 52ae216c..5c6c20c2 100644 --- a/umap/static/umap/js/modules/permissions.js +++ b/umap/static/umap/js/modules/permissions.js @@ -25,6 +25,7 @@ export class MapPermissions { this.options = Object.assign( { owner: null, + group: null, editors: [], share_status: null, edit_status: null, @@ -96,6 +97,16 @@ export class MapPermissions { 'options.owner', { handler: 'ManageOwner', label: translate("Map's owner") }, ]) + if (this.map.options.user?.groups?.length) { + fields.push([ + 'options.group', + { + handler: 'ManageGroup', + label: translate('Attach map to a team'), + groups: this.map.options.user.groups, + }, + ]) + } } fields.push([ 'options.editors', @@ -150,6 +161,7 @@ export class MapPermissions { formData.append('edit_status', this.options.edit_status) if (this.isOwner()) { formData.append('owner', this.options.owner?.id) + formData.append('group', this.options.group?.id || '') formData.append('share_status', this.options.share_status) } const [data, response, error] = await this.map.server.post( diff --git a/umap/static/umap/js/umap.forms.js b/umap/static/umap/js/umap.forms.js index cb6760d8..15dc3311 100644 --- a/umap/static/umap/js/umap.forms.js +++ b/umap/static/umap/js/umap.forms.js @@ -1086,6 +1086,23 @@ L.FormBuilder.ManageEditors = L.FormBuilder.Element.extend({ }, }) +L.FormBuilder.ManageGroup = L.FormBuilder.IntSelect.extend({ + getOptions: function () { + return [[null, L._('None')]].concat( + this.options.groups.map((group) => [group.id, group.name]) + ) + }, + toHTML: function () { + return this.get()?.id + }, + toJS: function () { + const value = this.value() + for (const group of this.options.groups) { + if (group.id === value) return group + } + }, +}) + U.FormBuilder = L.FormBuilder.extend({ options: { className: 'umap-form', diff --git a/umap/tests/base.py b/umap/tests/base.py index 9491dada..4d0ce144 100644 --- a/umap/tests/base.py +++ b/umap/tests/base.py @@ -3,6 +3,7 @@ import json import factory from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group from django.core.files.base import ContentFile from django.urls import reverse @@ -58,6 +59,13 @@ class UserFactory(factory.django.DjangoModelFactory): model = User +class GroupFactory(factory.django.DjangoModelFactory): + name = "Awesome Group" + + class Meta: + model = Group + + class MapFactory(factory.django.DjangoModelFactory): name = "test map" slug = "test-map" diff --git a/umap/tests/conftest.py b/umap/tests/conftest.py index 4dafa3dd..3bee02e7 100644 --- a/umap/tests/conftest.py +++ b/umap/tests/conftest.py @@ -9,6 +9,7 @@ from umap.models import Map from .base import ( DataLayerFactory, + GroupFactory, LicenceFactory, MapFactory, TileLayerFactory, @@ -29,6 +30,11 @@ def pytest_runtest_teardown(): cache.clear() +@pytest.fixture +def group(): + return GroupFactory() + + @pytest.fixture def user(): return UserFactory(password="123123") diff --git a/umap/tests/integration/test_owned_map.py b/umap/tests/integration/test_owned_map.py index bf189641..19d02804 100644 --- a/umap/tests/integration/test_owned_map.py +++ b/umap/tests/integration/test_owned_map.py @@ -81,7 +81,7 @@ def test_owner_permissions_form(map, datalayer, live_server, login): def test_map_update_with_editor(map, live_server, login, user): - map.edit_status = Map.EDITORS + map.edit_status = Map.COLLABORATORS map.editors.add(user) map.save() page = login(user) @@ -104,7 +104,7 @@ def test_map_update_with_editor(map, live_server, login, user): def test_permissions_form_with_editor(map, datalayer, live_server, login, user): - map.edit_status = Map.EDITORS + map.edit_status = Map.COLLABORATORS map.editors.add(user) map.save() page = login(user) @@ -141,7 +141,7 @@ def test_owner_has_delete_map_button(map, live_server, login): def test_editor_do_not_have_delete_map_button(map, live_server, login, user): - map.edit_status = Map.EDITORS + map.edit_status = Map.COLLABORATORS map.editors.add(user) map.save() page = login(user) @@ -241,3 +241,19 @@ def test_can_delete_datalayer(live_server, map, login, datalayer): expect(markers).to_have_count(0) # FIXME does not work, resolve to 1 element, even if this command is empty: expect(layers).to_have_count(0) + + +def test_can_set_group(map, live_server, login, group): + map.owner.groups.add(group) + map.owner.save() + page = login(map.owner) + page.goto(f"{live_server.url}{map.get_absolute_url()}?edit") + edit_permissions = page.get_by_title("Update permissions and editors") + edit_permissions.click() + page.locator("select[name=group]").select_option(str(group.pk)) + save = page.get_by_role("button", name="Save") + expect(save).to_be_visible() + with page.expect_response(re.compile(r".*/update/permissions/.*")): + save.click() + modified = Map.objects.get(pk=map.pk) + assert modified.group == group diff --git a/umap/tests/test_datalayer.py b/umap/tests/test_datalayer.py index ee564bc8..c60ab3c0 100644 --- a/umap/tests/test_datalayer.py +++ b/umap/tests/test_datalayer.py @@ -101,22 +101,33 @@ def test_should_remove_old_versions_on_save(map, settings): def test_anonymous_cannot_edit_in_editors_mode(datalayer): - datalayer.edit_status = DataLayer.EDITORS + datalayer.edit_status = DataLayer.COLLABORATORS datalayer.save() assert not datalayer.can_edit() def test_owner_can_edit_in_editors_mode(datalayer, user): - datalayer.edit_status = DataLayer.EDITORS + datalayer.edit_status = DataLayer.COLLABORATORS datalayer.save() assert datalayer.can_edit(datalayer.map.owner) -def test_editor_can_edit_in_editors_mode(datalayer, user): +def test_editor_can_edit_in_collaborators_mode(datalayer, user): map = datalayer.map map.editors.add(user) map.save() - datalayer.edit_status = DataLayer.EDITORS + datalayer.edit_status = DataLayer.COLLABORATORS + datalayer.save() + assert datalayer.can_edit(user) + + +def test_group_members_can_edit_in_collaborators_mode(datalayer, user, group): + user.groups.add(group) + user.save() + map = datalayer.map + map.group = group + map.save() + datalayer.edit_status = DataLayer.COLLABORATORS datalayer.save() assert datalayer.can_edit(user) @@ -170,6 +181,20 @@ def test_editors_cannot_edit_in_inherit_mode_and_map_in_owner_mode(datalayer, us assert not datalayer.can_edit(user) +def test_group_members_cannot_edit_in_inherit_mode_and_map_in_owner_mode( + datalayer, user, group +): + datalayer.edit_status = DataLayer.INHERIT + datalayer.save() + user.groups.add(group) + group.save() + map = datalayer.map + map.group = group + map.edit_status = Map.OWNER + map.save() + assert not datalayer.can_edit(user) + + def test_anonymous_cannot_edit_in_inherit_mode_and_map_in_owner_mode(datalayer): datalayer.edit_status = DataLayer.INHERIT datalayer.save() @@ -183,7 +208,7 @@ def test_owner_can_edit_in_inherit_mode_and_map_in_editors_mode(datalayer): datalayer.edit_status = DataLayer.INHERIT datalayer.save() map = datalayer.map - map.edit_status = Map.EDITORS + map.edit_status = Map.COLLABORATORS map.save() assert datalayer.can_edit(map.owner) @@ -193,7 +218,7 @@ def test_editors_can_edit_in_inherit_mode_and_map_in_editors_mode(datalayer, use datalayer.save() map = datalayer.map map.editors.add(user) - map.edit_status = Map.EDITORS + map.edit_status = Map.COLLABORATORS map.save() assert datalayer.can_edit(user) @@ -202,7 +227,7 @@ def test_anonymous_cannot_edit_in_inherit_mode_and_map_in_editors_mode(datalayer datalayer.edit_status = DataLayer.INHERIT datalayer.save() map = datalayer.map - map.edit_status = Map.EDITORS + map.edit_status = Map.COLLABORATORS map.save() assert not datalayer.can_edit() diff --git a/umap/tests/test_datalayer_views.py b/umap/tests/test_datalayer_views.py index c59a12fb..f50510d1 100644 --- a/umap/tests/test_datalayer_views.py +++ b/umap/tests/test_datalayer_views.py @@ -379,7 +379,7 @@ def test_owner_can_edit_in_owner_mode(datalayer, client, map, post_data): def test_editor_can_edit_in_editors_mode(datalayer, client, map, post_data): client.login(username=map.owner.username, password="123123") - datalayer.edit_status = DataLayer.EDITORS + datalayer.edit_status = DataLayer.COLLABORATORS datalayer.save() url = reverse("datalayer_update", args=(map.pk, datalayer.pk)) name = "new name" diff --git a/umap/tests/test_map.py b/umap/tests/test_map.py index 41cdf740..a0eb83e0 100644 --- a/umap/tests/test_map.py +++ b/umap/tests/test_map.py @@ -43,13 +43,31 @@ def test_editors_cannot_edit_if_status_owner(map, user): assert not map.can_edit(user) -def test_editors_can_edit_if_status_editors(map, user): - map.edit_status = map.EDITORS +def test_editors_can_edit_if_status_collaborators(map, user): + map.edit_status = map.COLLABORATORS map.editors.add(user) map.save() assert map.can_edit(user) +def test_group_members_cannot_edit_if_status_owner(map, user, group): + user.groups.add(group) + user.save() + map.edit_status = map.OWNER + map.group = group + map.save() + assert not map.can_edit(user) + + +def test_group_members_can_edit_if_status_collaborators(map, user, group): + user.groups.add(group) + user.save() + map.edit_status = map.COLLABORATORS + map.group = group + map.save() + assert map.can_edit(user) + + def test_logged_in_user_should_be_allowed_for_anonymous_map_with_anonymous_edit_status( map, user, rf ): # noqa @@ -87,6 +105,14 @@ def test_clone_should_keep_editors(map, user): assert user in clone.editors.all() +def test_clone_should_keep_group(map, user, group): + map.group = group + map.save() + clone = map.clone() + assert map.pk != clone.pk + assert clone.group == group + + def test_clone_should_update_owner_if_passed(map, user): clone = map.clone(owner=user) assert map.pk != clone.pk @@ -119,9 +145,9 @@ def test_publicmanager_should_get_only_public_maps(map, user, licence): def test_can_change_default_edit_status(user, settings): map = MapFactory(owner=user) assert map.edit_status == Map.OWNER - settings.UMAP_DEFAULT_EDIT_STATUS = Map.EDITORS + settings.UMAP_DEFAULT_EDIT_STATUS = Map.COLLABORATORS map = MapFactory(owner=user) - assert map.edit_status == Map.EDITORS + assert map.edit_status == Map.COLLABORATORS def test_can_change_default_share_status(user, settings): diff --git a/umap/tests/test_map_views.py b/umap/tests/test_map_views.py index aeb65e5a..03c1adee 100644 --- a/umap/tests/test_map_views.py +++ b/umap/tests/test_map_views.py @@ -210,7 +210,7 @@ def test_user_not_allowed_should_not_clone_map(client, map, user, settings): def test_clone_should_set_cloner_as_owner(client, map, user): url = reverse("map_clone", kwargs={"map_id": map.pk}) - map.edit_status = map.EDITORS + map.edit_status = map.COLLABORATORS map.editors.add(user) map.save() client.login(username=user.username, password="123123") @@ -330,7 +330,7 @@ def test_only_owner_can_delete(client, map, user): def test_map_editors_do_not_see_owner_change_input(client, map, user): map.editors.add(user) - map.edit_status = map.EDITORS + map.edit_status = map.COLLABORATORS map.save() url = reverse("map_update_permissions", kwargs={"map_id": map.pk}) client.login(username=user.username, password="123123") diff --git a/umap/tests/test_views.py b/umap/tests/test_views.py index c693cdd2..4e307ddb 100644 --- a/umap/tests/test_views.py +++ b/umap/tests/test_views.py @@ -474,7 +474,7 @@ def test_websocket_token_returns_a_valid_token_when_authorized(client, user, map @pytest.mark.django_db def test_websocket_token_is_generated_for_editors(client, user, user2, map): - map.edit_status = Map.EDITORS + map.edit_status = Map.COLLABORATORS map.editors.add(user2) map.save() diff --git a/umap/views.py b/umap/views.py index 5f976977..50a0dd0c 100644 --- a/umap/views.py +++ b/umap/views.py @@ -459,14 +459,16 @@ def simple_json_response(**kwargs): class SessionMixin: def get_user_data(self): data = {} + user = self.request.user if hasattr(self, "object"): - data["is_owner"] = self.object.is_owner(self.request.user, self.request) - if self.request.user.is_anonymous: + data["is_owner"] = self.object.is_owner(user, self.request) + if user.is_anonymous: return data return { - "id": self.request.user.pk, + "id": user.pk, "name": str(self.request.user), "url": reverse("user_dashboard"), + "groups": [group.get_metadata() for group in user.groups.all()], **data, } @@ -605,6 +607,8 @@ class PermissionsMixin: {"id": editor.pk, "name": str(editor)} for editor in self.object.editors.all() ] + if self.object.group: + permissions["group"] = self.object.group.get_metadata() 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