Merge pull request #683 from umap-project/fav
Allow to star maps and retrieve starred maps
25
umap/migrations/0009_star.py
Normal 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')),
|
||||
],
|
||||
),
|
||||
]
|
|
@ -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)
|
||||
|
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
@ -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 |
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 16 KiB |
|
@ -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 |
|
@ -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)
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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;
|
||||
|
|
20
umap/templates/auth/user_stars.html
Normal 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 %}
|
|
@ -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 %}
|
||||
|
|
|
@ -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()
|
||||
|
|
10
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<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)),
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|