(WIP) Allow to star map and retrieve starred maps

This commit is contained in:
Yohan Boniface 2019-04-09 09:42:09 +02:00 committed by Yohan Boniface
parent 70c74455b0
commit 37b4d05da5
15 changed files with 226 additions and 22 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: -122px -162px;
}
.leaflet-control-star.starred a {
background-position: -82px -162px;
}
.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,21 @@ 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

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.MapStar.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(
@ -143,6 +152,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>.+)/$", views.user_maps, name="user_maps"), re_path(r"^user/(?P<username>.+)/$", views.user_maps, name="user_maps"),
re_path(r'^user/(?P<username>[-_\w@]+)/stars/$', views.user_stars, name='user_stars'),
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:
@ -180,6 +180,30 @@ 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_context_data(self, **kwargs):
owner = self.request.user == self.object
manager = Map.objects if owner else Map.public
stars = Star.objects.filter(by=self.object).values("map")
maps = manager.filter(pk__in=stars)
if owner:
per_page = settings.UMAP_MAPS_PER_PAGE_OWNER
limit = 100
else:
per_page = settings.UMAP_MAPS_PER_PAGE
limit = 50
maps = maps.order_by('-modified_at')[:limit]
maps = self.paginate(maps, per_page)
kwargs.update({
"maps": maps
})
return kwargs
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 +384,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 +429,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 +517,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 +665,20 @@ class MapClone(PermissionsMixin, View):
return response return response
class MapStar(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(map=map_inst, by=self.request.user).save()
status = True
return simple_json_response(starred=status)
class MapShortUrl(RedirectView): class MapShortUrl(RedirectView):
query_string = True query_string = True
permanent = True permanent = True