diff --git a/umap/admin.py b/umap/admin.py index 34ae2321..b92fb8e7 100644 --- a/umap/admin.py +++ b/umap/admin.py @@ -1,6 +1,6 @@ from django.contrib.gis import admin -from .models import DataLayer, Licence, Map, Pictogram, TileLayer +from .models import DataLayer, Licence, Map, Pictogram, Team, TileLayer class TileLayerAdmin(admin.ModelAdmin): @@ -26,8 +26,13 @@ class PictogramAdmin(admin.ModelAdmin): list_filter = ("category",) +class TeamAdmin(admin.ModelAdmin): + filter_horizontal = ("users",) + + admin.site.register(Map, MapAdmin) admin.site.register(DataLayer) admin.site.register(Pictogram, PictogramAdmin) admin.site.register(TileLayer, TileLayerAdmin) admin.site.register(Licence) +admin.site.register(Team, TeamAdmin) diff --git a/umap/decorators.py b/umap/decorators.py index 3ae667c5..42b08d5f 100644 --- a/umap/decorators.py +++ b/umap/decorators.py @@ -5,7 +5,7 @@ from django.http import HttpResponseForbidden from django.shortcuts import get_object_or_404 from django.urls import reverse_lazy -from .models import Map +from .models import Map, Team from .views import simple_json_response LOGIN_URL = getattr(settings, "LOGIN_URL", "login") @@ -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: @@ -60,3 +60,14 @@ def can_view_map(view_func): return view_func(request, *args, **kwargs) return wrapper + + +def team_members_only(view_func): + @wraps(view_func) + def wrapper(request, *args, **kwargs): + team = get_object_or_404(Team, pk=kwargs["pk"]) + if not request.user.is_authenticated or team not in request.user.teams.all(): + return HttpResponseForbidden() + return view_func(request, *args, **kwargs) + + return wrapper diff --git a/umap/forms.py b/umap/forms.py index d1225b22..a6afade7 100644 --- a/umap/forms.py +++ b/umap/forms.py @@ -6,7 +6,7 @@ from django.forms.utils import ErrorList from django.template.defaultfilters import slugify from django.utils.translation import gettext_lazy as _ -from .models import DataLayer, Map +from .models import DataLayer, Map, Team DEFAULT_LATITUDE = ( settings.LEAFLET_LATITUDE if hasattr(settings, "LEAFLET_LATITUDE") else 51 @@ -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", "team") class AnonymousMapPermissionsForm(forms.ModelForm): @@ -110,3 +110,27 @@ class UserProfileForm(forms.ModelForm): class Meta: model = User fields = ("username", "first_name", "last_name") + + +class TeamMembersField(forms.ModelMultipleChoiceField): + def set_choices(self, choices): + iterator = self.iterator(self) + # Override queryset so to expose only selected choices: + # - we don't want a select with 100000 options + # - the select values will be used by the autocomplete widget to display + # already existing members of the team + iterator.queryset = choices + self.choices = iterator + + +class TeamForm(forms.ModelForm): + class Meta: + model = Team + fields = ["name", "description", "members"] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["members"].set_choices(self.initial["members"]) + self.fields["members"].widget.attrs["hidden"] = "hidden" + + members = TeamMembersField(queryset=User.objects.all()) diff --git a/umap/migrations/0022_add_team.py b/umap/migrations/0022_add_team.py new file mode 100644 index 00000000..8037bca3 --- /dev/null +++ b/umap/migrations/0022_add_team.py @@ -0,0 +1,94 @@ +# Generated by Django 5.1 on 2024-08-15 11:33 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + +import umap.models + + +class Migration(migrations.Migration): + dependencies = [("umap", "0021_remove_map_description")] + + 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"), + ), + ( + "users", + models.ManyToManyField( + related_name="teams", to=settings.AUTH_USER_MODEL + ), + ), + ], + ), + migrations.AddField( + model_name="map", + name="team", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="umap.team", + verbose_name="team", + ), + ), + 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..f3b03d56 100644 --- a/umap/models.py +++ b/umap/models.py @@ -49,6 +49,26 @@ def get_default_edit_status(): 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")) + 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): name = models.CharField(max_length=200, verbose_name=_("name")) @@ -137,7 +157,7 @@ class Map(NamedModel): """ ANONYMOUS = 1 - EDITORS = 2 + COLLABORATORS = 2 OWNER = 3 PUBLIC = 1 OPEN = 2 @@ -145,13 +165,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 +200,13 @@ class Map(NamedModel): editors = models.ManyToManyField( settings.AUTH_USER_MODEL, blank=True, verbose_name=_("editors") ) + team = models.ForeignKey( + Team, + blank=True, + null=True, + verbose_name=_("team"), + on_delete=models.SET_NULL, + ) edit_status = models.SmallIntegerField( choices=EDIT_STATUS, default=get_default_edit_status, @@ -251,6 +278,9 @@ class Map(NamedModel): path = reverse("map_anonymous_edit_url", kwargs={"signature": signature}) return settings.SITE_URL + path + def get_author(self): + return self.team or self.owner + def is_owner(self, user=None, request=None): if user and self.owner == user: return True @@ -281,7 +311,7 @@ class Map(NamedModel): In owner mode: - only owner by default (OWNER) - - any editor if mode is EDITORS + - any editor or team member if mode is COLLABORATORS - anyone otherwise (ANONYMOUS) In anonymous owner mode: - only owner (has ownership cookie) by default (OWNER) @@ -297,8 +327,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.team in user.teams.all(): + can = True return can def can_view(self, request): @@ -308,12 +339,15 @@ class Map(NamedModel): can = True elif self.share_status in [self.PUBLIC, self.OPEN]: can = True + elif not request.user.is_authenticated: + 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.team not in request.user.teams.all() ) return can @@ -383,12 +417,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 +572,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.team in user.teams.all(): + can = True return can diff --git a/umap/static/umap/base.css b/umap/static/umap/base.css index 82cba270..f233ff83 100644 --- a/umap/static/umap/base.css +++ b/umap/static/umap/base.css @@ -345,7 +345,7 @@ input + .error { input[type="file"] + .error { margin-top: 0; } -input:invalid { +input[value]:invalid { border-color: red; background-color: darkred; } diff --git a/umap/static/umap/content.css b/umap/static/umap/content.css index 04cc8946..386c8581 100644 --- a/umap/static/umap/content.css +++ b/umap/static/umap/content.css @@ -133,6 +133,8 @@ h2.section { text-align: center; } h2.tabs { + display: flex; + justify-content: space-around; text-transform: uppercase; color: var(--color-darkBlue); text-align: start; @@ -144,7 +146,6 @@ h2.tabs a { text-decoration-thickness: 3px; text-decoration-skip-ink: none; text-underline-offset: 7px; - margin-inline-end: 2rem; display: inline-block; } h2.tabs a:not(.selected) { @@ -193,26 +194,15 @@ input[type="submit"], .button-primary { font-weight: bold; } -.wrapper input[type="submit"]:hover { - background-color: #35537c; -} .wrapper .neutral, .wrapper input[type="submit"].neutral { background-color: var(--button-neutral-background); color: var(--button-neutral-color); } -.wrapper.somber { - background-color: #2E3641; - color: #efefef; - padding-top: 20px; - margin-top: 20px; -} -.wrapper.somber .row { - margin-top: 0; -} .wrapper .button, .wrapper input { height: 56px; line-height: 43px; + font-weight: bold; } /* **************************** */ @@ -273,9 +263,7 @@ ul.umap-autocomplete { border: 1px solid #202425; padding: 7px; color: #eeeeec; -} -.umap-multiresult li + li { - margin-top: 7px; + margin-bottom: 7px; } .umap-singleresult div .close, .umap-multiresult li .close { diff --git a/umap/static/umap/js/modules/autocomplete.js b/umap/static/umap/js/modules/autocomplete.js index e693d6be..1eadd791 100644 --- a/umap/static/umap/js/modules/autocomplete.js +++ b/umap/static/umap/js/modules/autocomplete.js @@ -9,8 +9,8 @@ import { Request, ServerRequest } from './request.js' import { escapeHTML, generateId } from './utils.js' export class BaseAutocomplete { - constructor(el, options) { - this.el = el + constructor(parent, options) { + this.parent = parent this.options = { placeholder: translate('Start typing...'), emptyMessage: translate('No result'), @@ -43,7 +43,7 @@ export class BaseAutocomplete { this.input = DomUtil.element({ tagName: 'input', type: 'text', - parent: this.el, + parent: this.parent, placeholder: this.options.placeholder, autocomplete: 'off', className: this.options.className, diff --git a/umap/static/umap/js/modules/caption.js b/umap/static/umap/js/modules/caption.js index 36c63c4c..5496688b 100644 --- a/umap/static/umap/js/modules/caption.js +++ b/umap/static/umap/js/modules/caption.js @@ -20,7 +20,7 @@ export default class Caption { const container = DomUtil.create('div', 'umap-caption') const hgroup = DomUtil.element({ tagName: 'hgroup', parent: container }) DomUtil.createTitle(hgroup, this.map.options.name, 'icon-caption icon-block') - this.map.permissions.addOwnerLink('h4', hgroup) + this.map.addAuthorLink('h4', hgroup) if (this.map.options.description) { const description = DomUtil.element({ tagName: 'div', diff --git a/umap/static/umap/js/modules/permissions.js b/umap/static/umap/js/modules/permissions.js index 52ae216c..2dde4002 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, + team: 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?.teams?.length) { + fields.push([ + 'options.team', + { + handler: 'ManageTeam', + label: translate('Attach map to a team'), + teams: this.map.options.user.teams, + }, + ]) + } } 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('team', this.options.team?.id || '') formData.append('share_status', this.options.share_status) } const [data, response, error] = await this.map.server.post( @@ -177,23 +189,6 @@ export class MapPermissions { }) } - addOwnerLink(element, container) { - if (this.options.owner?.name && this.options.owner.url) { - const ownerContainer = DomUtil.add( - element, - 'umap-map-owner', - container, - ` ${translate('by')} ` - ) - DomUtil.createLink( - '', - ownerContainer, - this.options.owner.name, - this.options.owner.url - ) - } - } - commit() { this.map.options.permissions = Object.assign( this.map.options.permissions, diff --git a/umap/static/umap/js/umap.forms.js b/umap/static/umap/js/umap.forms.js index cb6760d8..8eb730eb 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.ManageTeam = L.FormBuilder.IntSelect.extend({ + getOptions: function () { + return [[null, L._('None')]].concat( + this.options.teams.map((team) => [team.id, team.name]) + ) + }, + toHTML: function () { + return this.get()?.id + }, + toJS: function () { + const value = this.value() + for (const team of this.options.teams) { + if (team.id === value) return team + } + }, +}) + U.FormBuilder = L.FormBuilder.extend({ options: { className: 'umap-form', diff --git a/umap/static/umap/js/umap.js b/umap/static/umap/js/umap.js index 0e060182..3493e082 100644 --- a/umap/static/umap/js/umap.js +++ b/umap/static/umap/js/umap.js @@ -1582,7 +1582,7 @@ U.Map = L.Map.extend({ ) const name = L.DomUtil.create('h3', '', container) L.DomEvent.disableClickPropagation(container) - this.permissions.addOwnerLink('span', container) + this.addAuthorLink('span', container) if (this.getOption('captionMenus')) { L.DomUtil.createButton( 'umap-about-link flat', @@ -1887,4 +1887,21 @@ U.Map = L.Map.extend({ .filter((val, idx, arr) => arr.indexOf(val) === idx) .sort(U.Utils.naturalSort) }, + + addAuthorLink: function (element, container) { + if (this.options.author?.name) { + const authorContainer = L.DomUtil.add( + element, + 'umap-map-author', + container, + ` ${L._('by')} ` + ) + L.DomUtil.createLink( + '', + authorContainer, + this.options.author.name, + this.options.author.url + ) + } + }, }) diff --git a/umap/static/umap/map.css b/umap/static/umap/map.css index 6db07c63..8bbb455c 100644 --- a/umap/static/umap/map.css +++ b/umap/static/umap/map.css @@ -922,7 +922,7 @@ a.umap-control-caption, .datalayer-name { cursor: pointer; } -.umap-caption .umap-map-owner { +.umap-caption .umap-map-author { padding-inline-start: 31px; } diff --git a/umap/templates/auth/user_form.html b/umap/templates/auth/user_form.html index bbcc5f7e..534d0f80 100644 --- a/umap/templates/auth/user_form.html +++ b/umap/templates/auth/user_form.html @@ -3,12 +3,7 @@ {% load i18n %} {% block maincontent %} -
-

- {% trans "My Maps" %} - {% trans "My Profile" %} -

-
+ {% include "umap/dashboard_menu.html" with selected="profile" %}
{% if form.non_field_errors %} diff --git a/umap/templates/umap/dashboard_menu.html b/umap/templates/umap/dashboard_menu.html new file mode 100644 index 00000000..ac07420f --- /dev/null +++ b/umap/templates/umap/dashboard_menu.html @@ -0,0 +1,15 @@ +{% load i18n %} + + diff --git a/umap/templates/umap/map_list.html b/umap/templates/umap/map_list.html index 76948266..9e7f7f3e 100644 --- a/umap/templates/umap/map_list.html +++ b/umap/templates/umap/map_list.html @@ -6,9 +6,9 @@ {% map_fragment map_inst prefix=prefix page=request.GET.p %}
{{ map_inst.name }} - {% if map_inst.owner %} - {% trans "by" %} {{ map_inst.owner }} - {% endif %} + {% with author=map_inst.get_author %} + {% trans "by" %} {{ author }} + {% endwith %}
{% endfor %} diff --git a/umap/templates/umap/team_confirm_delete.html b/umap/templates/umap/team_confirm_delete.html new file mode 100644 index 00000000..deceb4b1 --- /dev/null +++ b/umap/templates/umap/team_confirm_delete.html @@ -0,0 +1,19 @@ +{% extends "umap/content.html" %} + +{% load i18n %} + +{% block maincontent %} + {% include "umap/dashboard_menu.html" with selected="teams" %} +
+
+
+ {% csrf_token %} +

+ Are you sure you want to delete "{{ object }}"? +

+ {{ form }} + +
+
+
+{% endblock maincontent %} diff --git a/umap/templates/umap/team_detail.html b/umap/templates/umap/team_detail.html new file mode 100644 index 00000000..14f86ba7 --- /dev/null +++ b/umap/templates/umap/team_detail.html @@ -0,0 +1,27 @@ +{% extends "umap/content.html" %} + +{% load i18n %} + +{% block maincontent %} +
+
+
+

+ {% blocktrans %}Browse {{ current_team }}'s maps{% endblocktrans %} +

+ {% if current_team.description %} +

{{ current_team.description }}

+ {% endif %} +
+
+
+ {% if maps %} + {% include "umap/map_list.html" %} + {% else %} +
+ {% blocktrans %}{{ current_team }} has no public maps.{% endblocktrans %} +
+ {% endif %} +
+
+{% endblock maincontent %} diff --git a/umap/templates/umap/team_form.html b/umap/templates/umap/team_form.html new file mode 100644 index 00000000..a8501ac4 --- /dev/null +++ b/umap/templates/umap/team_form.html @@ -0,0 +1,60 @@ +{% extends "umap/content.html" %} + +{% load i18n %} + +{% block maincontent %} + {% include "umap/dashboard_menu.html" with selected="teams" %} +
+
+ {% if form.non_field_errors %} +
    + {% for error in form.non_field_errors %} +
  • + {{ error }} +
  • + {% endfor %} +
+ {% endif %} +
+ {% csrf_token %} + {{ form }} + +
+ {% if team.users.count == 1 %} + {% trans "Delete this team" %} + {% endif %} +
+
+ +{% endblock maincontent %} diff --git a/umap/templates/umap/user_dashboard.html b/umap/templates/umap/user_dashboard.html index 9459ee66..48cbf408 100644 --- a/umap/templates/umap/user_dashboard.html +++ b/umap/templates/umap/user_dashboard.html @@ -7,13 +7,7 @@ {% endblock head_title %} {% block maincontent %} {% trans "Search my maps" as placeholder %} -
-

- {% blocktranslate with count=maps.paginator.count %}My Maps ({{ count }}){% endblocktranslate %} - - {% trans "My profile" %} -

-
+ {% include "umap/dashboard_menu.html" with selected="maps" %}
diff --git a/umap/templates/umap/user_teams.html b/umap/templates/umap/user_teams.html new file mode 100644 index 00000000..159c2b60 --- /dev/null +++ b/umap/templates/umap/user_teams.html @@ -0,0 +1,51 @@ +{% extends "umap/content.html" %} + +{% load i18n %} + +{% block maincontent %} + {% include "umap/dashboard_menu.html" with selected="teams" %} +
+
+
+ + + + + + + + + + {% for team in teams %} + + + + + + {% endfor %} + +
+ {% blocktrans %}Name{% endblocktrans %} + + {% blocktrans %}Users{% endblocktrans %} + + {% blocktrans %}Actions{% endblocktrans %} +
+ {{ team }} + + {% for user in team.users.all %} + {{ user }}{% if not forloop.last %}, {% endif %} + {% endfor %} + + + + {% translate "Edit" %} + +
+
+ {% trans "New team" %} +
+
+{% endblock maincontent %} diff --git a/umap/tests/base.py b/umap/tests/base.py index 9491dada..12b672fc 100644 --- a/umap/tests/base.py +++ b/umap/tests/base.py @@ -7,7 +7,7 @@ from django.core.files.base import ContentFile from django.urls import reverse from umap.forms import DEFAULT_CENTER -from umap.models import DataLayer, Licence, Map, TileLayer +from umap.models import DataLayer, Licence, Map, Team, TileLayer User = get_user_model() @@ -58,6 +58,13 @@ class UserFactory(factory.django.DjangoModelFactory): model = User +class TeamFactory(factory.django.DjangoModelFactory): + name = "Awesome Team" + + class Meta: + model = Team + + 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..bbf36927 100644 --- a/umap/tests/conftest.py +++ b/umap/tests/conftest.py @@ -11,6 +11,7 @@ from .base import ( DataLayerFactory, LicenceFactory, MapFactory, + TeamFactory, TileLayerFactory, UserFactory, ) @@ -29,6 +30,11 @@ def pytest_runtest_teardown(): cache.clear() +@pytest.fixture +def team(): + return TeamFactory() + + @pytest.fixture def user(): return UserFactory(password="123123") diff --git a/umap/tests/integration/test_caption.py b/umap/tests/integration/test_caption.py index 1458a8a3..f9228fe5 100644 --- a/umap/tests/integration/test_caption.py +++ b/umap/tests/integration/test_caption.py @@ -25,3 +25,23 @@ def test_caption(live_server, page, map): panel.locator(".datalayer-legend .off").get_by_text(non_loaded.name) ).to_be_visible() expect(panel.locator(".datalayer-legend").get_by_text(hidden.name)).to_be_hidden() + + +def test_caption_should_display_owner_as_author(live_server, page, map): + map.settings["properties"]["onLoadPanel"] = "caption" + map.save() + page.goto(f"{live_server.url}{map.get_absolute_url()}") + panel = page.locator(".panel.left.on") + expect(panel).to_be_visible() + expect(panel.get_by_text("By Gabriel")).to_be_visible() + + +def test_caption_should_display_team_as_author(live_server, page, map, team): + map.settings["properties"]["onLoadPanel"] = "caption" + map.team = team + map.save() + page.goto(f"{live_server.url}{map.get_absolute_url()}") + panel = page.locator(".panel.left.on") + expect(panel).to_be_visible() + expect(panel.get_by_text("By Gabriel")).to_be_hidden() + expect(panel.get_by_text("By Awesome Team")).to_be_visible() diff --git a/umap/tests/integration/test_owned_map.py b/umap/tests/integration/test_owned_map.py index bf189641..77518d69 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_team(map, live_server, login, team): + map.owner.teams.add(team) + 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=team]").select_option(str(team.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.team == team diff --git a/umap/tests/integration/test_team.py b/umap/tests/integration/test_team.py new file mode 100644 index 00000000..0a3beea4 --- /dev/null +++ b/umap/tests/integration/test_team.py @@ -0,0 +1,47 @@ +import re + +import pytest + +from umap.models import Team + +pytestmark = pytest.mark.django_db + + +def test_can_add_user_to_team(live_server, map, user, team, login): + map.owner.teams.add(team) + map.owner.save() + assert Team.objects.count() == 1 + page = login(map.owner) + with page.expect_navigation(): + page.get_by_role("link", name="My teams").click() + with page.expect_navigation(): + page.get_by_role("link", name="Edit").click() + page.get_by_placeholder("Add user").click() + with page.expect_response(re.compile(r".*/agnocomplete/.*")): + page.get_by_placeholder("Add user").press_sequentially("joe") + page.get_by_text("Joe").click() + page.get_by_role("button", name="Save").click() + assert Team.objects.count() == 1 + modified = Team.objects.first() + assert user in modified.users.all() + + +def test_can_remove_user_from_team(live_server, map, user, user2, team, login): + map.owner.teams.add(team) + map.owner.save() + user.teams.add(team) + user.save() + user2.teams.add(team) + user2.save() + assert Team.objects.count() == 1 + page = login(map.owner) + with page.expect_navigation(): + page.get_by_role("link", name="My teams").click() + with page.expect_navigation(): + page.get_by_role("link", name="Edit").click() + page.locator("li").filter(has_text="Averell").locator(".close").click() + page.get_by_role("button", name="Save").click() + assert Team.objects.count() == 1 + modified = Team.objects.first() + assert user in modified.users.all() + assert user2 not in modified.users.all() diff --git a/umap/tests/test_datalayer.py b/umap/tests/test_datalayer.py index ee564bc8..99b0f5bb 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_team_members_can_edit_in_collaborators_mode(datalayer, user, team): + user.teams.add(team) + user.save() + map = datalayer.map + map.team = team + 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_team_members_cannot_edit_in_inherit_mode_and_map_in_owner_mode( + datalayer, user, team +): + datalayer.edit_status = DataLayer.INHERIT + datalayer.save() + user.teams.add(team) + team.save() + map = datalayer.map + map.team = team + 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..ff134e4c 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_team_members_cannot_edit_if_status_owner(map, user, team): + user.teams.add(team) + user.save() + map.edit_status = map.OWNER + map.team = team + map.save() + assert not map.can_edit(user) + + +def test_team_members_can_edit_if_status_collaborators(map, user, team): + user.teams.add(team) + user.save() + map.edit_status = map.COLLABORATORS + map.team = team + 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_team(map, user, team): + map.team = team + map.save() + clone = map.clone() + assert map.pk != clone.pk + assert clone.team == team + + 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_team_views.py b/umap/tests/test_team_views.py new file mode 100644 index 00000000..7bce42b0 --- /dev/null +++ b/umap/tests/test_team_views.py @@ -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 diff --git a/umap/tests/test_views.py b/umap/tests/test_views.py index c693cdd2..c6a6dbc3 100644 --- a/umap/tests/test_views.py +++ b/umap/tests/test_views.py @@ -288,6 +288,20 @@ def test_user_dashboard_display_user_maps(client, map): assert "Owner only" in body +@pytest.mark.django_db +def test_user_dashboard_display_user_team_maps(client, map, team, user): + user.teams.add(team) + user.save() + map.team = team + map.save() + client.login(username=user.username, password="123123") + response = client.get(reverse("user_dashboard")) + assert response.status_code == 200 + body = response.content.decode() + assert map.name in body + assert map.get_absolute_url() in body + + @pytest.mark.django_db def test_user_dashboard_display_user_maps_distinct(client, map): # cf https://github.com/umap-project/umap/issues/1325 @@ -474,7 +488,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/urls.py b/umap/urls.py index ecc9353a..df2de682 100644 --- a/umap/urls.py +++ b/umap/urls.py @@ -16,6 +16,7 @@ from .decorators import ( can_edit_map, can_view_map, login_required_if_not_anonymous_allowed, + team_members_only, ) from .utils import decorated_patterns @@ -24,6 +25,10 @@ admin.autodiscover() urlpatterns = [ re_path(r"^admin/", admin.site.urls), re_path("", include("social_django.urls", namespace="social")), + re_path( + r"^agnocomplete/", + include(("agnocomplete.urls", "agnocomplete"), namespace="agnocomplete"), + ), re_path(r"^m/(?P\d+)/$", views.MapShortUrl.as_view(), name="map_short_url"), re_path(r"^ajax-proxy/$", cache_page(180)(views.ajax_proxy), name="ajax-proxy"), re_path( @@ -39,7 +44,6 @@ urlpatterns = [ name="password_change_done", ), re_path(r"^i18n/", include("django.conf.urls.i18n")), - re_path(r"^agnocomplete/", include("agnocomplete.urls")), re_path(r"^map/oembed/", views.MapOEmbed.as_view(), name="map_oembed"), re_path( r"^map/(?P\d+)/download/", @@ -96,12 +100,12 @@ i18n_urls += decorated_patterns( ) i18n_urls += decorated_patterns( [ensure_csrf_cookie], - re_path(r"^map/$", views.MapPreview.as_view(), name="map_preview"), - re_path(r"^map/new/$", views.MapNew.as_view(), name="map_new"), + path("map/", views.MapPreview.as_view(), name="map_preview"), + path("map/new/", views.MapNew.as_view(), name="map_new"), ) i18n_urls += decorated_patterns( [login_required_if_not_anonymous_allowed, never_cache], - re_path(r"^map/create/$", views.MapCreate.as_view(), name="map_create"), + path("map/create/", views.MapCreate.as_view(), name="map_create"), ) i18n_urls += decorated_patterns( [login_required], @@ -110,9 +114,16 @@ i18n_urls += decorated_patterns( views.ToggleMapStarStatus.as_view(), name="map_star", ), - re_path(r"^me$", views.user_dashboard, name="user_dashboard"), - re_path(r"^me/profile$", views.user_profile, name="user_profile"), - re_path(r"^me/download$", views.user_download, name="user_download"), + path("me", views.user_dashboard, name="user_dashboard"), + path("me/profile", views.user_profile, name="user_profile"), + path("me/download", views.user_download, name="user_download"), + path("me/teams", views.UserTeams.as_view(), name="user_teams"), + path("team/create/", views.TeamNew.as_view(), name="team_new"), +) +i18n_urls += decorated_patterns( + [login_required, team_members_only], + path("team//edit/", views.TeamUpdate.as_view(), name="team_update"), + path("team//delete/", views.TeamDelete.as_view(), name="team_delete"), ) map_urls = [ re_path( @@ -179,14 +190,13 @@ datalayer_urls = [ i18n_urls += decorated_patterns([can_edit_map, never_cache], *map_urls) i18n_urls += decorated_patterns([never_cache], *datalayer_urls) urlpatterns += i18n_patterns( - re_path(r"^$", views.home, name="home"), - re_path( - r"^showcase/$", cache_page(24 * 60 * 60)(views.showcase), name="maps_showcase" - ), - re_path(r"^search/$", views.search, name="search"), - re_path(r"^about/$", views.about, name="about"), + path("", views.home, name="home"), + path("showcase/", cache_page(24 * 60 * 60)(views.showcase), name="maps_showcase"), + path("search/", views.search, name="search"), + path("about/", views.about, name="about"), re_path(r"^user/(?P.+)/stars/$", views.user_stars, name="user_stars"), re_path(r"^user/(?P.+)/$", views.user_maps, name="user_maps"), + path("team//", views.TeamMaps.as_view(), name="team_maps"), re_path(r"", include(i18n_urls)), ) urlpatterns += ( diff --git a/umap/views.py b/umap/views.py index 5f976977..b7efd747 100644 --- a/umap/views.py +++ b/umap/views.py @@ -62,10 +62,11 @@ from .forms import ( FlatErrorList, MapSettingsForm, SendLinkForm, + TeamForm, UpdateMapPermissionsForm, UserProfileForm, ) -from .models import DataLayer, Licence, Map, Pictogram, Star, TileLayer +from .models import DataLayer, Licence, Map, Pictogram, Star, Team, TileLayer from .utils import ( ConflictError, _urls_for_js, @@ -188,6 +189,70 @@ class About(Home): about = About.as_view() +class TeamNew(CreateView): + model = Team + fields = ["name", "description"] + success_url = reverse_lazy("user_teams") + + def form_valid(self, form): + response = super().form_valid(form) + self.request.user.teams.add(self.object) + self.request.user.save() + return response + + +class TeamUpdate(UpdateView): + model = Team + form_class = TeamForm + success_url = reverse_lazy("user_teams") + + def get_initial(self): + initial = super().get_initial() + initial["members"] = self.object.users.all() + return initial + + def form_valid(self, form): + actual = self.object.users.all() + wanted = form.cleaned_data["members"] + for user in wanted: + if user not in actual: + user.teams.add(self.object) + user.save() + for user in actual: + if user not in wanted: + user.teams.remove(self.object) + user.save() + return super().form_valid(form) + + +class TeamDelete(DeleteView): + model = Team + success_url = reverse_lazy("user_teams") + + def form_valid(self, form): + if self.object.users.count() > 1: + return HttpResponseBadRequest( + _("Cannot delete a team with more than one member") + ) + messages.info( + self.request, + _("Team ā€œ%(name)sā€ has been deleted") % {"name": self.object.name}, + ) + return super().form_valid(form) + + +class UserTeams(DetailView): + model = User + template_name = "umap/user_teams.html" + + def get_object(self): + return self.get_queryset().get(pk=self.request.user.pk) + + def get_context_data(self, **kwargs): + kwargs.update({"teams": self.object.teams.all()}) + return super().get_context_data(**kwargs) + + class UserProfile(UpdateView): model = User form_class = UserProfileForm @@ -247,6 +312,21 @@ class UserStars(UserMaps): user_stars = UserStars.as_view() +class TeamMaps(PaginatorMixin, DetailView): + model = Team + list_template_name = "umap/map_list.html" + context_object_name = "current_team" + + def get_maps(self): + return Map.public.filter(team=self.object).order_by("-modified_at") + + def get_context_data(self, **kwargs): + kwargs.update( + {"maps": self.paginate(self.get_maps(), settings.UMAP_MAPS_PER_PAGE)} + ) + return super().get_context_data(**kwargs) + + class SearchMixin: def get_search_queryset(self, **kwargs): q = self.request.GET.get("q") @@ -293,7 +373,12 @@ class UserDashboard(PaginatorMixin, DetailView, SearchMixin): def get_maps(self): qs = self.get_search_queryset() or Map.objects.all() - qs = qs.filter(owner=self.object).union(qs.filter(editors=self.object)) + teams = self.object.teams.all() + qs = ( + qs.filter(owner=self.object) + .union(qs.filter(editors=self.object)) + .union(qs.filter(team__in=teams)) + ) return qs.order_by("-modified_at") def get_context_data(self, **kwargs): @@ -459,14 +544,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"), + "teams": [team.get_metadata() for team in user.teams.all()], **data, } @@ -605,6 +692,8 @@ class PermissionsMixin: {"id": editor.pk, "name": str(editor)} for editor in self.object.editors.all() ] + if self.object.team: + permissions["team"] = self.object.team.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 @@ -669,6 +758,12 @@ class MapView(MapDetailMixin, PermissionsMixin, DetailView): map_settings["properties"] = {} map_settings["properties"]["name"] = self.object.name map_settings["properties"]["permissions"] = self.get_permissions() + author = self.object.get_author() + if author: + map_settings["properties"]["author"] = { + "name": str(author), + "url": author.get_url(), + } return map_settings def is_starred(self):