Merge pull request #683 from umap-project/fav

Allow to star maps and retrieve starred maps
This commit is contained in:
Yohan Boniface 2023-05-15 15:40:21 +02:00 committed by GitHub
commit 317a8ba429
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 246 additions and 33 deletions

View file

@ -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')),
],
),
]

View file

@ -349,3 +349,9 @@ class DataLayer(NamedModel):
self.geojson.storage.delete(path) self.geojson.storage.delete(path)
except FileNotFoundError: except FileNotFoundError:
pass 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)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View file

@ -13,7 +13,7 @@
height="200" height="200"
id="svg2" id="svg2"
version="1.1" 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" sodipodi:docname="24-white.svg"
inkscape:export-filename="/home/ybon/Code/py/umap/umap/static/umap/img/24-white.png" inkscape:export-filename="/home/ybon/Code/py/umap/umap/static/umap/img/24-white.png"
inkscape:export-xdpi="96" inkscape:export-xdpi="96"
@ -27,9 +27,9 @@
borderopacity="1.0" borderopacity="1.0"
inkscape:pageopacity="0.0" inkscape:pageopacity="0.0"
inkscape:pageshadow="2" inkscape:pageshadow="2"
inkscape:zoom="22.4" inkscape:zoom="16"
inkscape:cx="124.98783" inkscape:cx="119.65216"
inkscape:cy="45.00337" inkscape:cy="34.240239"
inkscape:document-units="px" inkscape:document-units="px"
inkscape:current-layer="layer1" inkscape:current-layer="layer1"
showgrid="true" showgrid="true"

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View file

