chore: use our own Team model

We suppose we'll quickly want more than a name, like a description
or a logo_url, and maybe a access_status or permissions…
This commit is contained in:
Yohan Boniface 2024-08-29 21:59:45 +02:00
parent 6b6be017bb
commit 13735a5739
28 changed files with 435 additions and 378 deletions

View file

@ -1,12 +1,11 @@
from functools import wraps from functools import wraps
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Group
from django.http import HttpResponseForbidden from django.http import HttpResponseForbidden
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.urls import reverse_lazy from django.urls import reverse_lazy
from .models import Map from .models import Map, Team
from .views import simple_json_response from .views import simple_json_response
LOGIN_URL = getattr(settings, "LOGIN_URL", "login") LOGIN_URL = getattr(settings, "LOGIN_URL", "login")
@ -63,11 +62,11 @@ def can_view_map(view_func):
return wrapper return wrapper
def group_members_only(view_func): def team_members_only(view_func):
@wraps(view_func) @wraps(view_func)
def wrapper(request, *args, **kwargs): def wrapper(request, *args, **kwargs):
group = get_object_or_404(Group, pk=kwargs["pk"]) team = get_object_or_404(Team, pk=kwargs["pk"])
if group not in request.user.groups.all(): if not request.user.is_authenticated or team not in request.user.teams.all():
return HttpResponseForbidden() return HttpResponseForbidden()
return view_func(request, *args, **kwargs) return view_func(request, *args, **kwargs)

View file

