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"])
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:

View file

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

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

View file

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

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({
options: {
className: 'umap-form',

View file

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

View file

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

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

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):
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()

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):
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"

View file

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

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):
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")

View file

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

View file

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