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)
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)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View file

@ -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"

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/) -->
<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"
height="200"
id="svg2"
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"
inkscape:export-filename="/home/ybon/Code/js/Leaflet.Storage/src/img/24.png"
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
id="defs4" />
<sodipodi:namedview
@ -27,19 +27,22 @@
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="15.839192"
inkscape:cx="101.80916"
inkscape:cy="48.17346"
inkscape:zoom="3.3180469"
inkscape:cx="168.92468"
inkscape:cy="113.47037"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="true"
inkscape:window-width="3840"
inkscape:window-height="2032"
inkscape:window-width="1920"
inkscape:window-height="1019"
inkscape:window-x="0"
inkscape:window-y="54"
inkscape:window-y="0"
inkscape:window-maximized="1"
showguides="true"
inkscape:guide-bbox="true">
inkscape:guide-bbox="true"
inkscape:showpageshadow="2"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1">
<inkscape:grid
type="xygrid"
id="grid3004"
@ -464,5 +467,21 @@
style="fill:#464646;fill-opacity:1;stroke:none;stroke-width:4.28879309"
id="path4819-7"
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>
</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({
initialize: function (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',
label: L._('Display the data layers control'),
},
starControl: {
handler: 'ControlChoice',
label: L._('Display the star map button'),
},
},
initialize: function (obj, fields, options) {

View file

@ -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())

View file

@ -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;

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>
{% if user.is_authenticated %}
<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 %}
<li><a href="{% url 'login' %}" class="login">{% trans "Log in" %} / {% trans "Sign in" %}</a></li>
{% endif %}

View file

@ -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()

View file

@ -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<map_id>[\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<username>.+)/stars/$", views.user_stars, name='user_stars'),
re_path(r"^user/(?P<username>.+)/$", views.user_maps, name="user_maps"),
re_path(r"", include(i18n_urls)),
)

View file

@ -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