@ -1,13 +1,12 @@
from django import forms from django import forms
from django.conf import settings from django.conf import settings
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.contrib.gis.geos import Point from django.contrib.gis.geos import Point
from django.forms.utils import ErrorList from django.forms.utils import ErrorList
from django.template.defaultfilters import slugify from django.template.defaultfilters import slugify
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from .models import DataLayer, Map from .models import DataLayer, Map, Team
DEFAULT_LATITUDE = ( DEFAULT_LATITUDE = (
settings.LEAFLET_LATITUDE if hasattr(settings, "LEAFLET_LATITUDE") else 51 settings.LEAFLET_LATITUDE if hasattr(settings, "LEAFLET_LATITUDE") else 51
@ -37,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", "group") fields = ("edit_status", "editors", "share_status", "owner", "team")
class AnonymousMapPermissionsForm(forms.ModelForm): class AnonymousMapPermissionsForm(forms.ModelForm):
@ -113,25 +112,25 @@ class UserProfileForm(forms.ModelForm):
fields = ("username", "first_name", "last_name") fields = ("username", "first_name", "last_name")
class GroupMembersField(forms.ModelMultipleChoiceField): class TeamMembersField(forms.ModelMultipleChoiceField):
def set_choices(self, choices): def set_choices(self, choices):
iterator = self.iterator(self) iterator = self.iterator(self)
# Override queryset so to expose only selected choices: # Override queryset so to expose only selected choices:
# - we don't want a select with 100000 options # - we don't want a select with 100000 options
# - the select values will be used by the autocomplete widget to display # - the select values will be used by the autocomplete widget to display
# already existing members of the group # already existing members of the team
iterator.queryset = choices iterator.queryset = choices
self.choices = iterator self.choices = iterator
class GroupForm(forms.ModelForm): class TeamForm(forms.ModelForm):
class Meta: class Meta:
model = Group model = Team
fields = ["name", "members"] fields = ["name", "description", "logo_url", "members"]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields["members"].set_choices(self.initial["members"]) self.fields["members"].set_choices(self.initial["members"])
self.fields["members"].widget.attrs["hidden"] = "hidden" self.fields["members"].widget.attrs["hidden"] = "hidden"
members = GroupMembersField(queryset=User.objects.all()) members = TeamMembersField(queryset=User.objects.all())

View file

@ -1,27 +1,62 @@
# Generated by Django 5.1 on 2024-08-15 11:33 # Generated by Django 5.1 on 2024-08-15 11:33
import django.db.models.deletion import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
import umap.models import umap.models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [("umap", "0021_remove_map_description")]
("auth", "0012_alter_user_first_name_max_length"),
("umap", "0021_remove_map_description"),
]
operations = [ operations = [
migrations.CreateModel(
name="Team",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"name",
models.CharField(max_length=200, unique=True, verbose_name="name"),
),
(
"description",
models.TextField(blank=True, null=True, verbose_name="description"),
),
(
"logo_url",
models.URLField(
help_text="URL to an image.",
verbose_name="Logo URL",
blank=True,
null=True,
),
),
(
"users",
models.ManyToManyField(
related_name="teams", to=settings.AUTH_USER_MODEL
),
),
],
),
migrations.AddField( migrations.AddField(
model_name="map", model_name="map",
name="group", name="team",
field=models.ForeignKey( field=models.ForeignKey(
blank=True, blank=True,
null=True, null=True,
on_delete=django.db.models.deletion.SET_NULL, on_delete=django.db.models.deletion.SET_NULL,
to="auth.group", to="umap.team",
verbose_name="group", verbose_name="team",
), ),
), ),
migrations.AlterField( migrations.AlterField(

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 Group, User from django.contrib.auth.models import 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,19 +36,9 @@ 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 reverse("group_maps", kwargs={"pk": self.pk})
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():
@ -59,6 +49,32 @@ def get_default_edit_status():
return settings.UMAP_DEFAULT_EDIT_STATUS or Map.OWNER return settings.UMAP_DEFAULT_EDIT_STATUS or Map.OWNER
class Team(models.Model):
name = models.CharField(
max_length=200, verbose_name=_("name"), unique=True, blank=False, null=False
)
description = models.TextField(blank=True, null=True, verbose_name=_("description"))
logo_url = models.URLField(
verbose_name=_("Logo URL"),
help_text=_("URL to an image."),
null=True,
blank=True,
)
users = models.ManyToManyField(User, related_name="teams")
def __unicode__(self):
return self.name
def __str__(self):
return self.name
def get_url(self):
return reverse("team_maps", kwargs={"pk": self.pk})
def get_metadata(self):
return {"id": self.pk, "name": self.name, "url": self.get_url()}
class NamedModel(models.Model): class NamedModel(models.Model):
name = models.CharField(max_length=200, verbose_name=_("name")) name = models.CharField(max_length=200, verbose_name=_("name"))
@ -190,8 +206,8 @@ 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( team = models.ForeignKey(
"auth.Group", Team,
blank=True, blank=True,
null=True, null=True,
verbose_name=_("team"), verbose_name=_("team"),
@ -269,7 +285,7 @@ class Map(NamedModel):
return settings.SITE_URL + path return settings.SITE_URL + path
def get_author(self): def get_author(self):
return self.group or self.owner return self.team or self.owner
def is_owner(self, user=None, request=None): def is_owner(self, user=None, request=None):
if user and self.owner == user: if user and self.owner == user:
@ -301,7 +317,7 @@ class Map(NamedModel):
In owner mode: In owner mode:
- only owner by default (OWNER) - only owner by default (OWNER)
- any editor or group member if mode is COLLABORATORS - any editor or team 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)
@ -318,7 +334,7 @@ class Map(NamedModel):
elif user == self.owner: elif user == self.owner:
can = True can = True
elif self.edit_status == self.COLLABORATORS: elif self.edit_status == self.COLLABORATORS:
if user in self.editors.all() or self.group in user.groups.all(): if user in self.editors.all() or self.team in user.teams.all():
can = True can = True
return can return can
@ -337,7 +353,7 @@ class Map(NamedModel):
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() and self.team not in request.user.teams.all()
) )
return can return can
@ -563,7 +579,7 @@ class DataLayer(NamedModel):
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 user is not None and self.edit_status == self.COLLABORATORS: 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(): if user in self.map.editors.all() or self.map.team in user.teams.all():
can = True can = True
return can return can

View file

@ -345,7 +345,7 @@ input + .error {
input[type="file"] + .error { input[type="file"] + .error {
margin-top: 0; margin-top: 0;
} }
input:invalid { input[value]:invalid {
border-color: red; border-color: red;
background-color: darkred; background-color: darkred;
} }

View file

@ -133,6 +133,8 @@ h2.section {
text-align: center; text-align: center;
} }
h2.tabs { h2.tabs {
display: flex;
justify-content: space-around;
text-transform: uppercase; text-transform: uppercase;
color: var(--color-darkBlue); color: var(--color-darkBlue);
text-align: start; text-align: start;
@ -144,7 +146,6 @@ h2.tabs a {
text-decoration-thickness: 3px; text-decoration-thickness: 3px;
text-decoration-skip-ink: none; text-decoration-skip-ink: none;
text-underline-offset: 7px; text-underline-offset: 7px;
margin-inline-end: 2rem;
display: inline-block; display: inline-block;
} }
h2.tabs a:not(.selected) { h2.tabs a:not(.selected) {

View file

@ -25,7 +25,7 @@ export class MapPermissions {
this.options = Object.assign( this.options = Object.assign(
{ {
owner: null, owner: null,
group: null, team: null,
editors: [], editors: [],
share_status: null, share_status: null,
edit_status: null, edit_status: null,
@ -97,13 +97,13 @@ 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) { if (this.map.options.user?.teams?.length) {
fields.push([ fields.push([
'options.group', 'options.team',
{ {
handler: 'ManageGroup', handler: 'ManageTeam',
label: translate('Attach map to a team'), label: translate('Attach map to a team'),
groups: this.map.options.user.groups, teams: this.map.options.user.teams,
}, },
]) ])
} }
@ -161,7 +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('team', this.options.team?.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,10 +1086,10 @@ L.FormBuilder.ManageEditors = L.FormBuilder.Element.extend({
}, },
}) })
L.FormBuilder.ManageGroup = L.FormBuilder.IntSelect.extend({ L.FormBuilder.ManageTeam = L.FormBuilder.IntSelect.extend({
getOptions: function () { getOptions: function () {
return [[null, L._('None')]].concat( return [[null, L._('None')]].concat(
this.options.groups.map((group) => [group.id, group.name]) this.options.teams.map((team) => [team.id, team.name])
) )
}, },
toHTML: function () { toHTML: function () {
@ -1097,8 +1097,8 @@ L.FormBuilder.ManageGroup = L.FormBuilder.IntSelect.extend({
}, },
toJS: function () { toJS: function () {
const value = this.value() const value = this.value()
for (const group of this.options.groups) { for (const team of this.options.teams) {
if (group.id === value) return group if (team.id === value) return team
} }
}, },
}) })

View file

@ -1,22 +0,0 @@
{% extends "umap/content.html" %}
{% load i18n %}
{% block maincontent %}
<div class="col wide">
<h2 class="section">
{% blocktrans %}Browse {{ current_group }}'s maps{% endblocktrans %}
</h2>
</div>
<div class="wrapper">
<div class="map_list row">
{% if maps %}
{% include "umap/map_list.html" %}
{% else %}
<div>
{% blocktrans %}{{ current_group }} has no public maps.{% endblocktrans %}
</div>
{% endif %}
</div>
</div>
{% endblock maincontent %}

View file

@ -1,58 +0,0 @@
{% extends "umap/content.html" %}
{% load i18n %}
{% block maincontent %}
{% include "umap/dashboard_menu.html" with selected="groups" %}
<div class="wrapper">
<div class="row">
{% if form.non_field_errors %}
<ul class="form-errors">
{% for error in form.non_field_errors %}
<li>
{{ error }}
</li>
{% endfor %}
</ul>
{% endif %}
<form id="group_form" method="post">
{% csrf_token %}
{{ form }}
<input type="submit" value="{% trans "Save" %}" />
</form>
{% if group.user_set.count == 1 %}
<a href="{% url 'group_delete' group.pk %}">{% trans "Delete this team" %}</a>
{% endif %}
</div>
</div>
<script type="module" defer>
const form = document.querySelector("#group_form")
const select = form.querySelector('#id_members')
function onSelect({item: {value, label}}) {
const option = document.createElement('option')
option.value = value
option.textContent = label
option.selected = "selected"
select.appendChild(option)
}
function onUnselect({item: {value, label}}) {
const option = select.querySelector(`[value="${value}"]`)
select.removeChild(option)
}
const options = {
className: 'edit-group-members',
on_select: onSelect,
on_unselect: onUnselect,
placeholder: "{% trans "Add user" %}"
}
const autocomplete = new U.AjaxAutocompleteMultiple(form, options)
for (const option of select.options) {
autocomplete.displaySelected({
item: { value: option.value, label: option.textContent },
})
}
const submit = form.querySelector('input[type="submit"]')
// Move it after the autocomplete widget.
form.appendChild(submit)
</script>
{% endblock maincontent %}

View file

@ -9,7 +9,7 @@
{% endif %} {% endif %}
<a {% if selected == "profile" %}class="selected"{% endif %} <a {% if selected == "profile" %}class="selected"{% endif %}
href="{% url 'user_profile' %}">{% trans "My profile" %}</a> href="{% url 'user_profile' %}">{% trans "My profile" %}</a>
<a {% if selected == "groups" %}class="selected"{% endif %} <a {% if selected == "teams" %}class="selected"{% endif %}
href="{% url 'user_groups' %}">{% trans "My teams" %}</a> href="{% url 'user_teams' %}">{% trans "My teams" %}</a>
</h2> </h2>
</div> </div>

View file

@ -3,7 +3,7 @@
{% load i18n %} {% load i18n %}
{% block maincontent %} {% block maincontent %}
{% include "umap/dashboard_menu.html" with selected="groups" %} {% include "umap/dashboard_menu.html" with selected="teams" %}
<div class="wrapper"> <div class="wrapper">
<div class="row"> <div class="row">
<form method="post"> <form method="post">

View file

@ -0,0 +1,30 @@
{% extends "umap/content.html" %}
{% load i18n %}
{% block maincontent %}
<div class="wrapper">
<div class="row">
<div class="col wide">
<h2 class="section">
{% blocktrans %}Browse {{ current_team }}'s maps{% endblocktrans %}
</h2>
{% if current_team.description %}
<p>{{ current_team.description }}</p>
{% endif %}
{% if current_team.logo_url %}
<p><img class="logo" src="{{ current_team.logo_url }}" /></p>
{% endif %}
</div>
</div>
<div class="map_list row">
{% if maps %}
{% include "umap/map_list.html" %}
{% else %}
<div>
{% blocktrans %}{{ current_team }} has no public maps.{% endblocktrans %}
</div>
{% endif %}
</div>
</div>
{% endblock maincontent %}

View file

@ -0,0 +1,60 @@
{% extends "umap/content.html" %}
{% load i18n %}
{% block maincontent %}
{% include "umap/dashboard_menu.html" with selected="teams" %}
<div class="wrapper">
<div class="row">
{% if form.non_field_errors %}
<ul class="form-errors">
{% for error in form.non_field_errors %}
<li>
{{ error }}
</li>
{% endfor %}
</ul>
{% endif %}
<form id="team_form" method="post">
{% csrf_token %}
{{ form }}
<input type="submit" value="{% trans "Save" %}" />
</form>
{% if team.users.count == 1 %}
<a href="{% url 'team_delete' team.pk %}">{% trans "Delete this team" %}</a>
{% endif %}
</div>
</div>
<script type="module" defer>
const form = document.querySelector("#team_form")
const select = form.querySelector('#id_members')
if (select) {
function onSelect({item: {value, label}}) {
const option = document.createElement('option')
option.value = value
option.textContent = label
option.selected = "selected"
select.appendChild(option)
}
function onUnselect({item: {value, label}}) {
const option = select.querySelector(`[value="${value}"]`)
select.removeChild(option)
}
const options = {
className: 'edit-team-members',
on_select: onSelect,
on_unselect: onUnselect,
placeholder: "{% trans "Add user" %}"
}
const autocomplete = new U.AjaxAutocompleteMultiple(form, options)
for (const option of select.options) {
autocomplete.displaySelected({
item: { value: option.value, label: option.textContent },
})
}
const submit = form.querySelector('input[type="submit"]')
// Move it after the autocomplete widget.
form.appendChild(submit)
}
</script>
{% endblock maincontent %}

View file

@ -1,19 +0,0 @@
{% extends "umap/content.html" %}
{% load i18n %}
{% block maincontent %}
{% include "umap/dashboard_menu.html" with selected="groups" %}
<div class="wrapper">
<div class="row">
<ul>
{% for group in groups %}
<li>
<a href="{% url 'group_maps' group.pk %}">{{ group }}</a> <a href="{% url 'group_update' group.pk %}">({% trans "Edit" %})</a>
</li>
{% endfor %}
</ul>
<a href="{% url 'group_new' %}">{% trans "New team" %}</a>
</div>
</div>
{% endblock maincontent %}

View file

@ -0,0 +1,19 @@
{% extends "umap/content.html" %}
{% load i18n %}
{% block maincontent %}
{% include "umap/dashboard_menu.html" with selected="teams" %}
<div class="wrapper">
<div class="row">
<ul>
{% for team in teams %}
<li>
<a href="{% url 'team_maps' team.pk %}">{{ team }}</a> <a href="{% url 'team_update' team.pk %}">({% trans "Edit" %})</a>
</li>
{% endfor %}
</ul>
<a href="{% url 'team_new' %}">{% trans "New team" %}</a>
</div>
</div>
{% endblock maincontent %}

View file

@ -3,12 +3,11 @@ 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
from umap.forms import DEFAULT_CENTER from umap.forms import DEFAULT_CENTER
from umap.models import DataLayer, Licence, Map, TileLayer from umap.models import DataLayer, Licence, Map, TileLayer, Team
User = get_user_model() User = get_user_model()
@ -59,11 +58,11 @@ class UserFactory(factory.django.DjangoModelFactory):
model = User model = User
class GroupFactory(factory.django.DjangoModelFactory): class TeamFactory(factory.django.DjangoModelFactory):
name = "Awesome Group" name = "Awesome Team"
class Meta: class Meta:
model = Group model = Team
class MapFactory(factory.django.DjangoModelFactory): class MapFactory(factory.django.DjangoModelFactory):

View file

@ -9,7 +9,7 @@ from umap.models import Map
from .base import ( from .base import (
DataLayerFactory, DataLayerFactory,
GroupFactory, TeamFactory,
LicenceFactory, LicenceFactory,
MapFactory, MapFactory,
TileLayerFactory, TileLayerFactory,
@ -31,8 +31,8 @@ def pytest_runtest_teardown():
@pytest.fixture @pytest.fixture
def group(): def team():
return GroupFactory() return TeamFactory()
@pytest.fixture @pytest.fixture

View file

@ -36,12 +36,12 @@ def test_caption_should_display_owner_as_author(live_server, page, map):
expect(panel.get_by_text("By Gabriel")).to_be_visible() expect(panel.get_by_text("By Gabriel")).to_be_visible()
def test_caption_should_display_group_as_author(live_server, page, map, group): def test_caption_should_display_team_as_author(live_server, page, map, team):
map.settings["properties"]["onLoadPanel"] = "caption" map.settings["properties"]["onLoadPanel"] = "caption"
map.group = group map.team = team
map.save() map.save()
page.goto(f"{live_server.url}{map.get_absolute_url()}") page.goto(f"{live_server.url}{map.get_absolute_url()}")
panel = page.locator(".panel.left.on") panel = page.locator(".panel.left.on")
expect(panel).to_be_visible() expect(panel).to_be_visible()
expect(panel.get_by_text("By Gabriel")).to_be_hidden() expect(panel.get_by_text("By Gabriel")).to_be_hidden()
expect(panel.get_by_text("By Awesome Group")).to_be_visible() expect(panel.get_by_text("By Awesome Team")).to_be_visible()

View file

@ -243,17 +243,17 @@ def test_can_delete_datalayer(live_server, map, login, datalayer):
expect(layers).to_have_count(0) expect(layers).to_have_count(0)
def test_can_set_group(map, live_server, login, group): def test_can_set_team(map, live_server, login, team):
map.owner.groups.add(group) map.owner.teams.add(team)
map.owner.save() map.owner.save()
page = login(map.owner) page = login(map.owner)
page.goto(f"{live_server.url}{map.get_absolute_url()}?edit") page.goto(f"{live_server.url}{map.get_absolute_url()}?edit")
edit_permissions = page.get_by_title("Update permissions and editors") edit_permissions = page.get_by_title("Update permissions and editors")
edit_permissions.click() edit_permissions.click()
page.locator("select[name=group]").select_option(str(group.pk)) page.locator("select[name=team]").select_option(str(team.pk))
save = page.get_by_role("button", name="Save") save = page.get_by_role("button", name="Save")
expect(save).to_be_visible() expect(save).to_be_visible()
with page.expect_response(re.compile(r".*/update/permissions/.*")): with page.expect_response(re.compile(r".*/update/permissions/.*")):
save.click() save.click()
modified = Map.objects.get(pk=map.pk) modified = Map.objects.get(pk=map.pk)
assert modified.group == group assert modified.team == team

View file

@ -1,15 +1,16 @@
import re import re
import pytest import pytest
from django.contrib.auth.models import Group
from umap.models import Team
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
def test_can_add_user_to_group(live_server, map, user, group, login): def test_can_add_user_to_team(live_server, map, user, team, login):
map.owner.groups.add(group) map.owner.teams.add(team)
map.owner.save() map.owner.save()
assert Group.objects.count() == 1 assert Team.objects.count() == 1
page = login(map.owner) page = login(map.owner)
with page.expect_navigation(): with page.expect_navigation():
page.get_by_role("link", name="My teams").click() page.get_by_role("link", name="My teams").click()
@ -20,19 +21,19 @@ def test_can_add_user_to_group(live_server, map, user, group, login):
page.get_by_placeholder("Add user").press_sequentially("joe") page.get_by_placeholder("Add user").press_sequentially("joe")
page.get_by_text("Joe").click() page.get_by_text("Joe").click()
page.get_by_role("button", name="Save").click() page.get_by_role("button", name="Save").click()
assert Group.objects.count() == 1 assert Team.objects.count() == 1
modified = Group.objects.first() modified = Team.objects.first()
assert user in modified.user_set.all() assert user in modified.users.all()
def test_can_remove_user_from_group(live_server, map, user, user2, group, login): def test_can_remove_user_from_team(live_server, map, user, user2, team, login):
map.owner.groups.add(group) map.owner.teams.add(team)
map.owner.save() map.owner.save()
user.groups.add(group) user.teams.add(team)
user.save() user.save()
user2.groups.add(group) user2.teams.add(team)
user2.save() user2.save()
assert Group.objects.count() == 1 assert Team.objects.count() == 1
page = login(map.owner) page = login(map.owner)
with page.expect_navigation(): with page.expect_navigation():
page.get_by_role("link", name="My teams").click() page.get_by_role("link", name="My teams").click()
@ -40,7 +41,7 @@ def test_can_remove_user_from_group(live_server, map, user, user2, group, login)
page.get_by_role("link", name="Edit").click() page.get_by_role("link", name="Edit").click()
page.locator("li").filter(has_text="Averell").locator(".close").click() page.locator("li").filter(has_text="Averell").locator(".close").click()
page.get_by_role("button", name="Save").click() page.get_by_role("button", name="Save").click()
assert Group.objects.count() == 1 assert Team.objects.count() == 1
modified = Group.objects.first() modified = Team.objects.first()
assert user in modified.user_set.all() assert user in modified.users.all()
assert user2 not in modified.user_set.all() assert user2 not in modified.users.all()

View file

@ -121,11 +121,11 @@ def test_editor_can_edit_in_collaborators_mode(datalayer, user):
assert datalayer.can_edit(user) assert datalayer.can_edit(user)
def test_group_members_can_edit_in_collaborators_mode(datalayer, user, group): def test_team_members_can_edit_in_collaborators_mode(datalayer, user, team):
user.groups.add(group) user.teams.add(team)
user.save() user.save()
map = datalayer.map map = datalayer.map
map.group = group map.team = team
map.save() map.save()
datalayer.edit_status = DataLayer.COLLABORATORS datalayer.edit_status = DataLayer.COLLABORATORS
datalayer.save() datalayer.save()
@ -181,15 +181,15 @@ 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( def test_team_members_cannot_edit_in_inherit_mode_and_map_in_owner_mode(
datalayer, user, group datalayer, user, team
): ):
datalayer.edit_status = DataLayer.INHERIT datalayer.edit_status = DataLayer.INHERIT
datalayer.save() datalayer.save()
user.groups.add(group) user.teams.add(team)
group.save() team.save()
map = datalayer.map map = datalayer.map
map.group = group map.team = team
map.edit_status = Map.OWNER map.edit_status = Map.OWNER
map.save() map.save()
assert not datalayer.can_edit(user) assert not datalayer.can_edit(user)

View file

@ -1,130 +0,0 @@
import pytest
from django.contrib.auth.models import Group
from django.urls import reverse
pytestmark = pytest.mark.django_db
def test_can_see_group_maps(client, map, group):
map.group = group
map.save()
url = reverse("group_maps", args=(group.pk,))
response = client.get(url)
assert response.status_code == 200
assert map.name in response.content.decode()
def test_user_can_see_their_groups(client, group, user):
user.groups.add(group)
user.save()
url = reverse("user_groups")
client.login(username=user.username, password="123123")
response = client.get(url)
assert response.status_code == 200
assert group.name in response.content.decode()
def test_can_create_a_group(client, user):
assert not Group.objects.count()
url = reverse("group_new")
client.login(username=user.username, password="123123")
response = client.post(url, {"name": "my new group", "members": [user.pk]})
assert response.status_code == 302
assert response["Location"] == "/en/me/groups"
assert Group.objects.count() == 1
group = Group.objects.first()
assert group.name == "my new group"
assert group in user.groups.all()
def test_can_edit_a_group_name(client, user, group):
user.groups.add(group)
user.save()
assert Group.objects.count() == 1
url = reverse("group_update", args=(group.pk,))
client.login(username=user.username, password="123123")
response = client.post(url, {"name": "my new group", "members": [user.pk]})
assert response.status_code == 302
assert response["Location"] == "/en/me/groups"
assert Group.objects.count() == 1
modified = Group.objects.first()
assert modified.name == "my new group"
assert modified in user.groups.all()
def test_can_add_user_to_group(client, user, user2, group):
user.groups.add(group)
user.save()
assert Group.objects.count() == 1
url = reverse("group_update", args=(group.pk,))
client.login(username=user.username, password="123123")
response = client.post(url, {"name": group.name, "members": [user.pk, user2.pk]})
assert response.status_code == 302
assert response["Location"] == "/en/me/groups"
assert Group.objects.count() == 1
modified = Group.objects.first()
assert user in modified.user_set.all()
assert user2 in modified.user_set.all()
def test_can_remove_user_from_group(client, user, user2, group):
user.groups.add(group)
user.save()
user2.groups.add(group)
user2.save()
assert Group.objects.count() == 1
url = reverse("group_update", args=(group.pk,))
client.login(username=user.username, password="123123")
response = client.post(url, {"name": group.name, "members": [user.pk]})
assert response.status_code == 302
assert response["Location"] == "/en/me/groups"
assert Group.objects.count() == 1
modified = Group.objects.first()
assert user in modified.user_set.all()
assert user2 not in modified.user_set.all()
def test_cannot_edit_a_group_if_not_member(client, user, user2, group):
user.groups.add(group)
user.save()
assert Group.objects.count() == 1
url = reverse("group_update", args=(group.pk,))
client.login(username=user2.username, password="123123")
response = client.post(url, {"name": "my new group", "members": [user.pk]})
assert response.status_code == 403
def test_can_delete_a_group(client, user, group):
user.groups.add(group)
user.save()
assert Group.objects.count() == 1
url = reverse("group_delete", args=(group.pk,))
client.login(username=user.username, password="123123")
response = client.post(url)
assert response.status_code == 302
assert response["Location"] == "/en/me/groups"
assert Group.objects.count() == 0
def test_cannot_delete_a_group_if_not_member(client, user, user2, group):
user.groups.add(group)
user.save()
assert Group.objects.count() == 1
url = reverse("group_delete", args=(group.pk,))
client.login(username=user2.username, password="123123")
response = client.post(url)
assert response.status_code == 403
assert Group.objects.count() == 1
def test_cannot_delete_a_group_if_more_than_one_member(client, user, user2, group):
user.groups.add(group)
user.save()
user2.groups.add(group)
user2.save()
assert Group.objects.count() == 1
url = reverse("group_delete", args=(group.pk,))
client.login(username=user.username, password="123123")
response = client.post(url)
assert response.status_code == 400
assert Group.objects.count() == 1

View file

@ -50,20 +50,20 @@ def test_editors_can_edit_if_status_collaborators(map, user):
assert map.can_edit(user) assert map.can_edit(user)
def test_group_members_cannot_edit_if_status_owner(map, user, group): def test_team_members_cannot_edit_if_status_owner(map, user, team):
user.groups.add(group) user.teams.add(team)
user.save() user.save()
map.edit_status = map.OWNER map.edit_status = map.OWNER
map.group = group map.team = team
map.save() map.save()
assert not map.can_edit(user) assert not map.can_edit(user)
def test_group_members_can_edit_if_status_collaborators(map, user, group): def test_team_members_can_edit_if_status_collaborators(map, user, team):
user.groups.add(group) user.teams.add(team)
user.save() user.save()
map.edit_status = map.COLLABORATORS map.edit_status = map.COLLABORATORS
map.group = group map.team = team
map.save() map.save()
assert map.can_edit(user) assert map.can_edit(user)
@ -105,12 +105,12 @@ 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): def test_clone_should_keep_team(map, user, team):
map.group = group map.team = team
map.save() map.save()
clone = map.clone() clone = map.clone()
assert map.pk != clone.pk assert map.pk != clone.pk
assert clone.group == group assert clone.team == team
def test_clone_should_update_owner_if_passed(map, user): def test_clone_should_update_owner_if_passed(map, user):

View file

@ -0,0 +1,131 @@
import pytest
from django.urls import reverse
from umap.models import Team
pytestmark = pytest.mark.django_db
def test_can_see_team_maps(client, map, team):
map.team = team
map.save()
url = reverse("team_maps", args=(team.pk,))
response = client.get(url)
assert response.status_code == 200
assert map.name in response.content.decode()
def test_user_can_see_their_teams(client, team, user):
user.teams.add(team)
user.save()
url = reverse("user_teams")
client.login(username=user.username, password="123123")
response = client.get(url)
assert response.status_code == 200
assert team.name in response.content.decode()
def test_can_create_a_team(client, user):
assert not Team.objects.count()
url = reverse("team_new")
client.login(username=user.username, password="123123")
response = client.post(url, {"name": "my new team", "members": [user.pk]})
assert response.status_code == 302
assert response["Location"] == "/en/me/teams"
assert Team.objects.count() == 1
team = Team.objects.first()
assert team.name == "my new team"
assert team in user.teams.all()
def test_can_edit_a_team_name(client, user, team):
user.teams.add(team)
user.save()
assert Team.objects.count() == 1
url = reverse("team_update", args=(team.pk,))
client.login(username=user.username, password="123123")
response = client.post(url, {"name": "my new team", "members": [user.pk]})
assert response.status_code == 302
assert response["Location"] == "/en/me/teams"
assert Team.objects.count() == 1
modified = Team.objects.first()
assert modified.name == "my new team"
assert modified in user.teams.all()
def test_can_add_user_to_team(client, user, user2, team):
user.teams.add(team)
user.save()
assert Team.objects.count() == 1
url = reverse("team_update", args=(team.pk,))
client.login(username=user.username, password="123123")
response = client.post(url, {"name": team.name, "members": [user.pk, user2.pk]})
assert response.status_code == 302
assert response["Location"] == "/en/me/teams"
assert Team.objects.count() == 1
modified = Team.objects.first()
assert user in modified.users.all()
assert user2 in modified.users.all()
def test_can_remove_user_from_team(client, user, user2, team):
user.teams.add(team)
user.save()
user2.teams.add(team)
user2.save()
assert Team.objects.count() == 1
url = reverse("team_update", args=(team.pk,))
client.login(username=user.username, password="123123")
response = client.post(url, {"name": team.name, "members": [user.pk]})
assert response.status_code == 302
assert response["Location"] == "/en/me/teams"
assert Team.objects.count() == 1
modified = Team.objects.first()
assert user in modified.users.all()
assert user2 not in modified.users.all()
def test_cannot_edit_a_team_if_not_member(client, user, user2, team):
user.teams.add(team)
user.save()
assert Team.objects.count() == 1
url = reverse("team_update", args=(team.pk,))
client.login(username=user2.username, password="456456")
response = client.post(url, {"name": "my new team", "members": [user.pk]})
assert response.status_code == 403
def test_can_delete_a_team(client, user, team):
user.teams.add(team)
user.save()
assert Team.objects.count() == 1
url = reverse("team_delete", args=(team.pk,))
client.login(username=user.username, password="123123")
response = client.post(url)
assert response.status_code == 302
assert response["Location"] == "/en/me/teams"
assert Team.objects.count() == 0
def test_cannot_delete_a_team_if_not_member(client, user, user2, team):
user.teams.add(team)
user.save()
assert Team.objects.count() == 1
url = reverse("team_delete", args=(team.pk,))
client.login(username=user2.username, password="456456")
response = client.post(url)
assert response.status_code == 403
assert Team.objects.count() == 1
def test_cannot_delete_a_team_if_more_than_one_member(client, user, user2, team):
user.teams.add(team)
user.save()
user2.teams.add(team)
user2.save()
assert Team.objects.count() == 1
url = reverse("team_delete", args=(team.pk,))
client.login(username=user.username, password="123123")
response = client.post(url)
assert response.status_code == 400
assert Team.objects.count() == 1

View file

@ -289,10 +289,10 @@ def test_user_dashboard_display_user_maps(client, map):
@pytest.mark.django_db @pytest.mark.django_db
def test_user_dashboard_display_user_group_maps(client, map, group, user): def test_user_dashboard_display_user_team_maps(client, map, team, user):
user.groups.add(group) user.teams.add(team)
user.save() user.save()
map.group = group map.team = team
map.save() map.save()
client.login(username=user.username, password="123123") client.login(username=user.username, password="123123")
response = client.get(reverse("user_dashboard")) response = client.get(reverse("user_dashboard"))

View file

@ -15,7 +15,7 @@ from . import views
from .decorators import ( from .decorators import (
can_edit_map, can_edit_map,
can_view_map, can_view_map,
group_members_only, team_members_only,
login_required_if_not_anonymous_allowed, login_required_if_not_anonymous_allowed,
) )
from .utils import decorated_patterns from .utils import decorated_patterns
@ -117,13 +117,13 @@ i18n_urls += decorated_patterns(
path("me", views.user_dashboard, name="user_dashboard"), path("me", views.user_dashboard, name="user_dashboard"),
path("me/profile", views.user_profile, name="user_profile"), path("me/profile", views.user_profile, name="user_profile"),
path("me/download", views.user_download, name="user_download"), path("me/download", views.user_download, name="user_download"),
path("me/groups", views.UserGroups.as_view(), name="user_groups"), path("me/teams", views.UserTeams.as_view(), name="user_teams"),
path("group/create/", views.GroupNew.as_view(), name="group_new"), path("team/create/", views.TeamNew.as_view(), name="team_new"),
) )
i18n_urls += decorated_patterns( i18n_urls += decorated_patterns(
[login_required, group_members_only], [login_required, team_members_only],
path("group/<int:pk>/edit/", views.GroupUpdate.as_view(), name="group_update"), path("team/<int:pk>/edit/", views.TeamUpdate.as_view(), name="team_update"),
path("group/<int:pk>/delete/", views.GroupDelete.as_view(), name="group_delete"), path("team/<int:pk>/delete/", views.TeamDelete.as_view(), name="team_delete"),
) )
map_urls = [ map_urls = [
re_path( re_path(
@ -196,7 +196,7 @@ urlpatterns += i18n_patterns(
path("about/", views.about, name="about"), path("about/", views.about, name="about"),
re_path(r"^user/(?P<identifier>.+)/stars/$", views.user_stars, name="user_stars"), re_path(r"^user/(?P<identifier>.+)/stars/$", views.user_stars, name="user_stars"),
re_path(r"^user/(?P<identifier>.+)/$", views.user_maps, name="user_maps"), re_path(r"^user/(?P<identifier>.+)/$", views.user_maps, name="user_maps"),
path("group/<int:pk>/", views.group_maps, name="group_maps"), path("team/<int:pk>/", views.TeamMaps.as_view(), name="team_maps"),
re_path(r"", include(i18n_urls)), re_path(r"", include(i18n_urls)),
) )
urlpatterns += ( urlpatterns += (

View file

@ -18,7 +18,6 @@ from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth import logout as do_logout from django.contrib.auth import logout as do_logout
from django.contrib.auth.models import Group
from django.contrib.gis.measure import D from django.contrib.gis.measure import D
from django.contrib.postgres.search import SearchQuery, SearchVector from django.contrib.postgres.search import SearchQuery, SearchVector
from django.contrib.staticfiles.storage import staticfiles_storage from django.contrib.staticfiles.storage import staticfiles_storage
@ -61,13 +60,13 @@ from .forms import (
DataLayerForm, DataLayerForm,
DataLayerPermissionsForm, DataLayerPermissionsForm,
FlatErrorList, FlatErrorList,
GroupForm, TeamForm,
MapSettingsForm, MapSettingsForm,
SendLinkForm, SendLinkForm,
UpdateMapPermissionsForm, UpdateMapPermissionsForm,
UserProfileForm, UserProfileForm,
) )
from .models import DataLayer, Licence, Map, Pictogram, Star, TileLayer from .models import DataLayer, Licence, Map, Pictogram, Star, Team, TileLayer
from .utils import ( from .utils import (
ConflictError, ConflictError,
_urls_for_js, _urls_for_js,
@ -190,67 +189,67 @@ class About(Home):
about = About.as_view() about = About.as_view()
class GroupNew(CreateView): class TeamNew(CreateView):
model = Group model = Team
fields = ["name"] fields = ["name", "description", "logo_url"]
success_url = reverse_lazy("user_groups") success_url = reverse_lazy("user_teams")
def form_valid(self, form): def form_valid(self, form):
response = super().form_valid(form) response = super().form_valid(form)
self.request.user.groups.add(self.object) self.request.user.teams.add(self.object)
self.request.user.save() self.request.user.save()
return response return response
class GroupUpdate(UpdateView): class TeamUpdate(UpdateView):
model = Group model = Team
form_class = GroupForm form_class = TeamForm
success_url = reverse_lazy("user_groups") success_url = reverse_lazy("user_teams")
def get_initial(self): def get_initial(self):
initial = super().get_initial() initial = super().get_initial()
initial["members"] = self.object.user_set.all() initial["members"] = self.object.users.all()
return initial return initial
def form_valid(self, form): def form_valid(self, form):
actual = self.object.user_set.all() actual = self.object.users.all()
wanted = form.cleaned_data["members"] wanted = form.cleaned_data["members"]
for user in wanted: for user in wanted:
if user not in actual: if user not in actual:
user.groups.add(self.object) user.teams.add(self.object)
user.save() user.save()
for user in actual: for user in actual:
if user not in wanted: if user not in wanted:
user.groups.remove(self.object) user.teams.remove(self.object)
user.save() user.save()
return super().form_valid(form) return super().form_valid(form)
class GroupDelete(DeleteView): class TeamDelete(DeleteView):
model = Group model = Team
success_url = reverse_lazy("user_groups") success_url = reverse_lazy("user_teams")
def form_valid(self, form): def form_valid(self, form):
if self.object.user_set.count() > 1: if self.object.users.count() > 1:
return HttpResponseBadRequest( return HttpResponseBadRequest(
_("Cannot delete a group with more than one member") _("Cannot delete a team with more than one member")
) )
messages.info( messages.info(
self.request, self.request,
_("Group%(name)s” has been deleted") % {"name": self.object.name}, _("Team%(name)s” has been deleted") % {"name": self.object.name},
) )
return super().form_valid(form) return super().form_valid(form)
class UserGroups(DetailView): class UserTeams(DetailView):
model = User model = User
template_name = "umap/user_groups.html" template_name = "umap/user_teams.html"
def get_object(self): def get_object(self):
return self.get_queryset().get(pk=self.request.user.pk) return self.get_queryset().get(pk=self.request.user.pk)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs.update({"groups": self.object.groups.all()}) kwargs.update({"teams": self.object.teams.all()})
return super().get_context_data(**kwargs) return super().get_context_data(**kwargs)
@ -313,13 +312,13 @@ class UserStars(UserMaps):
user_stars = UserStars.as_view() user_stars = UserStars.as_view()
class GroupMaps(PaginatorMixin, DetailView): class TeamMaps(PaginatorMixin, DetailView):
model = Group model = Team
list_template_name = "umap/map_list.html" list_template_name = "umap/map_list.html"
context_object_name = "current_group" context_object_name = "current_team"
def get_maps(self): def get_maps(self):
return Map.public.filter(group=self.object).order_by("-modified_at") return Map.public.filter(team=self.object).order_by("-modified_at")
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs.update( kwargs.update(
@ -328,9 +327,6 @@ class GroupMaps(PaginatorMixin, DetailView):
return super().get_context_data(**kwargs) return super().get_context_data(**kwargs)
group_maps = GroupMaps.as_view()
class SearchMixin: class SearchMixin:
def get_search_queryset(self, **kwargs): def get_search_queryset(self, **kwargs):
q = self.request.GET.get("q") q = self.request.GET.get("q")
@ -377,11 +373,11 @@ class UserDashboard(PaginatorMixin, DetailView, SearchMixin):
def get_maps(self): def get_maps(self):
qs = self.get_search_queryset() or Map.objects.all() qs = self.get_search_queryset() or Map.objects.all()
groups = self.object.groups.all() teams = self.object.teams.all()
qs = ( qs = (
qs.filter(owner=self.object) qs.filter(owner=self.object)
.union(qs.filter(editors=self.object)) .union(qs.filter(editors=self.object))
.union(qs.filter(group__in=groups)) .union(qs.filter(team__in=teams))
) )
return qs.order_by("-modified_at") return qs.order_by("-modified_at")
@ -557,7 +553,7 @@ class SessionMixin:
"id": 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()], "teams": [team.get_metadata() for team in user.teams.all()],
**data, **data,
} }
@ -696,8 +692,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: if self.object.team:
permissions["group"] = self.object.group.get_metadata() permissions["team"] = self.object.team.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