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 %} -
+ {% include "umap/dashboard_menu.html" with selected="profile" %}{{ current_team.description }}
+ {% endif %} ++ {% 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" %} + + | +