@ -2,22 +2,22 @@
<!-- Created with Inkscape (http://www.inkscape.org/) --> <!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg <svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="200" width="200"
height="200" height="200"
id="svg2" id="svg2"
version="1.1" version="1.1"
inkscape:version="0.92.4 5da689c313, 2019-01-14" inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
sodipodi:docname="24.svg" sodipodi:docname="24.svg"
inkscape:export-filename="/home/ybon/Code/js/Leaflet.Storage/src/img/24.png" inkscape:export-filename="/home/ybon/Code/js/Leaflet.Storage/src/img/24.png"
inkscape:export-xdpi="89.996864" inkscape:export-xdpi="89.996864"
inkscape:export-ydpi="89.996864"> inkscape:export-ydpi="89.996864"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<defs <defs
id="defs4" /> id="defs4" />
<sodipodi:namedview <sodipodi:namedview
@ -27,19 +27,22 @@
borderopacity="1.0" borderopacity="1.0"
inkscape:pageopacity="0.0" inkscape:pageopacity="0.0"
inkscape:pageshadow="2" inkscape:pageshadow="2"
inkscape:zoom="15.839192" inkscape:zoom="3.3180469"
inkscape:cx="101.80916" inkscape:cx="168.92468"
inkscape:cy="48.17346" inkscape:cy="113.47037"
inkscape:document-units="px" inkscape:document-units="px"
inkscape:current-layer="layer1" inkscape:current-layer="layer1"
showgrid="true" showgrid="true"
inkscape:window-width="3840" inkscape:window-width="1920"
inkscape:window-height="2032" inkscape:window-height="1019"
inkscape:window-x="0" inkscape:window-x="0"
inkscape:window-y="54" inkscape:window-y="0"
inkscape:window-maximized="1" inkscape:window-maximized="1"
showguides="true" showguides="true"
inkscape:guide-bbox="true"> inkscape:guide-bbox="true"
inkscape:showpageshadow="2"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1">
<inkscape:grid <inkscape:grid
type="xygrid" type="xygrid"
id="grid3004" id="grid3004"
@ -464,5 +467,21 @@
style="fill:#464646;fill-opacity:1;stroke:none;stroke-width:4.28879309" style="fill:#464646;fill-opacity:1;stroke:none;stroke-width:4.28879309"
id="path4819-7" id="path4819-7"
transform="rotate(-111.82202)" /> transform="rotate(-111.82202)" />
<path
style="fill:#464646;fill-opacity:1;stroke:none;stroke-width:6.97516;stroke-linecap:square;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
inkscape:transform-center-x="-0.0017170804"
inkscape:transform-center-y="-1.1593678"
d="m 130.9794,1042.1863 2.18213,-8.2579 -6.37758,-5.1021 h 8.14486 l 2.85897,-8.6349 2.85166,8.6333 h 8.14451 l -6.38242,5.1066 2.17462,8.2601 -6.79622,-4.7336 z"
id="path4734"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccccccccc" />
<path
style="fill:none;fill-opacity:1;stroke:#464646;stroke-width:1;stroke-linecap:square;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
inkscape:transform-center-x="-0.0017170804"
inkscape:transform-center-y="-1.1593678"
d="m 170.9794,1042.1863 2.18213,-8.2579 -6.37758,-5.1021 h 8.14486 l 2.85897,-8.6349 2.85166,8.6333 h 8.14451 l -6.38242,5.1066 2.17462,8.2601 -6.79623,-4.7336 z"
id="path4734-3"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccccccccc" />
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 36 KiB

View file

@ -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({ L.U.Search = L.PhotonSearch.extend({
initialize: function (map, input, options) { initialize: function (map, input, options) {
L.PhotonSearch.prototype.initialize.call(this, map, input, options) L.PhotonSearch.prototype.initialize.call(this, map, input, options)

View file

@ -1060,6 +1060,10 @@ L.U.FormBuilder = L.FormBuilder.extend({
handler: 'DataLayersControl', handler: 'DataLayersControl',
label: L._('Display the data layers control'), label: L._('Display the data layers control'),
}, },
starControl: {
handler: 'ControlChoice',
label: L._('Display the star map button'),
},
}, },
initialize: function (obj, fields, options) { initialize: function (obj, fields, options) {

View file

@ -60,6 +60,7 @@ L.U.Map.include({
'tilelayers', 'tilelayers',
'editinosm', 'editinosm',
'datalayers', 'datalayers',
'star',
], ],
initialize: function (el, geojson) { initialize: function (el, geojson) {
@ -309,6 +310,7 @@ L.U.Map.include({
this._controls.search = new L.U.SearchControl() this._controls.search = new L.U.SearchControl()
this._controls.embed = new L.Control.Embed(this, this.options.embedOptions) this._controls.embed = new L.Control.Embed(this, this.options.embedOptions)
this._controls.tilelayers = new L.U.TileLayerControl(this) this._controls.tilelayers = new L.U.TileLayerControl(this)
this._controls.star = new L.U.StarControl(this)
this._controls.editinosm = new L.Control.EditInOSM({ this._controls.editinosm = new L.Control.EditInOSM({
position: 'topleft', position: 'topleft',
widgetOptions: { widgetOptions: {
@ -1283,6 +1285,7 @@ L.U.Map.include({
'embedControl', 'embedControl',
'measureControl', 'measureControl',
'tilelayersControl', 'tilelayersControl',
'starControl',
'easing', 'easing',
], ],
@ -1369,6 +1372,28 @@ L.U.Map.include({
return (this.options.umap_id && this.getEditUrl()) || this.getCreateUrl() 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 () { geometry: function () {
/* Return a GeoJSON geometry Object */ /* Return a GeoJSON geometry Object */
var latlng = this.latLng(this.options.center || this.getCenter()) var latlng = this.latLng(this.options.center || this.getCenter())

View file

@ -88,6 +88,12 @@ a.umap-control-less {
background-position: -80px -161px; background-position: -80px -161px;
box-shadow: 0 0 4px 0 black inset; 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 { .leaflet-control-search a {
background-position: -41px -121px; background-position: -41px -121px;
display: block; display: block;

View file

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

View file

@ -8,6 +8,7 @@
<ul> <ul>
{% if user.is_authenticated %} {% if user.is_authenticated %}
<li><a href="{% url 'user_maps' user.username %}">{% trans "My maps" %} ({{ user }})</a></li> <li><a href="{% url 'user_maps' user.username %}">{% trans "My maps" %} ({{ user }})</a></li>
<li><a href="{% url 'user_stars' user.username %}">{% trans "Starred maps" %}</a></li>
{% else %} {% else %}
<li><a href="{% url 'login' %}" class="login">{% trans "Log in" %} / {% trans "Sign in" %}</a></li> <li><a href="{% url 'login' %}" class="login">{% trans "Log in" %} / {% trans "Sign in" %}</a></li>
{% endif %} {% endif %}

View file

@ -5,7 +5,7 @@ from django.contrib.auth import get_user_model
from django.urls import reverse from django.urls import reverse
from django.core.signing import Signer from django.core.signing import Signer
from umap.models import DataLayer, Map from umap.models import DataLayer, Map, Star
from .base import login_required from .base import login_required
@ -539,3 +539,33 @@ def test_search(client, map):
url = reverse("search") url = reverse("search")
response = client.get(url + "?q=Blé") response = client.get(url + "?q=Blé")
assert "Blé dur" in response.content.decode() 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()

View file

@ -4,6 +4,7 @@ from django.conf.urls.i18n import i18n_patterns
from django.conf.urls.static import static from django.conf.urls.static import static
from django.contrib import admin from django.contrib import admin
from django.contrib.auth import views as auth_views 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.contrib.staticfiles.urls import staticfiles_urlpatterns
from django.views.decorators.cache import cache_control, cache_page, never_cache from django.views.decorators.cache import cache_control, cache_page, never_cache
from django.views.decorators.csrf import ensure_csrf_cookie 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], [login_required_if_not_anonymous_allowed, never_cache],
re_path(r"^map/create/$", views.MapCreate.as_view(), name="map_create"), re_path(r"^map/create/$", views.MapCreate.as_view(), name="map_create"),
) )
i18n_urls += decorated_patterns(
[login_required],
re_path(
r'^map/(?P<map_id>[\d]+)/star/$',
views.ToggleMapStarStatus.as_view(),
name='map_star'
),
)
i18n_urls += decorated_patterns( i18n_urls += decorated_patterns(
[map_permissions_check, never_cache], [map_permissions_check, never_cache],
re_path( re_path(
@ -142,6 +151,7 @@ urlpatterns += i18n_patterns(
), ),
re_path(r"^search/$", views.search, name="search"), re_path(r"^search/$", views.search, name="search"),
re_path(r"^about/$", views.about, name="about"), re_path(r"^about/$", views.about, name="about"),
re_path(r"^user/(?P<username>.+)/stars/$", views.user_stars, name='user_stars'),
re_path(r"^user/(?P<username>.+)/$", views.user_maps, name="user_maps"), re_path(r"^user/(?P<username>.+)/$", views.user_maps, name="user_maps"),
re_path(r"", include(i18n_urls)), re_path(r"", include(i18n_urls)),
) )

View file

@ -45,7 +45,7 @@ from .forms import (
MapSettingsForm, MapSettingsForm,
UpdateMapPermissionsForm, 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 from .utils import get_uri_template, gzip_file, is_ajax
try: try:
@ -154,18 +154,26 @@ class UserMaps(DetailView, PaginatorMixin):
list_template_name = "umap/map_list.html" list_template_name = "umap/map_list.html"
context_object_name = "current_user" 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): def get_context_data(self, **kwargs):
owner = self.request.user == self.object kwargs.update({"maps": self.paginate(self.get_maps(), self.per_page)})
manager = Map.objects if owner else Map.public return super().get_context_data(**kwargs)
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)
def get_template_names(self): def get_template_names(self):
""" """
@ -180,6 +188,19 @@ class UserMaps(DetailView, PaginatorMixin):
user_maps = UserMaps.as_view() 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): class Search(TemplateView, PaginatorMixin):
template_name = "umap/search.html" template_name = "umap/search.html"
list_template_name = "umap/map_list.html" list_template_name = "umap/map_list.html"
@ -360,6 +381,7 @@ class MapDetailMixin:
"allowEdit": self.is_edit_allowed(), "allowEdit": self.is_edit_allowed(),
"default_iconUrl": "%sumap/img/marker.png" % settings.STATIC_URL, # noqa "default_iconUrl": "%sumap/img/marker.png" % settings.STATIC_URL, # noqa
"umap_id": self.get_umap_id(), "umap_id": self.get_umap_id(),
'starred': self.is_starred(),
"licences": dict((l.name, l.json) for l in Licence.objects.all()), "licences": dict((l.name, l.json) for l in Licence.objects.all()),
"edit_statuses": [(i, str(label)) for i, label in Map.EDIT_STATUS], "edit_statuses": [(i, str(label)) for i, label in Map.EDIT_STATUS],
"share_statuses": [ "share_statuses": [
@ -404,6 +426,9 @@ class MapDetailMixin:
def get_umap_id(self): def get_umap_id(self):
return None return None
def is_starred(self):
return False
def get_geojson(self): def get_geojson(self):
return { return {
"geometry": { "geometry": {
@ -489,6 +514,12 @@ class MapView(MapDetailMixin, PermissionsMixin, DetailView):
map_settings["properties"]["permissions"] = self.get_permissions() map_settings["properties"]["permissions"] = self.get_permissions()
return map_settings 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): class MapViewGeoJSON(MapView):
def get_canonical_url(self): def get_canonical_url(self):
@ -631,6 +662,20 @@ class MapClone(PermissionsMixin, View):
return response 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): class MapShortUrl(RedirectView):
query_string = True query_string = True
permanent = True permanent = True