diff --git a/geonode/api/api.py b/geonode/api/api.py index cb5a23bb510..ce92cb50b58 100644 --- a/geonode/api/api.py +++ b/geonode/api/api.py @@ -27,11 +27,18 @@ from django.contrib.contenttypes.models import ContentType from django.conf import settings from django.db.models import Count +from django.http.response import HttpResponse +from django.template.response import TemplateResponse from django.utils.translation import get_language from avatar.templatetags.avatar_tags import avatar_url +from tastypie import http +from tastypie.exceptions import BadRequest + +from geonode import qgis_server, geoserver +from geonode.qgis_server.models import QGISServerStyle from guardian.shortcuts import get_objects_for_user -from tastypie.authorization import Authorization +from tastypie.authorization import DjangoAuthorization from tastypie.bundle import Bundle from geonode.base.models import ResourceBase @@ -40,7 +47,7 @@ from geonode.base.models import HierarchicalKeyword from geonode.base.models import ThesaurusKeywordLabel -from geonode.layers.models import Layer, Style, LayerFile +from geonode.layers.models import Layer, Style from geonode.maps.models import Map from geonode.documents.models import Document from geonode.groups.models import GroupProfile, GroupCategory @@ -48,10 +55,12 @@ from django.core.serializers.json import DjangoJSONEncoder from tastypie.serializers import Serializer from tastypie import fields -from tastypie.resources import ModelResource, Resource +from tastypie.resources import ModelResource from tastypie.constants import ALL, ALL_WITH_RELATIONS from tastypie.utils import trailing_slash +from geonode.utils import check_ogc_backend + FILTER_TYPES = { 'layer': Layer, 'map': Map, @@ -429,146 +438,255 @@ class Meta: serializer = CountJSONSerializer() -class GeoserverStyleResource(ModelResource): - """Styles api for Geoserver backend.""" +class QGISStyleResource(ModelResource): + """Styles API for QGIS Server backend.""" + + body = fields.CharField(attribute='body', use_in='detail') + name = fields.CharField(attribute='name') + title = fields.CharField(attribute='title') + layer = fields.ForeignKey( + 'geonode.api.resourcebase_api.LayerResource', + attribute='layer', + null=True) + style_url = fields.CharField(attribute='style_url') + type = fields.CharField(attribute='type') class Meta: - queryset = Style.objects.all() + queryset = QGISServerStyle.objects.all() resource_name = 'styles' - allowed_methods = ['get', 'post', 'put'] - ordering = ['layername'] + detail_uri_name = 'id' + allowed_methods = ['get', 'post', 'delete'] + authorization = DjangoAuthorization() + filtering = { + 'id': ALL, + 'title': ALL, + 'name': ALL, + 'layer': ALL_WITH_RELATIONS + } + + def populate_object(self, style): + """Populate results with necessary fields + + :param style: Style objects + :type style: QGISServerStyle + :return: + """ + try: + qgis_layer = style.layer_styles.first() + """:type: geonode.qgis_server.QGISServerLayer""" + style.layer = qgis_layer.layer + style.type = 'qml' + except: + pass + return style + + def build_filters(self, filters=None): + """Apply custom filters for layer.""" + filters = super(QGISStyleResource, self).build_filters(filters) + # Convert layer__ filters into layer_styles__layer__ + updated_filters = {} + for key, value in filters.iteritems(): + key = key.replace('layer__', 'layer_styles__layer__') + updated_filters[key] = value + return updated_filters + + def build_bundle( + self, obj=None, data=None, request=None, objects_saved=None): + """Override build_bundle method to add additional info.""" + + if obj is None and self._meta.object_class: + obj = self._meta.object_class() + + elif obj: + obj = self.populate_object(obj) + + return Bundle( + obj=obj, + data=data, + request=request, + objects_saved=objects_saved + ) + + def post_list(self, request, **kwargs): + """Attempt to redirect to QGIS Server Style management. + A post method should have the following field: -class QGISStyleObject(object): + name: Slug name of style + title: Title of style + style: the style file uploaded - def __init__(self, style_id=None, name=None, layer=None, - style_file=None, style_type='qml', style_url=None): - # File name of QML style file - self.name = name - # Related layers - if layer and len(layer) > 0: - self.layer = layer[0] - # File object of the style - self.file = style_file - # LayerFile id of QML style in upload session - self.id = style_id - # Extension type of this style - # Currently only for qml - self.type = style_type - # Url of downloadable QML Style - self.url = style_url + Also, should have kwargs: + layername or layer__name: The layer name associated with the style -class QGISStyleResource(Resource): - """Styles api for QGIS Server backend.""" + or + layer__id: The layer id associated with the style + + """ + from geonode.qgis_server.views import qml_style + + # Extract layer name information + POST = request.POST + FILES = request.FILES + layername = POST.get('layername') or POST.get('layer__name') + if not layername: + layer_id = POST.get('layer__id') + layer = Layer.objects.get(id=layer_id) + layername = layer.name + + # move style file + FILES['qml'] = FILES['style'] + + response = qml_style(request, layername) + + if isinstance(response, TemplateResponse): + if response.status_code == 201: + obj = QGISServerStyle.objects.get( + layer_styles__layer__name=layername, + name=POST['name']) + updated_bundle = self.build_bundle(obj=obj, request=request) + location = self.get_resource_uri(updated_bundle) + + if not self._meta.always_return_data: + return http.HttpCreated(location=location) + else: + updated_bundle = self.full_dehydrate(updated_bundle) + updated_bundle = self.alter_detail_data_to_serialize( + request, updated_bundle) + return self.create_response( + request, updated_bundle, + response_class=http.HttpCreated, + location=location) + else: + context = response.context_data + # Check form valid + style_upload_form = context['style_upload_form'] + if not style_upload_form.is_valid(): + raise BadRequest(style_upload_form.errors.as_text()) + alert_message = context['alert_message'] + raise BadRequest(alert_message) + elif isinstance(response, HttpResponse): + response_class = None + if response.status_code == 403: + response_class = http.HttpForbidden + return self.error_response( + request, response.content, + response_class=response_class) + + def delete_detail(self, request, **kwargs): + """Attempt to redirect to QGIS Server Style management.""" + from geonode.qgis_server.views import qml_style + style_id = kwargs.get('id') + + qgis_style = QGISServerStyle.objects.get(id=style_id) + layername = qgis_style.layer_styles.first().layer.name + + response = qml_style(request, layername, style_name=qgis_style.name) + + if isinstance(response, TemplateResponse): + if response.status_code == 200: + # style deleted + return http.HttpNoContent() + else: + context = response.context_data + # Check form valid + style_upload_form = context['style_upload_form'] + if not style_upload_form.is_valid(): + raise BadRequest(style_upload_form.errors.as_text()) + alert_message = context['alert_message'] + raise BadRequest(alert_message) + elif isinstance(response, HttpResponse): + response_class = None + if response.status_code == 403: + response_class = http.HttpForbidden + return self.error_response( + request, response.content, + response_class=response_class) + + def delete_list(self, request, **kwargs): + """Do not allow delete list""" + return http.HttpForbidden() + + +class GeoserverStyleResource(ModelResource): + """Styles API for Geoserver backend.""" + body = fields.CharField( + attribute='sld_body', + use_in='detail') name = fields.CharField(attribute='name') - layer = fields.ForeignKey( + title = fields.CharField(attribute='sld_title') + # layer_default_style is polymorphic, so it will have many to many + # relation + layer = fields.ManyToManyField( 'geonode.api.resourcebase_api.LayerResource', - attribute='layer') - id = fields.IntegerField(attribute='id') + attribute='layer_default_style', + null=True) + version = fields.CharField( + attribute='sld_version', + null=True, + blank=True) + style_url = fields.CharField(attribute='sld_url') + workspace = fields.CharField(attribute='workspace', null=True) type = fields.CharField(attribute='type') - url = fields.CharField(attribute='url', null=True) - file = fields.FileField(attribute='file') class Meta: + queryset = Style.objects.all() resource_name = 'styles' detail_uri_name = 'id' - object_class = QGISStyleObject - authorization = Authorization() + authorization = DjangoAuthorization() + allowed_methods = ['get'] + filtering = { + 'id': ALL, + 'title': ALL, + 'name': ALL, + 'layer': ALL_WITH_RELATIONS + } - def _build_style_url(self, layerfile): - """Build downloadable url for QML Style. + def build_filters(self, filters=None): + """Apply custom filters for layer.""" + filters = super(GeoserverStyleResource, self).build_filters(filters) + # Convert layer__ filters into layer_styles__layer__ + updated_filters = {} + for key, value in filters.iteritems(): + key = key.replace('layer__', 'layer_default_style__') + updated_filters[key] = value + return updated_filters + + def populate_object(self, style): + """Populate results with necessary fields + + :param style: Style objects + :type style: Style + :return: + """ + style.type = 'sld' + return style - :param layerfile: LayerFile object - :type layerfile: geonode.layers.models.LayerFile + def build_bundle( + self, obj=None, data=None, request=None, objects_saved=None): + """Override build_bundle method to add additional info.""" - :return: url - """ - try: - layer = Layer.objects.get(upload_session=layerfile.upload_session) - layername = layer.name - style_url = reverse( - 'qgis_server:download-qml', - kwargs={'layername': layername}) - except Layer.DoesNotExist: - # There exists a stale layerfile - return None - return style_url - - def _generate_result(self, layerfiles): - results = [ - QGISStyleObject( - layer=lf.upload_session.layer_set.all(), - style_id=lf.id, - name=lf.file.name, - style_file=lf.file, - style_url=self._build_style_url(lf)) for lf in layerfiles - ] - results = [r for r in results if r.url] - return results - - def detail_uri_kwargs(self, bundle_or_obj): - kwargs = {} - - if isinstance(bundle_or_obj, Bundle): - kwargs[self._meta.detail_uri_name] = getattr( - bundle_or_obj.obj, self._meta.detail_uri_name) - else: - kwargs[self._meta.detail_uri_name] = getattr( - bundle_or_obj, self._meta.detail_uri_name) - - return kwargs - - def obj_get_list(self, bundle, **kwargs): - styles = [] - if 'layername' in kwargs: - layername = kwargs.pop('layername') - layer = Layer.objects.get(name=layername) - styles = layer.upload_session.layerfile_set.filter(name='qml') - if not kwargs.items(): - styles = LayerFile.objects.filter(name='qml') - return self._generate_result(styles) - - def obj_get(self, bundle, **kwargs): - styles = [] - if self._meta.detail_uri_name in kwargs: - style_id = kwargs.pop(self._meta.detail_uri_name) - styles = LayerFile.objects.filter(id=style_id) - results = self._generate_result(styles) - return results[0] - - def obj_create(self, bundle, **kwargs): - bundle.obj = QGISStyleObject() - bundle = self.full_hydrate(bundle) - return bundle - - def obj_update(self, bundle, **kwargs): - return self.obj_create(bundle, **kwargs) - - def obj_delete_list(self, bundle, **kwargs): - bucket = self._bucket() - - for key in bucket.get_keys(): - obj = bucket.get(key) - obj.delete() - - def obj_delete(self, bundle, **kwargs): - bucket = self._bucket() - obj = bucket.get(kwargs['pk']) - obj.delete() - - def deserialize(self, request, data, format=None): - if not format: - format = request.Meta.get('CONTENT_TYPE', 'application/json') - if format == 'application/x-www-form-urlencoded': - return request.POST - if format.startswith('multipart'): - data = request.POST.copy() - data.update(request.FILES) - return data - return super(QGISStyleResource, self).deserialize( - request, data, format) - - def rollback(self, bundles): + if obj is None and self._meta.object_class: + obj = self._meta.object_class() + + elif obj: + obj = self.populate_object(obj) + + return Bundle( + obj=obj, + data=data, + request=request, + objects_saved=objects_saved + ) + + +if check_ogc_backend(qgis_server.BACKEND_PACKAGE): + class StyleResource(QGISStyleResource): + """Wrapper for Generic Style Resource""" + pass +elif check_ogc_backend(geoserver.BACKEND_PACKAGE): + class StyleResource(GeoserverStyleResource): + """Wrapper for Generic Style Resource""" pass diff --git a/geonode/api/resourcebase_api.py b/geonode/api/resourcebase_api.py index f6a720e1903..4b3d028e2b3 100644 --- a/geonode/api/resourcebase_api.py +++ b/geonode/api/resourcebase_api.py @@ -17,11 +17,16 @@ # along with this program. If not, see . # ######################################################################### - +import json import re + +from django.core.urlresolvers import resolve from django.db.models import Q from django.http import HttpResponse from django.conf import settings +from django.template.response import TemplateResponse +from tastypie import http +from tastypie.bundle import Bundle from tastypie.constants import ALL, ALL_WITH_RELATIONS from tastypie.resources import ModelResource @@ -38,12 +43,13 @@ from tastypie.utils.mime import build_content_type -from geonode import get_version +from geonode import get_version, qgis_server, geoserver from geonode.layers.models import Layer from geonode.maps.models import Map from geonode.documents.models import Document from geonode.base.models import ResourceBase from geonode.base.models import HierarchicalKeyword +from geonode.utils import check_ogc_backend from .authorization import GeoNodeAuthorization @@ -569,6 +575,31 @@ class Meta(CommonMetaApi): class LayerResource(CommonModelApi): """Layer API""" + links = fields.ListField( + attribute='links', + null=True, + use_in='all', + default=[]) + if check_ogc_backend(qgis_server.BACKEND_PACKAGE): + default_style = fields.ForeignKey( + 'geonode.api.api.StyleResource', + attribute='qgis_default_style', + null=True) + styles = fields.ManyToManyField( + 'geonode.api.api.StyleResource', + attribute='qgis_styles', + null=True, + use_in='detail') + elif check_ogc_backend(geoserver.BACKEND_PACKAGE): + default_style = fields.ForeignKey( + 'geonode.api.api.StyleResource', + attribute='default_style', + null=True) + styles = fields.ManyToManyField( + 'geonode.api.api.StyleResource', + attribute='styles', + null=True, + use_in='detail') def format_objects(self, objects): """ @@ -585,31 +616,138 @@ def format_objects(self, objects): formatted_obj = model_to_dict(obj, fields=values) # add the geogig link formatted_obj['geogig_link'] = obj.geogig_link - # add Link link - link_fields = [ - 'extension', - 'link_type', - 'name', - 'mime', - 'url' - ] - links = [] - for l in obj.link_set.all(): - formatted_link = model_to_dict(l, fields=link_fields) - links.append(formatted_link) - formatted_obj['links'] = links + # provide style information + bundle = self.build_bundle(obj=obj) + formatted_obj['default_style'] = self.default_style.dehydrate( + bundle, for_list=True) + + if self.links.use_in == 'all' or self.links.use_in == 'list': + formatted_obj['links'] = self.dehydrate_links( + bundle) + # Add resource uri + formatted_obj['resource_uri'] = self.get_resource_uri(bundle) # put the object on the response stack formatted_objects.append(formatted_obj) return formatted_objects + def dehydrate_links(self, bundle): + """Dehydrate links field.""" + + dehydrated = [] + obj = bundle.obj + link_fields = [ + 'extension', + 'link_type', + 'name', + 'mime', + 'url' + ] + for l in obj.link_set.all(): + formatted_link = model_to_dict(l, fields=link_fields) + dehydrated.append(formatted_link) + + return dehydrated + + def populate_object(self, obj): + """Populate results with necessary fields + + :param obj: Layer obj + :type obj: Layer + :return: + """ + if check_ogc_backend(qgis_server.BACKEND_PACKAGE): + # Provides custom links for QGIS Server styles info + # Default style + try: + obj.qgis_default_style = obj.qgis_layer.default_style + except: + pass + + # Styles + try: + obj.qgis_styles = obj.qgis_layer.styles + except: + pass + return obj + + def build_bundle( + self, obj=None, data=None, request=None, objects_saved=None): + """Override build_bundle method to add additional info.""" + + if obj is None and self._meta.object_class: + obj = self._meta.object_class() + + elif obj: + obj = self.populate_object(obj) + + return Bundle( + obj=obj, + data=data, + request=request, + objects_saved=objects_saved) + + def patch_detail(self, request, **kwargs): + """Allow patch request to update default_style. + + Request body must match this: + + { + 'default_style': + } + + """ + reason = 'Can only patch "default_style" field.' + try: + body = json.loads(request.body) + if 'default_style' not in body: + return http.HttpBadRequest(reason=reason) + match = resolve(body['default_style']) + style_id = match.kwargs['id'] + api_name = match.kwargs['api_name'] + resource_name = match.kwargs['resource_name'] + if not (resource_name == 'styles' and api_name == 'api'): + raise Exception() + + from geonode.qgis_server.models import QGISServerStyle + + style = QGISServerStyle.objects.get(id=style_id) + + layer_id = kwargs['id'] + layer = Layer.objects.get(id=layer_id) + except: + return http.HttpBadRequest(reason=reason) + + from geonode.qgis_server.views import default_qml_style + + request.method = 'POST' + response = default_qml_style( + request, + layername=layer.name, + style_name=style.name) + + if isinstance(response, TemplateResponse): + if response.status_code == 200: + return HttpResponse(status=200) + + return self.error_response(request, response.content) + class Meta(CommonMetaApi): queryset = Layer.objects.distinct().order_by('-date') if settings.RESOURCE_PUBLISHING: queryset = queryset.filter(is_published=True) resource_name = 'layers' detail_uri_name = 'id' + include_resource_uri = True + allowed_methods = ['get', 'patch'] excludes = ['csw_anytext', 'metadata_xml'] + filtering = CommonMetaApi.filtering + # Allow filtering using ID + filtering.update({ + 'id': ALL, + 'name': ALL, + 'typename': ALL, + }) class MapResource(CommonModelApi): diff --git a/geonode/api/tests.py b/geonode/api/tests.py index 6bd38fb561c..c3da4df53d3 100644 --- a/geonode/api/tests.py +++ b/geonode/api/tests.py @@ -17,12 +17,24 @@ # along with this program. If not, see . # ######################################################################### +import json +import os +from StringIO import StringIO +import gisdata +from django.core.management import call_command from django.core.urlresolvers import reverse +from django.test.testcases import LiveServerTestCase + +from geonode import geoserver, qgis_server +from geonode.decorators import on_ogc_backend +from geonode.layers.utils import file_upload from tastypie.test import ResourceTestCase -from geonode.base.populate_test_data import create_models, all_public +from geonode.base.populate_test_data import create_models, all_public, \ + reconnect_signals, disconnect_signals from geonode.layers.models import Layer +from geonode.utils import check_ogc_backend class PermissionsApiTests(ResourceTestCase): @@ -226,3 +238,269 @@ def test_date_filter(self): resp = self.api_client.get(filter_url) self.assertValidJSONResponse(resp) self.assertEquals(len(self.deserialize(resp)['objects']), 4) + + +class LayersStylesApiInteractionTests(LiveServerTestCase, ResourceTestCase): + + """Test Layers""" + + fixtures = ['initial_data.json', 'bobby'] + + def setUp(self): + super(LayersStylesApiInteractionTests, self).setUp() + + # Reconnect Geoserver signals + if check_ogc_backend(geoserver.BACKEND_PACKAGE): + reconnect_signals() + + call_command('loaddata', 'people_data', verbosity=0) + + self.layer_list_url = reverse( + 'api_dispatch_list', + kwargs={ + 'api_name': 'api', + 'resource_name': 'layers'}) + self.style_list_url = reverse( + 'api_dispatch_list', + kwargs={ + 'api_name': 'api', + 'resource_name': 'styles'}) + filename = os.path.join(gisdata.GOOD_DATA, 'raster/test_grid.tif') + self.layer = file_upload(filename) + all_public() + + def tearDown(self): + Layer.objects.all().delete() + + # Disconnect Geoserver signals + if check_ogc_backend(geoserver.BACKEND_PACKAGE): + disconnect_signals() + + def test_layer_interaction(self): + """Layer API interaction check.""" + layer_id = self.layer.id + + layer_detail_url = reverse( + 'api_dispatch_detail', + kwargs={ + 'api_name': 'api', + 'resource_name': 'layers', + 'id': layer_id + } + ) + resp = self.api_client.get(layer_detail_url) + self.assertValidJSONResponse(resp) + obj = self.deserialize(resp) + # Should have links + self.assertTrue('links' in obj and obj['links']) + # Should have default style + self.assertTrue('default_style' in obj and obj['default_style']) + # Should have styles + self.assertTrue('styles' in obj and obj['styles']) + + # Test filter layers by id + filter_url = self.layer_list_url + '?id=' + str(layer_id) + resp = self.api_client.get(filter_url) + self.assertValidJSONResponse(resp) + # This is a list url + objects = self.deserialize(resp)['objects'] + self.assertEqual(len(objects), 1) + obj = objects[0] + # Should have links + self.assertTrue('links' in obj) + # Should not have styles + self.assertTrue('styles' not in obj) + # Should have default_style + self.assertTrue('default_style' in obj and obj['default_style']) + # Should have resource_uri to browse layer detail + self.assertTrue('resource_uri' in obj and obj['resource_uri']) + + prev_obj = obj + # Test filter layers by name + filter_url = self.layer_list_url + '?name=' + self.layer.name + resp = self.api_client.get(filter_url) + self.assertValidJSONResponse(resp) + # This is a list url + objects = self.deserialize(resp)['objects'] + self.assertEqual(len(objects), 1) + obj = objects[0] + + self.assertEqual(obj, prev_obj) + + def test_style_interaction(self): + """Style API interaction check.""" + + # filter styles by layer id + filter_url = self.style_list_url + '?layer__id=' + str(self.layer.id) + resp = self.api_client.get(filter_url) + self.assertValidJSONResponse(resp) + # This is a list url + objects = self.deserialize(resp)['objects'] + + self.assertEqual(len(objects), 1) + + # filter styles by layer name + filter_url = self.style_list_url + '?layer__name=' + self.layer.name + resp = self.api_client.get(filter_url) + self.assertValidJSONResponse(resp) + # This is a list url + objects = self.deserialize(resp)['objects'] + + self.assertEqual(len(objects), 1) + + # Check necessary list fields + obj = objects[0] + field_list = [ + 'layer', + 'name', + 'title', + 'style_url', + 'type', + 'resource_uri' + ] + + # Additional field based on OGC Backend + if check_ogc_backend(geoserver.BACKEND_PACKAGE): + field_list += [ + 'version', + 'workspace' + ] + elif check_ogc_backend(qgis_server.BACKEND_PACKAGE): + field_list += [ + 'style_legend_url' + ] + for f in field_list: + self.assertTrue(f in obj) + + if check_ogc_backend(geoserver.BACKEND_PACKAGE): + self.assertEqual(obj['type'], 'sld') + elif check_ogc_backend(qgis_server.BACKEND_PACKAGE): + self.assertEqual(obj['type'], 'qml') + + # Check style detail + detail_url = obj['resource_uri'] + resp = self.api_client.get(detail_url) + self.assertValidJSONResponse(resp) + obj = self.deserialize(resp) + + # should include body field + self.assertTrue('body' in obj and obj['body']) + + @on_ogc_backend(qgis_server.BACKEND_PACKAGE) + def test_add_delete_styles(self): + """Style API Add/Delete interaction.""" + # Check styles count + style_list_url = reverse( + 'api_dispatch_list', + kwargs={ + 'api_name': 'api', + 'resource_name': 'styles' + } + ) + filter_url = style_list_url + '?layer__name=' + self.layer.name + resp = self.api_client.get(filter_url) + self.assertValidJSONResponse(resp) + objects = self.deserialize(resp)['objects'] + + self.assertEqual(len(objects), 1) + + # Fetch default style + layer_detail_url = reverse( + 'api_dispatch_detail', + kwargs={ + 'api_name': 'api', + 'resource_name': 'layers', + 'id': self.layer.id + } + ) + resp = self.api_client.get(layer_detail_url) + self.assertValidJSONResponse(resp) + obj = self.deserialize(resp) + # Take default style url from Layer detail info + default_style_url = obj['default_style'] + resp = self.api_client.get(default_style_url) + self.assertValidJSONResponse(resp) + obj = self.deserialize(resp) + style_body = obj['body'] + + style_stream = StringIO(style_body) + # Add virtual filename + style_stream.name = 'style.qml' + data = { + 'layer__id': self.layer.id, + 'name': 'new_style', + 'title': 'New Style', + 'style': style_stream + } + # Use default client to request + resp = self.client.post(style_list_url, data=data) + + # Should not be able to add style without authentication + self.assertEqual(resp.status_code, 403) + + # Login using anonymous user + self.client.login(username='AnonymousUser') + style_stream.seek(0) + resp = self.client.post(style_list_url, data=data) + # Should not be able to add style without correct permission + self.assertEqual(resp.status_code, 403) + self.client.logout() + + # Use admin credentials + self.client.login(username='admin', password='admin') + style_stream.seek(0) + resp = self.client.post(style_list_url, data=data) + self.assertEqual(resp.status_code, 201) + + # Check styles count + filter_url = style_list_url + '?layer__name=' + self.layer.name + resp = self.api_client.get(filter_url) + self.assertValidJSONResponse(resp) + objects = self.deserialize(resp)['objects'] + + self.assertEqual(len(objects), 2) + + # Attempt to set default style + resp = self.api_client.get(layer_detail_url) + self.assertValidJSONResponse(resp) + obj = self.deserialize(resp) + # Get style list and get new default style + styles = obj['styles'] + new_default_style = None + for s in styles: + if not s == obj['default_style']: + new_default_style = s + break + obj['default_style'] = new_default_style + # Put the new update + patch_data = { + 'default_style': new_default_style + } + resp = self.client.patch( + layer_detail_url, + data=json.dumps(patch_data), + content_type='application/json') + self.assertEqual(resp.status_code, 200) + + # check new default_style + resp = self.api_client.get(layer_detail_url) + self.assertValidJSONResponse(resp) + obj = self.deserialize(resp) + self.assertEqual(obj['default_style'], new_default_style) + + # Attempt to delete style + filter_url = style_list_url + '?layer__id=%d&name=%s' % ( + self.layer.id, data['name']) + resp = self.api_client.get(filter_url) + self.assertValidJSONResponse(resp) + objects = self.deserialize(resp)['objects'] + + resource_uri = objects[0]['resource_uri'] + + resp = self.client.delete(resource_uri) + self.assertEqual(resp.status_code, 204) + + resp = self.api_client.get(filter_url) + meta = self.deserialize(resp)['meta'] + + self.assertEqual(meta['total_count'], 0) diff --git a/geonode/api/urls.py b/geonode/api/urls.py index 074acec4695..f959d854c74 100644 --- a/geonode/api/urls.py +++ b/geonode/api/urls.py @@ -20,9 +20,7 @@ from tastypie.api import Api -from geonode import qgis_server -from geonode.api.api import QGISStyleResource -from geonode.utils import check_ogc_backend +from geonode.api.api import StyleResource from .api import TagResource, TopicCategoryResource, ProfileResource, \ GroupResource, RegionResource, OwnersResource, ThesaurusKeywordResource, \ GroupCategoryResource @@ -44,6 +42,4 @@ api.register(OwnersResource()) api.register(ThesaurusKeywordResource()) api.register(GroupCategoryResource()) - -if check_ogc_backend(qgis_server.BACKEND_PACKAGE): - api.register(QGISStyleResource()) +api.register(StyleResource()) diff --git a/geonode/base/populate_test_data.py b/geonode/base/populate_test_data.py index 89643383286..8ea302ae923 100644 --- a/geonode/base/populate_test_data.py +++ b/geonode/base/populate_test_data.py @@ -38,7 +38,8 @@ import os.path -if 'geonode.geoserver' in settings.INSTALLED_APPS: +def disconnect_signals(): + """Disconnect signals for test class purposes.""" from django.db.models import signals from geonode.geoserver.signals import geoserver_pre_save_maplayer from geonode.geoserver.signals import geoserver_post_save_map @@ -49,6 +50,23 @@ signals.pre_save.disconnect(geoserver_pre_save, sender=Layer) signals.post_save.disconnect(geoserver_post_save, sender=Layer) + +def reconnect_signals(): + """Reconnect signals for test class purposes.""" + from django.db.models import signals + from geonode.geoserver.signals import geoserver_pre_save_maplayer + from geonode.geoserver.signals import geoserver_post_save_map + from geonode.geoserver.signals import geoserver_pre_save + from geonode.geoserver.signals import geoserver_post_save + signals.pre_save.connect(geoserver_pre_save_maplayer, sender=MapLayer) + signals.post_save.connect(geoserver_post_save_map, sender=Map) + signals.pre_save.connect(geoserver_pre_save, sender=Layer) + signals.post_save.connect(geoserver_post_save, sender=Layer) + + +if 'geonode.geoserver' in settings.INSTALLED_APPS: + disconnect_signals() + # This is used to populate the database with the search fixture data. This is # primarily used as a first step to generate the json data for the fixture using # django's dumpdata diff --git a/geonode/layers/forms.py b/geonode/layers/forms.py index b7ae67e0c8f..c47c140c3ff 100644 --- a/geonode/layers/forms.py +++ b/geonode/layers/forms.py @@ -25,6 +25,10 @@ from django.conf import settings from django import forms + +from geonode import geoserver, qgis_server +from geonode.utils import check_ogc_backend + try: import json except ImportError: @@ -193,9 +197,9 @@ def write_files(self): class NewLayerUploadForm(LayerUploadForm): - if 'geonode.geoserver' in settings.INSTALLED_APPS: + if check_ogc_backend(geoserver.BACKEND_PACKAGE): sld_file = forms.FileField(required=False) - if 'geonode.qgis_server' in settings.INSTALLED_APPS: + if check_ogc_backend(qgis_server.BACKEND_PACKAGE): qml_file = forms.FileField(required=False) xml_file = forms.FileField(required=False) @@ -213,9 +217,9 @@ class NewLayerUploadForm(LayerUploadForm): "xml_file", ] # Adding style file based on the backend - if 'geonode.geoserver' in settings.INSTALLED_APPS: + if check_ogc_backend(geoserver.BACKEND_PACKAGE): spatial_files.append('sld_file') - if 'geonode.qgis_server' in settings.INSTALLED_APPS: + if check_ogc_backend(qgis_server.BACKEND_PACKAGE): spatial_files.append('qml_file') spatial_files = tuple(spatial_files) diff --git a/geonode/layers/models.py b/geonode/layers/models.py index 573b519c0e3..5127fb61af6 100644 --- a/geonode/layers/models.py +++ b/geonode/layers/models.py @@ -35,7 +35,9 @@ from geonode.people.utils import get_valid_user from agon_ratings.models import OverallRating from geonode.utils import check_shp_columnnames -from geonode.security.models import remove_object_permissions +from geonode.security.models import ( + remove_object_permissions, + PermissionLevelMixin) logger = logging.getLogger("geonode.layers.models") @@ -59,7 +61,7 @@ } -class Style(models.Model): +class Style(models.Model, PermissionLevelMixin): """Model for storing styles. """ @@ -89,6 +91,16 @@ def absolute_url(self): logger.error("SLD URL is empty for Style %s" % self.name.encode('utf-8')) return None + def get_self_resource(self): + """Get associated resource base.""" + # Associate this model with resource + try: + layer = self.layer_styles.first() + """:type: Layer""" + return layer.get_self_resource() + except: + return None + class LayerManager(ResourceBaseManager): diff --git a/geonode/layers/views.py b/geonode/layers/views.py index 324c3017168..2e8f70c3356 100644 --- a/geonode/layers/views.py +++ b/geonode/layers/views.py @@ -41,6 +41,9 @@ from django.conf import settings from django.template import RequestContext from django.utils.translation import ugettext as _ + +from geonode import geoserver, qgis_server + try: import json except ImportError: @@ -61,7 +64,7 @@ from geonode.base.models import TopicCategory from geonode.groups.models import GroupProfile -from geonode.utils import default_map_config +from geonode.utils import default_map_config, check_ogc_backend from geonode.utils import GXPLayer from geonode.utils import GXPMap from geonode.layers.utils import file_upload, is_raster, is_vector @@ -77,7 +80,7 @@ if 'geonode.geoserver' in settings.INSTALLED_APPS: from geonode.geoserver.helpers import _render_thumbnail -if 'geonode.qgis_server' in settings.INSTALLED_APPS: +if check_ogc_backend(qgis_server.BACKEND_PACKAGE): from geonode.qgis_server.models import QGISServerLayer CONTEXT_LOG_FILE = ogc_server_settings.LOG_FILE @@ -202,6 +205,7 @@ def layer_upload(request, template='upload/layer_upload.html'): out['url'] = reverse( 'layer_detail', args=[ saved_layer.service_typename]) + out['ogc_backend'] = settings.OGC_SERVER['default']['BACKEND'] upload_session = saved_layer.upload_session upload_session.processed = True upload_session.save() @@ -765,17 +769,19 @@ def layer_replace(request, layername, template='layers/layer_replace.html'): out['success'] = False out['errors'] = _("You are attempting to replace a raster layer with a vector.") else: - if 'geonode.geoserver' in settings.INSTALLED_APPS: + if check_ogc_backend(geoserver.BACKEND_PACKAGE): # delete geoserver's store before upload cat = gs_catalog cascading_delete(cat, layer.typename) - elif 'geonode.qgis_server' in settings.INSTALLED_APPS: + out['ogc_backend'] = geoserver.BACKEND_PACKAGE + elif check_ogc_backend(qgis_server.BACKEND_PACKAGE): try: qgis_layer = QGISServerLayer.objects.get( layer=layer) qgis_layer.delete() except QGISServerLayer.DoesNotExist: pass + out['ogc_backend'] = qgis_server.BACKEND_PACKAGE saved_layer = file_upload( base_file, diff --git a/geonode/maps/templates/maps/map_detail.html b/geonode/maps/templates/maps/map_detail.html index 32a3a7e3518..9563a7aa13a 100644 --- a/geonode/maps/templates/maps/map_detail.html +++ b/geonode/maps/templates/maps/map_detail.html @@ -264,7 +264,7 @@

To embed this map, add the following code snippet and customize its properties (scrolling, width, height) based on your needs to your site:

- <iframe scrolling="yes" src="{{ SITEURL }}{% url "map_embed" resource.id %}" + <iframe scrolling="yes" src="{{ SITEURL|slice:":-1" }}{% url "map_embed" resource.id %}" width="480px" height="360px"></iframe>

@@ -310,7 +310,6 @@ - {% endblock %} {% block extra_script %} diff --git a/geonode/maps/urls.py b/geonode/maps/urls.py index c133109a0cb..3f7c89dde14 100644 --- a/geonode/maps/urls.py +++ b/geonode/maps/urls.py @@ -19,10 +19,12 @@ ######################################################################### from django.conf.urls import patterns, url -from django.conf import settings from django.views.generic import TemplateView -from geonode.maps.qgis_server_views import MapCreateView, MapDetailView, MapEmbedView +from geonode import geoserver, qgis_server +from geonode.maps.qgis_server_views import MapCreateView, \ + MapDetailView, MapEmbedView +from geonode.utils import check_ogc_backend js_info_dict = { 'packages': ('geonode.maps',), @@ -31,12 +33,12 @@ new_map_view = 'new_map' existing_map_view = 'map_view' -if 'geonode.geoserver' in settings.INSTALLED_APPS: +if check_ogc_backend(geoserver.BACKEND_PACKAGE): new_map_view = 'new_map' existing_map_view = 'map_view' map_embed = 'map_embed' -elif 'geonode.qgis_server' in settings.INSTALLED_APPS: +elif check_ogc_backend(qgis_server.BACKEND_PACKAGE): new_map_view = MapCreateView.as_view() existing_map_view = MapDetailView.as_view() map_embed = MapEmbedView.as_view() diff --git a/geonode/qgis_server/helpers.py b/geonode/qgis_server/helpers.py index c67eba5de41..5e0c5f86a4b 100644 --- a/geonode/qgis_server/helpers.py +++ b/geonode/qgis_server/helpers.py @@ -23,6 +23,7 @@ import re import shutil import urllib +import json from urlparse import urljoin import requests @@ -468,6 +469,98 @@ def legend_url(layer, layertitle=False, style=None, internal=True): return url +def qgs_url(layer, style=None, internal=True): + """Construct QGIS Server url to fetch QGS. + + :param layer: Layer to use + :type layer: Layer + + :param style: Layer style to choose + :type style: str + + :param internal: Flag to switch between public url and internal url. + Public url will be served by Django Geonode (proxified). + :type internal: bool + + :return: QGIS Server request url for QGS + :rtype: str + """ + qgis_server_url = qgis_server_endpoint(internal) + + ogc_url = reverse('qgis_server:layer-request', + kwargs={'layername': layer.name}) + url = settings.SITEURL + ogc_url.replace("/", "", 1) + + # for now, style always set to '' + if not style: + style = '' + else: + style = 'default' + + layers = [{ + 'type': 'raster', + 'display': layer.name, + 'driver': 'wms', + 'crs': 'EPSG:4326', + 'format': 'image/png', + 'styles': style, + 'layers': layer.name, + 'url': url + }] + + json_layers = json.dumps(layers) + + url_server = qgis_server_url + \ + '?SERVICE=PROJECTDEFINITIONS&LAYERS=' + json_layers + + return url_server + + +def qlr_url(layer, style=None, internal=True): + """Construct QGIS Server url to fetch QLR. + + :param layer: Layer to use + :type layer: Layer + + :param style: Layer style to choose + :type style: str + + :param internal: Flag to switch between public url and internal url. + Public url will be served by Django Geonode (proxified). + :type internal: bool + + :return: QGIS Server request url for QLR + :rtype: str + """ + qgis_server_url = qgis_server_endpoint(internal) + ogc_url = reverse('qgis_server:layer-request', + kwargs={'layername': layer.name}) + url = settings.SITEURL + ogc_url.replace("/", "", 1) + + # for now, style always set to '' + if not style: + style = '' + else: + style = 'default' + + layers = [{ + 'type': 'raster', + 'display': layer.name, + 'driver': 'wms', + 'crs': 'EPSG:4326', + 'format': 'image/png', + 'styles': style, + 'layers': layer.name, + 'url': url + }] + json_layers = json.dumps(layers) + + url_server = qgis_server_url + \ + '?SERVICE=LAYERDEFINITIONS&LAYERS=' + json_layers + + return url_server + + def wms_get_capabilities_url(layer=None, internal=True): """Construct WMS GetCapabilities request. diff --git a/geonode/qgis_server/management/commands/import_qgis_styles.py b/geonode/qgis_server/management/commands/import_qgis_styles.py new file mode 100644 index 00000000000..77c390767b2 --- /dev/null +++ b/geonode/qgis_server/management/commands/import_qgis_styles.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +######################################################################### +# +# Copyright (C) 2016 OSGeo +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +######################################################################### + +from django.core.management.base import BaseCommand +from geonode.layers.models import Layer +from geonode.qgis_server.helpers import style_list + + +class Command(BaseCommand): + help = ("Import QGIS Server styles associated with layers.") + + def handle(self, *args, **options): + layers = Layer.objects.all() + + for l in layers: + try: + l.qgis_layer + except: + print 'Layer %s has no associated qgis_layer' % l.name + continue + + if l.qgis_layer: + print 'Fetching styles for layer %s' % l.name + + try: + styles = style_list(l, internal=False) + except: + print 'Failed to fetch styles' + continue + + print 'Successfully fetch %d style(s)' % len(styles) + print '' diff --git a/geonode/qgis_server/management/commands/tests.py b/geonode/qgis_server/management/commands/tests.py index 0d4ddf59339..edf3c6f724f 100644 --- a/geonode/qgis_server/management/commands/tests.py +++ b/geonode/qgis_server/management/commands/tests.py @@ -24,7 +24,7 @@ import gisdata from django.core.management import call_command from django.test import LiveServerTestCase -from geonode.qgis_server.models import QGISServerLayer +from geonode.qgis_server.models import QGISServerLayer, QGISServerStyle from geonode.layers.models import Layer @@ -134,3 +134,43 @@ def test_import_layers(self): # Cleanup layer.delete() + + def test_import_qgis_styles(self): + """import_qgis_styles management commands should run properly.""" + filename = os.path.join( + gisdata.GOOD_DATA, + 'vector/san_andres_y_providencia_administrative.shp') + call_command('importlayers', filename, overwrite=True) + + # Check layer have default style after importing + layer = Layer.objects.get( + name='san_andres_y_providencia_administrative') + + qgis_layer = layer.qgis_layer + self.assertTrue(qgis_layer.default_style) + + self.assertTrue(QGISServerStyle.objects.count() == 1) + + # Delete styles + qgis_layer.default_style.delete() + + self.assertTrue(QGISServerStyle.objects.count() == 0) + + qgis_layer.refresh_from_db() + + self.assertFalse(qgis_layer.default_style) + + # Import styles + + call_command('import_qgis_styles') + + layer.refresh_from_db() + qgis_layer = layer.qgis_layer + qgis_layer.refresh_from_db() + + self.assertTrue(qgis_layer.default_style) + + self.assertTrue(QGISServerStyle.objects.count() == 1) + + # Cleanup + layer.delete() diff --git a/geonode/qgis_server/migrations/0005_auto_20170823_0341.py b/geonode/qgis_server/migrations/0005_auto_20170823_0341.py new file mode 100644 index 00000000000..63d413bf447 --- /dev/null +++ b/geonode/qgis_server/migrations/0005_auto_20170823_0341.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('qgis_server', '0004_auto_20170805_0223'), + ] + + operations = [ + migrations.AlterField( + model_name='qgisserverlayer', + name='default_style', + field=models.ForeignKey(related_name='layer_default_style', on_delete=django.db.models.deletion.SET_NULL, default=None, to='qgis_server.QGISServerStyle', null=True), + ), + ] diff --git a/geonode/qgis_server/models.py b/geonode/qgis_server/models.py index 53133921de6..8b1692a7539 100644 --- a/geonode/qgis_server/models.py +++ b/geonode/qgis_server/models.py @@ -27,6 +27,7 @@ from django.conf import settings from django.db import models from django.utils.translation import ugettext_lazy as _ +from geonode.security.models import PermissionLevelMixin from lxml import etree from geonode import qgis_server @@ -44,7 +45,7 @@ os.mkdir(QGIS_LAYER_DIRECTORY) -class QGISServerLayer(models.Model): +class QGISServerLayer(models.Model, PermissionLevelMixin): """Model for Layer in QGIS Server Backend. """ @@ -71,7 +72,8 @@ class QGISServerLayer(models.Model): 'QGISServerStyle', related_name='layer_default_style', default=None, - null=True) + null=True, + on_delete=models.SET_NULL) styles = models.ManyToManyField( 'QGISServerStyle', related_name='layer_styles') @@ -175,8 +177,16 @@ def delete_qgis_layer(self): for style in QGISServerStyle.objects.filter(layer_styles=None): style.delete() + def get_self_resource(self): + """Get associated resource base.""" + # Associate this model with resource + try: + return self.layer.get_self_resource() + except: + return None + -class QGISServerStyle(models.Model): +class QGISServerStyle(models.Model, PermissionLevelMixin): """Model wrapper for QGIS Server styles.""" name = models.CharField(_('style name'), max_length=255) @@ -296,8 +306,18 @@ def style_tile_cache_path(self): return os.path.join( QGIS_TILES_DIRECTORY, self.layer_styles.first().layer.name, self.name) + def get_self_resource(self): + """Get associated resource base.""" + # Associate this model with resource + try: + qgis_layer = self.layer_styles.first() + """:type: QGISServerLayer""" + return qgis_layer.get_self_resource() + except: + return None + -class QGISServerMap(models.Model): +class QGISServerMap(models.Model, PermissionLevelMixin): """Model wrapper for QGIS Server Map.""" map = models.OneToOneField( @@ -347,6 +367,14 @@ def cache_path(self): """ return os.path.join(QGIS_TILES_DIRECTORY, self.qgis_map_name) + def get_self_resource(self): + """Get associated resource base.""" + # Associate this model with resource + try: + return self.layer.get_self_resource() + except: + return None + from geonode.qgis_server.signals import \ register_qgis_server_signals # noqa: F402,F401 diff --git a/geonode/qgis_server/signals.py b/geonode/qgis_server/signals.py index 76a2ec56632..8f4f4e4a226 100644 --- a/geonode/qgis_server/signals.py +++ b/geonode/qgis_server/signals.py @@ -215,6 +215,26 @@ def qgis_server_post_save(instance, sender, **kwargs): ) ) + # QGS link layer workspace + ogc_qgs_url = urljoin( + base_url, + reverse( + 'qgis_server:download-qgs', + kwargs={'layername': instance.name})) + logger.debug('qgs_download_url: %s' % ogc_qgs_url) + link_name = 'QGS Layer file' + link_mime = 'application/xml' + Link.objects.update_or_create( + resource=instance.resourcebase_ptr, + name=link_name, + defaults=dict( + extension='qgs', + mime=link_mime, + url=ogc_qgs_url, + link_type='data' + ) + ) + if instance.is_vector(): # WFS link layer workspace ogc_wfs_url = urljoin( @@ -236,6 +256,26 @@ def qgis_server_post_save(instance, sender, **kwargs): ) ) + # QLR link layer workspace + ogc_qlr_url = urljoin( + base_url, + reverse( + 'qgis_server:download-qlr', + kwargs={'layername': instance.name})) + logger.debug('qlr_download_url: %s' % ogc_qlr_url) + link_name = 'QGIS Layer file' + link_mime = 'application/xml' + Link.objects.update_or_create( + resource=instance.resourcebase_ptr, + name=link_name, + defaults=dict( + extension='qlr', + mime=link_mime, + url=ogc_qlr_url, + link_type='data' + ) + ) + # if layer has overwrite attribute, then it probably comes from # importlayers management command and needs to be overwritten overwrite = getattr(instance, 'overwrite', False) diff --git a/geonode/qgis_server/tests/data/test_grid.qml b/geonode/qgis_server/tests/data/test_grid.qml new file mode 100644 index 00000000000..217a5921214 --- /dev/null +++ b/geonode/qgis_server/tests/data/test_grid.qml @@ -0,0 +1,16 @@ + + + + + +-9999 +-236.1545301164897 +StretchToMinimumMaximum + + + + + + +0 + diff --git a/geonode/qgis_server/tests/test_views.py b/geonode/qgis_server/tests/test_views.py index e5be43d4758..592df6b7b43 100644 --- a/geonode/qgis_server/tests/test_views.py +++ b/geonode/qgis_server/tests/test_views.py @@ -171,7 +171,7 @@ def test_ogc_specific_layer(self): reverse('qgis_server:set-thumbnail', kwargs=params), data=data) # User dont have permission - self.assertEqual(response.status_code, 401) + self.assertEqual(response.status_code, 403) # Should log in self.client.login(username='admin', password='admin') response = self.client.post( @@ -291,6 +291,11 @@ class QGISServerStyleManagerTest(LiveServerTestCase): def setUp(self): call_command('loaddata', 'people_data', verbosity=0) + def data_path(self, path): + project_root = os.path.abspath(settings.PROJECT_ROOT) + return os.path.join( + project_root, 'qgis_server/tests/data', path) + @on_ogc_backend(qgis_server.BACKEND_PACKAGE) def test_list_style(self): """Test querying list of styles from QGIS Server.""" @@ -304,8 +309,95 @@ def test_list_style(self): # There will be a default style self.assertEqual( set(expected_list_style), - set([style.name for style in actual_list_style]) - ) + set([style.name for style in actual_list_style])) + + style_list_url = reverse( + 'qgis_server:download-qml', + kwargs={ + 'layername': layer.name + }) + response = self.client.get(style_list_url) + self.assertEqual(response.status_code, 200) + actual_list_style = json.loads(response.content) + + # There will be a default style + self.assertEqual( + set(expected_list_style), + set([style['name'] for style in actual_list_style])) + + layer.delete() + + @on_ogc_backend(qgis_server.BACKEND_PACKAGE) + def test_add_delete_style(self): + """Test add new style using qgis_server views.""" + filename = os.path.join(gisdata.GOOD_DATA, 'raster/test_grid.tif') + layer = file_upload(filename) + """:type: geonode.layers.models.Layer""" + + self.client.login(username='admin', password='admin') + + qml_path = self.data_path('test_grid.qml') + add_style_url = reverse( + 'qgis_server:upload-qml', + kwargs={ + 'layername': layer.name}) + + with open(qml_path) as file_handle: + form_data = { + 'name': 'new_style', + 'title': 'New Style', + 'qml': file_handle + } + response = self.client.post( + add_style_url, + data=form_data) + + self.assertEqual(response.status_code, 201) + + actual_list_style = style_list(layer, internal=False) + + expected_list_style = ['default', 'new_style'] + + self.assertEqual( + set(expected_list_style), + set([style.name for style in actual_list_style])) + + # Test delete request + delete_style_url = reverse( + 'qgis_server:remove-qml', + kwargs={ + 'layername': layer.name, + 'style_name': 'default'}) + + response = self.client.delete(delete_style_url) + self.assertEqual(response.status_code, 200) + + actual_list_style = style_list(layer, internal=False) + + expected_list_style = ['new_style'] + + self.assertEqual( + set(expected_list_style), + set([style.name for style in actual_list_style])) + + # Check new default + default_style_url = reverse( + 'qgis_server:default-qml', + kwargs={ + 'layername': layer.name}) + + response = self.client.get(default_style_url) + + self.assertEqual(response.status_code, 200) + expected_default_style_retval = { + 'name': 'new_style', + } + actual_default_style_retval = json.loads(response.content) + + for key, value in expected_default_style_retval.iteritems(): + self.assertEqual(actual_default_style_retval[key], value) + + layer.delete() class ThumbnailGenerationTest(LiveServerTestCase): diff --git a/geonode/qgis_server/urls.py b/geonode/qgis_server/urls.py index 0297511d78a..6988ce2ad20 100644 --- a/geonode/qgis_server/urls.py +++ b/geonode/qgis_server/urls.py @@ -22,6 +22,7 @@ from geonode.qgis_server.views import ( download_zip, + download_qgs, tile, tile_404, legend, @@ -30,6 +31,7 @@ qgis_server_pdf, qgis_server_map_print, geotiff, + download_qlr, qml_style, set_thumbnail, default_qml_style) @@ -42,6 +44,11 @@ download_zip, name='download-zip' ), + url( + r'^download-qgs/(?P[\w]*)$', + download_qgs, + name='download-qgs' + ), url( r'^tiles/' r'(?P[^/]*)/' @@ -123,28 +130,33 @@ name='map-print' ), url( - r'^style/(?P[^/]*)(?:/(?P[^/]*))?/edit$', - qml_style, - name='upload-qml' + r'^style/default/(?P[^/]*)(?:/(?P[^/]*))?$', + default_qml_style, + name='default-qml' ), url( - r'^style/(?P[^/]*)(?:/(?P[^/]*))?$', + r'^style/upload/(?P[^/]*)(?:/(?P[^/]*))?$', qml_style, - name='download-qml' + name='upload-qml' ), url( - r'^style/(?P[^/]*)(?:/(?P[^/]*))?$', + r'^style/remove/(?P[^/]*)/(?P[^/]*)$', qml_style, name='remove-qml' ), url( - r'^style/(?P[^/]*)/(?P[^/]*)/default$', - default_qml_style, - name='default-qml' + r'^style/download/(?P[^/]*)(?:/(?P[^/]*))?$', + qml_style, + name='download-qml' ), url( r'^thumbnail/set/(?P[^/]*)$', set_thumbnail, name='set-thumbnail' ), + url( + r'^download-qlr/(?P[\w]*)$', + download_qlr, + name='download-qlr' + ), ) diff --git a/geonode/qgis_server/views.py b/geonode/qgis_server/views.py index 02603f09bf5..8a22586d07a 100644 --- a/geonode/qgis_server/views.py +++ b/geonode/qgis_server/views.py @@ -47,6 +47,8 @@ tile_url_format, legend_url, tile_url, + qgs_url, + qlr_url, qgis_server_endpoint, style_get_url, style_list, style_add_url, style_remove_url, style_set_default_url) from geonode.qgis_server.models import QGISServerLayer @@ -106,6 +108,29 @@ def download_zip(request, layername): return resp +def download_qgs(request, layername): + """Download QGS file for a layer. + + :param layername: The request from frontend. + :type layername: HttpRequest + + :param layername: The layer name in Geonode. + :type layername: basestring + + :return: QGS file. + """ + layer = get_object_or_404(Layer, name=layername) + url = qgs_url(layer, internal=True) + request = requests.get(url) + response = HttpResponse( + request.content, content_type="application/xml", + status=request.status_code) + response['Content-Disposition'] = \ + 'attachment; filename=%s' % layer.name + '.qgs' + + return response + + def legend(request, layername, layertitle=False, style=None): """Get the legend from a layer. @@ -498,7 +523,7 @@ def qml_style(request, layername, style_name=None): 'change_resourcebase', layer.get_self_resource()): return HttpResponse( 'User does not have permission to change QML style.', - status=401) + status=403) # Request about adding new QML style @@ -512,7 +537,7 @@ def qml_style(request, layername, style_name=None): 'resource': layer, 'style_upload_form': form }, - status=200).render() + status=400).render() try: uploaded_qml = request.FILES['qml'] @@ -561,7 +586,7 @@ def qml_style(request, layername, style_name=None): 'alert_message': response.content, 'alert_class': 'alert-danger' }, - status=200).render() + status=response.status_code).render() # We succeeded on adding new style @@ -583,7 +608,7 @@ def qml_style(request, layername, style_name=None): 'alert_class': 'alert-success', 'alert_message': alert_message }, - status=200).render() + status=201).render() except Exception as e: logger.exception(e) @@ -621,7 +646,7 @@ def qml_style(request, layername, style_name=None): 'alert_message': alert_message, 'alert_class': 'alert-danger' }, - status=200).render() + status=response.status_code).render() # Successfully removed styles # Handle when default style is deleted. @@ -645,7 +670,7 @@ def qml_style(request, layername, style_name=None): return HttpResponseBadRequest() -def default_qml_style(request, layername, style_name): +def default_qml_style(request, layername, style_name=None): """Set default style used by layer. :param layername: The layer name in Geonode. @@ -667,6 +692,16 @@ def default_qml_style(request, layername, style_name): return HttpResponse( json.dumps(retval), content_type='application/json') elif request.method == 'POST': + # For people who uses API request + if not request.user.has_perm( + 'change_resourcebase', layer.get_self_resource()): + return HttpResponse( + 'User does not have permission to change QML style.', + status=403) + + if not style_name: + return HttpResponseBadRequest() + style_url = style_set_default_url(layer, style_name) response = requests.get(style_url) @@ -715,7 +750,7 @@ def set_thumbnail(request, layername): 'change_resourcebase', layer.get_self_resource()): return HttpResponse( 'User does not have permission to change thumbnail.', - status=401) + status=403) # extract bbox bbox_string = request.POST['bbox'] @@ -730,3 +765,25 @@ def set_thumbnail(request, layername): } return HttpResponse( json.dumps(retval), content_type="application/json") + + +def download_qlr(request, layername): + """Download QLR file for a layer. + + :param layername: The layer name in Geonode. + :type layername: basestring + + :return: QLR file. + """ + layer = get_object_or_404(Layer, name=layername) + url = qlr_url(layer, internal=True) + + request = requests.get(url) + response = HttpResponse( + request.content, + content_type="application/xml", + status=request.status_code) + response['Content-Disposition'] = \ + 'attachment; filename=%s' % layer.name + '.qlr' + + return response diff --git a/geonode/qgis_server/xml_utilities.py b/geonode/qgis_server/xml_utilities.py index 331c77f8c76..dd039cede47 100644 --- a/geonode/qgis_server/xml_utilities.py +++ b/geonode/qgis_server/xml_utilities.py @@ -103,7 +103,12 @@ def insert_xml_element(root, element_path): element = root.find(path, XML_NS) if element is None: # if a parent is missing insert it at the right place - element = ElementTree.SubElement(parent, tag) + try: + element = ElementTree.SubElement(parent, tag) + except: + # In some cases we can't add parent because the tag name is + # not specific + pass parent = element return element diff --git a/geonode/settings.py b/geonode/settings.py index 5a185ce6112..beb022f77f5 100644 --- a/geonode/settings.py +++ b/geonode/settings.py @@ -867,6 +867,8 @@ DOWNLOAD_FORMATS_VECTOR = [ 'JPEG', 'PDF', 'PNG', 'Zipped Shapefile', 'GML 2.0', 'GML 3.1.1', 'CSV', 'Excel', 'GeoJSON', 'KML', 'View in Google Earth', 'Tiles', + 'QGIS Layer file', + 'QGS Layer file', ] DOWNLOAD_FORMATS_RASTER = [ 'JPEG', @@ -881,6 +883,8 @@ 'Tiles', 'GML', 'GZIP', + 'QGIS Layer file', + 'QGS Layer file', 'Zipped All Files' ] diff --git a/geonode/static/geonode/js/upload/LayerInfo.js b/geonode/static/geonode/js/upload/LayerInfo.js index b2f00325283..112b2d20252 100644 --- a/geonode/static/geonode/js/upload/LayerInfo.js +++ b/geonode/static/geonode/js/upload/LayerInfo.js @@ -339,6 +339,10 @@ define(function (require, exports) { var b = '' + gettext('Edit Metadata') + ''; var c = '' + gettext('Upload Metadata') + ''; var d = '' + gettext('Manage Styles') + ''; + if(resp.ogc_backend == 'geonode.qgis_server'){ + // QGIS Server has no manage style interaction. + d = ''; + } var msg_col = ""; if (resp.info){ var msg_template = gettext('The column %1 was renamed to %2
'); diff --git a/geonode/upload/forms.py b/geonode/upload/forms.py index 38fda01cdd4..0e5351ab7f9 100644 --- a/geonode/upload/forms.py +++ b/geonode/upload/forms.py @@ -24,9 +24,12 @@ from django import forms from django.conf import settings from django.core.exceptions import ValidationError + +from geonode import geoserver, qgis_server from geonode.layers.forms import JSONField from geonode.upload.models import UploadFile from geonode.geoserver.helpers import ogc_server_settings +from geonode.utils import check_ogc_backend class UploadFileForm(forms.ModelForm): @@ -43,9 +46,9 @@ class LayerUploadForm(forms.Form): prj_file = forms.FileField(required=False) xml_file = forms.FileField(required=False) - if 'geonode.geoserver' in settings.INSTALLED_APPS: + if check_ogc_backend(geoserver.BACKEND_PACKAGE): sld_file = forms.FileField(required=False) - if 'geonode.qgis_server' in settings.INSTALLED_APPS: + if check_ogc_backend(qgis_server.BACKEND_PACKAGE): qml_file = forms.FileField(required=False) geogig = forms.BooleanField(required=False) @@ -77,9 +80,9 @@ class LayerUploadForm(forms.Form): "xml_file", ] # Adding style file based on the backend - if 'geonode.geoserver' in settings.INSTALLED_APPS: + if check_ogc_backend(geoserver.BACKEND_PACKAGE): spatial_files.append('sld_file') - if 'geonode.qgis_server' in settings.INSTALLED_APPS: + if check_ogc_backend(qgis_server.BACKEND_PACKAGE): spatial_files.append('qml_file') spatial_files = tuple(spatial_files) diff --git a/pavement.py b/pavement.py index ce1032837db..9c8f2b8dcbc 100644 --- a/pavement.py +++ b/pavement.py @@ -436,9 +436,31 @@ def test(options): """ Run GeoNode's Unit Test Suite """ - sh("%s manage.py test %s.tests --noinput --liveserver=0.0.0.0:8000" % ( - options.get('prefix'), '.tests '.join(GEONODE_APPS))) + from geonode.settings import INSTALLED_APPS + success = False + + if 'geonode.geoserver' in INSTALLED_APPS: + _reset() + # Start GeoServer + call_task('start_geoserver') + info("GeoNode is now available, running the tests now.") + + try: + sh("%s manage.py test %s.tests --noinput --liveserver=0.0.0.0:8000" % ( + options.get('prefix'), '.tests '.join(GEONODE_APPS))) + except BuildFailure as e: + info('Tests failed! %s' % str(e)) + else: + success = True + finally: + if 'geonode.geoserver' in INSTALLED_APPS: + # don't use call task here - it won't run since it already has + stop() + + _reset() + if not success: + sys.exit(1) @task def test_javascript(options):