wip: use auth.models.Group and manage permissions

This commit is contained in:
Yohan Boniface 2024-08-15 14:54:43 +02:00
parent 6126a6666e
commit dce0ee5f73
15 changed files with 237 additions and 34 deletions

View file

@ -35,7 +35,7 @@ def can_edit_map(view_func):
map_inst = get_object_or_404(Map, pk=kwargs["map_id"]) map_inst = get_object_or_404(Map, pk=kwargs["map_id"])
user = request.user user = request.user
kwargs["map_inst"] = map_inst # Avoid rerequesting the map in the view 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) can_edit = map_inst.can_edit(user=user, request=request)
if not can_edit: if not can_edit:
if map_inst.owner and not user.is_authenticated: if map_inst.owner and not user.is_authenticated:

View file

@ -36,7 +36,7 @@ class SendLinkForm(forms.Form):
class UpdateMapPermissionsForm(forms.ModelForm): class UpdateMapPermissionsForm(forms.ModelForm):
class Meta: class Meta:
model = Map model = Map
fields = ("edit_status", "editors", "share_status", "owner") fields = ("edit_status", "editors", "share_status", "owner", "group")
class AnonymousMapPermissionsForm(forms.ModelForm): class AnonymousMapPermissionsForm(forms.ModelForm):

View file

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

View file

