diff --git a/Makefile b/Makefile index 09c76e57..b2245e16 100644 --- a/Makefile +++ b/Makefile @@ -1,2 +1,5 @@ test: py.test + +develop: + python setup.py develop diff --git a/requirements.txt b/requirements.txt index 9c62a3eb..f75f92f8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,7 @@ -Django==1.11 +Django==2.0.5 django-compressor==2.1.1 -django-leaflet-storage==0.8.2 -Pillow==4.1.0 -psycopg2==2.7.1 -requests==2.13.0 -social-auth-app-django==1.1.0 +Pillow==5.1.0 +psycopg2==2.7.4 +requests==2.18.4 +social-auth-app-django==2.1.0 social-auth-core==1.7.0 diff --git a/umap/admin.py b/umap/admin.py new file mode 100644 index 00000000..74193e3b --- /dev/null +++ b/umap/admin.py @@ -0,0 +1,13 @@ +from django.contrib.gis import admin +from .models import Map, DataLayer, Pictogram, TileLayer, Licence + + +class TileLayerAdmin(admin.ModelAdmin): + list_display = ('name', 'rank', ) + list_editable = ('rank', ) + +admin.site.register(Map, admin.OSMGeoAdmin) +admin.site.register(DataLayer) +admin.site.register(Pictogram) +admin.site.register(TileLayer, TileLayerAdmin) +admin.site.register(Licence) diff --git a/umap/decorators.py b/umap/decorators.py new file mode 100644 index 00000000..a6f27134 --- /dev/null +++ b/umap/decorators.py @@ -0,0 +1,57 @@ +from functools import wraps + +from django.urls import reverse_lazy +from django.shortcuts import get_object_or_404 +from django.http import HttpResponseForbidden +from django.conf import settings + +from .views import simple_json_response +from .models import Map + + +LOGIN_URL = getattr(settings, "LOGIN_URL", "login") +LOGIN_URL = (reverse_lazy(LOGIN_URL) if not LOGIN_URL.startswith("/") + else LOGIN_URL) + + +def login_required_if_not_anonymous_allowed(view_func): + @wraps(view_func) + def wrapper(request, *args, **kwargs): + if (not getattr(settings, "LEAFLET_STORAGE_ALLOW_ANONYMOUS", False) + and not request.user.is_authenticated): + return simple_json_response(login_required=str(LOGIN_URL)) + return view_func(request, *args, **kwargs) + return wrapper + + +def map_permissions_check(view_func): + """ + Used for URLs dealing with the map. + """ + @wraps(view_func) + def wrapper(request, *args, **kwargs): + map_inst = get_object_or_404(Map, pk=kwargs['map_id']) + user = request.user + kwargs['map_inst'] = map_inst # Avoid rerequesting the map in the view + if map_inst.edit_status >= map_inst.EDITORS: + can_edit = map_inst.can_edit(user=user, request=request) + if not can_edit: + if map_inst.owner and not user.is_authenticated: + return simple_json_response(login_required=str(LOGIN_URL)) + else: + return HttpResponseForbidden('Action not allowed for user.') + return view_func(request, *args, **kwargs) + return wrapper + + +def jsonize_view(view_func): + @wraps(view_func) + def wrapper(request, *args, **kwargs): + response = view_func(request, *args, **kwargs) + response_kwargs = {} + if hasattr(response, 'rendered_content'): + response_kwargs['html'] = response.rendered_content + if response.has_header('location'): + response_kwargs['redirect'] = response['location'] + return simple_json_response(**response_kwargs) + return wrapper diff --git a/umap/fields.py b/umap/fields.py new file mode 100644 index 00000000..f60a62fa --- /dev/null +++ b/umap/fields.py @@ -0,0 +1,33 @@ +import json + +from django.utils import six +from django.db import models +from django.utils.encoding import smart_text + + +class DictField(models.TextField): + """ + A very simple field to store JSON in db. + """ + + def get_prep_value(self, value): + if not value: + value = {} + if not isinstance(value, six.string_types): + value = json.dumps(value) + return value + + def from_db_value(self, value, expression, connection, context): + return self.to_python(value) + + def to_python(self, value): + if not value: + value = {} + if isinstance(value, six.string_types): + return json.loads(value) + else: + return value + + def value_to_string(self, obj): + """Return value from object converted to string properly""" + return smart_text(self.get_prep_value(self.value_from_object(obj))) diff --git a/umap/forms.py b/umap/forms.py new file mode 100644 index 00000000..136cbba0 --- /dev/null +++ b/umap/forms.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8 -*- + +from django import forms +from django.contrib.gis.geos import Point +from django.contrib.auth import get_user_model +from django.utils.translation import ugettext_lazy as _ +from django.template.defaultfilters import slugify +from django.conf import settings +from django.forms.utils import ErrorList + +from .models import Map, DataLayer + +DEFAULT_LATITUDE = settings.LEAFLET_LATITUDE if hasattr(settings, "LEAFLET_LATITUDE") else 51 +DEFAULT_LONGITUDE = settings.LEAFLET_LONGITUDE if hasattr(settings, "LEAFLET_LONGITUDE") else 2 +DEFAULT_CENTER = Point(DEFAULT_LONGITUDE, DEFAULT_LATITUDE) + +User = get_user_model() + + +class FlatErrorList(ErrorList): + def __unicode__(self): + return self.flat() + + def flat(self): + if not self: + return u'' + return u' — '.join([e for e in self]) + + +class UpdateMapPermissionsForm(forms.ModelForm): + owner = forms.ModelChoiceField(User.objects, required=True) + + class Meta: + model = Map + fields = ('edit_status', 'editors', 'share_status', 'owner') + + +class AnonymousMapPermissionsForm(forms.ModelForm): + + def __init__(self, *args, **kwargs): + super(AnonymousMapPermissionsForm, self).__init__(*args, **kwargs) + full_secret_link = "%s%s" % (settings.SITE_URL, self.instance.get_anonymous_edit_url()) + help_text = _('Secret edit link is %s') % full_secret_link + self.fields['edit_status'].help_text = _(help_text) + + STATUS = ( + (Map.ANONYMOUS, _('Everyone can edit')), + (Map.OWNER, _('Only editable with secret edit link')) + ) + + edit_status = forms.ChoiceField(choices=STATUS) + + class Meta: + model = Map + fields = ('edit_status', ) + + +class DataLayerForm(forms.ModelForm): + + class Meta: + model = DataLayer + fields = ('geojson', 'name', 'display_on_load', 'rank') + + +class MapSettingsForm(forms.ModelForm): + + def __init__(self, *args, **kwargs): + super(MapSettingsForm, self).__init__(*args, **kwargs) + self.fields["slug"].required = False + + def clean_slug(self): + slug = self.cleaned_data.get('slug', None) + name = self.cleaned_data.get('name', None) + if not slug and name: + # If name is empty, don't do nothing, validation will raise + # later on the process because name is required + self.cleaned_data['slug'] = slugify(name) or "map" + return self.cleaned_data['slug'][:50] + else: + return "" + + def clean_center(self): + if not self.cleaned_data['center']: + point = DEFAULT_CENTER + self.cleaned_data['center'] = point + return self.cleaned_data['center'] + + class Meta: + fields = ('settings', 'name', 'center', 'slug') + model = Map diff --git a/umap/management/commands/anonymous_edit_url.py b/umap/management/commands/anonymous_edit_url.py new file mode 100644 index 00000000..f23d0f56 --- /dev/null +++ b/umap/management/commands/anonymous_edit_url.py @@ -0,0 +1,28 @@ +import sys + +from django.core.management.base import BaseCommand +from django.conf import settings + +from umap.models import Map + + +class Command(BaseCommand): + help = ('Retrieve anonymous edit url of a map. ' + 'Eg.: python manage.py anonymous_edit_url 1234') + + def add_arguments(self, parser): + parser.add_argument('pk', help='PK of the map to retrieve.') + + def abort(self, msg): + self.stderr.write(msg) + sys.exit(1) + + def handle(self, *args, **options): + pk = options['pk'] + try: + map_ = Map.objects.get(pk=pk) + except Map.DoesNotExist: + self.abort('Map with pk {} not found'.format(pk)) + if map_.owner: + self.abort('Map is not anonymous (owner: {})'.format(map_.owner)) + print(settings.SITE_URL + map_.get_anonymous_edit_url()) diff --git a/umap/management/commands/generate_js_locale.py b/umap/management/commands/generate_js_locale.py new file mode 100644 index 00000000..533035c0 --- /dev/null +++ b/umap/management/commands/generate_js_locale.py @@ -0,0 +1,42 @@ +import io +import os + +from django.core.management.base import BaseCommand +from django.conf import settings +from django.contrib.staticfiles import finders +from django.template.loader import render_to_string +from django.utils.translation import to_locale + + +class Command(BaseCommand): + + def handle(self, *args, **options): + self.verbosity = options['verbosity'] + for code, name in settings.LANGUAGES: + code = to_locale(code) + if self.verbosity > 0: + print("Processing", name) + path = finders.find('storage/src/locale/{code}.json'.format( + code=code)) + if not path: + print("No file for", code, "Skipping") + else: + with io.open(path, "r", encoding="utf-8") as f: + if self.verbosity > 1: + print("Found file", path) + self.render(code, f.read()) + + def render(self, code, json): + path = os.path.join( + settings.STATIC_ROOT, + "storage/src/locale/", + "{code}.js".format(code=code) + ) + with io.open(path, "w", encoding="utf-8") as f: + content = render_to_string('umap/locale.js', { + "locale": json, + "locale_code": code + }) + if self.verbosity > 1: + print("Exporting to", path) + f.write(content) diff --git a/umap/management/commands/import_pictograms.py b/umap/management/commands/import_pictograms.py index 11cae597..fe3b05c0 100644 --- a/umap/management/commands/import_pictograms.py +++ b/umap/management/commands/import_pictograms.py @@ -3,7 +3,7 @@ import os from django.core.files import File from django.core.management.base import BaseCommand -from leaflet_storage.models import Pictogram +from umap.models import Pictogram class Command(BaseCommand): diff --git a/umap/managers.py b/umap/managers.py new file mode 100644 index 00000000..d1728890 --- /dev/null +++ b/umap/managers.py @@ -0,0 +1,8 @@ +from django.db.models import Manager + + +class PublicManager(Manager): + + def get_queryset(self): + return super(PublicManager, self).get_queryset().filter( + share_status=self.model.PUBLIC) diff --git a/umap/models.py b/umap/models.py new file mode 100644 index 00000000..b2909820 --- /dev/null +++ b/umap/models.py @@ -0,0 +1,356 @@ +# -*- coding: utf-8 -*- + +import os +import time + +from django.contrib.gis.db import models +from django.conf import settings +from django.urls import reverse +from django.utils.translation import ugettext_lazy as _ +from django.core.signing import Signer +from django.contrib import messages +from django.template.defaultfilters import slugify +from django.core.files.base import File + +from .fields import DictField +from .managers import PublicManager + + +class NamedModel(models.Model): + name = models.CharField(max_length=200, verbose_name=_("name")) + + class Meta: + abstract = True + ordering = ('name', ) + + def __unicode__(self): + return self.name + + def __str__(self): + return self.name + + +def get_default_licence(): + """ + Returns a default Licence, creates it if it doesn't exist. + Needed to prevent a licence deletion from deleting all the linked + maps. + """ + return Licence.objects.get_or_create( + # can't use ugettext_lazy for database storage, see #13965 + name=getattr(settings, "LEAFLET_STORAGE_DEFAULT_LICENCE_NAME", + 'No licence set') + )[0] + + +class Licence(NamedModel): + """ + The licence one map is published on. + """ + details = models.URLField( + verbose_name=_('details'), + help_text=_('Link to a page where the licence is detailed.') + ) + + @property + def json(self): + return { + 'name': self.name, + 'url': self.details + } + + +class TileLayer(NamedModel): + url_template = models.CharField( + max_length=200, + help_text=_("URL template using OSM tile format") + ) + minZoom = models.IntegerField(default=0) + maxZoom = models.IntegerField(default=18) + attribution = models.CharField(max_length=300) + rank = models.SmallIntegerField( + blank=True, + null=True, + help_text=_('Order of the tilelayers in the edit box') + ) + # See https://wiki.openstreetmap.org/wiki/TMS#The_Y_coordinate + tms = models.BooleanField(default=False) + + @property + def json(self): + return dict((field.name, getattr(self, field.name)) + for field in self._meta.fields) + + @classmethod + def get_default(cls): + """ + Returns the default tile layer (used for a map when no layer is set). + """ + return cls.objects.order_by('rank')[0] # FIXME, make it administrable + + @classmethod + def get_list(cls, selected=None): + l = [] + for t in cls.objects.all(): + fields = t.json + if selected and selected.pk == t.pk: + fields['selected'] = True + l.append(fields) + return l + + class Meta: + ordering = ('rank', 'name', ) + + +class Map(NamedModel): + """ + A single thematical map. + """ + ANONYMOUS = 1 + EDITORS = 2 + OWNER = 3 + PUBLIC = 1 + OPEN = 2 + PRIVATE = 3 + EDIT_STATUS = ( + (ANONYMOUS, _('Everyone can edit')), + (EDITORS, _('Only editors can edit')), + (OWNER, _('Only owner can edit')), + ) + SHARE_STATUS = ( + (PUBLIC, _('everyone (public)')), + (OPEN, _('anyone with link')), + (PRIVATE, _('editors only')), + ) + slug = models.SlugField(db_index=True) + description = models.TextField(blank=True, null=True, verbose_name=_("description")) + center = models.PointField(geography=True, verbose_name=_("center")) + zoom = models.IntegerField(default=7, verbose_name=_("zoom")) + locate = models.BooleanField(default=False, verbose_name=_("locate"), help_text=_("Locate user on load?")) + licence = models.ForeignKey( + Licence, + help_text=_("Choose the map licence."), + verbose_name=_('licence'), + on_delete=models.SET_DEFAULT, + default=get_default_licence + ) + modified_at = models.DateTimeField(auto_now=True) + tilelayer = models.ForeignKey(TileLayer, blank=True, null=True, related_name="maps", verbose_name=_("background"), on_delete=models.PROTECT) + owner = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True, related_name="owned_maps", verbose_name=_("owner"), on_delete=models.PROTECT) + editors = models.ManyToManyField(settings.AUTH_USER_MODEL, blank=True, verbose_name=_("editors")) + edit_status = models.SmallIntegerField(choices=EDIT_STATUS, default=OWNER, verbose_name=_("edit status")) + share_status = models.SmallIntegerField(choices=SHARE_STATUS, default=PUBLIC, verbose_name=_("share status")) + settings = DictField(blank=True, null=True, verbose_name=_("settings")) + + objects = models.Manager() + public = PublicManager() + + def get_absolute_url(self): + return reverse("map", kwargs={'slug': self.slug or "map", 'pk': self.pk}) + + def get_anonymous_edit_url(self): + signer = Signer() + signature = signer.sign(self.pk) + return reverse('map_anonymous_edit_url', kwargs={'signature': signature}) + + def is_anonymous_owner(self, request): + if self.owner: + # edit cookies are only valid while map hasn't owner + return False + key, value = self.signed_cookie_elements + try: + has_anonymous_cookie = int(request.get_signed_cookie(key, False)) == value + except ValueError: + has_anonymous_cookie = False + return has_anonymous_cookie + + def can_edit(self, user=None, request=None): + """ + Define if a user can edit or not the instance, according to his account + or the request. + """ + can = False + if request and not self.owner: + if (getattr(settings, "LEAFLET_STORAGE_ALLOW_ANONYMOUS", False) + and self.is_anonymous_owner(request)): + can = True + if user and user.is_authenticated: + # if user is authenticated, attach as owner + self.owner = user + self.save() + msg = _("Your anonymous map has been attached to your account %s" % user) + messages.info(request, msg) + if self.edit_status == self.ANONYMOUS: + can = True + elif not user.is_authenticated: + pass + elif user == self.owner: + can = True + elif self.edit_status == self.EDITORS and user in self.editors.all(): + can = True + return can + + def can_view(self, request): + if self.owner is None: + can = True + elif self.share_status in [self.PUBLIC, self.OPEN]: + can = True + elif request.user == self.owner: + can = True + else: + can = not (self.share_status == self.PRIVATE and request.user not in self.editors.all()) + return can + + @property + def signed_cookie_elements(self): + return ('anonymous_owner|%s' % self.pk, self.pk) + + def get_tilelayer(self): + return self.tilelayer or TileLayer.get_default() + + def clone(self, **kwargs): + new = self.__class__.objects.get(pk=self.pk) + new.pk = None + new.name = u"%s %s" % (_("Clone of"), self.name) + if "owner" in kwargs: + # can be None in case of anonymous cloning + new.owner = kwargs["owner"] + new.save() + for editor in self.editors.all(): + new.editors.add(editor) + for datalayer in self.datalayer_set.all(): + datalayer.clone(map_inst=new) + return new + + +class Pictogram(NamedModel): + """ + An image added to an icon of the map. + """ + attribution = models.CharField(max_length=300) + pictogram = models.ImageField(upload_to="pictogram") + + @property + def json(self): + return { + "id": self.pk, + "attribution": self.attribution, + "name": self.name, + "src": self.pictogram.url + } + + +# Must be out of Datalayer for Django migration to run, because of python 2 +# serialize limitations. +def upload_to(instance, filename): + if instance.pk: + return instance.upload_to() + name = "%s.geojson" % slugify(instance.name)[:50] or "untitled" + return os.path.join(instance.storage_root(), name) + + +class DataLayer(NamedModel): + """ + Layer to store Features in. + """ + map = models.ForeignKey(Map, on_delete=models.CASCADE) + description = models.TextField( + blank=True, + null=True, + verbose_name=_("description") + ) + geojson = models.FileField(upload_to=upload_to, blank=True, null=True) + display_on_load = models.BooleanField( + default=False, + verbose_name=_("display on load"), + help_text=_("Display this layer on load.") + ) + rank = models.SmallIntegerField(default=0) + + class Meta: + ordering = ('rank',) + + def save(self, force_insert=False, force_update=False, **kwargs): + is_new = not bool(self.pk) + super(DataLayer, self).save(force_insert, force_update, **kwargs) + + if is_new: + force_insert, force_update = False, True + filename = self.upload_to() + old_name = self.geojson.name + new_name = self.geojson.storage.save(filename, self.geojson) + self.geojson.storage.delete(old_name) + self.geojson.name = new_name + super(DataLayer, self).save(force_insert, force_update, **kwargs) + self.purge_old_versions() + + def upload_to(self): + root = self.storage_root() + name = '%s_%s.geojson' % (self.pk, int(time.time() * 1000)) + return os.path.join(root, name) + + def storage_root(self): + path = ["datalayer", str(self.map.pk)[-1]] + if len(str(self.map.pk)) > 1: + path.append(str(self.map.pk)[-2]) + path.append(str(self.map.pk)) + return os.path.join(*path) + + @property + def metadata(self): + return { + "name": self.name, + "id": self.pk, + "displayOnLoad": self.display_on_load + } + + def clone(self, map_inst=None): + new = self.__class__.objects.get(pk=self.pk) + new.pk = None + if map_inst: + new.map = map_inst + new.geojson = File(new.geojson.file.file) + new.save() + return new + + def is_valid_version(self, name): + return name.startswith('%s_' % self.pk) and name.endswith('.geojson') + + def version_metadata(self, name): + els = name.split('.')[0].split('_') + return { + "name": name, + "at": els[1], + "size": self.geojson.storage.size(self.get_version_path(name)) + } + + def get_versions(self): + root = self.storage_root() + names = self.geojson.storage.listdir(root)[1] + names = [name for name in names if self.is_valid_version(name)] + names.sort(reverse=True) # Recent first. + return names + + @property + def versions(self): + names = self.get_versions() + return [self.version_metadata(name) for name in names] + + def get_version(self, name): + path = self.get_version_path(name) + with self.geojson.storage.open(path, 'r') as f: + return f.read() + + def get_version_path(self, name): + return '{root}/{name}'.format(root=self.storage_root(), name=name) + + def purge_old_versions(self): + root = self.storage_root() + names = self.get_versions()[settings.LEAFLET_STORAGE_KEEP_VERSIONS:] + for name in names: + for ext in ['', '.gz']: + path = os.path.join(root, name + ext) + try: + self.geojson.storage.delete(path) + except FileNotFoundError: + pass diff --git a/umap/settings/base.py b/umap/settings/base.py index 9c9c1166..05cdc25a 100644 --- a/umap/settings/base.py +++ b/umap/settings/base.py @@ -132,7 +132,7 @@ TEMPLATES = [ # Middleware # ============================================================================= -MIDDLEWARE_CLASSES = ( +MIDDLEWARE = ( 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.locale.LocaleMiddleware', 'django.middleware.common.CommonMiddleware', @@ -141,6 +141,7 @@ MIDDLEWARE_CLASSES = ( 'django.contrib.messages.middleware.MessageMiddleware', ) + # ============================================================================= # Auth / security # ============================================================================= diff --git a/umap/templates/auth/user_detail.html b/umap/templates/auth/user_detail.html index 51374464..363b1529 100644 --- a/umap/templates/auth/user_detail.html +++ b/umap/templates/auth/user_detail.html @@ -9,7 +9,7 @@