diff --git a/umap/migrations/0009_star.py b/umap/migrations/0009_star.py
new file mode 100644
index 00000000..7f2fec0e
--- /dev/null
+++ b/umap/migrations/0009_star.py
@@ -0,0 +1,25 @@
+# Generated by Django 4.1.7 on 2023-05-05 18:02
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('umap', '0008_alter_map_settings'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Star',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('at', models.DateTimeField(auto_now=True)),
+ ('by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stars', to=settings.AUTH_USER_MODEL)),
+ ('map', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='umap.map')),
+ ],
+ ),
+ ]
diff --git a/umap/models.py b/umap/models.py
index 2e317abc..de3071f8 100644
--- a/umap/models.py
+++ b/umap/models.py
@@ -349,3 +349,9 @@ class DataLayer(NamedModel):
self.geojson.storage.delete(path)
except FileNotFoundError:
pass
+
+
+class Star(models.Model):
+ at = models.DateTimeField(auto_now=True)
+ map = models.ForeignKey(Map, on_delete=models.CASCADE)
+ by = models.ForeignKey(settings.AUTH_USER_MODEL, related_name="stars", on_delete=models.CASCADE)
diff --git a/umap/static/umap/img/24-white.png b/umap/static/umap/img/24-white.png
index 95edf1d6..e3a43024 100644
Binary files a/umap/static/umap/img/24-white.png and b/umap/static/umap/img/24-white.png differ
diff --git a/umap/static/umap/img/24-white.svg b/umap/static/umap/img/24-white.svg
index 28c7fc4d..861e45f5 100644
--- a/umap/static/umap/img/24-white.svg
+++ b/umap/static/umap/img/24-white.svg
@@ -13,7 +13,7 @@
height="200"
id="svg2"
version="1.1"
- inkscape:version="0.92.2 2405546, 2018-03-11"
+ inkscape:version="0.92.4 5da689c313, 2019-01-14"
sodipodi:docname="24-white.svg"
inkscape:export-filename="/home/ybon/Code/py/umap/umap/static/umap/img/24-white.png"
inkscape:export-xdpi="96"
@@ -27,9 +27,9 @@
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
- inkscape:zoom="22.4"
- inkscape:cx="124.98783"
- inkscape:cy="45.00337"
+ inkscape:zoom="16"
+ inkscape:cx="119.65216"
+ inkscape:cy="34.240239"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="true"
diff --git a/umap/static/umap/img/24.png b/umap/static/umap/img/24.png
index c4a9bddf..d6c038fb 100644
Binary files a/umap/static/umap/img/24.png and b/umap/static/umap/img/24.png differ
diff --git a/umap/static/umap/img/24.svg b/umap/static/umap/img/24.svg
index 5835faf0..4cf1ac8d 100644
--- a/umap/static/umap/img/24.svg
+++ b/umap/static/umap/img/24.svg
@@ -2,22 +2,22 @@
diff --git a/umap/static/umap/js/umap.controls.js b/umap/static/umap/js/umap.controls.js
index 695b3de9..e9bb9dc5 100644
--- a/umap/static/umap/js/umap.controls.js
+++ b/umap/static/umap/js/umap.controls.js
@@ -1018,6 +1018,28 @@ L.U.AttributionControl = L.Control.Attribution.extend({
},
})
+L.U.StarControl = L.Control.extend({
+ options: {
+ position: 'topleft',
+ },
+
+ onAdd: function (map) {
+ var status = map.options.starred ? ' starred' : ''
+ var container = L.DomUtil.create(
+ 'div',
+ 'leaflet-control-star umap-control' + status
+ ),
+ link = L.DomUtil.create('a', '', container)
+ link.href = '#'
+ link.title = L._('Star this map')
+ L.DomEvent.on(link, 'click', L.DomEvent.stop)
+ .on(link, 'click', map.star, map)
+ .on(link, 'dblclick', L.DomEvent.stopPropagation)
+
+ return container
+ },
+})
+
L.U.Search = L.PhotonSearch.extend({
initialize: function (map, input, options) {
L.PhotonSearch.prototype.initialize.call(this, map, input, options)
diff --git a/umap/static/umap/js/umap.forms.js b/umap/static/umap/js/umap.forms.js
index eef555ce..7e7b7a06 100644
--- a/umap/static/umap/js/umap.forms.js
+++ b/umap/static/umap/js/umap.forms.js
@@ -1060,6 +1060,10 @@ L.U.FormBuilder = L.FormBuilder.extend({
handler: 'DataLayersControl',
label: L._('Display the data layers control'),
},
+ starControl: {
+ handler: 'ControlChoice',
+ label: L._('Display the star map button'),
+ },
},
initialize: function (obj, fields, options) {
diff --git a/umap/static/umap/js/umap.js b/umap/static/umap/js/umap.js
index d9479f0e..b5a520e6 100644
--- a/umap/static/umap/js/umap.js
+++ b/umap/static/umap/js/umap.js
@@ -60,6 +60,7 @@ L.U.Map.include({
'tilelayers',
'editinosm',
'datalayers',
+ 'star',
],
initialize: function (el, geojson) {
@@ -309,6 +310,7 @@ L.U.Map.include({
this._controls.search = new L.U.SearchControl()
this._controls.embed = new L.Control.Embed(this, this.options.embedOptions)
this._controls.tilelayers = new L.U.TileLayerControl(this)
+ this._controls.star = new L.U.StarControl(this)
this._controls.editinosm = new L.Control.EditInOSM({
position: 'topleft',
widgetOptions: {
@@ -1283,6 +1285,7 @@ L.U.Map.include({
'embedControl',
'measureControl',
'tilelayersControl',
+ 'starControl',
'easing',
],
@@ -1369,6 +1372,28 @@ L.U.Map.include({
return (this.options.umap_id && this.getEditUrl()) || this.getCreateUrl()
},
+ star: function () {
+ if (!this.options.umap_id)
+ return this.ui.alert({
+ content: L._('Please save the map first'),
+ level: 'error',
+ })
+ let url = L.Util.template(this.options.urls.map_star, {
+ map_id: this.options.umap_id,
+ })
+ this.post(url, {
+ context: this,
+ callback: function (data) {
+ this.options.starred = data.starred
+ let msg = data.starred
+ ? L._('Map has been starred')
+ : L._('Map has been unstarred')
+ this.ui.alert({ content: msg, level: 'info' })
+ this.renderControls()
+ },
+ })
+ },
+
geometry: function () {
/* Return a GeoJSON geometry Object */
var latlng = this.latLng(this.options.center || this.getCenter())
diff --git a/umap/static/umap/map.css b/umap/static/umap/map.css
index 91371c83..525027d8 100644
--- a/umap/static/umap/map.css
+++ b/umap/static/umap/map.css
@@ -88,6 +88,12 @@ a.umap-control-less {
background-position: -80px -161px;
box-shadow: 0 0 4px 0 black inset;
}
+.leaflet-control-star a {
+ background-position: -118px -160px;
+}
+.leaflet-control-star.starred a {
+ background-position: -158px -160px;
+}
.leaflet-control-search a {
background-position: -41px -121px;
display: block;
diff --git a/umap/templates/auth/user_stars.html b/umap/templates/auth/user_stars.html
new file mode 100644
index 00000000..47c3e569
--- /dev/null
+++ b/umap/templates/auth/user_stars.html
@@ -0,0 +1,20 @@
+{% extends "umap/content.html" %}
+
+{% load i18n %}
+
+{% block maincontent %}
+
+
{% blocktrans %}Browse {{ current_user }}'s starred maps{% endblocktrans %}
+
+
+
+ {% if maps %}
+ {% include "umap/map_list.html" %}
+ {% else %}
+
+ {% blocktrans %}{{ current_user }} has no starred maps yet.{% endblocktrans %}
+
+ {% endif %}
+
+
+{% endblock maincontent %}
diff --git a/umap/templates/umap/navigation.html b/umap/templates/umap/navigation.html
index 3282bc0b..acde349a 100644
--- a/umap/templates/umap/navigation.html
+++ b/umap/templates/umap/navigation.html
@@ -8,6 +8,7 @@
{% if user.is_authenticated %}
- {% trans "My maps" %} ({{ user }})
+ - {% trans "Starred maps" %}
{% else %}
- {% trans "Log in" %} / {% trans "Sign in" %}
{% endif %}
diff --git a/umap/tests/test_map_views.py b/umap/tests/test_map_views.py
index aa237517..7a8f2f49 100644
--- a/umap/tests/test_map_views.py
+++ b/umap/tests/test_map_views.py
@@ -5,7 +5,7 @@ from django.contrib.auth import get_user_model
from django.urls import reverse
from django.core.signing import Signer
-from umap.models import DataLayer, Map
+from umap.models import DataLayer, Map, Star
from .base import login_required
@@ -539,3 +539,33 @@ def test_search(client, map):
url = reverse("search")
response = client.get(url + "?q=Blé")
assert "Blé dur" in response.content.decode()
+
+
+def test_authenticated_user_can_star_map(client, map, user):
+ url = reverse('map_star', args=(map.pk,))
+ client.login(username=user.username, password="123123")
+ assert Star.objects.filter(by=user).count() == 0
+ response = client.post(url)
+ assert response.status_code == 200
+ assert Star.objects.filter(by=user).count() == 1
+
+
+def test_anonymous_cannot_star_map(client, map):
+ url = reverse('map_star', args=(map.pk,))
+ assert Star.objects.count() == 0
+ response = client.post(url)
+ assert response.status_code == 302
+ assert "login" in response["Location"]
+ assert Star.objects.count() == 0
+
+
+def test_user_can_see_their_star(client, map, user):
+ url = reverse('map_star', args=(map.pk,))
+ client.login(username=user.username, password="123123")
+ assert Star.objects.filter(by=user).count() == 0
+ response = client.post(url)
+ assert response.status_code == 200
+ url = reverse('user_stars', args=(user.username,))
+ response = client.get(url)
+ assert response.status_code == 200
+ assert map.name in response.content.decode()
diff --git a/umap/urls.py b/umap/urls.py
index 0d4d3157..1c35bdb7 100644
--- a/umap/urls.py
+++ b/umap/urls.py
@@ -4,6 +4,7 @@ from django.conf.urls.i18n import i18n_patterns
from django.conf.urls.static import static
from django.contrib import admin
from django.contrib.auth import views as auth_views
+from django.contrib.auth.decorators import login_required
from django.contrib.staticfiles.urls import staticfiles_urlpatterns
from django.views.decorators.cache import cache_control, cache_page, never_cache
from django.views.decorators.csrf import ensure_csrf_cookie
@@ -92,6 +93,14 @@ i18n_urls += decorated_patterns(
[login_required_if_not_anonymous_allowed, never_cache],
re_path(r"^map/create/$", views.MapCreate.as_view(), name="map_create"),
)
+i18n_urls += decorated_patterns(
+ [login_required],
+ re_path(
+ r'^map/(?P[\d]+)/star/$',
+ views.ToggleMapStarStatus.as_view(),
+ name='map_star'
+ ),
+)
i18n_urls += decorated_patterns(
[map_permissions_check, never_cache],
re_path(
@@ -142,6 +151,7 @@ urlpatterns += i18n_patterns(
),
re_path(r"^search/$", views.search, name="search"),
re_path(r"^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"),
re_path(r"", include(i18n_urls)),
)
diff --git a/umap/views.py b/umap/views.py
index 3c8f87c7..877e9501 100644
--- a/umap/views.py
+++ b/umap/views.py
@@ -45,7 +45,7 @@ from .forms import (
MapSettingsForm,
UpdateMapPermissionsForm,
)
-from .models import DataLayer, Licence, Map, Pictogram, TileLayer
+from .models import DataLayer, Licence, Map, Pictogram, Star, TileLayer
from .utils import get_uri_template, gzip_file, is_ajax
try:
@@ -154,18 +154,26 @@ class UserMaps(DetailView, PaginatorMixin):
list_template_name = "umap/map_list.html"
context_object_name = "current_user"
+ def is_owner(self):
+ return self.request.user == self.object
+
+ @property
+ def per_page(self):
+ if self.is_owner():
+ return settings.UMAP_MAPS_PER_PAGE_OWNER
+ return settings.UMAP_MAPS_PER_PAGE
+
+ def get_map_queryset(self):
+ return Map.objects if self.is_owner() else Map.public
+
+ def get_maps(self):
+ qs = self.get_map_queryset()
+ qs = qs.filter(Q(owner=self.object) | Q(editors=self.object))
+ return qs.distinct().order_by("-modified_at")
+
def get_context_data(self, **kwargs):
- owner = self.request.user == self.object
- manager = Map.objects if owner else Map.public
- maps = manager.filter(Q(owner=self.object) | Q(editors=self.object))
- if owner:
- per_page = settings.UMAP_MAPS_PER_PAGE_OWNER
- else:
- per_page = settings.UMAP_MAPS_PER_PAGE
- maps = maps.distinct().order_by("-modified_at")
- maps = self.paginate(maps, per_page)
- kwargs.update({"maps": maps})
- return super(UserMaps, self).get_context_data(**kwargs)
+ kwargs.update({"maps": self.paginate(self.get_maps(), self.per_page)})
+ return super().get_context_data(**kwargs)
def get_template_names(self):
"""
@@ -180,6 +188,19 @@ class UserMaps(DetailView, PaginatorMixin):
user_maps = UserMaps.as_view()
+class UserStars(UserMaps):
+ template_name = "auth/user_stars.html"
+
+ def get_maps(self):
+ qs = self.get_map_queryset()
+ stars = Star.objects.filter(by=self.object).values("map")
+ qs = qs.filter(pk__in=stars)
+ return qs.order_by("-modified_at")
+
+
+user_stars = UserStars.as_view()
+
+
class Search(TemplateView, PaginatorMixin):
template_name = "umap/search.html"
list_template_name = "umap/map_list.html"
@@ -360,6 +381,7 @@ class MapDetailMixin:
"allowEdit": self.is_edit_allowed(),
"default_iconUrl": "%sumap/img/marker.png" % settings.STATIC_URL, # noqa
"umap_id": self.get_umap_id(),
+ 'starred': self.is_starred(),
"licences": dict((l.name, l.json) for l in Licence.objects.all()),
"edit_statuses": [(i, str(label)) for i, label in Map.EDIT_STATUS],
"share_statuses": [
@@ -404,6 +426,9 @@ class MapDetailMixin:
def get_umap_id(self):
return None
+ def is_starred(self):
+ return False
+
def get_geojson(self):
return {
"geometry": {
@@ -489,6 +514,12 @@ class MapView(MapDetailMixin, PermissionsMixin, DetailView):
map_settings["properties"]["permissions"] = self.get_permissions()
return map_settings
+ def is_starred(self):
+ user = self.request.user
+ if not user.is_authenticated:
+ return False
+ return Star.objects.filter(by=user, map=self.object).exists()
+
class MapViewGeoJSON(MapView):
def get_canonical_url(self):
@@ -631,6 +662,20 @@ class MapClone(PermissionsMixin, View):
return response
+class ToggleMapStarStatus(View):
+
+ def post(self, *args, **kwargs):
+ map_inst = get_object_or_404(Map, pk=kwargs['map_id'])
+ qs = Star.objects.filter(map=map_inst, by=self.request.user)
+ if qs.exists():
+ qs.delete()
+ status = False
+ else:
+ Star.objects.create(map=map_inst, by=self.request.user)
+ status = True
+ return simple_json_response(starred=status)
+
+
class MapShortUrl(RedirectView):
query_string = True
permanent = True