@ -5,7 +5,7 @@ import time
import uuid import uuid
from django.conf import settings 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.contrib.gis.db import models
from django.core.files.base import File from django.core.files.base import File
from django.core.signing import Signer from django.core.signing import Signer
@ -36,9 +36,19 @@ def get_user_stars_url(self):
return reverse("user_stars", kwargs={"identifier": identifier}) 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("__str__", display_name)
User.add_to_class("get_url", get_user_url) User.add_to_class("get_url", get_user_url)
User.add_to_class("get_stars_url", get_user_stars_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(): def get_default_share_status():
@ -137,7 +147,7 @@ class Map(NamedModel):
""" """
ANONYMOUS = 1 ANONYMOUS = 1
EDITORS = 2 COLLABORATORS = 2
OWNER = 3 OWNER = 3
PUBLIC = 1 PUBLIC = 1
OPEN = 2 OPEN = 2
@ -145,13 +155,13 @@ class Map(NamedModel):
BLOCKED = 9 BLOCKED = 9
EDIT_STATUS = ( EDIT_STATUS = (
(ANONYMOUS, _("Everyone")), (ANONYMOUS, _("Everyone")),
(EDITORS, _("Editors only")), (COLLABORATORS, _("Editors and team only")),
(OWNER, _("Owner only")), (OWNER, _("Owner only")),
) )
SHARE_STATUS = ( SHARE_STATUS = (
(PUBLIC, _("Everyone (public)")), (PUBLIC, _("Everyone (public)")),
(OPEN, _("Anyone with link")), (OPEN, _("Anyone with link")),
(PRIVATE, _("Editors only")), (PRIVATE, _("Editors and team only")),
(BLOCKED, _("Blocked")), (BLOCKED, _("Blocked")),
) )
slug = models.SlugField(db_index=True) slug = models.SlugField(db_index=True)
@ -180,6 +190,13 @@ class Map(NamedModel):
editors = models.ManyToManyField( editors = models.ManyToManyField(
settings.AUTH_USER_MODEL, blank=True, verbose_name=_("editors") 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( edit_status = models.SmallIntegerField(
choices=EDIT_STATUS, choices=EDIT_STATUS,
default=get_default_edit_status, default=get_default_edit_status,
@ -281,7 +298,7 @@ class Map(NamedModel):
In owner mode: In owner mode:
- only owner by default (OWNER) - only owner by default (OWNER)
- any editor if mode is EDITORS - any editor or group member if mode is COLLABORATORS
- anyone otherwise (ANONYMOUS) - anyone otherwise (ANONYMOUS)
In anonymous owner mode: In anonymous owner mode:
- only owner (has ownership cookie) by default (OWNER) - only owner (has ownership cookie) by default (OWNER)
@ -297,7 +314,8 @@ class Map(NamedModel):
can = False can = False
elif user == self.owner: elif user == self.owner:
can = True can = True
elif self.edit_status == self.EDITORS and user in self.editors.all(): elif self.edit_status == self.COLLABORATORS:
if user in self.editors.all() or self.group in user.groups.all():
can = True can = True
return can return can
@ -308,12 +326,15 @@ class Map(NamedModel):
can = True can = True
elif self.share_status in [self.PUBLIC, self.OPEN]: elif self.share_status in [self.PUBLIC, self.OPEN]:
can = True can = True
elif request.user is None:
can = False
elif request.user == self.owner: elif request.user == self.owner:
can = True can = True
else: else:
can = not ( can = not (
self.share_status == self.PRIVATE self.share_status == self.PRIVATE
and request.user not in self.editors.all() and request.user not in self.editors.all()
and self.group not in request.user.groups.all()
) )
return can return can
@ -383,12 +404,12 @@ class DataLayer(NamedModel):
INHERIT = 0 INHERIT = 0
ANONYMOUS = 1 ANONYMOUS = 1
EDITORS = 2 COLLABORATORS = 2
OWNER = 3 OWNER = 3
EDIT_STATUS = ( EDIT_STATUS = (
(INHERIT, _("Inherit")), (INHERIT, _("Inherit")),
(ANONYMOUS, _("Everyone")), (ANONYMOUS, _("Everyone")),
(EDITORS, _("Editors only")), (COLLABORATORS, _("Editors and team only")),
(OWNER, _("Owner only")), (OWNER, _("Owner only")),
) )
uuid = models.UUIDField( uuid = models.UUIDField(
@ -538,7 +559,8 @@ class DataLayer(NamedModel):
can = True can = True
elif user is not None and user == self.map.owner: elif user is not None and user == self.map.owner:
can = True can = True
elif self.edit_status == self.EDITORS and user in self.map.editors.all(): 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 can = True
return can return can

View file

@ -25,6 +25,7 @@ export class MapPermissions {
this.options = Object.assign( this.options = Object.assign(
{ {
owner: null, owner: null,
group: null,
editors: [], editors: [],
share_status: null, share_status: null,
edit_status: null, edit_status: null,
@ -96,6 +97,16 @@ export class MapPermissions {
'options.owner', 'options.owner',
{ handler: 'ManageOwner', label: translate("Map's 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([ fields.push([
'options.editors', 'options.editors',
@ -150,6 +161,7 @@ export class MapPermissions {
formData.append('edit_status', this.options.edit_status) formData.append('edit_status', this.options.edit_status)
if (this.isOwner()) { if (this.isOwner()) {
formData.append('owner', this.options.owner?.id) formData.append('owner', this.options.owner?.id)
formData.append('group', this.options.group?.id || '')
formData.append('share_status', this.options.share_status) formData.append('share_status', this.options.share_status)
} }
const [data, response, error] = await this.map.server.post( const [data, response, error] = await this.map.server.post(

View file

@ -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({ U.FormBuilder = L.FormBuilder.extend({
options: { options: {
className: 'umap-form', className: 'umap-form',

View file

@ -3,6 +3,7 @@ import json
import factory import factory
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.urls import reverse from django.urls import reverse
@ -58,6 +59,13 @@ class UserFactory(factory.django.DjangoModelFactory):
model = User model = User
class GroupFactory(factory.django.DjangoModelFactory):
name = "Awesome Group"
class Meta:
model = Group
class MapFactory(factory.django.DjangoModelFactory): class MapFactory(factory.django.DjangoModelFactory):
name = "test map" name = "test map"
slug = "test-map" slug = "test-map"

View file

@ -9,6 +9,7 @@ from umap.models import Map
from .base import ( from .base import (
DataLayerFactory, DataLayerFactory,
GroupFactory,
LicenceFactory, LicenceFactory,
MapFactory, MapFactory,
TileLayerFactory, TileLayerFactory,
@ -29,6 +30,11 @@ def pytest_runtest_teardown():
cache.clear() cache.clear()
@pytest.fixture
def group():
return GroupFactory()
@pytest.fixture @pytest.fixture
def user(): def user():
return UserFactory(password="123123") return UserFactory(password="123123")

View file

@ -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): 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.editors.add(user)
map.save() map.save()
page = login(user) 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): 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.editors.add(user)
map.save() map.save()
page = login(user) 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): 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.editors.add(user)
map.save() map.save()
page = login(user) page = login(user)
@ -241,3 +241,19 @@ def test_can_delete_datalayer(live_server, map, login, datalayer):
expect(markers).to_have_count(0) expect(markers).to_have_count(0)
# FIXME does not work, resolve to 1 element, even if this command is empty: # FIXME does not work, resolve to 1 element, even if this command is empty:
expect(layers).to_have_count(0) 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

View file

@ -101,22 +101,33 @@ def test_should_remove_old_versions_on_save(map, settings):
def test_anonymous_cannot_edit_in_editors_mode(datalayer): def test_anonymous_cannot_edit_in_editors_mode(datalayer):
datalayer.edit_status = DataLayer.EDITORS datalayer.edit_status = DataLayer.COLLABORATORS
datalayer.save() datalayer.save()
assert not datalayer.can_edit() assert not datalayer.can_edit()
def test_owner_can_edit_in_editors_mode(datalayer, user): def test_owner_can_edit_in_editors_mode(datalayer, user):
datalayer.edit_status = DataLayer.EDITORS datalayer.edit_status = DataLayer.COLLABORATORS
datalayer.save() datalayer.save()
assert datalayer.can_edit(datalayer.map.owner) 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 = datalayer.map
map.editors.add(user) map.editors.add(user)
map.save() 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() datalayer.save()
assert datalayer.can_edit(user) 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) 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): def test_anonymous_cannot_edit_in_inherit_mode_and_map_in_owner_mode(datalayer):
datalayer.edit_status = DataLayer.INHERIT datalayer.edit_status = DataLayer.INHERIT
datalayer.save() 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.edit_status = DataLayer.INHERIT
datalayer.save() datalayer.save()
map = datalayer.map map = datalayer.map
map.edit_status = Map.EDITORS map.edit_status = Map.COLLABORATORS
map.save() map.save()
assert datalayer.can_edit(map.owner) 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() datalayer.save()
map = datalayer.map map = datalayer.map
map.editors.add(user) map.editors.add(user)
map.edit_status = Map.EDITORS map.edit_status = Map.COLLABORATORS
map.save() map.save()
assert datalayer.can_edit(user) 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.edit_status = DataLayer.INHERIT
datalayer.save() datalayer.save()
map = datalayer.map map = datalayer.map
map.edit_status = Map.EDITORS map.edit_status = Map.COLLABORATORS
map.save() map.save()
assert not datalayer.can_edit() assert not datalayer.can_edit()

View file

@ -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): def test_editor_can_edit_in_editors_mode(datalayer, client, map, post_data):
client.login(username=map.owner.username, password="123123") client.login(username=map.owner.username, password="123123")
datalayer.edit_status = DataLayer.EDITORS datalayer.edit_status = DataLayer.COLLABORATORS
datalayer.save() datalayer.save()
url = reverse("datalayer_update", args=(map.pk, datalayer.pk)) url = reverse("datalayer_update", args=(map.pk, datalayer.pk))
name = "new name" name = "new name"

View file

@ -43,13 +43,31 @@ def test_editors_cannot_edit_if_status_owner(map, user):
assert not map.can_edit(user) assert not map.can_edit(user)
def test_editors_can_edit_if_status_editors(map, user): def test_editors_can_edit_if_status_collaborators(map, user):
map.edit_status = map.EDITORS map.edit_status = map.COLLABORATORS
map.editors.add(user) map.editors.add(user)
map.save() map.save()
assert map.can_edit(user) 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( def test_logged_in_user_should_be_allowed_for_anonymous_map_with_anonymous_edit_status(
map, user, rf map, user, rf
): # noqa ): # noqa
@ -87,6 +105,14 @@ def test_clone_should_keep_editors(map, user):
assert user in clone.editors.all() 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): def test_clone_should_update_owner_if_passed(map, user):
clone = map.clone(owner=user) clone = map.clone(owner=user)
assert map.pk != clone.pk 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): def test_can_change_default_edit_status(user, settings):
map = MapFactory(owner=user) map = MapFactory(owner=user)
assert map.edit_status == Map.OWNER assert map.edit_status == Map.OWNER
settings.UMAP_DEFAULT_EDIT_STATUS = Map.EDITORS settings.UMAP_DEFAULT_EDIT_STATUS = Map.COLLABORATORS
map = MapFactory(owner=user) 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): def test_can_change_default_share_status(user, settings):

View file

@ -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): def test_clone_should_set_cloner_as_owner(client, map, user):
url = reverse("map_clone", kwargs={"map_id": map.pk}) url = reverse("map_clone", kwargs={"map_id": map.pk})
map.edit_status = map.EDITORS map.edit_status = map.COLLABORATORS
map.editors.add(user) map.editors.add(user)
map.save() map.save()
client.login(username=user.username, password="123123") 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): def test_map_editors_do_not_see_owner_change_input(client, map, user):
map.editors.add(user) map.editors.add(user)
map.edit_status = map.EDITORS map.edit_status = map.COLLABORATORS
map.save() map.save()
url = reverse("map_update_permissions", kwargs={"map_id": map.pk}) url = reverse("map_update_permissions", kwargs={"map_id": map.pk})
client.login(username=user.username, password="123123") client.login(username=user.username, password="123123")

View file

@ -474,7 +474,7 @@ def test_websocket_token_returns_a_valid_token_when_authorized(client, user, map
@pytest.mark.django_db @pytest.mark.django_db
def test_websocket_token_is_generated_for_editors(client, user, user2, map): 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.editors.add(user2)
map.save() map.save()

View file

@ -459,14 +459,16 @@ def simple_json_response(**kwargs):
class SessionMixin: class SessionMixin:
def get_user_data(self): def get_user_data(self):
data = {} data = {}
user = self.request.user
if hasattr(self, "object"): if hasattr(self, "object"):
data["is_owner"] = self.object.is_owner(self.request.user, self.request) data["is_owner"] = self.object.is_owner(user, self.request)
if self.request.user.is_anonymous: if user.is_anonymous:
return data return data
return { return {
"id": self.request.user.pk, "id": user.pk,
"name": str(self.request.user), "name": str(self.request.user),
"url": reverse("user_dashboard"), "url": reverse("user_dashboard"),
"groups": [group.get_metadata() for group in user.groups.all()],
**data, **data,
} }
@ -605,6 +607,8 @@ class PermissionsMixin:
{"id": editor.pk, "name": str(editor)} {"id": editor.pk, "name": str(editor)}
for editor in self.object.editors.all() 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): if not self.object.owner and self.object.is_anonymous_owner(self.request):
permissions["anonymous_edit_url"] = self.object.get_anonymous_edit_url() permissions["anonymous_edit_url"] = self.object.get_anonymous_edit_url()
return permissions return permissions